Enhance MFA login flows (#372)
This commit is contained in:
parent
3031be586e
commit
6884baa9e0
@ -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'
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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: '暂时无法发起多因素验证,请稍后再试。',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user