feat(auth): support standard CAS 2.0/3.0 SSO protocol

Implement native CAS protocol ticket validation for enterprise SSO
integration, supporting both CAS 2.0 (XML) and CAS 3.0 (JSON) modes.

Backend:
- Introduce IdentityClaims interface to abstract identity providers;
  OAuthClaims now implements it, enabling CAS reuse of IdentityBindingService
- CasProperties with @PostConstruct HTTPS validation and feature flag
- CasTicketValidator: validates tickets via /serviceValidate (2.0) or
  /p3/serviceValidate (3.0), parses XML/JSON responses
- CasLoginController: /api/v1/auth/cas/login (redirect) and /callback
  (ticket validation + session establishment)
- RouteSecurityPolicyRegistry: permit /api/v1/auth/cas/**
- AuthMethodCatalog: expose CAS as CAS_REDIRECT method type

Frontend:
- LoginButton renders CAS_REDIRECT methods alongside OAuth providers
- Runtime config adds authCasEnabled flag
- CAS logo SVG added

Closes #456
This commit is contained in:
dongmucat 2026-05-27 12:43:43 +08:00
parent 1cf2e481ee
commit 72ca98552e
20 changed files with 1020 additions and 8 deletions

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
import com.iflytek.skillhub.auth.cas.CasProperties;
import com.iflytek.skillhub.auth.direct.DirectAuthProvider;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
import com.iflytek.skillhub.config.AuthSessionBootstrapProperties;
@ -25,17 +26,20 @@ public class AuthMethodCatalog {
private final OAuth2ClientProperties oAuth2ClientProperties;
private final DirectAuthProperties directAuthProperties;
private final AuthSessionBootstrapProperties sessionBootstrapProperties;
private final CasProperties casProperties;
private final List<DirectAuthProvider> directAuthProviders;
private final List<PassiveSessionAuthenticator> passiveSessionAuthenticators;
public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties,
DirectAuthProperties directAuthProperties,
AuthSessionBootstrapProperties sessionBootstrapProperties,
CasProperties casProperties,
List<DirectAuthProvider> directAuthProviders,
List<PassiveSessionAuthenticator> passiveSessionAuthenticators) {
this.oAuth2ClientProperties = oAuth2ClientProperties;
this.directAuthProperties = directAuthProperties;
this.sessionBootstrapProperties = sessionBootstrapProperties;
this.casProperties = casProperties;
this.directAuthProviders = directAuthProviders;
this.passiveSessionAuthenticators = passiveSessionAuthenticators;
}
@ -102,6 +106,16 @@ public class AuthMethodCatalog {
)));
}
if (casProperties.isEnabled()) {
methods.add(new AuthMethodResponse(
"cas",
"CAS_REDIRECT",
"cas",
"CAS",
buildCasLoginUrl(sanitizedReturnTo)
));
}
return methods;
}
@ -112,4 +126,12 @@ public class AuthMethodCatalog {
}
return baseUrl + "?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
}
private String buildCasLoginUrl(String returnTo) {
String baseUrl = "/api/v1/auth/cas/login";
if (returnTo == null) {
return baseUrl;
}
return baseUrl + "?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
}
}

View file

@ -104,6 +104,16 @@ skillhub:
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
cas:
enabled: ${SKILLHUB_AUTH_CAS_ENABLED:false}
server-url: ${SKILLHUB_AUTH_CAS_SERVER_URL:}
service-url: ${SKILLHUB_AUTH_CAS_SERVICE_URL:}
protocol-version: ${SKILLHUB_AUTH_CAS_PROTOCOL_VERSION:3.0}
allow-insecure-server: ${SKILLHUB_AUTH_CAS_ALLOW_INSECURE:false}
attributes:
username: ${SKILLHUB_AUTH_CAS_ATTR_USERNAME:uid}
display-name: ${SKILLHUB_AUTH_CAS_ATTR_DISPLAY_NAME:cn}
email: ${SKILLHUB_AUTH_CAS_ATTR_EMAIL:mail}
public:
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
access-policy:

View file

@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import com.iflytek.skillhub.auth.bootstrap.PassiveSessionAuthenticator;
import com.iflytek.skillhub.auth.cas.CasProperties;
import com.iflytek.skillhub.auth.direct.DirectAuthProvider;
import com.iflytek.skillhub.auth.direct.DirectAuthRequest;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
@ -62,6 +63,7 @@ class AuthMethodCatalogTest {
oauthProperties,
directAuthProperties,
bootstrapProperties,
new CasProperties(),
List.of(directProvider),
List.of(bootstrapProvider)
);
@ -111,6 +113,7 @@ class AuthMethodCatalogTest {
oauthProperties,
directAuthProperties,
bootstrapProperties,
new CasProperties(),
List.of(directProvider),
List.of(bootstrapProvider)
);

View file

@ -0,0 +1,28 @@
package com.iflytek.skillhub.auth.cas;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
import java.util.Map;
/**
* Adapts CAS ticket validation attributes to the platform-neutral IdentityClaims interface.
*/
public record CasIdentityClaims(
String subject,
String email,
String providerLogin,
Map<String, Object> extra
) implements IdentityClaims {
public static final String PROVIDER = "cas";
@Override
public String provider() {
return PROVIDER;
}
@Override
public boolean emailVerified() {
return email != null && !email.isBlank();
}
}

View file

@ -0,0 +1,124 @@
package com.iflytek.skillhub.auth.cas;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.domain.user.UserStatus;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
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.util.UriComponentsBuilder;
/**
* Handles CAS SSO login flow: redirect to CAS server and callback with ticket validation.
*/
@Controller
@RequestMapping("/api/v1/auth/cas")
public class CasLoginController {
private static final Logger log = LoggerFactory.getLogger(CasLoginController.class);
private final CasProperties casProperties;
private final CasTicketValidator ticketValidator;
private final IdentityBindingService identityBindingService;
private final PlatformSessionService sessionService;
public CasLoginController(
CasProperties casProperties,
CasTicketValidator ticketValidator,
IdentityBindingService identityBindingService,
PlatformSessionService sessionService
) {
this.casProperties = casProperties;
this.ticketValidator = ticketValidator;
this.identityBindingService = identityBindingService;
this.sessionService = sessionService;
}
/**
* Initiates CAS login by redirecting to the CAS server.
*/
@GetMapping("/login")
public String login(
@RequestParam(required = false) String returnTo,
HttpServletRequest request
) {
if (!casProperties.isEnabled()) {
log.warn("CAS login attempted but CAS is not enabled");
return "redirect:/login?error=cas_disabled";
}
String sanitized = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
if (sanitized != null) {
HttpSession session = request.getSession(true);
session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, sanitized);
}
String casLoginUrl = UriComponentsBuilder
.fromHttpUrl(casProperties.getServerUrl() + "/login")
.queryParam("service", casProperties.getServiceUrl())
.toUriString();
log.debug("Redirecting to CAS login: {}", casLoginUrl);
return "redirect:" + casLoginUrl;
}
/**
* Handles CAS callback with ticket validation and session establishment.
*/
@GetMapping("/callback")
public String callback(
@RequestParam(required = false) String ticket,
HttpServletRequest request
) {
if (!casProperties.isEnabled()) {
log.warn("CAS callback received but CAS is not enabled");
return "redirect:/login?error=cas_disabled";
}
if (ticket == null || ticket.isBlank()) {
log.warn("CAS callback received without ticket parameter");
return "redirect:/login?error=missing_ticket";
}
try {
CasIdentityClaims claims = ticketValidator.validate(ticket);
log.info("CAS ticket validated successfully for user: {}", claims.subject());
PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
sessionService.establishSession(principal, request);
HttpSession session = request.getSession(false);
String returnTo = null;
if (session != null) {
returnTo = (String) session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
}
String targetUrl = returnTo != null ? returnTo : OAuthLoginRedirectSupport.DEFAULT_TARGET_URL;
log.debug("CAS login successful, redirecting to: {}", targetUrl);
return "redirect:" + targetUrl;
} catch (AccountPendingException e) {
log.warn("CAS user account pending approval: {}", ticket);
return "redirect:/pending-approval";
} catch (AccountDisabledException e) {
log.warn("CAS user account disabled: {}", ticket);
return "redirect:/access-denied";
} catch (CasValidationException e) {
log.error("CAS ticket validation failed: {}", e.getMessage());
return "redirect:/login?error=cas_validation_failed";
} catch (Exception e) {
log.error("Unexpected error during CAS callback", e);
return "redirect:/login?error=internal_error";
}
}
}

View file

@ -0,0 +1,107 @@
package com.iflytek.skillhub.auth.cas;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* Configuration properties for CAS SSO integration.
*/
@Component
@ConfigurationProperties(prefix = "skillhub.auth.cas")
public class CasProperties {
private boolean enabled = false;
private String serverUrl;
private String serviceUrl;
private String protocolVersion = "3.0";
private boolean allowInsecureServer = false;
private Map<String, String> attributes = new HashMap<>();
@PostConstruct
public void validate() {
if (!enabled) {
return;
}
if (serverUrl == null || serverUrl.isBlank()) {
throw new IllegalStateException("skillhub.auth.cas.server-url must be configured when CAS is enabled");
}
if (serviceUrl == null || serviceUrl.isBlank()) {
throw new IllegalStateException("skillhub.auth.cas.service-url must be configured when CAS is enabled");
}
if (!allowInsecureServer && !serverUrl.startsWith("https://")) {
throw new IllegalStateException(
"CAS server URL must use HTTPS in production. " +
"Set skillhub.auth.cas.allow-insecure-server=true to override for development."
);
}
if (!"2.0".equals(protocolVersion) && !"3.0".equals(protocolVersion)) {
throw new IllegalStateException("skillhub.auth.cas.protocol-version must be either '2.0' or '3.0'");
}
if (attributes.get("username") == null || attributes.get("username").isBlank()) {
attributes.put("username", "uid");
}
if (attributes.get("display-name") == null || attributes.get("display-name").isBlank()) {
attributes.put("display-name", "cn");
}
if (attributes.get("email") == null || attributes.get("email").isBlank()) {
attributes.put("email", "mail");
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getServerUrl() {
return serverUrl;
}
public void setServerUrl(String serverUrl) {
this.serverUrl = serverUrl;
}
public String getServiceUrl() {
return serviceUrl;
}
public void setServiceUrl(String serviceUrl) {
this.serviceUrl = serviceUrl;
}
public String getProtocolVersion() {
return protocolVersion;
}
public void setProtocolVersion(String protocolVersion) {
this.protocolVersion = protocolVersion;
}
public boolean isAllowInsecureServer() {
return allowInsecureServer;
}
public void setAllowInsecureServer(boolean allowInsecureServer) {
this.allowInsecureServer = allowInsecureServer;
}
public Map<String, String> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, String> attributes) {
this.attributes = attributes;
}
}

View file

@ -0,0 +1,201 @@
package com.iflytek.skillhub.auth.cas;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Map;
/**
* Validates CAS tickets by calling the CAS server's serviceValidate endpoint.
* Supports both CAS 2.0 (XML) and CAS 3.0 (JSON) protocols.
*/
@Component
public class CasTicketValidator {
private static final Logger log = LoggerFactory.getLogger(CasTicketValidator.class);
private final CasProperties casProperties;
private final RestClient restClient;
private final ObjectMapper objectMapper;
@Autowired
public CasTicketValidator(CasProperties casProperties, ObjectMapper objectMapper) {
this(casProperties, objectMapper, RestClient.builder().build());
}
CasTicketValidator(CasProperties casProperties, ObjectMapper objectMapper, RestClient restClient) {
this.casProperties = casProperties;
this.objectMapper = objectMapper;
this.restClient = restClient;
}
/**
* Validates a CAS ticket and returns the user attributes.
*
* @param ticket the service ticket from CAS redirect
* @return CasIdentityClaims with user attributes
* @throws CasValidationException if validation fails
*/
public CasIdentityClaims validate(String ticket) {
if (!casProperties.isEnabled()) {
throw new IllegalStateException("CAS authentication is not enabled");
}
String validationUrl = buildValidationUrl(ticket);
log.debug("Validating CAS ticket at: {}", validationUrl);
try {
String response = restClient.get()
.uri(validationUrl)
.retrieve()
.body(String.class);
if (response == null || response.isBlank()) {
throw new CasValidationException("Empty response from CAS server");
}
if ("3.0".equals(casProperties.getProtocolVersion())) {
return parseJsonResponse(response);
} else {
return parseXmlResponse(response);
}
} catch (CasValidationException e) {
throw e;
} catch (Exception e) {
log.error("CAS ticket validation failed", e);
throw new CasValidationException("Failed to validate CAS ticket: " + e.getMessage(), e);
}
}
private String buildValidationUrl(String ticket) {
String endpoint = "3.0".equals(casProperties.getProtocolVersion())
? "/p3/serviceValidate"
: "/serviceValidate";
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(casProperties.getServerUrl() + endpoint)
.queryParam("ticket", ticket)
.queryParam("service", casProperties.getServiceUrl());
if ("3.0".equals(casProperties.getProtocolVersion())) {
builder.queryParam("format", "JSON");
}
return builder.toUriString();
}
private CasIdentityClaims parseJsonResponse(String response) throws Exception {
JsonNode root = objectMapper.readTree(response);
JsonNode serviceResponse = root.path("serviceResponse");
if (serviceResponse.has("authenticationFailure")) {
String code = serviceResponse.path("authenticationFailure").path("code").asText("UNKNOWN");
String description = serviceResponse.path("authenticationFailure").path("description").asText("Unknown error");
throw new CasValidationException("CAS authentication failed: " + code + " - " + description);
}
JsonNode authSuccess = serviceResponse.path("authenticationSuccess");
if (authSuccess.isMissingNode()) {
throw new CasValidationException("Invalid CAS response: missing authenticationSuccess");
}
String user = authSuccess.path("user").asText(null);
if (user == null || user.isBlank()) {
throw new CasValidationException("CAS response missing user identifier");
}
JsonNode attributesNode = authSuccess.path("attributes");
Map<String, Object> attributes = new HashMap<>();
if (attributesNode.isObject()) {
attributesNode.fields().forEachRemaining(entry -> {
JsonNode value = entry.getValue();
if (value.isTextual()) {
attributes.put(entry.getKey(), value.asText());
} else if (value.isArray() && value.size() > 0) {
attributes.put(entry.getKey(), value.get(0).asText());
} else {
attributes.put(entry.getKey(), value.toString());
}
});
}
return extractClaims(user, attributes);
}
private CasIdentityClaims parseXmlResponse(String response) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(response.getBytes()));
Element root = doc.getDocumentElement();
NodeList failures = root.getElementsByTagNameNS("*", "authenticationFailure");
if (failures.getLength() > 0) {
Element failure = (Element) failures.item(0);
String code = failure.getAttribute("code");
String description = failure.getTextContent();
throw new CasValidationException("CAS authentication failed: " + code + " - " + description);
}
NodeList successNodes = root.getElementsByTagNameNS("*", "authenticationSuccess");
if (successNodes.getLength() == 0) {
throw new CasValidationException("Invalid CAS response: missing authenticationSuccess");
}
Element authSuccess = (Element) successNodes.item(0);
NodeList userNodes = authSuccess.getElementsByTagNameNS("*", "user");
if (userNodes.getLength() == 0) {
throw new CasValidationException("CAS response missing user identifier");
}
String user = userNodes.item(0).getTextContent();
if (user == null || user.isBlank()) {
throw new CasValidationException("CAS response has blank user identifier");
}
Map<String, Object> attributes = new HashMap<>();
NodeList attributesNodes = authSuccess.getElementsByTagNameNS("*", "attributes");
if (attributesNodes.getLength() > 0) {
Element attributesElement = (Element) attributesNodes.item(0);
NodeList children = attributesElement.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
if (children.item(i) instanceof Element) {
Element attr = (Element) children.item(i);
String localName = attr.getLocalName();
String value = attr.getTextContent();
if (localName != null && value != null) {
attributes.put(localName, value);
}
}
}
}
return extractClaims(user, attributes);
}
private CasIdentityClaims extractClaims(String user, Map<String, Object> attributes) {
String usernameAttr = casProperties.getAttributes().get("username");
String displayNameAttr = casProperties.getAttributes().get("display-name");
String emailAttr = casProperties.getAttributes().get("email");
String subject = attributes.getOrDefault(usernameAttr, user).toString();
String displayName = attributes.getOrDefault(displayNameAttr, user).toString();
String email = attributes.containsKey(emailAttr) ? attributes.get(emailAttr).toString() : null;
return new CasIdentityClaims(subject, email, displayName, attributes);
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.auth.cas;
/**
* Thrown when CAS ticket validation fails.
*/
public class CasValidationException extends RuntimeException {
public CasValidationException(String message) {
super(message);
}
public CasValidationException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -1,7 +1,6 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
@ -17,7 +16,7 @@ import java.util.Set;
import java.util.stream.Collectors;
/**
* Resolves external OAuth identities to platform users, creating or updating
* Resolves external identities (OAuth, CAS, etc.) to platform users, creating or updating
* bindings and user records as needed.
*/
@Service
@ -39,7 +38,7 @@ public class IdentityBindingService {
}
@Transactional
public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStatus) {
public PlatformPrincipal bindOrCreate(IdentityClaims claims, UserStatus initialStatus) {
IdentityBinding binding = bindingRepo
.findByProviderCodeAndSubject(claims.provider(), claims.subject())
.orElse(null);
@ -90,7 +89,7 @@ public class IdentityBindingService {
}
@Transactional
public void createPendingUserIfAbsent(OAuthClaims claims) {
public void createPendingUserIfAbsent(IdentityClaims claims) {
IdentityBinding existingBinding = bindingRepo
.findByProviderCodeAndSubject(claims.provider(), claims.subject())
.orElse(null);

View file

@ -0,0 +1,41 @@
package com.iflytek.skillhub.auth.identity;
import java.util.Map;
/**
* Provider-neutral identity claims extracted from external authentication systems.
* Implementations adapt provider-specific formats (OAuth2, CAS, SAML, etc.) to this common interface.
*/
public interface IdentityClaims {
/**
* Provider identifier (e.g., "github", "gitlab", "cas").
*/
String provider();
/**
* Unique subject identifier from the provider.
* Must be stable across logins for the same user.
*/
String subject();
/**
* User's email address (may be null if provider doesn't expose it).
*/
String email();
/**
* Whether the email has been verified by the provider.
*/
boolean emailVerified();
/**
* Display name or username from the provider.
*/
String providerLogin();
/**
* Additional provider-specific attributes (e.g., avatar_url, groups, custom claims).
*/
Map<String, Object> extra();
}

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
import java.util.Map;
/**
@ -13,4 +14,4 @@ public record OAuthClaims(
boolean emailVerified,
String providerLogin,
Map<String, Object> extra
) {}
) implements IdentityClaims {}

View file

@ -27,6 +27,7 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"),
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"),
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"),
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/cas/**"),
RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"),
RouteAuthorizationPolicy.permitAll(null, "/actuator/health"),
RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"),

View file

@ -0,0 +1,190 @@
package com.iflytek.skillhub.auth.cas;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
@ExtendWith(MockitoExtension.class)
class CasLoginControllerTest {
@Mock
private CasTicketValidator ticketValidator;
@Mock
private IdentityBindingService identityBindingService;
@Mock
private PlatformSessionService sessionService;
private CasProperties casProperties;
private CasLoginController controller;
@BeforeEach
void setUp() {
casProperties = new CasProperties();
casProperties.setEnabled(true);
casProperties.setServerUrl("https://cas.example.com");
casProperties.setServiceUrl("https://skillhub.example.com/api/v1/auth/cas/callback");
casProperties.setProtocolVersion("3.0");
casProperties.setAllowInsecureServer(true);
controller = new CasLoginController(casProperties, ticketValidator, identityBindingService, sessionService);
}
@Test
void login_redirectsToCasServer() {
MockHttpServletRequest request = new MockHttpServletRequest();
String result = controller.login(null, request);
assertThat(result).startsWith("redirect:https://cas.example.com/login");
assertThat(result).contains("service=");
}
@Test
void login_storesReturnToInSession() {
MockHttpServletRequest request = new MockHttpServletRequest();
controller.login("/skills", request);
assertThat(request.getSession().getAttribute("skillhub.oauth.returnTo")).isEqualTo("/skills");
}
@Test
void login_rejectsInvalidReturnTo() {
MockHttpServletRequest request = new MockHttpServletRequest();
controller.login("https://evil.com", request);
assertThat(request.getSession(false)).isNull();
}
@Test
void login_whenDisabled_redirectsWithError() {
casProperties.setEnabled(false);
MockHttpServletRequest request = new MockHttpServletRequest();
String result = controller.login(null, request);
assertThat(result).isEqualTo("redirect:/login?error=cas_disabled");
}
@Test
void callback_successfulTicketValidation() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpSession session = new MockHttpSession();
session.setAttribute("skillhub.oauth.returnTo", "/dashboard");
request.setSession(session);
CasIdentityClaims claims = new CasIdentityClaims("zhangsan", "zhangsan@example.com", "Zhang San", Map.of());
PlatformPrincipal principal = new PlatformPrincipal("usr_123", "Zhang San", "zhangsan@example.com", null, "cas", Set.of("USER"));
when(ticketValidator.validate("ST-12345")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
String result = controller.callback("ST-12345", request);
assertThat(result).isEqualTo("redirect:/dashboard");
verify(sessionService).establishSession(eq(principal), eq(request));
}
@Test
void callback_usesDefaultTargetWhenNoReturnTo() {
MockHttpServletRequest request = new MockHttpServletRequest();
CasIdentityClaims claims = new CasIdentityClaims("user1", null, "User One", Map.of());
PlatformPrincipal principal = new PlatformPrincipal("usr_456", "User One", null, null, "cas", Set.of("USER"));
when(ticketValidator.validate("ST-99999")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
String result = controller.callback("ST-99999", request);
assertThat(result).isEqualTo("redirect:/dashboard");
}
@Test
void callback_missingTicket_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();
String result = controller.callback(null, request);
assertThat(result).isEqualTo("redirect:/login?error=missing_ticket");
verify(ticketValidator, never()).validate(any());
}
@Test
void callback_blankTicket_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();
String result = controller.callback(" ", request);
assertThat(result).isEqualTo("redirect:/login?error=missing_ticket");
}
@Test
void callback_whenDisabled_redirectsWithError() {
casProperties.setEnabled(false);
MockHttpServletRequest request = new MockHttpServletRequest();
String result = controller.callback("ST-12345", request);
assertThat(result).isEqualTo("redirect:/login?error=cas_disabled");
}
@Test
void callback_accountPending_redirectsToPendingApproval() {
MockHttpServletRequest request = new MockHttpServletRequest();
CasIdentityClaims claims = new CasIdentityClaims("pending-user", null, "Pending", Map.of());
when(ticketValidator.validate("ST-pending")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenThrow(new AccountPendingException());
String result = controller.callback("ST-pending", request);
assertThat(result).isEqualTo("redirect:/pending-approval");
}
@Test
void callback_accountDisabled_redirectsToAccessDenied() {
MockHttpServletRequest request = new MockHttpServletRequest();
CasIdentityClaims claims = new CasIdentityClaims("disabled-user", null, "Disabled", Map.of());
when(ticketValidator.validate("ST-disabled")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenThrow(new AccountDisabledException());
String result = controller.callback("ST-disabled", request);
assertThat(result).isEqualTo("redirect:/access-denied");
}
@Test
void callback_validationFailed_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();
when(ticketValidator.validate("ST-invalid")).thenThrow(new CasValidationException("Invalid ticket"));
String result = controller.callback("ST-invalid", request);
assertThat(result).isEqualTo("redirect:/login?error=cas_validation_failed");
}
}

View file

@ -0,0 +1,248 @@
package com.iflytek.skillhub.auth.cas;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestToUriTemplate;
import static org.hamcrest.Matchers.containsString;
class CasTicketValidatorTest {
private MockRestServiceServer mockServer;
private CasProperties casProperties;
private CasTicketValidator validator;
@BeforeEach
void setUp() {
casProperties = new CasProperties();
casProperties.setEnabled(true);
casProperties.setServerUrl("https://cas.example.com");
casProperties.setServiceUrl("https://skillhub.example.com/api/v1/auth/cas/callback");
casProperties.setProtocolVersion("3.0");
casProperties.setAllowInsecureServer(true);
Map<String, String> attributes = new HashMap<>();
attributes.put("username", "uid");
attributes.put("display-name", "cn");
attributes.put("email", "mail");
casProperties.setAttributes(attributes);
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
RestClient restClient = builder.build();
validator = new CasTicketValidator(casProperties, new ObjectMapper(), restClient);
}
@Test
void validate_cas30_json_success() {
String jsonResponse = """
{
"serviceResponse": {
"authenticationSuccess": {
"user": "zhangsan",
"attributes": {
"uid": "zhangsan",
"cn": "Zhang San",
"mail": "zhangsan@example.com",
"department": "Engineering"
}
}
}
}
""";
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
CasIdentityClaims claims = validator.validate("ST-12345");
assertThat(claims.subject()).isEqualTo("zhangsan");
assertThat(claims.providerLogin()).isEqualTo("Zhang San");
assertThat(claims.email()).isEqualTo("zhangsan@example.com");
assertThat(claims.provider()).isEqualTo("cas");
assertThat(claims.extra()).containsEntry("department", "Engineering");
mockServer.verify();
}
@Test
void validate_cas30_json_authenticationFailure() {
String jsonResponse = """
{
"serviceResponse": {
"authenticationFailure": {
"code": "INVALID_TICKET",
"description": "Ticket ST-expired has expired"
}
}
}
""";
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> validator.validate("ST-expired"))
.isInstanceOf(CasValidationException.class)
.hasMessageContaining("INVALID_TICKET");
mockServer.verify();
}
@Test
void validate_cas20_xml_success() {
casProperties.setProtocolVersion("2.0");
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
validator = new CasTicketValidator(casProperties, new ObjectMapper(), builder.build());
String xmlResponse = """
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationSuccess>
<cas:user>lisi</cas:user>
<cas:attributes>
<cas:uid>lisi</cas:uid>
<cas:cn>Li Si</cas:cn>
<cas:mail>lisi@example.com</cas:mail>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
""";
mockServer.expect(requestTo(containsString("/serviceValidate")))
.andRespond(withSuccess(xmlResponse, MediaType.APPLICATION_XML));
CasIdentityClaims claims = validator.validate("ST-67890");
assertThat(claims.subject()).isEqualTo("lisi");
assertThat(claims.providerLogin()).isEqualTo("Li Si");
assertThat(claims.email()).isEqualTo("lisi@example.com");
assertThat(claims.provider()).isEqualTo("cas");
mockServer.verify();
}
@Test
void validate_cas20_xml_authenticationFailure() {
casProperties.setProtocolVersion("2.0");
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
validator = new CasTicketValidator(casProperties, new ObjectMapper(), builder.build());
String xmlResponse = """
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationFailure code="INVALID_SERVICE">
Service not recognized
</cas:authenticationFailure>
</cas:serviceResponse>
""";
mockServer.expect(requestTo(containsString("/serviceValidate")))
.andRespond(withSuccess(xmlResponse, MediaType.APPLICATION_XML));
assertThatThrownBy(() -> validator.validate("ST-bad"))
.isInstanceOf(CasValidationException.class)
.hasMessageContaining("INVALID_SERVICE");
mockServer.verify();
}
@Test
void validate_cas30_json_missingUser() {
String jsonResponse = """
{
"serviceResponse": {
"authenticationSuccess": {
"attributes": {
"uid": "someone"
}
}
}
}
""";
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> validator.validate("ST-nouser"))
.isInstanceOf(CasValidationException.class)
.hasMessageContaining("missing user identifier");
mockServer.verify();
}
@Test
void validate_whenDisabled_throwsIllegalState() {
casProperties.setEnabled(false);
assertThatThrownBy(() -> validator.validate("ST-any"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not enabled");
}
@Test
void validate_cas30_json_arrayAttributes() {
String jsonResponse = """
{
"serviceResponse": {
"authenticationSuccess": {
"user": "wangwu",
"attributes": {
"uid": ["wangwu"],
"cn": ["Wang Wu"],
"mail": ["wangwu@example.com"],
"memberOf": ["group1", "group2"]
}
}
}
}
""";
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
CasIdentityClaims claims = validator.validate("ST-array");
assertThat(claims.subject()).isEqualTo("wangwu");
assertThat(claims.providerLogin()).isEqualTo("Wang Wu");
assertThat(claims.email()).isEqualTo("wangwu@example.com");
mockServer.verify();
}
@Test
void validate_fallsBackToUserWhenAttributesMissing() {
String jsonResponse = """
{
"serviceResponse": {
"authenticationSuccess": {
"user": "fallback-user",
"attributes": {}
}
}
}
""";
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
CasIdentityClaims claims = validator.validate("ST-noattrs");
assertThat(claims.subject()).isEqualTo("fallback-user");
assertThat(claims.providerLogin()).isEqualTo("fallback-user");
assertThat(claims.email()).isNull();
mockServer.verify();
}
}

View file

@ -15,9 +15,10 @@ set -eu
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED:=false}"
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER:=}"
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO:=false}"
: "${SKILLHUB_WEB_AUTH_CAS_ENABLED:=false}"
# Generate runtime-config.js
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED} ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}' \
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED} ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO} ${SKILLHUB_WEB_AUTH_CAS_ENABLED}' \
< /usr/share/nginx/html/runtime-config.js.template \
> /usr/share/nginx/html/runtime-config.js

5
web/public/cas-logo.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<circle cx="12" cy="16" r="1"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

View file

@ -5,5 +5,6 @@ window.__SKILLHUB_RUNTIME_CONFIG__ = {
authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}",
authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}",
authSessionBootstrapProvider: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER}",
authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}"
authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}",
authCasEnabled: "${SKILLHUB_WEB_AUTH_CAS_ENABLED}"
};

View file

@ -63,6 +63,7 @@ type RuntimeConfig = {
authSessionBootstrapEnabled?: string
authSessionBootstrapProvider?: string
authSessionBootstrapAuto?: string
authCasEnabled?: string
}
declare global {
@ -163,6 +164,17 @@ export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfi
}
}
export type CasAuthRuntimeConfig = {
enabled: boolean
}
export function getCasAuthRuntimeConfig(): CasAuthRuntimeConfig {
const config = getRuntimeConfig()
return {
enabled: parseBooleanFlag(config.authCasEnabled),
}
}
type ApiEnvelope<T> = {
code: number
msg: string

View file

@ -26,6 +26,7 @@ function ensureRuntimeConfigFallback() {
authSessionBootstrapEnabled: 'false',
authSessionBootstrapProvider: '',
authSessionBootstrapAuto: 'false',
authCasEnabled: 'false',
}
}
}

View file

@ -27,7 +27,9 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
const { t } = useTranslation()
const { data, isLoading } = useAuthMethods(returnTo)
const providers = (data ?? []).filter((method) => method.methodType === 'OAUTH_REDIRECT')
const providers = (data ?? []).filter(
(method) => method.methodType === 'OAUTH_REDIRECT' || method.methodType === 'CAS_REDIRECT',
)
if (isLoading) {
return (