From cf46616429652c5ba33670644cf69a4c151dc0f7 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 3 Aug 2026 13:08:25 +0800 Subject: [PATCH] feat: add platform SSO and CAS login endpoints --- .../application/CasTicketValidator.java | 87 +++++++++++++++++++ .../CasAuthenticationController.java | 63 ++++++++++++++ .../interfaces/PlatformSsoController.java | 47 ++++++++++ .../security/config/SecurityConfig.java | 4 +- .../shared/web/GlobalExceptionHandler.java | 7 ++ .../application/CasTicketValidatorTest.java | 21 +++++ .../interfaces/PlatformSsoControllerTest.java | 43 +++++++++ 7 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java create mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java create mode 100644 src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java create mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java create mode 100644 src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java new file mode 100644 index 0000000..e1a73fb --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java @@ -0,0 +1,87 @@ +package com.yau.digitalrmb.platformintegration.application; + +import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import org.w3c.dom.Document; +import org.xml.sax.InputSource; +import org.xml.sax.helpers.DefaultHandler; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathFactory; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +@Component +public class CasTicketValidator { + private final PlatformIntegrationProperties.Cas properties; + private final HttpClient httpClient; + + public CasTicketValidator(PlatformIntegrationProperties properties) { + this(properties.getCas(), HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()); + } + + CasTicketValidator(PlatformIntegrationProperties.Cas properties, HttpClient httpClient) { + this.properties = properties; + this.httpClient = httpClient; + } + + public String validate(String ticket) { + try { + URI uri = UriComponentsBuilder.fromUriString(properties.getValidateUrl()) + .queryParam("service", properties.getCallbackUrl()) + .queryParam("ticket", ticket) + .build().encode().toUri(); + HttpRequest request = HttpRequest.newBuilder(uri).GET().timeout(Duration.ofSeconds(5)).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw rejected(); + } + return parseAccount(response.body()); + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "CAS 票据校验失败"); + } + } + + static String parseAccount(String xml) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + var builder = factory.newDocumentBuilder(); + builder.setErrorHandler(new DefaultHandler()); + Document document = builder.parse(new InputSource(new StringReader(xml))); + String account = (String) XPathFactory.newInstance().newXPath().evaluate( + "string(//*[local-name()='authenticationSuccess']/*[local-name()='user'])", + document, XPathConstants.STRING); + if (account == null || account.isBlank()) { + throw rejected(); + } + return account.trim(); + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw rejected(); + } + } + + private static BusinessException rejected() { + return new BusinessException(ErrorCode.UNAUTHORIZED, "CAS 票据无效"); + } +} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java new file mode 100644 index 0000000..9e5a2ae --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java @@ -0,0 +1,63 @@ +package com.yau.digitalrmb.platformintegration.interfaces; + +import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.platformintegration.application.CasTicketValidator; +import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository; +import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; +import com.yau.digitalrmb.platformintegration.domain.PlatformActor; +import com.yau.digitalrmb.security.application.LoginExchangeCodeService; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; + +@RestController +@RequestMapping("/api/v1/auth/cas") +public class CasAuthenticationController { + private final CasTicketValidator ticketValidator; + private final PlatformIdentityRepository identityRepository; + private final PlatformIdentityProjectionService projectionService; + private final LoginExchangeCodeService exchangeCodeService; + private final PlatformIntegrationProperties.Cas cas; + private final PlatformIntegrationProperties.Frontend frontend; + + public CasAuthenticationController(CasTicketValidator ticketValidator, + PlatformIdentityRepository identityRepository, + PlatformIdentityProjectionService projectionService, + LoginExchangeCodeService exchangeCodeService, + PlatformIntegrationProperties properties) { + this.ticketValidator = ticketValidator; + this.identityRepository = identityRepository; + this.projectionService = projectionService; + this.exchangeCodeService = exchangeCodeService; + this.cas = properties.getCas(); + this.frontend = properties.getFrontend(); + } + + @GetMapping("/login") + public ResponseEntity login() { + String location = UriComponentsBuilder.fromUriString(cas.getLoginUrl()) + .queryParam("service", cas.getCallbackUrl()).build().encode().toUriString(); + return ResponseEntity.status(302).header(HttpHeaders.LOCATION, location) + .header(HttpHeaders.CACHE_CONTROL, "no-store").build(); + } + + @GetMapping("/callback") + public ResponseEntity callback(@RequestParam("ticket") String ticket) { + String account = ticketValidator.validate(ticket); + PlatformActor actor = identityRepository.findBySchoolAccount(account) + .orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED, "用户无权访问本系统")); + projectionService.project(actor); + String exchangeCode = exchangeCodeService.issue(actor.platformUserId()); + String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) + .queryParam("code", exchangeCode).build().encode().toUriString(); + return ResponseEntity.status(302).header(HttpHeaders.LOCATION, location) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .header("Referrer-Policy", "no-referrer").build(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java new file mode 100644 index 0000000..60f464e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java @@ -0,0 +1,47 @@ +package com.yau.digitalrmb.platformintegration.interfaces; + +import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; +import com.yau.digitalrmb.security.application.LoginExchangeCodeService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; + +@RestController +@RequestMapping("/api/v1/auth") +public class PlatformSsoController { + private final PlatformTokenVerifier tokenVerifier; + private final PlatformIdentityProjectionService projectionService; + private final LoginExchangeCodeService exchangeCodeService; + private final PlatformIntegrationProperties.Frontend frontend; + + public PlatformSsoController(PlatformTokenVerifier tokenVerifier, + PlatformIdentityProjectionService projectionService, + LoginExchangeCodeService exchangeCodeService, + PlatformIntegrationProperties properties) { + this.tokenVerifier = tokenVerifier; + this.projectionService = projectionService; + this.exchangeCodeService = exchangeCodeService; + this.frontend = properties.getFrontend(); + } + + @GetMapping("/sso") + public ResponseEntity loginFromPlatform(@RequestParam("token") String token) { + VerifiedPlatformToken verified = tokenVerifier.verify(token); + projectionService.project(verified.actor()); + String exchangeCode = exchangeCodeService.issue(verified.actor().platformUserId()); + String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl()) + .queryParam("code", exchangeCode).build().encode().toUriString(); + return ResponseEntity.status(302) + .header(HttpHeaders.LOCATION, location) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .header("Referrer-Policy", "no-referrer") + .build(); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java index 9108f9a..927548e 100644 --- a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java @@ -43,7 +43,9 @@ public class SecurityConfig { return http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/actuator/health", "/api/v1/auth/login", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") + .requestMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso", + "/api/v1/auth/cas/**", "/api/v1/auth/session/exchange", + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwt -> { })) diff --git a/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java index e4dae3c..fc1daa7 100644 --- a/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java +++ b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ package com.yau.digitalrmb.shared.web; import com.yau.digitalrmb.shared.api.ApiResponse; import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.platformintegration.application.PlatformTokenException; import jakarta.validation.ConstraintViolationException; import org.slf4j.MDC; import org.slf4j.Logger; @@ -29,6 +30,12 @@ public class GlobalExceptionHandler { return ApiResponse.failure(exception.getErrorCode(), exception.getMessage(), traceId()); } + @ExceptionHandler(PlatformTokenException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public ApiResponse handlePlatformToken(PlatformTokenException exception) { + return ApiResponse.failure(ErrorCode.UNAUTHORIZED, "平台登录凭据无效", traceId()); + } + @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResponse handleUnexpected(Exception exception) { diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java new file mode 100644 index 0000000..82c77bb --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java @@ -0,0 +1,21 @@ +package com.yau.digitalrmb.platformintegration.application; + +import com.yau.digitalrmb.shared.exception.BusinessException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CasTicketValidatorTest { + @Test + void parsesSuccessfulCasAccountAndRejectsExternalEntityPayloads() { + String success = "" + + "t001" + + ""; + String xxe = "]>" + + "&xxe;"; + + assertThat(CasTicketValidator.parseAccount(success)).isEqualTo("t001"); + assertThatThrownBy(() -> CasTicketValidator.parseAccount(xxe)).isInstanceOf(BusinessException.class); + } +} diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java new file mode 100644 index 0000000..b2743b1 --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java @@ -0,0 +1,43 @@ +package com.yau.digitalrmb.platformintegration.interfaces; + +import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService; +import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier; +import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken; +import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties; +import com.yau.digitalrmb.platformintegration.domain.PlatformActor; +import com.yau.digitalrmb.platformintegration.domain.PlatformRole; +import com.yau.digitalrmb.security.application.LoginExchangeCodeService; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformSsoControllerTest { + @Test + void ssoRedirectDoesNotLeakIncomingToken() throws Exception { + PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class); + PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class); + LoginExchangeCodeService exchangeCodes = mock(LoginExchangeCodeService.class); + PlatformIntegrationProperties properties = new PlatformIntegrationProperties(); + properties.getFrontend().setCallbackUrl("https://rmb.example.edu/sso-callback"); + PlatformActor actor = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER, + Instant.parse("2026-01-01T00:00:00Z")); + when(verifier.verify(anyString())).thenReturn(new VerifiedPlatformToken(actor, "fingerprint")); + when(exchangeCodes.issue(101L)).thenReturn("one-time-code"); + MockMvc mvc = MockMvcBuilders.standaloneSetup(new PlatformSsoController(verifier, projection, exchangeCodes, properties)).build(); + + mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?code=one-time-code")) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(header().string("Referrer-Policy", "no-referrer")); + } +}