mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
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:
parent
098616dcb6
commit
eb8dc031e7
16 changed files with 403 additions and 2 deletions
|
|
@ -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_REGISTRATION_OIDC_CLIENT_NAME=OIDC
|
||||||
SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI=
|
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.
|
# SMTP configuration for password reset verification emails.
|
||||||
SPRING_MAIL_HOST=
|
SPRING_MAIL_HOST=
|
||||||
SPRING_MAIL_PORT=587
|
SPRING_MAIL_PORT=587
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ public class RouteSecurityPolicyRegistry {
|
||||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"),
|
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/direct/login"),
|
||||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"),
|
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/local/**"),
|
||||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"),
|
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/device/**"),
|
||||||
|
RouteAuthorizationPolicy.permitAll(null, "/api/v1/auth/sso/**"),
|
||||||
RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"),
|
RouteAuthorizationPolicy.permitAll(null, "/api/v1/check"),
|
||||||
RouteAuthorizationPolicy.permitAll(null, "/actuator/health"),
|
RouteAuthorizationPolicy.permitAll(null, "/actuator/health"),
|
||||||
RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"),
|
RouteAuthorizationPolicy.permitAll(null, "/v3/api-docs/**"),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,15 @@ set -eu
|
||||||
|
|
||||||
: "${SKILLHUB_WEB_API_BASE_URL:=}"
|
: "${SKILLHUB_WEB_API_BASE_URL:=}"
|
||||||
: "${SKILLHUB_PUBLIC_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
|
# 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.template \
|
||||||
> /usr/share/nginx/html/runtime-config.js
|
> /usr/share/nginx/html/runtime-config.js
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,6 @@ window.__SKILLHUB_RUNTIME_CONFIG__ = {
|
||||||
authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}",
|
authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}",
|
||||||
authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}",
|
authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}",
|
||||||
authSessionBootstrapProvider: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER}",
|
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}"
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ type RuntimeConfig = {
|
||||||
authSessionBootstrapEnabled?: string
|
authSessionBootstrapEnabled?: string
|
||||||
authSessionBootstrapProvider?: string
|
authSessionBootstrapProvider?: string
|
||||||
authSessionBootstrapAuto?: string
|
authSessionBootstrapAuto?: string
|
||||||
|
authSsoEnabled?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
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> = {
|
type ApiEnvelope<T> = {
|
||||||
code: number
|
code: number
|
||||||
msg: string
|
msg: string
|
||||||
|
|
|
||||||
29
web/src/features/auth/sso-login-entry.tsx
Normal file
29
web/src/features/auth/sso-login-entry.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"enterpriseSsoAutoHint": "This deployment has automatic {{name}} probing enabled. If it does not succeed, you can continue with the standard login methods.",
|
||||||
"enterpriseSsoAction": "Try {{name}}",
|
"enterpriseSsoAction": "Try {{name}}",
|
||||||
"enterpriseSsoSubmitting": "Trying {{name}}...",
|
"enterpriseSsoSubmitting": "Trying {{name}}...",
|
||||||
|
"ssoLogin": "Enterprise SSO Login",
|
||||||
"agreementPrefix": "By logging in, you agree to our",
|
"agreementPrefix": "By logging in, you agree to our",
|
||||||
"terms": "Terms of Service",
|
"terms": "Terms of Service",
|
||||||
"and": "and",
|
"and": "and",
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@
|
||||||
"enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。",
|
"enterpriseSsoAutoHint": "当前部署已启用自动 {{name}} 探测。若未成功,你仍可继续使用现有登录方式。",
|
||||||
"enterpriseSsoAction": "尝试 {{name}} 登录",
|
"enterpriseSsoAction": "尝试 {{name}} 登录",
|
||||||
"enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...",
|
"enterpriseSsoSubmitting": "正在尝试 {{name}} 登录...",
|
||||||
|
"ssoLogin": "企业 SSO 登录",
|
||||||
"agreementPrefix": "登录即表示你同意我们的",
|
"agreementPrefix": "登录即表示你同意我们的",
|
||||||
"terms": "服务条款",
|
"terms": "服务条款",
|
||||||
"and": "和",
|
"and": "和",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ vi.mock('lucide-react', () => ({
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
|
getDirectAuthRuntimeConfig: () => ({ enabled: false }),
|
||||||
|
getSsoRuntimeConfig: () => ({ enabled: false }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/features/auth/login-button', () => ({
|
vi.mock('@/features/auth/login-button', () => ({
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { getDirectAuthRuntimeConfig } from '@/api/client'
|
import { getDirectAuthRuntimeConfig } from '@/api/client'
|
||||||
import { LoginButton } from '@/features/auth/login-button'
|
import { LoginButton } from '@/features/auth/login-button'
|
||||||
import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry'
|
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 { useAuthMethods } from '@/features/auth/use-auth-methods'
|
||||||
import { usePasswordLogin } from '@/features/auth/use-password-login'
|
import { usePasswordLogin } from '@/features/auth/use-password-login'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
|
|
@ -88,6 +89,8 @@ export function LoginPage() {
|
||||||
onAuthenticated={() => navigate({ to: returnTo })}
|
onAuthenticated={() => navigate({ to: returnTo })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SsoLoginEntry />
|
||||||
|
|
||||||
<Tabs defaultValue="password" className="space-y-6">
|
<Tabs defaultValue="password" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="password">{t('login.tabPassword')}</TabsTrigger>
|
<TabsTrigger value="password">{t('login.tabPassword')}</TabsTrigger>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue