mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 07:11:46 +00:00
feat(auth): add email-based password reset with MFA + session invalidation
Adds /auth/forgot-password and /auth/reset-password endpoints plus two new
client pages. When SMTP is configured the user receives a branded, i18n-aware
reset email; when it isn't the reset link is logged to the server console in
a clearly-fenced block so self-hosters can relay it manually.
Security properties:
- 256-bit cryptographically-random tokens, only SHA-256 hashes stored in DB
- 60 min expiry, single-use, prior unconsumed tokens auto-invalidated
- Enumeration-safe: /forgot-password always responds {ok:true} with a minimum
latency pad so timing doesn't leak account existence
- Per-IP rate limit (3/15min on forgot, 5/15min on reset) + per-email throttle
- If the user has MFA enabled, a valid TOTP or backup code is required at
reset-complete time — a compromised mailbox alone cannot take over a
2FA-protected account
- New users.password_version column + JWT "pv" claim: bumping it on reset
invalidates every live session immediately
- Full audit-log coverage (user.password_reset_request/_success/_fail)
- Forgot-page shows a visible hint when SMTP is unconfigured
Migration 115 adds users.password_version and password_reset_tokens
(user_id, token_hash UNIQUE, expires_at, consumed_at, created_ip).
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mail, ArrowLeft, CheckCircle2, Terminal } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
|
||||
const inputBase: React.CSSProperties = {
|
||||
width: '100%', padding: '11px 12px 11px 38px', borderRadius: 12,
|
||||
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
|
||||
outline: 'none', transition: 'border-color 120ms',
|
||||
background: 'white', color: '#111827',
|
||||
}
|
||||
|
||||
const ForgotPasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [smtpConfigured, setSmtpConfigured] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Probe whether SMTP is configured so we can warn the user up-front
|
||||
// that the link will land in the server console instead of their
|
||||
// inbox. Null while pending — hint is hidden until we know.
|
||||
authApi.getAppConfig?.()
|
||||
.then((cfg: any) => {
|
||||
const hasEmail = !!cfg?.available_channels?.email
|
||||
setSmtpConfigured(hasEmail)
|
||||
})
|
||||
.catch(() => setSmtpConfigured(null))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await authApi.forgotPassword({ email: email.trim() })
|
||||
} catch {
|
||||
// Enumeration-safe: success UX regardless of server outcome.
|
||||
}
|
||||
setSubmitted(true)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 420, background: 'white', borderRadius: 20,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
padding: '32px 28px',
|
||||
}}>
|
||||
<button type="button" onClick={() => navigate('/login')} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 13, fontFamily: 'inherit', marginBottom: 22,
|
||||
}}>
|
||||
<ArrowLeft size={14} />{t('login.backToLogin')}
|
||||
</button>
|
||||
|
||||
{submitted ? (
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#059669', marginBottom: 16,
|
||||
}}>
|
||||
<CheckCircle2 size={28} />
|
||||
</div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
|
||||
{t('login.forgotPasswordSentTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.forgotPasswordSentBody')}
|
||||
</p>
|
||||
{smtpConfigured === false && (
|
||||
<div style={{
|
||||
marginTop: 18, padding: '12px 14px',
|
||||
background: '#fffbeb', border: '1px solid #fde68a',
|
||||
borderRadius: 10, textAlign: 'left',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<Terminal size={16} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
|
||||
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.forgotPasswordSmtpHintOff')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" onClick={() => navigate('/login')} style={{
|
||||
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{t('login.backToLogin')}</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
|
||||
{t('login.forgotPasswordTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 16px 0' }}>
|
||||
{t('login.forgotPasswordBody')}
|
||||
</p>
|
||||
{smtpConfigured === false && (
|
||||
<div style={{
|
||||
padding: '10px 12px', marginBottom: 18,
|
||||
background: '#fffbeb', border: '1px solid #fde68a',
|
||||
borderRadius: 10, display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<Terminal size={15} style={{ color: '#92400e', marginTop: 1, flexShrink: 0 }} />
|
||||
<p style={{ fontSize: 12.5, color: '#92400e', lineHeight: 1.5, margin: 0 }}>
|
||||
{t('login.forgotPasswordSmtpHintOff')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="email" value={email}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
||||
required placeholder={t('login.emailPlaceholder')} style={inputBase}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={isLoading} style={{
|
||||
width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
|
||||
}}>
|
||||
{isLoading ? t('login.signingIn') : t('login.forgotPasswordSubmit')}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPasswordPage
|
||||
@@ -781,6 +781,17 @@ export default function LoginPage(): React.ReactElement {
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'login' && (
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#111827' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.color = '#6b7280' }}
|
||||
>{t('login.forgotPassword')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect, FormEvent, ChangeEvent } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Lock, KeyRound, CheckCircle2, AlertTriangle, Eye, EyeOff } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
const inputBase: React.CSSProperties = {
|
||||
width: '100%', padding: '11px 44px 11px 38px', borderRadius: 12,
|
||||
border: '1px solid #e5e7eb', fontSize: 14, fontFamily: 'inherit',
|
||||
outline: 'none', transition: 'border-color 120ms',
|
||||
background: 'white', color: '#111827',
|
||||
}
|
||||
|
||||
const ResetPasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
|
||||
const [pw, setPw] = useState('')
|
||||
const [pw2, setPw2] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [mfaCode, setMfaCode] = useState('')
|
||||
const [mfaRequired, setMfaRequired] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) setError(t('login.resetPasswordInvalidLink'))
|
||||
}, [token, t])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isLoading) return
|
||||
setError('')
|
||||
if (!token) return
|
||||
if (pw.length < 8) { setError(t('login.passwordMinLength')); return }
|
||||
if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return }
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await authApi.resetPassword({
|
||||
token,
|
||||
new_password: pw,
|
||||
...(mfaRequired && mfaCode ? { mfa_code: mfaCode.trim() } : {}),
|
||||
})
|
||||
if (res.mfa_required) {
|
||||
setMfaRequired(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
if (res.success) {
|
||||
setSuccess(true)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, t('login.resetPasswordFailed')))
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const shell = (inner: React.ReactNode) => (
|
||||
<div style={{
|
||||
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'linear-gradient(180deg, #f9fafb, #ffffff)', padding: 24, fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 440, background: 'white', borderRadius: 20,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
padding: '32px 28px',
|
||||
}}>{inner}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (success) {
|
||||
return shell(
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: '#ecfdf5',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#059669', marginBottom: 16,
|
||||
}}><CheckCircle2 size={28} /></div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
|
||||
{t('login.resetPasswordSuccessTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.resetPasswordSuccessBody')}
|
||||
</p>
|
||||
<button type="button" onClick={() => navigate('/login')} style={{
|
||||
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{t('login.signIn')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return shell(
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%', background: '#fef2f2',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#dc2626', marginBottom: 16,
|
||||
}}><AlertTriangle size={28} /></div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 10px 0' }}>
|
||||
{t('login.resetPasswordInvalidLink')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: '#4b5563', lineHeight: 1.55, margin: 0 }}>
|
||||
{t('login.resetPasswordInvalidLinkBody')}
|
||||
</p>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
marginTop: 24, padding: '11px 22px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>{t('login.forgotPasswordSubmit')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return shell(
|
||||
<>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, color: '#111827', margin: '0 0 8px 0' }}>
|
||||
{t('login.resetPasswordTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: 13.5, color: '#6b7280', lineHeight: 1.55, margin: '0 0 22px 0' }}>
|
||||
{mfaRequired ? t('login.resetPasswordMfaBody') : t('login.resetPasswordBody')}
|
||||
</p>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px 12px', background: '#fef2f2', border: '1px solid #fecaca',
|
||||
borderRadius: 10, color: '#991b1b', fontSize: 13, marginBottom: 14,
|
||||
}}>{error}</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{!mfaRequired && (
|
||||
<>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('login.newPassword')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'} value={pw}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw(e.target.value)}
|
||||
required placeholder="••••••••" style={inputBase}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPw(v => !v)} style={{
|
||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
|
||||
}}>{showPw ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('login.confirmPassword')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'} value={pw2}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setPw2(e.target.value)}
|
||||
required placeholder="••••••••" style={inputBase}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mfaRequired && (
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>
|
||||
{t('login.mfaCode')}
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<KeyRound size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
|
||||
<input
|
||||
type="text" inputMode="numeric" value={mfaCode}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value)}
|
||||
required placeholder="123456 or backup-code" style={{ ...inputBase, paddingRight: 12 }}
|
||||
autoFocus
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = '#111827' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = '#e5e7eb' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" disabled={isLoading} style={{
|
||||
width: '100%', padding: '12px', background: '#111827', color: 'white',
|
||||
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700,
|
||||
cursor: isLoading ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}>
|
||||
{isLoading ? '…' : (mfaRequired ? t('login.resetPasswordVerify') : t('login.resetPasswordSubmit'))}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPasswordPage
|
||||
Reference in New Issue
Block a user