Compare commits

...

3 Commits

Author SHA1 Message Date
d7a81481a2 MFA is working 2025-06-01 13:42:32 -07:00
360673e60d Moved JWT token to .env 2025-06-01 11:49:09 -07:00
32f81f6314 Implementing MFA 2025-05-31 19:40:30 -07:00
14 changed files with 1680 additions and 1675 deletions

View File

@ -0,0 +1,307 @@
This documents all authentication flows in Backstory. Here are the key flows explained:
# 🔐 Core Authentication Flows
1. Registration & Email Verification
`Registration → Email Sent → Email Verification → Account Activation → Login`
Includes resend verification with rate limiting
Handles expired tokens and error cases
2. Login on Trusted Device
`Login → Credentials Check → Device Trust Check → Immediate Access`
Fastest path with access/refresh tokens issued immediately
3. Login on New Device (MFA)
`Login → Credentials Check → New Device Detected → Auto-send MFA Email → MFA Dialog → Code Verification → Access Granted`
Optional device trust for future logins
4. App Initialization & Token Management
`App Start → Check Tokens → Auto-refresh if needed → Load Dashboard`
Handles expired tokens gracefully
# 🛡️ Security Features Covered
## Rate Limiting & Protection
* Login attempt limiting
* MFA resend limiting (max 3)
* Verification email rate limiting
* Account lockout for abuse
## Token Management
* Access token expiration handling
* Refresh token rotation
* Token blacklisting on logout
* Force logout on revoked tokens
## Device Security
* Device fingerprinting
* Trusted device management
* MFA for new devices
* Device removal capabilities
# 🔄 Key Decision Points
1. Has Valid Tokens? → Dashboard vs Login
2. Trusted Device? → Immediate access vs MFA
3. Account Active? → Login vs Error message
4. MFA Code Valid? → Success vs Retry/Lock
5. Token Expired? → Refresh vs Re-login
# 📱 User Experience Flows
## Happy Path (Returning User)
`App Start → Valid Tokens → Dashboard (2 steps)`
## New User Journey
`Registration → Email Verification → Login → Dashboard (4 steps)`
## New Device Login
`Login → MFA Email → Code Entry → Dashboard (3 steps)`
## 🔧 Implementation Notes
**Background Tasks**: Email sending doesn't block user flow
**Error Recovery**: Clear paths back to working states
**Admin Features**: User management and security monitoring
**Future Features**: Password reset flow is mapped out
# Flow Diagram
This diagram serves as the complete authentication architecture reference, showing every possible user journey and system state transition.
```
flowchart TD
%% ================================
%% REGISTRATION FLOWS
%% ================================
Start([User Visits App]) --> CheckTokens{Has Valid Tokens?}
CheckTokens -->|Yes| LoadUser[Load User Profile]
CheckTokens -->|No| LandingPage[Landing Page]
LandingPage --> RegisterChoice{Registration Type}
RegisterChoice --> CandidateReg[Candidate Registration Form]
RegisterChoice --> EmployerReg[Employer Registration Form]
RegisterChoice --> LoginPage[Login Page]
%% Candidate Registration Flow
CandidateReg --> CandidateValidation{Form Valid?}
CandidateValidation -->|No| CandidateReg
CandidateValidation -->|Yes| CandidateSubmit[POST /candidates]
CandidateSubmit --> CandidateCheck{User Exists?}
CandidateCheck -->|Yes| CandidateError[Show Error: User Exists]
CandidateError --> CandidateReg
CandidateCheck -->|No| CandidateEmailSent[Auto-send Verification Email]
CandidateEmailSent --> CandidateSuccess[Show Success Dialog]
%% Employer Registration Flow
EmployerReg --> EmployerValidation{Form Valid?}
EmployerValidation -->|No| EmployerReg
EmployerValidation -->|Yes| EmployerSubmit[POST /employers]
EmployerSubmit --> EmployerCheck{User Exists?}
EmployerCheck -->|Yes| EmployerError[Show Error: User Exists]
EmployerError --> EmployerReg
EmployerCheck -->|No| EmployerEmailSent[Auto-send Verification Email]
EmployerEmailSent --> EmployerSuccess[Show Success Dialog]
%% Email Verification Flow
CandidateSuccess --> CheckEmail[User Checks Email]
EmployerSuccess --> CheckEmail
CheckEmail --> ClickLink[Click Verification Link]
ClickLink --> VerifyEmail[GET /verify-email?token=xxx]
VerifyEmail --> TokenValid{Token Valid & Not Expired?}
TokenValid -->|No| VerifyError[Show Error: Invalid/Expired Token]
TokenValid -->|Yes| ActivateAccount[Activate Account in DB]
ActivateAccount --> VerifySuccess[Show Success: Account Activated]
VerifySuccess --> RedirectLogin[Redirect to Login]
%% Resend Verification
VerifyError --> ResendOption{Resend Verification?}
ResendOption -->|Yes| ResendEmail[POST /auth/resend-verification]
ResendEmail --> RateLimitCheck{Within Rate Limits?}
RateLimitCheck -->|No| ResendError[Show Rate Limit Error]
RateLimitCheck -->|Yes| FindPending{Pending Verification Found?}
FindPending -->|No| ResendGeneric[Generic Success Message]
FindPending -->|Yes| ResendSuccess[New Email Sent]
ResendSuccess --> CheckEmail
ResendGeneric --> CheckEmail
ResendOption -->|No| RegisterChoice
%% ================================
%% LOGIN FLOWS
%% ================================
RedirectLogin --> LoginPage
LoginPage --> LoginForm[Enter Email/Password]
LoginForm --> LoginSubmit[POST /auth/login]
LoginSubmit --> CredentialsValid{Credentials Valid?}
CredentialsValid -->|No| LoginError[Show Login Error]
LoginError --> LoginForm
CredentialsValid -->|Yes| AccountActive{Account Active?}
AccountActive -->|No| AccountError[Show Account Status Error]
AccountError --> LoginForm
AccountActive -->|Yes| DeviceCheck{Trusted Device?}
%% Trusted Device Flow
DeviceCheck -->|Yes| TrustedLogin[Update Last Login]
TrustedLogin --> IssueTokens[Issue Access + Refresh Tokens]
IssueTokens --> LoginSuccess[Store Tokens Locally]
LoginSuccess --> LoadUser
%% New Device Flow (MFA Required)
DeviceCheck -->|No| NewDevice[Detect New Device]
NewDevice --> GenerateMFA[Generate 6-digit MFA Code]
GenerateMFA --> SendMFAEmail[Auto-send MFA Email]
SendMFAEmail --> MFAResponse[Return MFA Required Response]
MFAResponse --> ShowMFADialog[Show MFA Input Dialog]
ShowMFADialog --> MFAInput[User Enters 6-digit Code]
MFAInput --> MFASubmit[POST /auth/mfa/verify]
MFASubmit --> MFAValid{Code Valid & Not Expired?}
MFAValid -->|No| MFAError[Show MFA Error]
MFAError --> MFARetry{Attempts < 5?}
MFARetry -->|Yes| MFAInput
MFARetry -->|No| MFALocked[Lock MFA Session]
MFALocked --> LoginForm
MFAValid -->|Yes| RememberDevice{Remember Device?}
RememberDevice -->|Yes| AddTrustedDevice[Add to Trusted Devices]
RememberDevice -->|No| SkipTrust[Skip Adding Device]
AddTrustedDevice --> MFASuccess[Update Last Login]
SkipTrust --> MFASuccess
MFASuccess --> IssueTokens
%% MFA Resend Flow
ShowMFADialog --> MFAResend{Need Resend?}
MFAResend -->|Yes| ResendMFA[POST /auth/mfa/resend]
ResendMFA --> ResendLimit{< 3 Resends?}
ResendLimit -->|No| ResendLocked[Max Resends Reached]
ResendLocked --> LoginForm
ResendLimit -->|Yes| NewMFACode[Generate New Code]
NewMFACode --> SendNewMFA[Send New Email]
SendNewMFA --> ShowMFADialog
%% ================================
%% APP INITIALIZATION & TOKEN MANAGEMENT
%% ================================
LoadUser --> TokenExpired{Access Token Expired?}
TokenExpired -->|No| Dashboard[Load Dashboard]
TokenExpired -->|Yes| RefreshCheck{Has Refresh Token?}
RefreshCheck -->|No| ClearTokens[Clear Local Storage]
ClearTokens --> LandingPage
RefreshCheck -->|Yes| RefreshAttempt[POST /auth/refresh]
RefreshAttempt --> RefreshValid{Refresh Token Valid?}
RefreshValid -->|No| ClearTokens
RefreshValid -->|Yes| NewTokens[Issue New Access Token]
NewTokens --> UpdateStorage[Update Local Storage]
UpdateStorage --> Dashboard
%% ================================
%% LOGOUT FLOWS
%% ================================
Dashboard --> LogoutChoice{Logout Type}
LogoutChoice --> SingleLogout[Logout This Device]
LogoutChoice --> LogoutAll[Logout All Devices]
SingleLogout --> LogoutRequest[POST /auth/logout]
LogoutRequest --> BlacklistTokens[Blacklist Tokens]
BlacklistTokens --> LogoutComplete[Clear Local Storage]
LogoutAll --> LogoutAllRequest[POST /auth/logout-all]
LogoutAllRequest --> RevokeAllTokens[Revoke All User Tokens]
RevokeAllTokens --> LogoutComplete
LogoutComplete --> LandingPage
%% ================================
%% ERROR HANDLING & EDGE CASES
%% ================================
Dashboard --> TokenRevoked{Token Blacklisted?}
TokenRevoked -->|Yes| ForceLogout[Force Logout]
ForceLogout --> ClearTokens
%% Rate Limiting
LoginForm --> RateLimit{Too Many Attempts?}
RateLimit -->|Yes| AccountLock[Temporary Account Lock]
AccountLock --> LockMessage[Show Lockout Message]
LockMessage --> WaitPeriod[Wait for Unlock]
WaitPeriod --> LoginForm
%% Network Errors
LoginSubmit --> NetworkError{Network Error?}
NetworkError -->|Yes| RetryLogin[Show Retry Option]
RetryLogin --> LoginForm
%% ================================
%% ADMIN FLOWS (Optional)
%% ================================
Dashboard --> AdminCheck{Is Admin?}
AdminCheck -->|Yes| AdminPanel[Admin Panel]
AdminPanel --> ManageVerifications[Manage Pending Verifications]
AdminPanel --> ViewSecurityLogs[View Security Logs]
AdminPanel --> ManageUsers[Manage User Accounts]
AdminCheck -->|No| Dashboard
%% ================================
%% PASSWORD RESET FLOW (Future)
%% ================================
LoginForm --> ForgotPassword[Forgot Password Link]
ForgotPassword --> ResetEmail[Enter Email for Reset]
ResetEmail --> ResetRequest[POST /auth/password-reset/request]
ResetRequest --> ResetEmailSent[Password Reset Email Sent]
ResetEmailSent --> ResetLink[Click Reset Link in Email]
ResetLink --> ResetForm[Enter New Password]
ResetForm --> ResetSubmit[POST /auth/password-reset/confirm]
ResetSubmit --> ResetSuccess[Password Reset Successfully]
ResetSuccess --> LoginForm
%% ================================
%% DEVICE MANAGEMENT
%% ================================
Dashboard --> DeviceSettings[Device Settings]
DeviceSettings --> ViewDevices[View Trusted Devices]
ViewDevices --> RemoveDevice[Remove Trusted Device]
RemoveDevice --> DeviceRemoved[Device Removed Successfully]
DeviceRemoved --> ViewDevices
%% ================================
%% STYLING
%% ================================
classDef startEnd fill:#e1f5fe
classDef process fill:#f3e5f5
classDef decision fill:#fff3e0
classDef error fill:#ffebee
classDef success fill:#e8f5e8
classDef security fill:#fce4ec
class Start,LandingPage startEnd
class LoginSuccess,VerifySuccess,MFASuccess,Dashboard success
class LoginError,VerifyError,MFAError,AccountError error
class DeviceCheck,TokenValid,CredentialsValid,MFAValid decision
class GenerateMFA,SendMFAEmail,BlacklistTokens security
```

View File

@ -29,12 +29,13 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab'; import { BackstoryPageProps } from './BackstoryTab';
import { useNavigate } from 'react-router-dom';
// Email Verification Component // Email Verification Component
const EmailVerificationPage = (props: BackstoryPageProps) => { const EmailVerificationPage = (props: BackstoryPageProps) => {
const { apiClient } = useAuth(); const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } = useAuth();
const navigate = useNavigate();
const [verificationToken, setVerificationToken] = useState(''); const [verificationToken, setVerificationToken] = useState('');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending'); const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [userType, setUserType] = useState<string>(''); const [userType, setUserType] = useState<string>('');
@ -57,62 +58,42 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
return; return;
} }
setLoading(true);
try { try {
const response = await fetch('/api/1.0/auth/verify-email', { const result = await verifyEmail({ token });
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
const data = await response.json(); if (result) {
if (data.success) {
setStatus('success'); setStatus('success');
setMessage(data.data.message); setMessage(result.message);
setUserType(data.data.userType); setUserType(result.userType);
// Redirect to login after 3 seconds // Redirect to login after 3 seconds
setTimeout(() => { setTimeout(() => {
window.location.href = '/login'; navigate('/login');
}, 3000); }, 3000);
} else { } else {
setStatus('error'); setStatus('error');
setMessage(data.error?.message || 'Verification failed'); setMessage('Email verification failed');
} }
} catch (error) { } catch (error) {
setStatus('error'); setStatus('error');
setMessage('Network error occurred. Please try again.'); setMessage('Email verification failed');
} finally {
setLoading(false);
} }
}; };
const handleResendVerification = async () => { const handleResendVerification = async () => {
// This would need the email address - you might want to add an input for it const email = getPendingVerificationEmail();
// or store it in localStorage from the registration process if (!email) {
try { setMessage('No pending verification email found.');
setLoading(true); return;
const response = await fetch('/api/1.0/auth/resend-verification', { }
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: localStorage.getItem('pendingVerificationEmail') || ''
}),
});
const data = await response.json(); try {
if (data.success) { const success = await resendEmailVerification(email);
setMessage('Verification email sent! Please check your inbox.'); if (success) {
setMessage('Verification email sent successfully!');
} }
} catch (error) { } catch (error) {
setMessage('Failed to resend verification email.'); setMessage('Failed to resend verification email.');
} finally {
setLoading(false);
} }
}; };
@ -167,18 +148,18 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
)} )}
</Box> </Box>
{loading && ( {isLoading && (
<Box display="flex" justifyContent="center" my={3}> <Box display="flex" justifyContent="center" my={3}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
)} )}
{message && ( {(message || error) && (
<Alert <Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'} severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
sx={{ mt: 2 }} sx={{ mt: 2 }}
> >
{message} {message || error}
</Alert> </Alert>
)} )}
@ -189,7 +170,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Typography> </Typography>
<Button <Button
variant="contained" variant="contained"
onClick={() => window.location.href = '/login'} onClick={() => navigate('/login')}
fullWidth fullWidth
> >
Go to Login Go to Login
@ -202,7 +183,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
<Button <Button
variant="outlined" variant="outlined"
onClick={handleResendVerification} onClick={handleResendVerification}
disabled={loading} disabled={isLoading}
startIcon={<RefreshIcon />} startIcon={<RefreshIcon />}
fullWidth fullWidth
sx={{ mb: 2 }} sx={{ mb: 2 }}
@ -211,7 +192,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
onClick={() => window.location.href = '/login'} onClick={() => navigate('/login')}
fullWidth fullWidth
> >
Back to Login Back to Login
@ -225,27 +206,34 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
} }
// MFA Verification Component // MFA Verification Component
const MFAVerificationDialog = ({ interface MFAVerificationDialogProps {
open,
onClose,
email,
deviceId,
deviceName,
onVerificationSuccess
}: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
email: string;
deviceId: string;
deviceName: string;
onVerificationSuccess: (authData: any) => void; onVerificationSuccess: (authData: any) => void;
}) => { }
const { apiClient } = useAuth(); const MFAVerificationDialog = (props: MFAVerificationDialogProps) => {
const {
open,
onClose,
onVerificationSuccess
} = props;
const { verifyMFA, resendMFACode, clearMFA, mfaResponse, isLoading, error } = useAuth();
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(false); const [rememberDevice, setRememberDevice] = useState(false);
const [loading, setLoading] = useState(false); const [localError, setLocalError] = useState('');
const [error, setError] = useState('');
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -254,7 +242,7 @@ const MFAVerificationDialog = ({
setTimeLeft((prev) => { setTimeLeft((prev) => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer); clearInterval(timer);
setError('MFA code has expired. Please try logging in again.'); setLocalError('MFA code has expired. Please try logging in again.');
return 0; return 0;
} }
return prev - 1; return prev - 1;
@ -272,73 +260,61 @@ const MFAVerificationDialog = ({
const handleVerifyMFA = async () => { const handleVerifyMFA = async () => {
if (!code || code.length !== 6) { if (!code || code.length !== 6) {
setError('Please enter a valid 6-digit code'); setLocalError('Please enter a valid 6-digit code');
return; return;
} }
setLoading(true); if (!mfaResponse || !mfaResponse.mfaData) {
setError(''); setLocalError('MFA data not available');
return;
}
setLocalError('');
try { try {
const response = await fetch('/api/1.0/auth/mfa/verify', { const success = await verifyMFA({
method: 'POST', email: mfaResponse.mfaData.email,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
code, code,
deviceId, deviceId: mfaResponse.mfaData.deviceId,
rememberDevice, rememberDevice,
}),
}); });
const data = await response.json(); if (success) {
onVerificationSuccess({ success: true });
if (data.success) {
onVerificationSuccess(data.data);
onClose(); onClose();
} else {
setError(data.error?.message || 'Invalid verification code');
} }
} catch (error) { } catch (error) {
setError('Network error occurred. Please try again.'); setLocalError('Verification failed. Please try again.');
} finally {
setLoading(false);
} }
}; };
const handleResendCode = async () => { const handleResendCode = async () => {
setLoading(true); if (!mfaResponse || !mfaResponse.mfaData) {
try { setLocalError('MFA data not available');
const response = await fetch('/api/1.0/auth/mfa/request', { return;
method: 'POST', }
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password: '', // This would need to be stored securely or re-entered
deviceId,
deviceName,
}),
});
const data = await response.json(); try {
if (data.success) { const success = await resendMFACode(mfaResponse.mfaData.email, mfaResponse.mfaData.deviceId, mfaResponse.mfaData.deviceName);
if (success) {
setTimeLeft(600); // Reset timer setTimeLeft(600); // Reset timer
setError(''); setLocalError('');
alert('New verification code sent to your email'); alert('New verification code sent to your email');
} }
} catch (error) { } catch (error) {
setError('Failed to resend code'); setLocalError('Failed to resend code');
} finally {
setLoading(false);
} }
}; };
const handleClose = () => {
clearMFA();
onClose();
};
if (!mfaResponse || !mfaResponse.mfaData) return null;
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle> <DialogTitle>
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<SecurityIcon color="primary" /> <SecurityIcon color="primary" />
@ -350,14 +326,14 @@ const MFAVerificationDialog = ({
<DialogContent> <DialogContent>
<Alert severity="info" sx={{ mb: 3 }}> <Alert severity="info" sx={{ mb: 3 }}>
We've detected a login from a new device: <strong>{deviceName}</strong> We've detected a login from a new device: <strong>{mfaResponse.mfaData.deviceName}</strong>
</Alert> </Alert>
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
We've sent a 6-digit verification code to: We've sent a 6-digit verification code to:
</Typography> </Typography>
<Typography variant="h6" color="primary" gutterBottom> <Typography variant="h6" color="primary" gutterBottom>
{email} {mfaResponse.mfaData.email}
</Typography> </Typography>
<TextField <TextField
@ -367,7 +343,7 @@ const MFAVerificationDialog = ({
onChange={(e) => { onChange={(e) => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6); const value = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(value); setCode(value);
setError(''); setLocalError('');
}} }}
placeholder="000000" placeholder="000000"
inputProps={{ inputProps={{
@ -379,8 +355,8 @@ const MFAVerificationDialog = ({
} }
}} }}
sx={{ mt: 2, mb: 2 }} sx={{ mt: 2, mb: 2 }}
error={!!error} error={!!(localError || errorMessage)}
helperText={error} helperText={localError || errorMessage}
/> />
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}> <Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
@ -390,7 +366,7 @@ const MFAVerificationDialog = ({
<Button <Button
size="small" size="small"
onClick={handleResendCode} onClick={handleResendCode}
disabled={loading || timeLeft > 540} // Allow resend after 1 minute disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute
> >
Resend Code Resend Code
</Button> </Button>
@ -414,15 +390,15 @@ const MFAVerificationDialog = ({
</DialogContent> </DialogContent>
<DialogActions sx={{ p: 3 }}> <DialogActions sx={{ p: 3 }}>
<Button onClick={onClose} disabled={loading}> <Button onClick={handleClose} disabled={isLoading}>
Cancel Cancel
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleVerifyMFA} onClick={handleVerifyMFA}
disabled={loading || !code || code.length !== 6 || timeLeft === 0} disabled={isLoading || !code || code.length !== 6 || timeLeft === 0}
> >
{loading ? <CircularProgress size={20} /> : 'Verify'} {isLoading ? <CircularProgress size={20} /> : 'Verify'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@ -441,29 +417,17 @@ const RegistrationSuccessDialog = ({
email: string; email: string;
userType: string; userType: string;
}) => { }) => {
const [resendLoading, setResendLoading] = useState(false); const { resendEmailVerification, isLoading } = useAuth();
const [resendMessage, setResendMessage] = useState(''); const [resendMessage, setResendMessage] = useState('');
const handleResendVerification = async () => { const handleResendVerification = async () => {
setResendLoading(true);
try { try {
const response = await fetch('/api/1.0/auth/resend-verification', { const success = await resendEmailVerification(email);
method: 'POST', if (success) {
headers: { setResendMessage('Verification email sent!');
'Content-Type': 'application/json', }
}, } catch (error: any) {
body: JSON.stringify({ email }), setResendMessage(error?.message || 'Network error. Please try again.');
});
const data = await response.json();
setResendMessage(data.success ?
'Verification email sent!' :
'Failed to resend email. Please try again later.'
);
} catch (error) {
setResendMessage('Network error. Please try again.');
} finally {
setResendLoading(false);
} }
}; };
@ -509,8 +473,8 @@ const RegistrationSuccessDialog = ({
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}> <DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
<Button <Button
onClick={handleResendVerification} onClick={handleResendVerification}
disabled={resendLoading} disabled={isLoading}
startIcon={resendLoading ? <CircularProgress size={16} /> : <RefreshIcon />} startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
> >
Resend Email Resend Email
</Button> </Button>
@ -524,69 +488,46 @@ const RegistrationSuccessDialog = ({
// Enhanced Login Component with MFA Support // Enhanced Login Component with MFA Support
const LoginForm = () => { const LoginForm = () => {
const { apiClient } = useAuth(); const { login, mfaResponse, isLoading, error } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [error, setError] = useState('');
const [mfaRequired, setMfaRequired] = useState(false); useEffect(() => {
const [mfaData, setMfaData] = useState<any>(null); if (!error) {
return;
}
/* Remove 'HTTP .*: ' from error string */
const jsonStr = error.replace(/^[^{]*/, '');
const data = JSON.parse(jsonStr);
setErrorMessage(data.error.message);
}, [error]);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true);
setError('');
try { const success = await login({
const response = await fetch('/api/1.0/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
login: email, login: email,
password, password
}),
}); });
const data = await response.json(); console.log(`login success: ${success}`);
if (success) {
if (data.success) { // Redirect based on user type - this could be handled in AuthContext
if (data.data.mfaRequired) { // or by a higher-level component that listens to auth state changes
// MFA required for new device handleLoginSuccess();
setMfaRequired(true);
setMfaData({
email,
deviceId: data.data.deviceId,
deviceName: data.data.deviceName,
});
} else {
// Normal login success
handleLoginSuccess(data.data);
}
} else {
setError(data.error?.message || 'Login failed');
}
} catch (error) {
setError('Network error occurred. Please try again.');
} finally {
setLoading(false);
} }
}; };
const handleMFASuccess = (authData: any) => { const handleMFASuccess = (authData: any) => {
handleLoginSuccess(authData); handleLoginSuccess();
}; };
const handleLoginSuccess = (authData: any) => { const handleLoginSuccess = () => {
// Store tokens // This could be handled by a router or parent component
localStorage.setItem('accessToken', authData.accessToken); // For now, just showing the pattern
localStorage.setItem('refreshToken', authData.refreshToken); console.log('Login successful - redirect to dashboard');
localStorage.setItem('user', JSON.stringify(authData.user));
// Redirect based on user type
const userType = authData.user.userType;
window.location.href = userType === 'employer' ? '/employer-dashboard' : '/candidate-dashboard';
}; };
return ( return (
@ -612,9 +553,9 @@ const LoginForm = () => {
autoComplete="current-password" autoComplete="current-password"
/> />
{error && ( {errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}> <Alert severity="error" sx={{ mt: 2 }}>
{error} {errorMessage}
</Alert> </Alert>
)} )}
@ -622,23 +563,18 @@ const LoginForm = () => {
type="submit" type="submit"
fullWidth fullWidth
variant="contained" variant="contained"
disabled={loading} disabled={isLoading}
sx={{ mt: 3, mb: 2 }} sx={{ mt: 3, mb: 2 }}
> >
{loading ? <CircularProgress size={20} /> : 'Sign In'} {isLoading ? <CircularProgress size={20} /> : 'Sign In'}
</Button> </Button>
{/* MFA Dialog */} {/* MFA Dialog */}
{mfaRequired && mfaData && (
<MFAVerificationDialog <MFAVerificationDialog
open={mfaRequired} open={mfaResponse?.mfaRequired || false}
onClose={() => setMfaRequired(false)} onClose={() => { }} // This will be handled by clearMFA in the dialog
email={mfaData.email}
deviceId={mfaData.deviceId}
deviceName={mfaData.deviceName}
onVerificationSuccess={handleMFASuccess} onVerificationSuccess={handleMFASuccess}
/> />
)}
</Box> </Box>
); );
} }

View File

@ -1,742 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
TextField,
Button,
Alert,
CircularProgress,
Link,
Divider,
InputAdornment,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Checkbox,
FormControlLabel,
Grid
} from '@mui/material';
import {
Email as EmailIcon,
Security as SecurityIcon,
CheckCircle as CheckCircleIcon,
ErrorOutline as ErrorIcon,
Refresh as RefreshIcon,
DevicesOther as DevicesIcon
} from '@mui/icons-material';
import { ApiClient } from 'services/api-client';
// Email Verification Component
export function EmailVerificationPage() {
const [verificationToken, setVerificationToken] = useState('');
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending');
const [message, setMessage] = useState('');
const [userType, setUserType] = useState<string>('');
const apiClient = new ApiClient();
useEffect(() => {
// Get token from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
setVerificationToken(token);
handleVerifyEmail(token);
}
}, []);
const handleVerifyEmail = async (token: string) => {
if (!token) {
setStatus('error');
setMessage('Invalid verification link');
return;
}
setLoading(true);
try {
const response = await fetch('/api/1.0/auth/verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
const data = await response.json();
if (data.success) {
setStatus('success');
setMessage(data.data.message);
setUserType(data.data.userType);
// Redirect to login after 3 seconds
setTimeout(() => {
window.location.href = '/login';
}, 3000);
} else {
setStatus('error');
setMessage(data.error?.message || 'Verification failed');
}
} catch (error) {
setStatus('error');
setMessage('Network error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleResendVerification = async () => {
// This would need the email address - you might want to add an input for it
// or store it in localStorage from the registration process
try {
setLoading(true);
const response = await fetch('/api/1.0/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: localStorage.getItem('pendingVerificationEmail') || ''
}),
});
const data = await response.json();
if (data.success) {
setMessage('Verification email sent! Please check your inbox.');
}
} catch (error) {
setMessage('Failed to resend verification email.');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.50',
p: 2
}}
>
<Card sx={{ maxWidth: 500, width: '100%' }}>
<CardContent sx={{ p: 4 }}>
<Box textAlign="center" mb={3}>
{status === 'pending' && (
<>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" gutterBottom>
Verifying Email
</Typography>
<Typography color="text.secondary">
Please wait while we verify your email address...
</Typography>
</>
)}
{status === 'success' && (
<>
<CheckCircleIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<Typography variant="h4" gutterBottom color="success.main">
Email Verified!
</Typography>
<Typography color="text.secondary">
Your {userType} account has been successfully activated.
</Typography>
</>
)}
{status === 'error' && (
<>
<ErrorIcon sx={{ fontSize: 64, color: 'error.main', mb: 2 }} />
<Typography variant="h4" gutterBottom color="error.main">
Verification Failed
</Typography>
<Typography color="text.secondary">
We couldn't verify your email address.
</Typography>
</>
)}
</Box>
{loading && (
<Box display="flex" justifyContent="center" my={3}>
<CircularProgress />
</Box>
)}
{message && (
<Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
sx={{ mt: 2 }}
>
{message}
</Alert>
)}
{status === 'success' && (
<Box mt={3} textAlign="center">
<Typography variant="body2" color="text.secondary" mb={2}>
You will be redirected to the login page in a few seconds...
</Typography>
<Button
variant="contained"
onClick={() => window.location.href = '/login'}
fullWidth
>
Go to Login
</Button>
</Box>
)}
{status === 'error' && (
<Box mt={3}>
<Button
variant="outlined"
onClick={handleResendVerification}
disabled={loading}
startIcon={<RefreshIcon />}
fullWidth
sx={{ mb: 2 }}
>
Resend Verification Email
</Button>
<Button
variant="contained"
onClick={() => window.location.href = '/login'}
fullWidth
>
Back to Login
</Button>
</Box>
)}
</CardContent>
</Card>
</Box>
);
}
// MFA Verification Component
export function MFAVerificationDialog({
open,
onClose,
email,
deviceId,
deviceName,
onVerificationSuccess
}: {
open: boolean;
onClose: () => void;
email: string;
deviceId: string;
deviceName: string;
onVerificationSuccess: (authData: any) => void;
}) {
const [code, setCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [timeLeft, setTimeLeft] = useState(600); // 10 minutes in seconds
const apiClient = new ApiClient();
useEffect(() => {
if (!open) return;
const timer = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(timer);
setError('MFA code has expired. Please try logging in again.');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [open]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleVerifyMFA = async () => {
if (!code || code.length !== 6) {
setError('Please enter a valid 6-digit code');
return;
}
setLoading(true);
setError('');
try {
const response = await fetch('/api/1.0/auth/mfa/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
code,
deviceId,
rememberDevice,
}),
});
const data = await response.json();
if (data.success) {
onVerificationSuccess(data.data);
onClose();
} else {
setError(data.error?.message || 'Invalid verification code');
}
} catch (error) {
setError('Network error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleResendCode = async () => {
setLoading(true);
try {
const response = await fetch('/api/1.0/auth/mfa/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
password: '', // This would need to be stored securely or re-entered
deviceId,
deviceName,
}),
});
const data = await response.json();
if (data.success) {
setTimeLeft(600); // Reset timer
setError('');
alert('New verification code sent to your email');
}
} catch (error) {
setError('Failed to resend code');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<SecurityIcon color="primary" />
<Typography variant="h6">
Verify Your Identity
</Typography>
</Box>
</DialogTitle>
<DialogContent>
<Alert severity="info" sx={{ mb: 3 }}>
We've detected a login from a new device: <strong>{deviceName}</strong>
</Alert>
<Typography variant="body1" gutterBottom>
We've sent a 6-digit verification code to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{email}
</Typography>
<TextField
fullWidth
label="Enter 6-digit code"
value={code}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(value);
setError('');
}}
placeholder="000000"
inputProps={{
maxLength: 6,
style: {
fontSize: 24,
textAlign: 'center',
letterSpacing: 8
}
}}
sx={{ mt: 2, mb: 2 }}
error={!!error}
helperText={error}
/>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="body2" color="text.secondary">
Code expires in: {formatTime(timeLeft)}
</Typography>
<Button
size="small"
onClick={handleResendCode}
disabled={loading || timeLeft > 540} // Allow resend after 1 minute
>
Resend Code
</Button>
</Box>
<FormControlLabel
control={
<Checkbox
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
/>
}
label="Remember this device for 90 days"
/>
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
If you didn't attempt to log in, please change your password immediately.
</Typography>
</Alert>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleVerifyMFA}
disabled={loading || !code || code.length !== 6 || timeLeft === 0}
>
{loading ? <CircularProgress size={20} /> : 'Verify'}
</Button>
</DialogActions>
</Dialog>
);
}
// Enhanced Registration Success Component
export function RegistrationSuccessDialog({
open,
onClose,
email,
userType
}: {
open: boolean;
onClose: () => void;
email: string;
userType: string;
}) {
const [resendLoading, setResendLoading] = useState(false);
const [resendMessage, setResendMessage] = useState('');
const handleResendVerification = async () => {
setResendLoading(true);
try {
const response = await fetch('/api/1.0/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
setResendMessage(data.success ?
'Verification email sent!' :
'Failed to resend email. Please try again later.'
);
} catch (error) {
setResendMessage('Network error. Please try again.');
} finally {
setResendLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent sx={{ textAlign: 'center', p: 4 }}>
<EmailIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h5" gutterBottom>
Check Your Email
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
We've sent a verification link to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{email}
</Typography>
<Alert severity="info" sx={{ mt: 2, mb: 3, textAlign: 'left' }}>
<Typography variant="body2">
<strong>Next steps:</strong>
<br />
1. Check your email inbox (and spam folder)
<br />
2. Click the verification link
<br />
3. Your {userType} account will be activated
</Typography>
</Alert>
{resendMessage && (
<Alert
severity={resendMessage.includes('sent') ? 'success' : 'error'}
sx={{ mb: 2 }}
>
{resendMessage}
</Alert>
)}
</DialogContent>
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
<Button
onClick={handleResendVerification}
disabled={resendLoading}
startIcon={resendLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
>
Resend Email
</Button>
<Button variant="contained" onClick={onClose}>
Got It
</Button>
</DialogActions>
</Dialog>
);
}
// Enhanced Login Component with MFA Support
export function EnhancedLoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [mfaRequired, setMfaRequired] = useState(false);
const [mfaData, setMfaData] = useState<any>(null);
const apiClient = new ApiClient();
// Generate device fingerprint (simplified)
const getDeviceFingerprint = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx!.textBaseline = 'top';
ctx!.font = '14px Arial';
ctx!.fillText('Device fingerprint', 2, 2);
const fingerprint = canvas.toDataURL() +
navigator.userAgent +
navigator.language +
screen.width + 'x' + screen.height;
return btoa(fingerprint).slice(0, 16);
};
const getDeviceName = () => {
const ua = navigator.userAgent;
const browserName = ua.includes('Chrome') ? 'Chrome' :
ua.includes('Firefox') ? 'Firefox' :
ua.includes('Safari') ? 'Safari' : 'Browser';
const osName = ua.includes('Windows') ? 'Windows' :
ua.includes('Mac') ? 'macOS' :
ua.includes('Linux') ? 'Linux' :
ua.includes('Android') ? 'Android' :
ua.includes('iOS') ? 'iOS' : 'Unknown OS';
return `${browserName} on ${osName}`;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/1.0/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
login: email,
password,
}),
});
const data = await response.json();
if (data.success) {
if (data.data.mfaRequired) {
// MFA required for new device
setMfaRequired(true);
setMfaData({
email,
deviceId: data.data.deviceId,
deviceName: data.data.deviceName,
});
} else {
// Normal login success
handleLoginSuccess(data.data);
}
} else {
setError(data.error?.message || 'Login failed');
}
} catch (error) {
setError('Network error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleMFASuccess = (authData: any) => {
handleLoginSuccess(authData);
};
const handleLoginSuccess = (authData: any) => {
// Store tokens
localStorage.setItem('accessToken', authData.accessToken);
localStorage.setItem('refreshToken', authData.refreshToken);
localStorage.setItem('user', JSON.stringify(authData.user));
// Redirect based on user type
const userType = authData.user.userType;
window.location.href = userType === 'employer' ? '/employer-dashboard' : '/candidate-dashboard';
};
return (
<Box component="form" onSubmit={handleLogin} sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
label="Email or Username"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? <CircularProgress size={20} /> : 'Sign In'}
</Button>
{/* MFA Dialog */}
{mfaRequired && mfaData && (
<MFAVerificationDialog
open={mfaRequired}
onClose={() => setMfaRequired(false)}
email={mfaData.email}
deviceId={mfaData.deviceId}
deviceName={mfaData.deviceName}
onVerificationSuccess={handleMFASuccess}
/>
)}
</Box>
);
}
// Device Management Component
export function TrustedDevicesManager() {
const [devices, setDevices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// This would need API endpoints to manage trusted devices
useEffect(() => {
// Load trusted devices
setLoading(false);
}, []);
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
<DevicesIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
Trusted Devices
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Manage devices that you've marked as trusted. You won't need to verify
your identity when signing in from these devices.
</Typography>
{devices.length === 0 ? (
<Alert severity="info">
No trusted devices yet. When you log in from a new device and choose
to remember it, it will appear here.
</Alert>
) : (
<Grid container spacing={2}>
{devices.map((device, index) => (
<Grid key={index} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1">
{device.deviceName}
</Typography>
<Typography variant="body2" color="text.secondary">
Added: {new Date(device.addedAt).toLocaleDateString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Last used: {new Date(device.lastUsed).toLocaleDateString()}
</Typography>
<Button
size="small"
color="error"
sx={{ mt: 1 }}
onClick={() => {
// Remove device
}}
>
Remove
</Button>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
</CardContent>
</Card>
);
}

View File

@ -27,6 +27,9 @@ const StyledMarkdown: React.FC<StyledMarkdownProps> = (props: StyledMarkdownProp
const theme = useTheme(); const theme = useTheme();
const overrides: any = { const overrides: any = {
p: { component: (element: any) =>{
return <div>{element.children}</div>
}},
pre: { pre: {
component: (element: any) => { component: (element: any) => {
const { className } = element.children.props; const { className } = element.children.props;

View File

@ -7,26 +7,40 @@ import { formatApiRequest, toCamelCase } from '../types/conversion';
// Types and Interfaces // Types and Interfaces
// ============================ // ============================
export interface AuthState {
interface AuthState {
user: Types.User | null; user: Types.User | null;
guest: Types.Guest | null; guest: Types.Guest | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
isInitializing: boolean; isInitializing: boolean;
error: string | null; error: string | null;
mfaResponse: Types.MFARequestResponse | null;
} }
export interface LoginRequest { interface LoginRequest {
login: string; // email or username login: string; // email or username
password: string; password: string;
} }
export interface PasswordResetRequest { interface MFAVerificationRequest {
email: string;
code: string;
deviceId: string;
rememberDevice?: boolean;
}
interface EmailVerificationRequest {
token: string;
}
interface ResendVerificationRequest {
email: string; email: string;
} }
// Re-export API client types for convenience interface PasswordResetRequest {
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client'; email: string;
}
// ============================ // ============================
// Token Storage Constants // Token Storage Constants
@ -37,7 +51,8 @@ const TOKEN_STORAGE = {
REFRESH_TOKEN: 'refreshToken', REFRESH_TOKEN: 'refreshToken',
USER_DATA: 'userData', USER_DATA: 'userData',
TOKEN_EXPIRY: 'tokenExpiry', TOKEN_EXPIRY: 'tokenExpiry',
GUEST_DATA: 'guestData' GUEST_DATA: 'guestData',
PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail'
} as const; } as const;
// ============================ // ============================
@ -195,14 +210,15 @@ function getStoredGuestData(): Types.Guest | null {
// Main Authentication Hook // Main Authentication Hook
// ============================ // ============================
export function useAuthenticationLogic() { function useAuthenticationLogic() {
const [authState, setAuthState] = useState<AuthState>({ const [authState, setAuthState] = useState<AuthState>({
user: null, user: null,
guest: null, guest: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
isInitializing: true, isInitializing: true,
error: null error: null,
mfaResponse: null,
}); });
const [apiClient] = useState(() => new ApiClient()); const [apiClient] = useState(() => new ApiClient());
@ -242,7 +258,8 @@ export function useAuthenticationLogic() {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: null error: null,
mfaResponse: null,
}); });
return; return;
} }
@ -263,7 +280,8 @@ export function useAuthenticationLogic() {
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: null error: null,
mfaResponse: null
}); });
console.log('Token refreshed successfully'); console.log('Token refreshed successfully');
@ -278,7 +296,8 @@ export function useAuthenticationLogic() {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: null error: null,
mfaResponse: null
}); });
} }
} else { } else {
@ -291,7 +310,8 @@ export function useAuthenticationLogic() {
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: null error: null,
mfaResponse: null
}); });
console.log('Restored authentication from stored tokens'); console.log('Restored authentication from stored tokens');
@ -308,7 +328,8 @@ export function useAuthenticationLogic() {
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
isInitializing: false, isInitializing: false,
error: null error: null,
mfaResponse: null
}); });
} finally { } finally {
initializationCompleted.current = true; initializationCompleted.current = true;
@ -348,12 +369,27 @@ export function useAuthenticationLogic() {
return () => clearTimeout(refreshTimer); return () => clearTimeout(refreshTimer);
}, [authState.isAuthenticated, initializeAuth]); }, [authState.isAuthenticated, initializeAuth]);
// Enhanced login with MFA support
const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => { const login = useCallback(async (loginData: LoginRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); setAuthState(prev => ({ ...prev, isLoading: true, error: null, mfaResponse: null, mfaData: null }));
try { try {
const authResponse = await apiClient.login(loginData); const result = await apiClient.login({
login: loginData.login,
password: loginData.password,
});
if ('mfaRequired' in result) {
// MFA required for new device
setAuthState(prev => ({
...prev,
isLoading: false,
mfaResponse: result,
}));
return false; // Login not complete yet
} else {
// Normal login success
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse); storeAuthData(authResponse);
apiClient.setAuthToken(authResponse.accessToken); apiClient.setAuthToken(authResponse.accessToken);
@ -362,13 +398,54 @@ export function useAuthenticationLogic() {
user: authResponse.user, user: authResponse.user,
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
error: null error: null,
mfaResponse: null,
})); }));
console.log('Login successful'); console.log('Login successful');
return true; return true;
}
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : 'Network error occurred. Please try again.';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
mfaResponse: null,
}));
return false;
}
}, [apiClient]);
// MFA verification
const verifyMFA = useCallback(async (mfaData: MFAVerificationRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.verifyMFA(mfaData);
if (result.accessToken) {
const authResponse: Types.AuthResponse = result;
storeAuthData(authResponse);
apiClient.setAuthToken(authResponse.accessToken);
setAuthState(prev => ({
...prev,
user: authResponse.user,
isAuthenticated: true,
isLoading: false,
error: null,
mfaResponse: null,
}));
console.log('MFA verification successful');
return true;
}
return false;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Login failed'; const errorMessage = error instanceof Error ? error.message : 'MFA verification failed';
console.log(errorMessage);
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
@ -378,6 +455,91 @@ export function useAuthenticationLogic() {
} }
}, [apiClient]); }, [apiClient]);
// Resend MFA code
const resendMFACode = useCallback(async (email: string, deviceId: string, deviceName: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await apiClient.requestMFA({
email,
password: '', // This would need to be stored securely or re-entered
deviceId,
deviceName,
});
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend MFA code';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
// Clear MFA state
const clearMFA = useCallback(() => {
setAuthState(prev => ({
...prev,
mfaResponse: null,
error: null
}));
}, []);
// Email verification
const verifyEmail = useCallback(async (verificationData: EmailVerificationRequest): Promise<{ message: string; userType: string } | null> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const result = await apiClient.verifyEmail(verificationData);
setAuthState(prev => ({ ...prev, isLoading: false }));
return {
message: result.message || 'Email verified successfully',
userType: result.userType || 'user'
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Email verification failed';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return null;
}
}, [apiClient]);
// Resend email verification
const resendEmailVerification = useCallback(async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
try {
await apiClient.resendVerificationEmail({ email });
setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to resend verification email';
setAuthState(prev => ({
...prev,
isLoading: false,
error: errorMessage
}));
return false;
}
}, [apiClient]);
// Store pending verification email
const setPendingVerificationEmail = useCallback((email: string) => {
localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email);
}, []);
// Get pending verification email
const getPendingVerificationEmail = useCallback((): string | null => {
return localStorage.getItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL);
}, []);
const logout = useCallback(() => { const logout = useCallback(() => {
clearStoredAuth(); clearStoredAuth();
apiClient.clearAuthToken(); apiClient.clearAuthToken();
@ -391,7 +553,8 @@ export function useAuthenticationLogic() {
guest, guest,
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
error: null error: null,
mfaResponse: null,
})); }));
console.log('User logged out'); console.log('User logged out');
@ -413,13 +576,11 @@ export function useAuthenticationLogic() {
const candidate = await apiClient.createCandidate(candidateData); const candidate = await apiClient.createCandidate(candidateData);
console.log('Candidate created:', candidate); console.log('Candidate created:', candidate);
// Auto-login after successful registration // Store email for potential verification resend
const loginSuccess = await login({ setPendingVerificationEmail(candidateData.email);
login: candidateData.email,
password: candidateData.password
});
return loginSuccess; setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({ setAuthState(prev => ({
@ -429,7 +590,7 @@ export function useAuthenticationLogic() {
})); }));
return false; return false;
} }
}, [apiClient, login]); }, [apiClient, setPendingVerificationEmail]);
const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => { const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
@ -438,12 +599,11 @@ export function useAuthenticationLogic() {
const employer = await apiClient.createEmployer(employerData); const employer = await apiClient.createEmployer(employerData);
console.log('Employer created:', employer); console.log('Employer created:', employer);
const loginSuccess = await login({ // Store email for potential verification resend
login: employerData.email, setPendingVerificationEmail(employerData.email);
password: employerData.password
});
return loginSuccess; setAuthState(prev => ({ ...prev, isLoading: false }));
return true;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; const errorMessage = error instanceof Error ? error.message : 'Account creation failed';
setAuthState(prev => ({ setAuthState(prev => ({
@ -453,7 +613,7 @@ export function useAuthenticationLogic() {
})); }));
return false; return false;
} }
}, [apiClient, login]); }, [apiClient, setPendingVerificationEmail]);
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => { const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
setAuthState(prev => ({ ...prev, isLoading: true, error: null })); setAuthState(prev => ({ ...prev, isLoading: true, error: null }));
@ -507,6 +667,13 @@ export function useAuthenticationLogic() {
apiClient, apiClient,
login, login,
logout, logout,
verifyMFA,
resendMFACode,
clearMFA,
verifyEmail,
resendEmailVerification,
setPendingVerificationEmail,
getPendingVerificationEmail,
createCandidateAccount, createCandidateAccount,
createEmployerAccount, createEmployerAccount,
requestPasswordReset, requestPasswordReset,
@ -521,7 +688,7 @@ export function useAuthenticationLogic() {
const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null); const AuthContext = createContext<ReturnType<typeof useAuthenticationLogic> | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) { function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useAuthenticationLogic(); const auth = useAuthenticationLogic();
return ( return (
@ -531,7 +698,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
); );
} }
export function useAuth() { function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { if (!context) {
throw new Error('useAuth must be used within an AuthProvider'); throw new Error('useAuth must be used within an AuthProvider');
@ -549,7 +716,7 @@ interface ProtectedRouteProps {
requiredUserType?: Types.UserType; requiredUserType?: Types.UserType;
} }
export function ProtectedRoute({ function ProtectedRoute({
children, children,
fallback = <div>Please log in to access this page.</div>, fallback = <div>Please log in to access this page.</div>,
requiredUserType requiredUserType
@ -573,3 +740,13 @@ export function ProtectedRoute({
return <>{children}</>; return <>{children}</>;
} }
export type {
AuthState, LoginRequest, MFAVerificationRequest, EmailVerificationRequest, ResendVerificationRequest, PasswordResetRequest
}
export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client';
export {
useAuthenticationLogic, AuthProvider, useAuth, ProtectedRoute
}

View File

@ -142,6 +142,7 @@ const documents : DocType[] = [
{ title: "BETA", route: "beta", description: "Details about the current beta version and upcoming features", icon: <CodeIcon /> }, { title: "BETA", route: "beta", description: "Details about the current beta version and upcoming features", icon: <CodeIcon /> },
{ title: "Resume Generation Architecture", route: "resume-generation", description: "Technical overview of how resumes are processed and generated", icon: <LayersIcon /> }, { title: "Resume Generation Architecture", route: "resume-generation", description: "Technical overview of how resumes are processed and generated", icon: <LayersIcon /> },
{ title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: <LayersIcon /> }, { title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: <LayersIcon /> },
{ title: "Authentication Architecture", route: "authentication.md", description: "Complete authentication architecture", icon: <LayersIcon /> },
{ title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: <DashboardIcon /> }, { title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: <DashboardIcon /> },
{ title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: <DashboardIcon /> }, { title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: <DashboardIcon /> },
{ title: "Chat Mockup", route: "mockup-chat-system", description: "Mockup of chat system", icon: <DashboardIcon /> }, { title: "Chat Mockup", route: "mockup-chat-system", description: "Mockup of chat system", icon: <DashboardIcon /> },

View File

@ -1,5 +1,5 @@
/** /**
* Enhanced API Client with Streaming Support and Date Conversion * API Client with Streaming Support and Date Conversion
* *
* This demonstrates how to use the generated types with the conversion utilities * This demonstrates how to use the generated types with the conversion utilities
* for seamless frontend-backend communication, including streaming responses and * for seamless frontend-backend communication, including streaming responses and
@ -54,11 +54,6 @@ interface StreamingResponse {
promise: Promise<Types.ChatMessage[]>; promise: Promise<Types.ChatMessage[]>;
} }
export interface LoginRequest {
login: string; // email or username
password: string;
}
export interface CreateCandidateRequest { export interface CreateCandidateRequest {
email: string; email: string;
username: string; username: string;
@ -261,37 +256,38 @@ class ApiClient {
/** /**
* Request MFA for new device * Request MFA for new device
*/ */
async requestMFA(request: MFARequest): Promise<MFARequestResponse> { async requestMFA(request: MFARequest): Promise<Types.MFARequestResponse> {
const response = await fetch(`${this.baseUrl}/auth/mfa/request`, { const response = await fetch(`${this.baseUrl}/auth/mfa/request`, {
method: 'POST', method: 'POST',
headers: this.defaultHeaders, headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)) body: JSON.stringify(formatApiRequest(request))
}); });
return handleApiResponse<MFARequestResponse>(response); return handleApiResponse<Types.MFARequestResponse>(response);
} }
/** /**
* Verify MFA code * Verify MFA code
*/ */
async verifyMFA(request: MFAVerifyRequest): Promise<Types.AuthResponse> { async verifyMFA(request: Types.MFAVerifyRequest): Promise<Types.AuthResponse> {
const formattedRequest = formatApiRequest(request)
const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, { const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, {
method: 'POST', method: 'POST',
headers: this.defaultHeaders, headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request)) body: JSON.stringify(formattedRequest)
}); });
return handleApiResponse<Types.AuthResponse>(response); return handleApiResponse<Types.AuthResponse>(response);
} }
/** /**
* Enhanced login with device detection * login with device detection
*/ */
async loginEnhanced(email: string, password: string): Promise<Types.AuthResponse | MFARequestResponse> { async login(auth: Types.LoginRequest): Promise<Types.AuthResponse | Types.MFARequestResponse> {
const response = await fetch(`${this.baseUrl}/auth/login`, { const response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST', method: 'POST',
headers: this.defaultHeaders, headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ login: email, password })) body: JSON.stringify(formatApiRequest(auth))
}); });
// This could return either a full auth response or MFA request // This could return either a full auth response or MFA request
@ -307,7 +303,7 @@ class ApiClient {
/** /**
* Logout with token revocation * Logout with token revocation
*/ */
async logoutEnhanced(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> { async logout(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> {
const response = await fetch(`${this.baseUrl}/auth/logout`, { const response = await fetch(`${this.baseUrl}/auth/logout`, {
method: 'POST', method: 'POST',
headers: this.defaultHeaders, headers: this.defaultHeaders,
@ -495,27 +491,6 @@ class ApiClient {
// ============================ // ============================
// Authentication Methods // Authentication Methods
// ============================ // ============================
async login(request: LoginRequest): Promise<Types.AuthResponse> {
const response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
// AuthResponse doesn't typically have date fields, use standard handler
return handleApiResponse<Types.AuthResponse>(response);
}
async logout(accessToken: string, refreshToken: string): Promise<Types.ApiResponse> {
const response = await fetch(`${this.baseUrl}/auth/logout`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ accessToken, refreshToken }))
});
return handleApiResponse<Types.ApiResponse>(response);
}
async refreshToken(refreshToken: string): Promise<Types.AuthResponse> { async refreshToken(refreshToken: string): Promise<Types.AuthResponse> {
const response = await fetch(`${this.baseUrl}/auth/refresh`, { const response = await fetch(`${this.baseUrl}/auth/refresh`, {
method: 'POST', method: 'POST',
@ -1094,7 +1069,7 @@ class ApiClient {
// ============================ // ============================
// Enhanced Request/Response Types // Request/Response Types
// ============================ // ============================
export interface CreateCandidateWithVerificationRequest { export interface CreateCandidateWithVerificationRequest {
@ -1133,13 +1108,6 @@ export interface MFARequest {
deviceName: string; deviceName: string;
} }
export interface MFAVerifyRequest {
email: string;
code: string;
deviceId: string;
rememberDevice: boolean;
}
export interface RegistrationResponse { export interface RegistrationResponse {
message: string; message: string;
email: string; email: string;
@ -1152,12 +1120,6 @@ export interface EmailVerificationResponse {
userType: string; userType: string;
} }
export interface MFARequestResponse {
mfaRequired: boolean;
message: string;
deviceId?: string;
}
export interface TrustedDevice { export interface TrustedDevice {
deviceId: string; deviceId: string;
deviceName: string; deviceName: string;
@ -1202,7 +1164,7 @@ export interface PendingVerification {
/* /*
// Registration with email verification // Registration with email verification
const apiClient = new EnhancedApiClient(); const apiClient = new ApiClient();
try { try {
const result = await apiClient.createCandidateWithVerification({ const result = await apiClient.createCandidateWithVerification({
@ -1226,9 +1188,9 @@ try {
console.error('Registration failed:', error); console.error('Registration failed:', error);
} }
// Enhanced login with MFA support // login with MFA support
try { try {
const loginResult = await apiClient.loginEnhanced('user@example.com', 'password'); const loginResult = await apiClient.login('user@example.com', 'password');
if ('mfaRequired' in loginResult && loginResult.mfaRequired) { if ('mfaRequired' in loginResult && loginResult.mfaRequired) {
// Show MFA dialog // Show MFA dialog

View File

@ -132,7 +132,7 @@ export function formatApiRequest<T extends Record<string, any>>(data: T): Record
} }
} }
return formatted; return toSnakeCase(formatted);
} }
/** /**

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // Source: src/backend/models.py
// Generated on: 2025-06-01T01:48:43.853130 // Generated on: 2025-06-01T20:40:46.797024
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -145,6 +145,7 @@ export interface BaseUser {
lastLogin?: Date; lastLogin?: Date;
profileImage?: string; profileImage?: string;
status: "active" | "inactive" | "pending" | "banned"; status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
} }
export interface BaseUserWithType { export interface BaseUserWithType {
@ -160,6 +161,7 @@ export interface BaseUserWithType {
lastLogin?: Date; lastLogin?: Date;
profileImage?: string; profileImage?: string;
status: "active" | "inactive" | "pending" | "banned"; status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
userType: "candidate" | "employer" | "guest"; userType: "candidate" | "employer" | "guest";
} }
@ -176,6 +178,7 @@ export interface Candidate {
lastLogin?: Date; lastLogin?: Date;
profileImage?: string; profileImage?: string;
status: "active" | "inactive" | "pending" | "banned"; status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
userType: "candidate"; userType: "candidate";
username: string; username: string;
description?: string; description?: string;
@ -395,6 +398,7 @@ export interface Employer {
lastLogin?: Date; lastLogin?: Date;
profileImage?: string; profileImage?: string;
status: "active" | "inactive" | "pending" | "banned"; status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
userType: "employer"; userType: "employer";
companyName: string; companyName: string;
industry: string; industry: string;
@ -539,13 +543,31 @@ export interface Location {
address?: string; address?: string;
} }
export interface MFARequest { export interface LoginRequest {
login: string;
password: string;
}
export interface MFAData {
message: string;
deviceId: string;
deviceName: string;
codeSent: string;
email: string; email: string;
}
export interface MFARequest {
username: string;
password: string; password: string;
deviceId: string; deviceId: string;
deviceName: string; deviceName: string;
} }
export interface MFARequestResponse {
mfaRequired: boolean;
mfaData?: MFAData;
}
export interface MFAVerifyRequest { export interface MFAVerifyRequest {
email: string; email: string;
code: string; code: string;

View File

@ -350,6 +350,154 @@ class RedisDatabase:
await self.redis.delete(key) await self.redis.delete(key)
# MFA and Email Verification operations # MFA and Email Verification operations
async def find_verification_token_by_email(self, email: str) -> Optional[Dict[str, Any]]:
"""Find pending verification token by email address"""
try:
pattern = "email_verification:*"
cursor = 0
email_lower = email.lower()
while True:
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
for key in keys:
token_data = await self.redis.get(key)
if token_data:
verification_info = json.loads(token_data)
if (verification_info.get("email", "").lower() == email_lower and
not verification_info.get("verified", False)):
# Extract token from key
token = key.replace("email_verification:", "")
verification_info["token"] = token
return verification_info
if cursor == 0:
break
return None
except Exception as e:
logger.error(f"❌ Error finding verification token by email {email}: {e}")
return None
async def get_pending_verifications_count(self) -> int:
"""Get count of pending email verifications (admin function)"""
try:
pattern = "email_verification:*"
cursor = 0
count = 0
while True:
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
for key in keys:
token_data = await self.redis.get(key)
if token_data:
verification_info = json.loads(token_data)
if not verification_info.get("verified", False):
count += 1
if cursor == 0:
break
return count
except Exception as e:
logger.error(f"❌ Error counting pending verifications: {e}")
return 0
async def cleanup_expired_verification_tokens(self) -> int:
"""Clean up expired verification tokens and return count of cleaned tokens"""
try:
pattern = "email_verification:*"
cursor = 0
cleaned_count = 0
current_time = datetime.now(timezone.utc)
while True:
cursor, keys = await self.redis.scan(cursor, match=pattern, count=100)
for key in keys:
token_data = await self.redis.get(key)
if token_data:
verification_info = json.loads(token_data)
expires_at = datetime.fromisoformat(verification_info.get("expires_at", ""))
if current_time > expires_at:
await self.redis.delete(key)
cleaned_count += 1
logger.debug(f"🧹 Cleaned expired verification token for {verification_info.get('email')}")
if cursor == 0:
break
if cleaned_count > 0:
logger.info(f"🧹 Cleaned up {cleaned_count} expired verification tokens")
return cleaned_count
except Exception as e:
logger.error(f"❌ Error cleaning up expired verification tokens: {e}")
return 0
async def get_verification_attempts_count(self, email: str) -> int:
"""Get the number of verification emails sent for an email in the last 24 hours"""
try:
key = f"verification_attempts:{email.lower()}"
data = await self.redis.get(key)
if not data:
return 0
attempts_data = json.loads(data)
current_time = datetime.now(timezone.utc)
window_start = current_time - timedelta(hours=24)
# Filter out old attempts
recent_attempts = [
attempt for attempt in attempts_data
if datetime.fromisoformat(attempt) > window_start
]
return len(recent_attempts)
except Exception as e:
logger.error(f"❌ Error getting verification attempts count for {email}: {e}")
return 0
async def record_verification_attempt(self, email: str) -> bool:
"""Record a verification email attempt"""
try:
key = f"verification_attempts:{email.lower()}"
current_time = datetime.now(timezone.utc)
# Get existing attempts
data = await self.redis.get(key)
attempts_data = json.loads(data) if data else []
# Add current attempt
attempts_data.append(current_time.isoformat())
# Keep only last 24 hours of attempts
window_start = current_time - timedelta(hours=24)
recent_attempts = [
attempt for attempt in attempts_data
if datetime.fromisoformat(attempt) > window_start
]
# Store with 24 hour expiration
await self.redis.setex(
key,
24 * 60 * 60, # 24 hours
json.dumps(recent_attempts)
)
return True
except Exception as e:
logger.error(f"❌ Error recording verification attempt for {email}: {e}")
return False
async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool: async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool:
"""Store email verification token with user data""" """Store email verification token with user data"""
try: try:
@ -410,6 +558,7 @@ class RedisDatabase:
async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool: async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool:
"""Store MFA code for verification""" """Store MFA code for verification"""
try: try:
logger.info("🔐 Storing MFA code for email: %s", email )
key = f"mfa_code:{email.lower()}:{device_id}" key = f"mfa_code:{email.lower()}:{device_id}"
mfa_data = { mfa_data = {
"code": code, "code": code,

View File

@ -1,4 +1,5 @@
import os import os
from typing import Tuple
from logger import logger from logger import logger
from email.mime.text import MIMEText # type: ignore from email.mime.text import MIMEText # type: ignore
from email.mime.multipart import MIMEMultipart # type: ignore from email.mime.multipart import MIMEMultipart # type: ignore
@ -11,165 +12,16 @@ from database import RedisDatabase
class EmailService: class EmailService:
def __init__(self): def __init__(self):
self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com") # Configure these in your .env file
self.smtp_server = os.getenv("SMTP_SERVER")
self.smtp_port = int(os.getenv("SMTP_PORT", "587")) self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.email_user = os.getenv("EMAIL_USER", "your-app@example.com") self.email_user = os.getenv("EMAIL_USER")
self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password") self.email_password = os.getenv("EMAIL_PASSWORD")
self.from_name = os.getenv("FROM_NAME", "Backstory")
async def send_verification_email(self, to_email: str, verification_token: str, user_name: str):
"""Send email verification email"""
try:
verification_link = f"{os.getenv('FRONTEND_URL', 'https://backstory-beta.ketrenos.com')}/verify-email?token={verification_token}"
subject = f"Welcome to {self.from_name} - Please verify your email"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Verification</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: white; padding: 30px; border: 1px solid #e1e5e9; }}
.button {{ display: inline-block; background: #667eea; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; margin: 20px 0; }}
.footer {{ background: #f8f9fa; padding: 20px; text-align: center; border-radius: 0 0 8px 8px; font-size: 14px; color: #6c757d; }}
.security-note {{ background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 6px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to {self.from_name}!</h1>
<p>Thanks for joining us, {user_name}</p>
</div>
<div class="content">
<h2>Please verify your email address</h2>
<p>To complete your registration and start using {self.from_name}, please verify your email address by clicking the button below:</p>
<a href="{verification_link}" class="button">Verify Email Address</a>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #667eea;">{verification_link}</p>
<div class="security-note">
<strong>Security Note:</strong> This verification link will expire in 24 hours. If you didn't create this account, please ignore this email.
</div>
</div>
<div class="footer">
<p>This email was sent to {to_email}<br>
If you have any questions, contact our support team.</p>
</div>
</div>
</body>
</html>
"""
await self._send_email(to_email, subject, html_content)
logger.info(f"📧 Verification email sent to {to_email}")
except Exception as e:
logger.error(f"❌ Failed to send verification email to {to_email}: {e}")
raise
async def send_mfa_email(self, to_email: str, mfa_code: str, device_name: str, user_name: str):
"""Send MFA code email"""
try:
subject = f"Security Code for {self.from_name}"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Code</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: white; padding: 30px; border: 1px solid #e1e5e9; }}
.code {{ background: #f8f9fa; border: 2px solid #667eea; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #667eea; border-radius: 8px; margin: 20px 0; }}
.footer {{ background: #f8f9fa; padding: 20px; text-align: center; border-radius: 0 0 8px 8px; font-size: 14px; color: #6c757d; }}
.warning {{ background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 15px; border-radius: 6px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 Security Code</h1>
<p>Hi {user_name}</p>
</div>
<div class="content">
<h2>New device login detected</h2>
<p>We detected a login attempt from a new device: <strong>{device_name}</strong></p>
<p>Please enter this security code to complete your login:</p>
<div class="code">{mfa_code}</div>
<p>This code will expire in 10 minutes.</p>
<div class="warning">
<strong> Important:</strong> If you didn't attempt to log in, please change your password immediately and contact our support team.
</div>
</div>
<div class="footer">
<p>This email was sent to {to_email}<br>
For security questions, contact our support team.</p>
</div>
</div>
</body>
</html>
"""
await self._send_email(to_email, subject, html_content)
logger.info(f"📧 MFA code sent to {to_email} for device {device_name}")
except Exception as e:
logger.error(f"❌ Failed to send MFA email to {to_email}: {e}")
raise
async def _send_email(self, to_email: str, subject: str, html_content: str):
"""Send email using SMTP"""
try:
# Create message
msg = MIMEMultipart('alternative')
msg['From'] = f"{self.from_name} <{self.email_user}>"
msg['To'] = to_email
msg['Subject'] = subject
# Add HTML content
html_part = MIMEText(html_content, 'html')
msg.attach(html_part)
# Send email
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
server.starttls()
server.login(self.email_user, self.email_password)
text = msg.as_string()
server.sendmail(self.email_user, to_email, text)
logger.debug(f"📧 Email sent successfully to {to_email}")
except Exception as e:
logger.error(f"❌ SMTP error sending to {to_email}: {e}")
raise
email_service = EmailService()
class EnhancedEmailService:
def __init__(self):
self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com")
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.email_user = os.getenv("EMAIL_USER", "your-app@example.com")
self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password")
self.from_name = os.getenv("FROM_NAME", "Backstory") self.from_name = os.getenv("FROM_NAME", "Backstory")
self.app_name = os.getenv("APP_NAME", "Backstory") self.app_name = os.getenv("APP_NAME", "Backstory")
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com") self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
if not self.smtp_server or self.smtp_port == 0 or self.email_user is None or self.email_password is None:
raise ValueError("SMTP configuration is not set in the environment variables")
def _get_template(self, template_name: str) -> dict: def _get_template(self, template_name: str) -> dict:
"""Get email template by name""" """Get email template by name"""
@ -279,6 +131,8 @@ class EnhancedEmailService:
async def _send_email(self, to_email: str, subject: str, html_content: str): async def _send_email(self, to_email: str, subject: str, html_content: str):
"""Send email using SMTP with improved error handling""" """Send email using SMTP with improved error handling"""
try: try:
if not self.email_user:
raise ValueError("Email user is not configured")
# Create message # Create message
msg = MIMEMultipart('alternative') msg = MIMEMultipart('alternative')
msg['From'] = f"{self.from_name} <{self.email_user}>" msg['From'] = f"{self.from_name} <{self.email_user}>"
@ -292,6 +146,9 @@ class EnhancedEmailService:
# Send email with connection pooling and retry logic # Send email with connection pooling and retry logic
max_retries = 3 max_retries = 3
if not self.smtp_server or self.smtp_port == 0 or not self.email_user or not self.email_password:
raise ValueError("SMTP configuration is not set in the environment variables")
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server: with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
@ -365,3 +222,65 @@ class EmailRateLimiter:
ttl_minutes * 60, ttl_minutes * 60,
json.dumps([timestamp.isoformat()]) json.dumps([timestamp.isoformat()])
) )
class VerificationEmailRateLimiter:
def __init__(self, database: RedisDatabase):
self.database = database
self.max_attempts_per_hour = 3 # Maximum 3 emails per hour
self.max_attempts_per_day = 10 # Maximum 10 emails per day
self.cooldown_minutes = 5 # 5 minute cooldown between emails
async def can_send_verification_email(self, email: str) -> Tuple[bool, str]:
"""
Check if verification email can be sent based on rate limiting
Returns (can_send, reason_if_not)
"""
try:
email_lower = email.lower()
current_time = datetime.now(timezone.utc)
# Check daily limit
daily_count = await self.database.get_verification_attempts_count(email)
if daily_count >= self.max_attempts_per_day:
return False, f"Daily limit reached. You can request up to {self.max_attempts_per_day} verification emails per day."
# Check hourly limit
hour_ago = current_time - timedelta(hours=1)
hourly_key = f"verification_attempts:{email_lower}"
data = await self.database.redis.get(hourly_key)
if data:
attempts_data = json.loads(data)
recent_attempts = [
attempt for attempt in attempts_data
if datetime.fromisoformat(attempt) > hour_ago
]
if len(recent_attempts) >= self.max_attempts_per_hour:
return False, f"Hourly limit reached. You can request up to {self.max_attempts_per_hour} verification emails per hour."
# Check cooldown period
if recent_attempts:
last_attempt = max(datetime.fromisoformat(attempt) for attempt in recent_attempts)
time_since_last = current_time - last_attempt
if time_since_last.total_seconds() < self.cooldown_minutes * 60:
remaining_minutes = self.cooldown_minutes - int(time_since_last.total_seconds() / 60)
return False, f"Please wait {remaining_minutes} more minute(s) before requesting another email."
return True, "OK"
except Exception as e:
logger.error(f"❌ Error checking verification email rate limit: {e}")
# On error, be conservative and deny
return False, "Rate limit check failed. Please try again later."
async def record_email_sent(self, email: str):
"""Record that a verification email was sent"""
await self.database.record_verification_attempt(email)
email_service = EmailService()

View File

@ -10,90 +10,93 @@ EMAIL_TEMPLATES = {
<title>Email Verification</title> <title>Email Verification</title>
<style> <style>
body {{ body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6; line-height: 1.6;
color: #333; color: #2E2E2E;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #f5f5f5; background-color: #D3CDBF;
}} }}
.container {{ .container {{
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
background-color: white; background-color: #FFFFFF;
border-radius: 12px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(26, 37, 54, 0.15);
margin-top: 40px; margin-top: 40px;
margin-bottom: 40px; margin-bottom: 40px;
}} }}
.header {{ .header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #1A2536 0%, #4A7A7D 100%);
color: white; color: #D3CDBF;
padding: 40px 30px; padding: 40px 30px;
text-align: center; text-align: center;
}} }}
.header h1 {{ .header h1 {{
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-size: 28px; font-size: 2rem;
font-weight: 600; font-weight: 500;
color: #D3CDBF;
}} }}
.header p {{ .header p {{
margin: 0; margin: 0;
opacity: 0.9; opacity: 0.9;
font-size: 16px; font-size: 1rem;
color: #D3CDBF;
}} }}
.content {{ .content {{
padding: 40px 30px; padding: 40px 30px;
}} }}
.content h2 {{ .content h2 {{
margin: 0 0 20px 0; margin: 0 0 20px 0;
color: #333; color: #2E2E2E;
font-size: 24px; font-size: 1.75rem;
font-weight: 500;
}} }}
.button {{ .button {{
display: inline-block; display: inline-block;
background: #667eea; background: #D4A017;
color: white; color: #FFFFFF;
padding: 16px 32px; padding: 16px 32px;
text-decoration: none; text-decoration: none;
border-radius: 8px; border-radius: 4px;
font-weight: 600; font-weight: 500;
margin: 24px 0; margin: 24px 0;
font-size: 16px; font-size: 1rem;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
}} }}
.button:hover {{ .button:hover {{
background: #5a6fd8; background: rgba(212, 160, 23, 0.8);
}} }}
.link-text {{ .link-text {{
word-break: break-all; word-break: break-all;
color: #667eea; color: #4A7A7D;
background-color: #f8f9ff; background-color: rgba(74, 122, 125, 0.1);
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 4px;
font-family: monospace; font-family: 'Roboto', monospace;
font-size: 14px; font-size: 0.875rem;
margin: 16px 0; margin: 16px 0;
}} }}
.footer {{ .footer {{
background: #f8f9fa; background: #D3CDBF;
padding: 30px; padding: 30px;
text-align: center; text-align: center;
font-size: 14px; font-size: 0.875rem;
color: #6c757d; color: #1A2536;
border-top: 1px solid #e9ecef; border-top: 1px solid rgba(26, 37, 54, 0.1);
}} }}
.security-note {{ .security-note {{
background: #fff3cd; background: rgba(212, 160, 23, 0.1);
border: 1px solid #ffeaa7; border: 1px solid #D4A017;
padding: 16px; padding: 16px;
border-radius: 8px; border-radius: 4px;
margin: 24px 0; margin: 24px 0;
color: #856404; color: #2E2E2E;
}} }}
.security-note strong {{ .security-note strong {{
color: #664d03; color: #1A2536;
}} }}
</style> </style>
</head> </head>
@ -140,83 +143,90 @@ EMAIL_TEMPLATES = {
<title>Security Code</title> <title>Security Code</title>
<style> <style>
body {{ body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6; line-height: 1.6;
color: #333; color: #2E2E2E;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #f5f5f5; background-color: #D3CDBF;
}} }}
.container {{ .container {{
max-width: 600px; max-width: 600px;
margin: 40px auto; margin: 40px auto;
background-color: white; background-color: #FFFFFF;
border-radius: 12px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(26, 37, 54, 0.15);
}} }}
.header {{ .header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #1A2536 0%, #4A7A7D 100%);
color: white; color: #D3CDBF;
padding: 40px 30px; padding: 40px 30px;
text-align: center; text-align: center;
}} }}
.header h1 {{ .header h1 {{
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-size: 28px; font-size: 2rem;
font-weight: 600; font-weight: 500;
color: #D3CDBF;
}} }}
.content {{ .content {{
padding: 40px 30px; padding: 40px 30px;
}} }}
.content h2 {{
margin: 0 0 20px 0;
color: #2E2E2E;
font-size: 1.75rem;
font-weight: 500;
}}
.device-info {{ .device-info {{
background: #e3f2fd; background: rgba(74, 122, 125, 0.1);
border: 1px solid #2196f3; border: 1px solid #4A7A7D;
padding: 16px; padding: 16px;
border-radius: 8px; border-radius: 4px;
margin: 20px 0; margin: 20px 0;
color: #1565c0; color: #1A2536;
}} }}
.code {{ .code {{
background: #f8f9fa; background: #D3CDBF;
border: 3px solid #667eea; border: 3px solid #D4A017;
padding: 24px; padding: 24px;
text-align: center; text-align: center;
font-size: 36px; font-size: 2.25rem;
font-weight: bold; font-weight: 500;
letter-spacing: 12px; letter-spacing: 12px;
color: #667eea; color: #1A2536;
border-radius: 12px; border-radius: 4px;
margin: 32px 0; margin: 32px 0;
font-family: 'Courier New', monospace; font-family: 'Roboto', 'Courier New', monospace;
}} }}
.footer {{ .footer {{
background: #f8f9fa; background: #D3CDBF;
padding: 30px; padding: 30px;
text-align: center; text-align: center;
font-size: 14px; font-size: 0.875rem;
color: #6c757d; color: #1A2536;
border-top: 1px solid #e9ecef; border-top: 1px solid rgba(26, 37, 54, 0.1);
}} }}
.warning {{ .warning {{
background: #f8d7da; background: rgba(212, 160, 23, 0.1);
border: 1px solid #f5c6cb; border: 1px solid #D4A017;
color: #721c24; color: #2E2E2E;
padding: 16px; padding: 16px;
border-radius: 8px; border-radius: 4px;
margin: 24px 0; margin: 24px 0;
}} }}
.warning strong {{ .warning strong {{
color: #491217; color: #1A2536;
}} }}
.expiry-info {{ .expiry-info {{
background: #fff3cd; background: rgba(74, 122, 125, 0.1);
border: 1px solid #ffeaa7; border: 1px solid #4A7A7D;
color: #856404; color: #1A2536;
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 4px;
margin: 16px 0; margin: 16px 0;
font-size: 14px; font-size: 0.875rem;
text-align: center; text-align: center;
}} }}
</style> </style>
@ -277,44 +287,75 @@ EMAIL_TEMPLATES = {
<title>Password Reset</title> <title>Password Reset</title>
<style> <style>
body {{ body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6; line-height: 1.6;
color: #333; color: #2E2E2E;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #f5f5f5; background-color: #D3CDBF;
}} }}
.container {{ .container {{
max-width: 600px; max-width: 600px;
margin: 40px auto; margin: 40px auto;
background-color: white; background-color: #FFFFFF;
border-radius: 12px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(26, 37, 54, 0.15);
}} }}
.header {{ .header {{
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); background: linear-gradient(135deg, #4A7A7D 0%, #1A2536 100%);
color: white; color: #D3CDBF;
padding: 40px 30px; padding: 40px 30px;
text-align: center; text-align: center;
}} }}
.content {{ padding: 40px 30px; }} .header h1 {{
margin: 0 0 10px 0;
font-size: 2rem;
font-weight: 500;
color: #D3CDBF;
}}
.content {{
padding: 40px 30px;
}}
.content h2 {{
margin: 0 0 20px 0;
color: #2E2E2E;
font-size: 1.75rem;
font-weight: 500;
}}
.button {{ .button {{
display: inline-block; display: inline-block;
background: #ff6b6b; background: #D4A017;
color: white; color: #FFFFFF;
padding: 16px 32px; padding: 16px 32px;
text-decoration: none; text-decoration: none;
border-radius: 8px; border-radius: 4px;
font-weight: 600; font-weight: 500;
margin: 24px 0; margin: 24px 0;
font-size: 1rem;
transition: background-color 0.3s ease;
}}
.button:hover {{
background: rgba(212, 160, 23, 0.8);
}} }}
.footer {{ .footer {{
background: #f8f9fa; background: #D3CDBF;
padding: 30px; padding: 30px;
text-align: center; text-align: center;
font-size: 14px; font-size: 0.875rem;
color: #6c757d; color: #1A2536;
border-top: 1px solid rgba(26, 37, 54, 0.1);
}}
.security-note {{
background: rgba(212, 160, 23, 0.1);
border: 1px solid #D4A017;
padding: 16px;
border-radius: 4px;
margin: 24px 0;
color: #2E2E2E;
}}
.security-note strong {{
color: #1A2536;
}} }}
</style> </style>
</head> </head>
@ -332,11 +373,14 @@ EMAIL_TEMPLATES = {
<a href="{reset_link}" class="button">Reset Password</a> <a href="{reset_link}" class="button">Reset Password</a>
</div> </div>
<p>This link will expire in 1 hour for security reasons.</p> <div class="security-note">
<p>If you didn't request a password reset, please ignore this email and your password will remain unchanged.</p> <strong>Security Information:</strong><br>
This link will expire in 1 hour for security reasons. If you didn't request a password reset, please ignore this email and your password will remain unchanged.
</div>
</div> </div>
<div class="footer"> <div class="footer">
<p>This email was sent to {to_email}</p> <p><strong>This email was sent to:</strong> {to_email}</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 {app_name}. All rights reserved.</p> <p>&copy; 2024 {app_name}. All rights reserved.</p>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,15 @@
from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated
from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator # type: ignore from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator, field_validator # type: ignore
from pydantic.types import constr, conint # type: ignore from pydantic.types import constr, conint # type: ignore
from datetime import datetime, date, UTC from datetime import datetime, date, UTC
from enum import Enum from enum import Enum
import uuid import uuid
from auth_utils import (
AuthenticationManager,
validate_password_strength,
sanitize_login_input,
SecurityConfig
)
# Generic type variable # Generic type variable
T = TypeVar('T') T = TypeVar('T')
@ -190,24 +196,62 @@ class SortOrder(str, Enum):
DESC = "desc" DESC = "desc"
class LoginRequest(BaseModel):
login: str # Can be email or username
password: str
@field_validator('login')
def sanitize_login(cls, v):
return sanitize_login_input(v)
@field_validator('password')
def validate_password_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('Password cannot be empty')
return v
# ============================ # ============================
# MFA Models # MFA Models
# ============================ # ============================
class EmailVerificationRequest(BaseModel): class EmailVerificationRequest(BaseModel):
token: str token: str
class MFARequest(BaseModel): class MFARequest(BaseModel):
email: EmailStr username: str
password: str password: str
device_id: str device_id: str = Field(..., alias="deviceId")
device_name: str device_name: str = Field(..., alias="deviceName")
model_config = {
"populate_by_name": True, # Allow both field names and aliases
}
class MFAVerifyRequest(BaseModel): class MFAVerifyRequest(BaseModel):
email: EmailStr email: EmailStr
code: str code: str
device_id: str device_id: str = Field(..., alias="deviceId")
remember_device: bool = False remember_device: bool = Field(False, alias="rememberDevice")
model_config = {
"populate_by_name": True, # Allow both field names and aliases
}
class MFAData(BaseModel):
message: str
device_id: str = Field(..., alias="deviceId")
device_name: str = Field(..., alias="deviceName")
code_sent: str = Field(..., alias="codeSent")
email: str
model_config = {
"populate_by_name": True, # Allow both field names and aliases
}
class MFARequestResponse(BaseModel):
mfa_required: bool = Field(..., alias="mfaRequired")
mfa_data: Optional[MFAData] = Field(None, alias="mfaData")
model_config = {
"populate_by_name": True, # Allow both field names and aliases
}
class ResendVerificationRequest(BaseModel): class ResendVerificationRequest(BaseModel):
email: EmailStr email: EmailStr
@ -401,6 +445,7 @@ class BaseUser(BaseModel):
last_login: Optional[datetime] = Field(None, alias="lastLogin") last_login: Optional[datetime] = Field(None, alias="lastLogin")
profile_image: Optional[str] = Field(None, alias="profileImage") profile_image: Optional[str] = Field(None, alias="profileImage")
status: UserStatus status: UserStatus
is_admin: bool = Field(default=False, alias="isAdmin")
model_config = { model_config = {
"populate_by_name": True, # Allow both field names and aliases "populate_by_name": True, # Allow both field names and aliases