feat(auth): add CAS-based SSO login support

Add backend CAS callback endpoints (redirect → validate ticket → establish
session), frontend SSO login button, and runtime config plumbing. All SSO
server URLs are externalized via SsoProperties with zero company-specific
defaults.

Also fix a pre-existing bug in web/docker-entrypoint.d/30-runtime-config.sh
where auth-related runtime config env vars were not being substituted into
runtime-config.js.
This commit is contained in:
jangrui 2026-05-16 03:27:15 +08:00
parent 098616dcb6
commit eb8dc031e7
16 changed files with 403 additions and 2 deletions

View file

@ -79,6 +79,18 @@ SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_SCOPE=openid,profile,email
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_OIDC_CLIENT_NAME=OIDC
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
# Optional: CAS-based SSO login (private deployments).
# After enabling, redirect users to /api/v1/auth/sso/login to initiate login.
# Register the application on the SSO admin console to obtain client-url and client-token.
# The response fields default to "account", "id", "name" — override if your SSO uses different JSON keys.
SKILLHUB_AUTH_SSO_ENABLED=false
SKILLHUB_AUTH_SSO_BASE_URL=
SKILLHUB_AUTH_SSO_VALIDATE_PATH=
SKILLHUB_AUTH_SSO_CLIENT_URL=
SKILLHUB_AUTH_SSO_CLIENT_TOKEN=
# Frontend runtime config: shows the "Enterprise SSO Login" button when enabled.
SKILLHUB_WEB_AUTH_SSO_ENABLED=false
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=
SPRING_MAIL_PORT=587

View file

@ -0,0 +1,94 @@
package com.iflytek.skillhub.controller;
import java.io.IOException;
import com.iflytek.skillhub.auth.config.SsoProperties;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.auth.sso.SsoClient;
import com.iflytek.skillhub.auth.sso.SsoIdentityService;
import com.iflytek.skillhub.auth.sso.SsoUser;
import com.iflytek.skillhub.auth.sso.TicketValidationException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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;
/**
* CAS-based SSO login controller.
*
* <p>Handles the redirect to the SSO server and the ticket-callback exchange
* that establishes a platform session on success.
*/
@Controller
@RequestMapping("/api/v1/auth/sso")
public class SsoLoginController {
private static final Logger log = LoggerFactory.getLogger(SsoLoginController.class);
private final SsoProperties properties;
private final SsoClient ssoClient;
private final SsoIdentityService ssoIdentityService;
private final PlatformSessionService platformSessionService;
public SsoLoginController(SsoProperties properties,
SsoClient ssoClient,
SsoIdentityService ssoIdentityService,
PlatformSessionService platformSessionService) {
this.properties = properties;
this.ssoClient = ssoClient;
this.ssoIdentityService = ssoIdentityService;
this.platformSessionService = platformSessionService;
}
/**
* Initiates SSO login by redirecting the browser to the SSO login page.
*/
@GetMapping("/login")
public void ssoLogin(HttpServletResponse response) throws IOException {
if (!properties.isEnabled()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
return;
}
String ssoLoginUrl = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl())
.path("/login")
.queryParam("clientUrl", properties.getClientUrl())
.build()
.toUriString();
response.sendRedirect(ssoLoginUrl);
}
/**
* Receives the CAS ticket callback from the SSO server, validates the
* ticket, establishes a platform session, and redirects the browser to the
* frontend home page.
*/
@GetMapping("/callback")
public void ssoCallback(@RequestParam("ticket") String ticket,
HttpServletRequest request,
HttpServletResponse response) throws IOException {
if (!properties.isEnabled()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "SSO login is disabled");
return;
}
try {
SsoUser ssoUser = ssoClient.validateTicket(ticket);
var principal = ssoIdentityService.resolveOrCreate(ssoUser);
platformSessionService.establishSession(principal, request);
response.sendRedirect("/");
} catch (TicketValidationException e) {
log.warn("SSO ticket validation failed: {}", e.getMessage());
response.sendRedirect("/login?error=sso_auth_failed");
} catch (Exception e) {
log.error("SSO callback error", e);
response.sendRedirect("/login?error=sso_error");
}
}
}

View file

@ -0,0 +1,60 @@
package com.iflytek.skillhub.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "skillhub.auth.sso")
public class SsoProperties {
/** Kept disabled in OSS by default. */
private boolean enabled = false;
/** SSO server base URL. */
private String baseUrl;
/** Ticket validation endpoint path on SSO server. */
private String validatePath;
/** Client URL registered in SSO (used as callback base). */
private String clientUrl;
/** Client token registered in SSO, used for logout API calls. */
private String clientToken;
/** Response field mapping for SSO user info JSON. */
private ResponseFields response = new ResponseFields();
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
public String getValidatePath() { return validatePath; }
public void setValidatePath(String validatePath) { this.validatePath = validatePath; }
public String getClientUrl() { return clientUrl; }
public void setClientUrl(String clientUrl) { this.clientUrl = clientUrl; }
public String getClientToken() { return clientToken; }
public void setClientToken(String clientToken) { this.clientToken = clientToken; }
public ResponseFields getResponse() { return response; }
public void setResponse(ResponseFields response) { this.response = response; }
public static class ResponseFields {
private String accountField = "account";
private String idField = "id";
private String nameField = "name";
public String getAccountField() { return accountField; }
public void setAccountField(String accountField) { this.accountField = accountField; }
public String getIdField() { return idField; }
public void setIdField(String idField) { this.idField = idField; }
public String getNameField() { return nameField; }
public void setNameField(String nameField) { this.nameField = nameField; }
}
}

View file

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

View file

@ -0,0 +1,64 @@
package com.iflytek.skillhub.auth.sso;
import java.util.Map;
import com.iflytek.skillhub.auth.config.SsoProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Client that validates a CAS service-ticket against the SSO server and
* returns the associated user identity.
*/
@Service
public class SsoClient {
private final SsoProperties properties;
private final RestTemplate restTemplate;
public SsoClient(SsoProperties properties, RestTemplateBuilder restTemplateBuilder) {
this.properties = properties;
this.restTemplate = restTemplateBuilder.build();
}
/**
* Validates a CAS service ticket and returns the resolved user identity,
* or throws {@link TicketValidationException} when the ticket is invalid
* or the SSO server is unreachable.
*/
public SsoUser validateTicket(String ticket) {
var request = Map.of(
"Ticket", ticket,
"Url", properties.getClientUrl()
);
var validateUrl = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl())
.path(properties.getValidatePath())
.build()
.toUriString();
@SuppressWarnings("unchecked")
Map<String, Object> response = restTemplate.postForObject(validateUrl, request, Map.class);
if (response == null || response.isEmpty()) {
throw new TicketValidationException("Empty response from ticket validation");
}
var fields = properties.getResponse();
Object account = response.get(fields.getAccountField());
Object id = response.get(fields.getIdField());
Object name = response.get(fields.getNameField());
if (account == null || id == null) {
throw new TicketValidationException(
"Ticket validation response missing required fields: "
+ fields.getAccountField() + ", " + fields.getIdField());
}
return new SsoUser(
String.valueOf(account),
String.valueOf(id),
name != null ? String.valueOf(name) : String.valueOf(account)
);
}
}

View file

@ -0,0 +1,90 @@
package com.iflytek.skillhub.auth.sso;
import java.util.Set;
import java.util.UUID;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Resolves SSO identities to platform users, creating or updating bindings and
* user records as needed.
*/
@Service
public class SsoIdentityService {
private static final String PROVIDER_CODE = "sso";
private final IdentityBindingRepository bindingRepo;
private final UserAccountRepository userRepo;
private final UserRoleBindingRepository roleBindingRepo;
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
public SsoIdentityService(IdentityBindingRepository bindingRepo,
UserAccountRepository userRepo,
UserRoleBindingRepository roleBindingRepo,
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
this.bindingRepo = bindingRepo;
this.userRepo = userRepo;
this.roleBindingRepo = roleBindingRepo;
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
}
/**
* Looks up or auto-creates a platform user for the given SSO identity and
* returns the corresponding {@link PlatformPrincipal}.
*/
@Transactional
public PlatformPrincipal resolveOrCreate(SsoUser ssoUser) {
IdentityBinding binding = bindingRepo
.findByProviderCodeAndSubject(PROVIDER_CODE, ssoUser.account())
.orElse(null);
UserAccount user;
if (binding != null) {
user = userRepo.findById(binding.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found for binding"));
user.setDisplayName(ssoUser.name());
user = userRepo.save(user);
} else {
user = new UserAccount(
"usr_" + UUID.randomUUID(),
ssoUser.name(),
null,
null
);
user.setStatus(UserStatus.ACTIVE);
user = userRepo.save(user);
globalNamespaceMembershipService.ensureMember(user.getId());
binding = new IdentityBinding(user.getId(), PROVIDER_CODE,
ssoUser.account(), ssoUser.account());
bindingRepo.save(binding);
}
if (user.getStatus() != UserStatus.ACTIVE) {
throw new IllegalStateException("User account is not active: " + user.getStatus());
}
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
.map(rb -> rb.getRole().getCode())
.collect(java.util.stream.Collectors.toSet());
roles = PlatformRoleDefaults.withDefaultUserRole(roles);
return new PlatformPrincipal(
user.getId(), user.getDisplayName(), user.getEmail(),
user.getAvatarUrl(), PROVIDER_CODE, roles
);
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.auth.sso;
/**
* Carrier for user-info returned by the SSO ticket-validation endpoint.
*
* @param account unique domain account / username
* @param id employee or user identifier
* @param name display name
*/
public record SsoUser(String account, String id, String name) {
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.auth.sso;
/**
* Thrown when the SSO ticket-validation endpoint rejects a ticket or returns
* an unexpected response.
*/
public class TicketValidationException extends RuntimeException {
public TicketValidationException(String message) {
super(message);
}
public TicketValidationException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -3,9 +3,15 @@ set -eu
: "${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_SSO_ENABLED:=}"
# Generate runtime-config.js
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL}' \
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_SSO_ENABLED}' \
< /usr/share/nginx/html/runtime-config.js.template \
> /usr/share/nginx/html/runtime-config.js

View file

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

View file

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

View file

@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import { getSsoRuntimeConfig } from '@/api/client'
import { Button } from '@/shared/ui/button'
/**
* Optional login entry that redirects the browser to the enterprise SSO login
* page, which follows the CAS protocol to authenticate and redirect back.
*/
export function SsoLoginEntry() {
const { t } = useTranslation()
const config = getSsoRuntimeConfig()
if (!config.enabled) {
return null
}
return (
<Button
className="w-full"
type="button"
variant="outline"
onClick={() => {
window.location.href = '/api/v1/auth/sso/login'
}}
>
{t('login.ssoLogin')}
</Button>
)
}

View file

@ -224,6 +224,7 @@
"enterpriseSsoAutoHint": "This deployment has automatic {{name}} probing enabled. If it does not succeed, you can continue with the standard login methods.",
"enterpriseSsoAction": "Try {{name}}",
"enterpriseSsoSubmitting": "Trying {{name}}...",
"ssoLogin": "Enterprise SSO Login",
"agreementPrefix": "By logging in, you agree to our",
"terms": "Terms of Service",
"and": "and",

View file

@ -224,6 +224,7 @@
"enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。",
"enterpriseSsoAction": "尝试 {{name}} 登录",
"enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...",
"ssoLogin": "企业 SSO 登录",
"agreementPrefix": "登录即表示你同意我们的",
"terms": "服务条款",
"and": "和",

View file

@ -24,6 +24,7 @@ vi.mock('lucide-react', () => ({
vi.mock('@/api/client', () => ({
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
getSsoRuntimeConfig: () => ({ enabled: false }),
}))
vi.mock('@/features/auth/login-button', () => ({

View file

@ -5,6 +5,7 @@ import { Eye, EyeOff } from 'lucide-react'
import { getDirectAuthRuntimeConfig } from '@/api/client'
import { LoginButton } from '@/features/auth/login-button'
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
import { SsoLoginEntry } from '@/features/auth/sso-login-entry'
import { useAuthMethods } from '@/features/auth/use-auth-methods'
import { usePasswordLogin } from '@/features/auth/use-password-login'
import { Button } from '@/shared/ui/button'
@ -88,6 +89,8 @@ export function LoginPage() {
onAuthenticated={() => navigate({ to: returnTo })}
/>
<SsoLoginEntry />
<Tabs defaultValue="password" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="password">{t('login.tabPassword')}</TabsTrigger>