From 1049a267fbd3bfdbdddfc3bda539499ade409f60 Mon Sep 17 00:00:00 2001 From: yhd Date: Fri, 14 Aug 2026 22:13:13 +0800 Subject: [PATCH] feat(auth): add local registration toggle and OAuth failure logging Deployments that only allow third-party sign-up can now disable local registration end to end: the register endpoint returns 403 when skillhub.auth.local.registration-enabled is false, and the web UI hides the entry when SKILLHUB_WEB_REGISTRATION_ENABLED is false. Also log OAuth2 login failures so callback errors are diagnosable from server logs. Fixes the runtime-config entrypoint so registration defaults are actually exported to envsubst. Signed-off-by: yhd --- .../controller/LocalAuthController.java | 10 +++++++- .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../auth/oauth/OAuth2LoginFailureHandler.java | 5 ++++ web/docker-entrypoint.d/30-runtime-config.sh | 6 ++++- web/runtime-config.js.template | 3 ++- web/src/api/client.ts | 9 +++++++ web/src/pages/login.test.tsx | 1 + web/src/pages/login.tsx | 24 +++++++++++-------- web/src/pages/register.tsx | 8 +++++-- 10 files changed, 53 insertions(+), 15 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 17e54fbe..fdf31f2b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.dto.LocalLoginRequest; import com.iflytek.skillhub.dto.LocalRegisterRequest; import com.iflytek.skillhub.dto.PasswordResetConfirmRequest; import com.iflytek.skillhub.dto.PasswordResetRequestDto; +import com.iflytek.skillhub.exception.ForbiddenException; import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; @@ -20,6 +21,7 @@ import com.iflytek.skillhub.security.AuthFailureThrottleService; import com.iflytek.skillhub.service.AuthMeResponseAssembler; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; @@ -40,6 +42,7 @@ public class LocalAuthController extends BaseApiController { private final AuthFailureThrottleService authFailureThrottleService; private final PasswordResetService passwordResetService; private final AuthMeResponseAssembler authMeResponseAssembler; + private final boolean registrationEnabled; public LocalAuthController(ApiResponseFactory responseFactory, LocalAuthService localAuthService, @@ -47,7 +50,8 @@ public class LocalAuthController extends BaseApiController { PlatformSessionService platformSessionService, AuthFailureThrottleService authFailureThrottleService, PasswordResetService passwordResetService, - AuthMeResponseAssembler authMeResponseAssembler) { + AuthMeResponseAssembler authMeResponseAssembler, + @Value("${skillhub.auth.local.registration-enabled:true}") boolean registrationEnabled) { super(responseFactory); this.localAuthService = localAuthService; this.skillHubMetrics = skillHubMetrics; @@ -55,12 +59,16 @@ public class LocalAuthController extends BaseApiController { this.authFailureThrottleService = authFailureThrottleService; this.passwordResetService = passwordResetService; this.authMeResponseAssembler = authMeResponseAssembler; + this.registrationEnabled = registrationEnabled; } @PostMapping("/register") @RateLimit(category = "auth-register", authenticated = 10, anonymous = 5, windowSeconds = 300) public ApiResponse register(@Valid @RequestBody LocalRegisterRequest request, HttpServletRequest httpRequest) { + if (!registrationEnabled) { + throw new ForbiddenException("error.auth.local.registration.disabled"); + } PlatformPrincipal principal = localAuthService.register(request.username(), request.password(), request.email()); skillHubMetrics.incrementUserRegister(); platformSessionService.establishSession(principal, httpRequest); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8791e6be..c919c863 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -40,6 +40,7 @@ error.auth.local.accountDisabled=This account has been disabled error.auth.local.accountPending=This account is pending activation error.auth.local.accountMerged=This account has been merged and can no longer be used to log in error.auth.local.locked=Too many failed attempts. Please try again in {0} minute(s) +error.auth.local.registration.disabled=Local registration is disabled. Please sign in with an authorized third-party account. error.auth.login.throttled=Too many login attempts. Please try again in {0} minute(s) error.auth.direct.disabled=Direct authentication compatibility is disabled error.auth.direct.providerUnsupported=Unsupported direct authentication provider: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 0e1b3fc3..54400763 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -40,6 +40,7 @@ error.auth.local.accountDisabled=该账号已被禁用 error.auth.local.accountPending=该账号尚未激活 error.auth.local.accountMerged=该账号已合并,不能再用于登录 error.auth.local.locked=连续失败次数过多,请在 {0} 分钟后重试 +error.auth.local.registration.disabled=本地注册已关闭,请使用授权的第三方账号登录 error.auth.login.throttled=登录尝试过于频繁,请在 {0} 分钟后重试 error.auth.direct.disabled=直连认证兼容层未启用 error.auth.direct.providerUnsupported=不支持的直连认证提供方:{0} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 14beac75..66c589ad 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.auth.oauth; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; @@ -16,6 +18,8 @@ import java.io.IOException; @Component public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { + private static final Logger log = LoggerFactory.getLogger(OAuth2LoginFailureHandler.class); + private final OAuthLoginFlowService oauthLoginFlowService; public OAuth2LoginFailureHandler(OAuthLoginFlowService oauthLoginFlowService) { @@ -26,6 +30,7 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + log.error("OAuth2 login failed [uri={}]", request.getRequestURI(), exception); String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false)); String redirectTarget = oauthLoginFlowService.resolveFailureRedirect(exception, returnTo); if (redirectTarget != null) { diff --git a/web/docker-entrypoint.d/30-runtime-config.sh b/web/docker-entrypoint.d/30-runtime-config.sh index a8bf88a4..ccbbf903 100644 --- a/web/docker-entrypoint.d/30-runtime-config.sh +++ b/web/docker-entrypoint.d/30-runtime-config.sh @@ -15,9 +15,13 @@ set -eu : "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED:=false}" : "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER:=}" : "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO:=false}" +# NB: `${VAR:=default}` only sets a shell variable, not an exported one, so +# envsubst would still substitute an empty string. Assign and export explicitly. +SKILLHUB_WEB_REGISTRATION_ENABLED="${SKILLHUB_WEB_REGISTRATION_ENABLED:-true}" +export SKILLHUB_WEB_REGISTRATION_ENABLED # 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_REGISTRATION_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 1375a380..f7d45322 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}", + registrationEnabled: "${SKILLHUB_WEB_REGISTRATION_ENABLED}" }; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5e4c7756..1819423e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -69,6 +69,7 @@ type RuntimeConfig = { authSessionBootstrapEnabled?: string authSessionBootstrapProvider?: string authSessionBootstrapAuto?: string + registrationEnabled?: string } declare global { @@ -182,6 +183,14 @@ export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfi } } +export function isLocalRegistrationEnabled(): boolean { + const value = getRuntimeConfig().registrationEnabled + if (value === undefined || value.trim() === '') { + return true + } + return parseBooleanFlag(value) +} + type ApiEnvelope = { code: number msg: string diff --git a/web/src/pages/login.test.tsx b/web/src/pages/login.test.tsx index dacfa477..83914477 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 }), + isLocalRegistrationEnabled: () => true, })) vi.mock('@/features/auth/login-button', () => ({ diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 1aab99c4..5e424a75 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -2,7 +2,7 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Eye, EyeOff } from 'lucide-react' -import { getDirectAuthRuntimeConfig } from '@/api/client' +import { getDirectAuthRuntimeConfig, isLocalRegistrationEnabled } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry' import { useAuthMethods } from '@/features/auth/use-auth-methods' @@ -166,15 +166,19 @@ export function LoginPage() {

- {t('login.noAccount')} - {' '} - - {t('login.register')} - + {isLocalRegistrationEnabled() ? ( + <> + {t('login.noAccount')} + {' '} + + {t('login.register')} + + + ) : null}

diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index a3a0aa6a..7d263731 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -1,7 +1,7 @@ -import { Link, useNavigate, useSearch } from '@tanstack/react-router' +import { Link, Navigate, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { ApiError } from '@/api/client' +import { ApiError, isLocalRegistrationEnabled } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { useLocalRegister } from '@/features/auth/use-local-auth' import { Button } from '@/shared/ui/button' @@ -63,6 +63,10 @@ export function RegisterPage() { const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + if (!isLocalRegistrationEnabled()) { + return + } + function validateUsername(value: string) { const trimmed = value.trim() if (!trimmed) {