diff --git a/.env.release.example b/.env.release.example index a6eadf21c..0ad0843f2 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/SsoLoginController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/SsoLoginController.java new file mode 100644 index 000000000..733f59247 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/SsoLoginController.java @@ -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. + * + *

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"); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SsoProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SsoProperties.java new file mode 100644 index 000000000..2ccef5cb0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SsoProperties.java @@ -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; } + } +} 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 10a14e085..43df8d5a9 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/sso/**"), RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"), RouteAuthorizationPolicy.permitAll(null, "/actuator/health"), RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoClient.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoClient.java new file mode 100644 index 000000000..afa770d4a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoClient.java @@ -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 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) + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoIdentityService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoIdentityService.java new file mode 100644 index 000000000..0b59cfbf2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoIdentityService.java @@ -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 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 + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoUser.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoUser.java new file mode 100644 index 000000000..b64eee3d9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/SsoUser.java @@ -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) { +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/TicketValidationException.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/TicketValidationException.java new file mode 100644 index 000000000..32b8882a9 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/sso/TicketValidationException.java @@ -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); + } +} diff --git a/web/docker-entrypoint.d/30-runtime-config.sh b/web/docker-entrypoint.d/30-runtime-config.sh index 8e4720a31..ef8b20e93 100644 --- a/web/docker-entrypoint.d/30-runtime-config.sh +++ b/web/docker-entrypoint.d/30-runtime-config.sh @@ -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 diff --git a/web/runtime-config.js.template b/web/runtime-config.js.template index 1375a3805..02d201cbc 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}", + authSsoEnabled: "${SKILLHUB_WEB_AUTH_SSO_ENABLED}" }; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3204d56ac..4877e4eda 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 + 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 = { code: number msg: string diff --git a/web/src/features/auth/sso-login-entry.tsx b/web/src/features/auth/sso-login-entry.tsx new file mode 100644 index 000000000..c726644c0 --- /dev/null +++ b/web/src/features/auth/sso-login-entry.tsx @@ -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 ( + + ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 53f1197b5..7a14771f3 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 73029a99b..bf21466a0 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -224,6 +224,7 @@ "enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。", "enterpriseSsoAction": "尝试 {{name}} 登录", "enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...", + "ssoLogin": "企业 SSO 登录", "agreementPrefix": "登录即表示你同意我们的", "terms": "服务条款", "and": "和", diff --git a/web/src/pages/login.test.tsx b/web/src/pages/login.test.tsx index dacfa4779..4b74afacd 100644 --- a/web/src/pages/login.test.tsx +++ b/web/src/pages/login.test.tsx @@ -24,6 +24,7 @@ vi.mock('lucide-react', () => ({ vi.mock('@/api/client', () => ({ getDirectAuthRuntimeConfig: () => ({ enabled: false }), + getSsoRuntimeConfig: () => ({ enabled: false }), })) vi.mock('@/features/auth/login-button', () => ({ diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 1aab99c48..c4e3bc4b6 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -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 })} /> + + {t('login.tabPassword')}