From 72ca98552eea010c1157721d7a0cd8e8fcd1b31e Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 27 May 2026 12:43:43 +0800 Subject: [PATCH] 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 --- .../skillhub/service/AuthMethodCatalog.java | 22 ++ .../src/main/resources/application.yml | 10 + .../service/AuthMethodCatalogTest.java | 3 + .../skillhub/auth/cas/CasIdentityClaims.java | 28 ++ .../skillhub/auth/cas/CasLoginController.java | 124 +++++++++ .../skillhub/auth/cas/CasProperties.java | 107 ++++++++ .../skillhub/auth/cas/CasTicketValidator.java | 201 ++++++++++++++ .../auth/cas/CasValidationException.java | 15 ++ .../auth/identity/IdentityBindingService.java | 7 +- .../auth/identity/IdentityClaims.java | 41 +++ .../skillhub/auth/oauth/OAuthClaims.java | 3 +- .../policy/RouteSecurityPolicyRegistry.java | 1 + .../auth/cas/CasLoginControllerTest.java | 190 ++++++++++++++ .../auth/cas/CasTicketValidatorTest.java | 248 ++++++++++++++++++ web/docker-entrypoint.d/30-runtime-config.sh | 3 +- web/public/cas-logo.svg | 5 + web/runtime-config.js.template | 3 +- web/src/api/client.ts | 12 + web/src/bootstrap.ts | 1 + web/src/features/auth/login-button.tsx | 4 +- 20 files changed, 1020 insertions(+), 8 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasIdentityClaims.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasLoginController.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProperties.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasTicketValidator.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasValidationException.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityClaims.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasLoginControllerTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasTicketValidatorTest.java create mode 100644 web/public/cas-logo.svg diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java index 84324f18..7ec24d78 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AuthMethodCatalog.java @@ -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 directAuthProviders; private final List passiveSessionAuthenticators; public AuthMethodCatalog(OAuth2ClientProperties oAuth2ClientProperties, DirectAuthProperties directAuthProperties, AuthSessionBootstrapProperties sessionBootstrapProperties, + CasProperties casProperties, List directAuthProviders, List 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); + } } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index a592b035..ddc9ad73 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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: diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java index 35ca8d75..145cc6d5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AuthMethodCatalogTest.java @@ -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) ); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasIdentityClaims.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasIdentityClaims.java new file mode 100644 index 00000000..83f30a42 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasIdentityClaims.java @@ -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 extra +) implements IdentityClaims { + + public static final String PROVIDER = "cas"; + + @Override + public String provider() { + return PROVIDER; + } + + @Override + public boolean emailVerified() { + return email != null && !email.isBlank(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasLoginController.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasLoginController.java new file mode 100644 index 00000000..2dcd01c6 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasLoginController.java @@ -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"; + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProperties.java new file mode 100644 index 00000000..0c1102f1 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasProperties.java @@ -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 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 getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasTicketValidator.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasTicketValidator.java new file mode 100644 index 00000000..713dae89 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasTicketValidator.java @@ -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 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 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 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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasValidationException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasValidationException.java new file mode 100644 index 00000000..43ca0e57 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/cas/CasValidationException.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index 2a4fae8b..d6696712 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -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); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityClaims.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityClaims.java new file mode 100644 index 00000000..144f7dd4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityClaims.java @@ -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 extra(); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java index 78dc9b80..f390fa37 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java @@ -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 extra -) {} +) implements IdentityClaims {} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 5ac6c1d1..fec78b58 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -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/**"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasLoginControllerTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasLoginControllerTest.java new file mode 100644 index 00000000..1560da96 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasLoginControllerTest.java @@ -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"); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasTicketValidatorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasTicketValidatorTest.java new file mode 100644 index 00000000..bdc13980 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/cas/CasTicketValidatorTest.java @@ -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 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 = """ + + + lisi + + lisi + Li Si + lisi@example.com + + + + """; + + 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 = """ + + + Service not recognized + + + """; + + 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(); + } +} diff --git a/web/docker-entrypoint.d/30-runtime-config.sh b/web/docker-entrypoint.d/30-runtime-config.sh index a8bf88a4..482849eb 100644 --- a/web/docker-entrypoint.d/30-runtime-config.sh +++ b/web/docker-entrypoint.d/30-runtime-config.sh @@ -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 diff --git a/web/public/cas-logo.svg b/web/public/cas-logo.svg new file mode 100644 index 00000000..da8cb81a --- /dev/null +++ b/web/public/cas-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/runtime-config.js.template b/web/runtime-config.js.template index 1375a380..de67ab94 100644 --- a/web/runtime-config.js.template +++ b/web/runtime-config.js.template @@ -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}" }; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56a..a9b3a661 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 = { code: number msg: string diff --git a/web/src/bootstrap.ts b/web/src/bootstrap.ts index 203d7ebb..cc6414b3 100644 --- a/web/src/bootstrap.ts +++ b/web/src/bootstrap.ts @@ -26,6 +26,7 @@ function ensureRuntimeConfigFallback() { authSessionBootstrapEnabled: 'false', authSessionBootstrapProvider: '', authSessionBootstrapAuto: 'false', + authCasEnabled: 'false', } } } diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index cde4453f..6a5e0cd5 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -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 (