Enhance MFA login flows (#372)

This commit is contained in:
shenlan 2025-10-02 15:34:02 +08:00 committed by GitHub
parent 3031be586e
commit 6884baa9e0
3 changed files with 70 additions and 15 deletions

View File

@ -13,6 +13,8 @@ type AccountLoginResponse = {
message?: string
}
type LoginMode = 'password_totp' | 'email_totp'
async function authenticateWithAccountService(payload: Record<string, unknown>) {
try {
const response = await fetch(`${ACCOUNT_SERVICE_URL}/api/auth/login`, {
@ -112,11 +114,16 @@ async function extractCredentials(request: NextRequest) {
const body = (await request.json().catch(() => ({}))) as Partial<CredentialPayload> & {
remember?: boolean
}
const loginMode = normalizeLoginMode(body?.loginMode)
const totpCode = String(body?.totpCode ?? '').replace(/\D/g, '').slice(0, 6)
const passwordInput = String(body?.password ?? '').trim()
const password = loginMode === 'password_totp' ? passwordInput : ''
return {
credentials: {
identifier: normalizeIdentifier(body),
password: String(body?.password ?? ''),
totpCode: String(body?.totpCode ?? '').trim(),
password: loginMode === 'password_totp' ? password : undefined,
totpCode,
loginMode,
},
remember: Boolean(body?.remember),
}
@ -128,11 +135,18 @@ async function extractCredentials(request: NextRequest) {
username: formData.get('username'),
email: formData.get('email'),
})
const password = String(formData.get('password') ?? '')
const totpCode = String(formData.get('totpCode') ?? '').trim()
const loginMode = normalizeLoginMode(formData.get('login-mode'))
const passwordValue = String(formData.get('password') ?? '').trim()
const password = loginMode === 'password_totp' ? passwordValue : ''
const totpCode = String(formData.get('totpCode') ?? '').replace(/\D/g, '').slice(0, 6)
const remember = formData.get('remember') === 'on'
return {
credentials: { identifier, password, totpCode },
credentials: {
identifier,
password: loginMode === 'password_totp' ? password : undefined,
totpCode,
loginMode,
},
remember,
}
}
@ -150,6 +164,8 @@ function handleErrorResponse(
credentials_in_query: 400,
mfa_setup_required: 401,
mfa_code_required: 400,
password_required: 401,
mfa_challenge_failed: 500,
}
return NextResponse.json(
{
@ -172,6 +188,7 @@ type CredentialPayload = {
password?: string
totpCode?: string
remember?: boolean
loginMode?: LoginMode | string | null
}
function normalizeIdentifier(payload: Partial<CredentialPayload>) {
@ -181,3 +198,10 @@ function normalizeIdentifier(payload: Partial<CredentialPayload>) {
String(payload?.email ?? '').trim()
return candidate
}
function normalizeLoginMode(value: unknown): LoginMode {
if (value === 'email_totp') {
return 'email_totp'
}
return 'password_totp'
}

View File

@ -35,8 +35,20 @@ export function LoginForm() {
setError(pageCopy.missingPassword)
return
}
if (!totpCode.trim()) {
setError(pageCopy.missingTotp ?? authCopy.alerts.mfa.missing)
const sanitizedTotp = totpCode.replace(/\D/g, '')
if (!sanitizedTotp) {
setError(pageCopy.missingTotp ?? authCopy.alerts.mfa?.missing ?? authCopy.alerts.missingCredentials)
return
}
if (sanitizedTotp.length !== 6) {
setError(
authCopy.alerts.mfa?.invalidFormat ??
authCopy.alerts.mfa?.invalid ??
pageCopy.missingTotp ??
authCopy.alerts.missingCredentials,
)
return
}
@ -52,8 +64,9 @@ export function LoginForm() {
body: JSON.stringify({
identifier: trimmedIdentifier,
password: loginMode === 'password_totp' ? password : undefined,
totpCode: totpCode.trim(),
totpCode: sanitizedTotp,
remember,
loginMode,
}),
credentials: 'include',
})
@ -71,11 +84,14 @@ export function LoginForm() {
case 'user_not_found':
setError(pageCopy.userNotFound)
break
case 'password_required':
setError(authCopy.alerts.passwordRequired ?? pageCopy.missingPassword)
break
case 'mfa_code_required':
setError(authCopy.alerts.mfa.missing)
setError(authCopy.alerts.mfa?.missing ?? pageCopy.missingTotp ?? authCopy.alerts.missingCredentials)
break
case 'invalid_mfa_code':
setError(authCopy.alerts.mfa.invalid)
setError(authCopy.alerts.mfa?.invalid ?? pageCopy.genericError)
break
case 'mfa_setup_required':
if (typeof window !== 'undefined' && typeof payload.mfaToken === 'string') {
@ -84,6 +100,9 @@ export function LoginForm() {
router.replace('/panel/account?setupMfa=1')
router.refresh()
return
case 'mfa_challenge_failed':
setError(authCopy.alerts.mfa?.challengeFailed ?? pageCopy.genericError)
break
default:
setError(pageCopy.genericError)
break
@ -212,7 +231,10 @@ export function LoginForm() {
inputMode="numeric"
pattern="[0-9]*"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
onChange={(event) => {
const digits = event.target.value.replace(/\D/g, '').slice(0, 6)
setTotpCode(digits)
}}
placeholder={authCopy.form.mfa.codePlaceholder}
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-gray-900 shadow-sm transition focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-200"
/>

View File

@ -105,10 +105,13 @@ type AuthLoginAlerts = {
invalidCredentials: string
userNotFound?: string
genericError: string
passwordRequired?: string
mfa?: {
missing: string
invalid: string
invalidFormat?: string
setupRequired?: string
challengeFailed?: string
}
}
@ -402,7 +405,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
success: 'Welcome back, {username}! 🎉',
goHome: 'Return to homepage',
missingUsername: 'Please enter a username to continue.',
missingPassword: 'Please enter your password to continue.',
missingPassword: 'Please enter your password or switch to email + authenticator mode.',
missingTotp: 'Enter the verification code from your authenticator app.',
invalidCredentials: 'Incorrect username or password. Please try again.',
userNotFound: 'We could not find an account with that username.',
@ -588,14 +591,17 @@ export const translations: Record<'en' | 'zh', Translation> = {
},
alerts: {
registered: 'Registration complete. Sign in to continue.',
missingCredentials: 'Please provide both your username and password.',
missingCredentials: 'Enter your username or email and the authenticator code to continue.',
invalidCredentials: 'Incorrect username or password. Please try again.',
userNotFound: 'We could not find an account with that username.',
genericError: 'We could not sign you in. Please try again later.',
passwordRequired: 'Enter your password when signing in with a username.',
mfa: {
missing: 'Enter the verification code from your authenticator app.',
invalid: 'The verification code is not valid. Try again.',
invalidFormat: 'Enter the 6-digit code from your authenticator app.',
setupRequired: 'Multi-factor authentication must be completed before accessing the console.',
challengeFailed: 'We could not prepare the multi-factor challenge. Try again later.',
},
},
},
@ -747,7 +753,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
success: '{username},欢迎回来!🎉',
goHome: '返回首页',
missingUsername: '请输入用户名后再尝试登录。',
missingPassword: '请输入密码后继续。',
missingPassword: '请输入密码,或切换为“邮箱 + 动态口令”模式。',
missingTotp: '请输入动态验证码完成登录。',
invalidCredentials: '用户名或密码不正确,请重试。',
userNotFound: '未找到该用户名对应的账户。',
@ -918,14 +924,17 @@ export const translations: Record<'en' | 'zh', Translation> = {
},
alerts: {
registered: '注册成功,请登录后继续。',
missingCredentials: '请输入用户名和密码。',
missingCredentials: '请输入用户名或邮箱,并填写动态验证码。',
invalidCredentials: '用户名或密码错误,请重试。',
userNotFound: '未找到该用户名对应的账户。',
genericError: '暂时无法登录,请稍后再试。',
passwordRequired: '使用用户名登录时需要输入密码。',
mfa: {
missing: '请输入动态验证码。',
invalid: '动态验证码不正确,请重试。',
invalidFormat: '请输入认证器生成的 6 位数字验证码。',
setupRequired: '请先完成多因素认证绑定后再访问控制台。',
challengeFailed: '暂时无法发起多因素验证,请稍后再试。',
},
},
},