feat: add platform SSO and CAS login endpoints

master
chenyuan 4 weeks ago
parent 5d97fabb21
commit cf46616429

@ -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<String> 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 票据无效");
}
}

@ -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<Void> 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<Void> 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();
}
}

@ -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<Void> 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();
}
}

@ -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 -> { }))

@ -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<Void> handlePlatformToken(PlatformTokenException exception) {
return ApiResponse.failure(ErrorCode.UNAUTHORIZED, "平台登录凭据无效", traceId());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleUnexpected(Exception exception) {

@ -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 = "<cas:serviceResponse xmlns:cas=\"http://www.yale.edu/tp/cas\">"
+ "<cas:authenticationSuccess><cas:user>t001</cas:user></cas:authenticationSuccess>"
+ "</cas:serviceResponse>";
String xxe = "<!DOCTYPE serviceResponse [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>"
+ "<serviceResponse><authenticationSuccess><user>&xxe;</user></authenticationSuccess></serviceResponse>";
assertThat(CasTicketValidator.parseAccount(success)).isEqualTo("t001");
assertThatThrownBy(() -> CasTicketValidator.parseAccount(xxe)).isInstanceOf(BusinessException.class);
}
}

@ -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"));
}
}
Loading…
Cancel
Save