feat: add platform SSO and CAS login endpoints
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();
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in New Issue