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 <yhd4711499@live.com>
This commit is contained in:
yhd 2026-08-14 22:13:13 +08:00
parent 94baeb5544
commit 1049a267fb
10 changed files with 53 additions and 15 deletions

View file

@ -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<AuthMeResponse> 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);

View file

@ -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}

View file

@ -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}

View file

@ -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) {

View file

@ -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

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}",
registrationEnabled: "${SKILLHUB_WEB_REGISTRATION_ENABLED}"
};

View file

@ -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<T> = {
code: number
msg: string

View file

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

View file

@ -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() {
</Link>
</p>
<p className="text-center text-sm text-muted-foreground">
{t('login.noAccount')}
{' '}
<Link
to="/register"
search={{ returnTo }}
className="font-medium text-primary hover:underline"
>
{t('login.register')}
</Link>
{isLocalRegistrationEnabled() ? (
<>
{t('login.noAccount')}
{' '}
<Link
to="/register"
search={{ returnTo }}
className="font-medium text-primary hover:underline"
>
{t('login.register')}
</Link>
</>
) : null}
</p>
</form>
</TabsContent>

View file

@ -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 <Navigate to="/login" search={{ returnTo }} />
}
function validateUsername(value: string) {
const trimmed = value.trim()
if (!trimmed) {