mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-11 22:51:04 +00:00
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:
parent
1cf2e481ee
commit
72ca98552e
20 changed files with 1020 additions and 8 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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/**"),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
5
web/public/cas-logo.svg
Normal 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 |
|
|
@ -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}"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ function ensureRuntimeConfigFallback() {
|
|||
authSessionBootstrapEnabled: 'false',
|
||||
authSessionBootstrapProvider: '',
|
||||
authSessionBootstrapAuto: 'false',
|
||||
authCasEnabled: 'false',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue