MFA is working

This commit is contained in:
James Ketr 2025-06-01 13:42:32 -07:00
parent 360673e60d
commit d7a81481a2
14 changed files with 1214 additions and 1648 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';
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
import { useNavigate } from 'react-router-dom';
// Email Verification Component
const EmailVerificationPage = (props: BackstoryPageProps) => {
const { apiClient } = useAuth();
const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } = useAuth();
const navigate = useNavigate();
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>('');
@ -57,62 +58,42 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
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 result = await verifyEmail({ token });
const data = await response.json();
if (data.success) {
if (result) {
setStatus('success');
setMessage(data.data.message);
setUserType(data.data.userType);
setMessage(result.message);
setUserType(result.userType);
// Redirect to login after 3 seconds
setTimeout(() => {
window.location.href = '/login';
navigate('/login');
}, 3000);
} else {
setStatus('error');
setMessage(data.error?.message || 'Verification failed');
setMessage('Email verification failed');
}
} catch (error) {
setStatus('error');
setMessage('Network error occurred. Please try again.');
} finally {
setLoading(false);
setMessage('Email verification failed');
}
};
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 email = getPendingVerificationEmail();
if (!email) {
setMessage('No pending verification email found.');
return;
}
const data = await response.json();
if (data.success) {
setMessage('Verification email sent! Please check your inbox.');
try {
const success = await resendEmailVerification(email);
if (success) {
setMessage('Verification email sent successfully!');
}
} catch (error) {
setMessage('Failed to resend verification email.');
} finally {
setLoading(false);
}
};
@ -167,18 +148,18 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
)}
</Box>
{loading && (
{isLoading && (
<Box display="flex" justifyContent="center" my={3}>
<CircularProgress />
</Box>
)}
{message && (
{(message || error) && (
<Alert
severity={status === 'success' ? 'success' : status === 'error' ? 'error' : 'info'}
sx={{ mt: 2 }}
>
{message}
{message || error}
</Alert>
)}
@ -189,7 +170,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Typography>
<Button
variant="contained"
onClick={() => window.location.href = '/login'}
onClick={() => navigate('/login')}
fullWidth
>
Go to Login
@ -202,7 +183,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
<Button
variant="outlined"
onClick={handleResendVerification}
disabled={loading}
disabled={isLoading}
startIcon={<RefreshIcon />}
fullWidth
sx={{ mb: 2 }}
@ -211,7 +192,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
</Button>
<Button
variant="contained"
onClick={() => window.location.href = '/login'}
onClick={() => navigate('/login')}
fullWidth
>
Back to Login
@ -225,27 +206,34 @@ const EmailVerificationPage = (props: BackstoryPageProps) => {
}
// MFA Verification Component
const MFAVerificationDialog = ({
open,
onClose,
email,
deviceId,
deviceName,
onVerificationSuccess
}: {
interface MFAVerificationDialogProps {
open: boolean;
onClose: () => void;
email: string;
deviceId: string;
deviceName: string;
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 [rememberDevice, setRememberDevice] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [localError, setLocalError] = useState('');
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(() => {
if (!open) return;
@ -254,7 +242,7 @@ const MFAVerificationDialog = ({
setTimeLeft((prev) => {
if (prev <= 1) {
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 prev - 1;
@ -272,73 +260,61 @@ const MFAVerificationDialog = ({
const handleVerifyMFA = async () => {
if (!code || code.length !== 6) {
setError('Please enter a valid 6-digit code');
setLocalError('Please enter a valid 6-digit code');
return;
}
setLoading(true);
setError('');
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
setLocalError('');
try {
const response = await fetch('/api/1.0/auth/mfa/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
const success = await verifyMFA({
email: mfaResponse.mfaData.email,
code,
deviceId,
deviceId: mfaResponse.mfaData.deviceId,
rememberDevice,
}),
});
const data = await response.json();
if (data.success) {
onVerificationSuccess(data.data);
if (success) {
onVerificationSuccess({ success: true });
onClose();
} else {
setError(data.error?.message || 'Invalid verification code');
}
} catch (error) {
setError('Network error occurred. Please try again.');
} finally {
setLoading(false);
setLocalError('Verification failed. Please try again.');
}
};
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,
}),
});
if (!mfaResponse || !mfaResponse.mfaData) {
setLocalError('MFA data not available');
return;
}
const data = await response.json();
if (data.success) {
try {
const success = await resendMFACode(mfaResponse.mfaData.email, mfaResponse.mfaData.deviceId, mfaResponse.mfaData.deviceName);
if (success) {
setTimeLeft(600); // Reset timer
setError('');
setLocalError('');
alert('New verification code sent to your email');
}
} catch (error) {
setError('Failed to resend code');
} finally {
setLoading(false);
setLocalError('Failed to resend code');
}
};
const handleClose = () => {
clearMFA();
onClose();
};
if (!mfaResponse || !mfaResponse.mfaData) return null;
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<SecurityIcon color="primary" />
@ -350,14 +326,14 @@ const MFAVerificationDialog = ({
<DialogContent>
<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>
<Typography variant="body1" gutterBottom>
We've sent a 6-digit verification code to:
</Typography>
<Typography variant="h6" color="primary" gutterBottom>
{email}
{mfaResponse.mfaData.email}
</Typography>
<TextField
@ -367,7 +343,7 @@ const MFAVerificationDialog = ({
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
setCode(value);
setError('');
setLocalError('');
}}
placeholder="000000"
inputProps={{
@ -379,8 +355,8 @@ const MFAVerificationDialog = ({
}
}}
sx={{ mt: 2, mb: 2 }}
error={!!error}
helperText={error}
error={!!(localError || errorMessage)}
helperText={localError || errorMessage}
/>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
@ -390,7 +366,7 @@ const MFAVerificationDialog = ({
<Button
size="small"
onClick={handleResendCode}
disabled={loading || timeLeft > 540} // Allow resend after 1 minute
disabled={isLoading || timeLeft > 540} // Allow resend after 1 minute
>
Resend Code
</Button>
@ -414,15 +390,15 @@ const MFAVerificationDialog = ({
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button onClick={onClose} disabled={loading}>
<Button onClick={handleClose} disabled={isLoading}>
Cancel
</Button>
<Button
variant="contained"
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>
</DialogActions>
</Dialog>
@ -441,29 +417,17 @@ const RegistrationSuccessDialog = ({
email: string;
userType: string;
}) => {
const [resendLoading, setResendLoading] = useState(false);
const { resendEmailVerification, isLoading } = useAuth();
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);
const success = await resendEmailVerification(email);
if (success) {
setResendMessage('Verification email sent!');
}
} catch (error: any) {
setResendMessage(error?.message || 'Network error. Please try again.');
}
};
@ -509,8 +473,8 @@ const RegistrationSuccessDialog = ({
<DialogActions sx={{ p: 3, justifyContent: 'space-between' }}>
<Button
onClick={handleResendVerification}
disabled={resendLoading}
startIcon={resendLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : <RefreshIcon />}
>
Resend Email
</Button>
@ -524,69 +488,46 @@ const RegistrationSuccessDialog = ({
// Enhanced Login Component with MFA Support
const LoginForm = () => {
const { apiClient } = useAuth();
const { login, mfaResponse, isLoading, error } = useAuth();
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 [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]);
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({
const success = await login({
login: email,
password,
}),
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);
console.log(`login success: ${success}`);
if (success) {
// Redirect based on user type - this could be handled in AuthContext
// or by a higher-level component that listens to auth state changes
handleLoginSuccess();
}
};
const handleMFASuccess = (authData: any) => {
handleLoginSuccess(authData);
handleLoginSuccess();
};
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';
const handleLoginSuccess = () => {
// This could be handled by a router or parent component
// For now, just showing the pattern
console.log('Login successful - redirect to dashboard');
};
return (
@ -612,9 +553,9 @@ const LoginForm = () => {
autoComplete="current-password"
/>
{error && (
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
{errorMessage}
</Alert>
)}
@ -622,23 +563,18 @@ const LoginForm = () => {
type="submit"
fullWidth
variant="contained"
disabled={loading}
disabled={isLoading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? <CircularProgress size={20} /> : 'Sign In'}
{isLoading ? <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}
open={mfaResponse?.mfaRequired || false}
onClose={() => { }} // This will be handled by clearMFA in the dialog
onVerificationSuccess={handleMFASuccess}
/>
)}
</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 overrides: any = {
p: { component: (element: any) =>{
return <div>{element.children}</div>
}},
pre: {
component: (element: any) => {
const { className } = element.children.props;

View File

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

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
* for seamless frontend-backend communication, including streaming responses and
@ -54,11 +54,6 @@ interface StreamingResponse {
promise: Promise<Types.ChatMessage[]>;
}
export interface LoginRequest {
login: string; // email or username
password: string;
}
export interface CreateCandidateRequest {
email: string;
username: string;
@ -261,37 +256,38 @@ class ApiClient {
/**
* 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`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<MFARequestResponse>(response);
return handleApiResponse<Types.MFARequestResponse>(response);
}
/**
* 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`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
body: JSON.stringify(formattedRequest)
});
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`, {
method: 'POST',
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
@ -307,7 +303,7 @@ class ApiClient {
/**
* 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`, {
method: 'POST',
headers: this.defaultHeaders,
@ -495,27 +491,6 @@ class ApiClient {
// ============================
// 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> {
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
method: 'POST',
@ -1094,7 +1069,7 @@ class ApiClient {
// ============================
// Enhanced Request/Response Types
// Request/Response Types
// ============================
export interface CreateCandidateWithVerificationRequest {
@ -1133,13 +1108,6 @@ export interface MFARequest {
deviceName: string;
}
export interface MFAVerifyRequest {
email: string;
code: string;
deviceId: string;
rememberDevice: boolean;
}
export interface RegistrationResponse {
message: string;
email: string;
@ -1152,12 +1120,6 @@ export interface EmailVerificationResponse {
userType: string;
}
export interface MFARequestResponse {
mfaRequired: boolean;
message: string;
deviceId?: string;
}
export interface TrustedDevice {
deviceId: string;
deviceName: string;
@ -1202,7 +1164,7 @@ export interface PendingVerification {
/*
// Registration with email verification
const apiClient = new EnhancedApiClient();
const apiClient = new ApiClient();
try {
const result = await apiClient.createCandidateWithVerification({
@ -1226,9 +1188,9 @@ try {
console.error('Registration failed:', error);
}
// Enhanced login with MFA support
// login with MFA support
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) {
// 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
// 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
// ============================
@ -145,6 +145,7 @@ export interface BaseUser {
lastLogin?: Date;
profileImage?: string;
status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
}
export interface BaseUserWithType {
@ -160,6 +161,7 @@ export interface BaseUserWithType {
lastLogin?: Date;
profileImage?: string;
status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
userType: "candidate" | "employer" | "guest";
}
@ -176,6 +178,7 @@ export interface Candidate {
lastLogin?: Date;
profileImage?: string;
status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
userType: "candidate";
username: string;
description?: string;
@ -395,6 +398,7 @@ export interface Employer {
lastLogin?: Date;
profileImage?: string;
status: "active" | "inactive" | "pending" | "banned";
isAdmin?: boolean;
userType: "employer";
companyName: string;
industry: string;
@ -539,13 +543,31 @@ export interface Location {
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;
}
export interface MFARequest {
username: string;
password: string;
deviceId: string;
deviceName: string;
}
export interface MFARequestResponse {
mfaRequired: boolean;
mfaData?: MFAData;
}
export interface MFAVerifyRequest {
email: string;
code: string;

View File

@ -558,6 +558,7 @@ class RedisDatabase:
async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool:
"""Store MFA code for verification"""
try:
logger.info("🔐 Storing MFA code for email: %s", email )
key = f"mfa_code:{email.lower()}:{device_id}"
mfa_data = {
"code": code,

View File

@ -1,4 +1,5 @@
import os
from typing import Tuple
from logger import logger
from email.mime.text import MIMEText # type: ignore
from email.mime.multipart import MIMEMultipart # type: ignore
@ -10,163 +11,11 @@ import json
from database import RedisDatabase
class EmailService:
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")
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):
# Configure these in your .env file
self.smtp_server = os.getenv("SMTP_SERVER")
self.smtp_port = int(os.getenv("SMTP_PORT", "0"))
self.email_user = os.getenv("EMAIL_USER",)
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.email_user = os.getenv("EMAIL_USER")
self.email_password = os.getenv("EMAIL_PASSWORD")
self.from_name = os.getenv("FROM_NAME", "Backstory")
self.app_name = os.getenv("APP_NAME", "Backstory")
@ -429,3 +278,9 @@ class VerificationEmailRateLimiter:
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>
<style>
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;
color: #333;
color: #2E2E2E;
margin: 0;
padding: 0;
background-color: #f5f5f5;
background-color: #D3CDBF;
}}
.container {{
max-width: 600px;
margin: 0 auto;
background-color: white;
border-radius: 12px;
background-color: #FFFFFF;
border-radius: 4px;
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-bottom: 40px;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
background: linear-gradient(135deg, #1A2536 0%, #4A7A7D 100%);
color: #D3CDBF;
padding: 40px 30px;
text-align: center;
}}
.header h1 {{
margin: 0 0 10px 0;
font-size: 28px;
font-weight: 600;
font-size: 2rem;
font-weight: 500;
color: #D3CDBF;
}}
.header p {{
margin: 0;
opacity: 0.9;
font-size: 16px;
font-size: 1rem;
color: #D3CDBF;
}}
.content {{
padding: 40px 30px;
}}
.content h2 {{
margin: 0 0 20px 0;
color: #333;
font-size: 24px;
color: #2E2E2E;
font-size: 1.75rem;
font-weight: 500;
}}
.button {{
display: inline-block;
background: #667eea;
color: white;
background: #D4A017;
color: #FFFFFF;
padding: 16px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
border-radius: 4px;
font-weight: 500;
margin: 24px 0;
font-size: 16px;
font-size: 1rem;
transition: background-color 0.3s ease;
}}
.button:hover {{
background: #5a6fd8;
background: rgba(212, 160, 23, 0.8);
}}
.link-text {{
word-break: break-all;
color: #667eea;
background-color: #f8f9ff;
color: #4A7A7D;
background-color: rgba(74, 122, 125, 0.1);
padding: 12px;
border-radius: 6px;
font-family: monospace;
font-size: 14px;
border-radius: 4px;
font-family: 'Roboto', monospace;
font-size: 0.875rem;
margin: 16px 0;
}}
.footer {{
background: #f8f9fa;
background: #D3CDBF;
padding: 30px;
text-align: center;
font-size: 14px;
color: #6c757d;
border-top: 1px solid #e9ecef;
font-size: 0.875rem;
color: #1A2536;
border-top: 1px solid rgba(26, 37, 54, 0.1);
}}
.security-note {{
background: #fff3cd;
border: 1px solid #ffeaa7;
background: rgba(212, 160, 23, 0.1);
border: 1px solid #D4A017;
padding: 16px;
border-radius: 8px;
border-radius: 4px;
margin: 24px 0;
color: #856404;
color: #2E2E2E;
}}
.security-note strong {{
color: #664d03;
color: #1A2536;
}}
</style>
</head>
@ -140,83 +143,90 @@ EMAIL_TEMPLATES = {
<title>Security Code</title>
<style>
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;
color: #333;
color: #2E2E2E;
margin: 0;
padding: 0;
background-color: #f5f5f5;
background-color: #D3CDBF;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: white;
border-radius: 12px;
background-color: #FFFFFF;
border-radius: 4px;
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 {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
background: linear-gradient(135deg, #1A2536 0%, #4A7A7D 100%);
color: #D3CDBF;
padding: 40px 30px;
text-align: center;
}}
.header h1 {{
margin: 0 0 10px 0;
font-size: 28px;
font-weight: 600;
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;
}}
.device-info {{
background: #e3f2fd;
border: 1px solid #2196f3;
background: rgba(74, 122, 125, 0.1);
border: 1px solid #4A7A7D;
padding: 16px;
border-radius: 8px;
border-radius: 4px;
margin: 20px 0;
color: #1565c0;
color: #1A2536;
}}
.code {{
background: #f8f9fa;
border: 3px solid #667eea;
background: #D3CDBF;
border: 3px solid #D4A017;
padding: 24px;
text-align: center;
font-size: 36px;
font-weight: bold;
font-size: 2.25rem;
font-weight: 500;
letter-spacing: 12px;
color: #667eea;
border-radius: 12px;
color: #1A2536;
border-radius: 4px;
margin: 32px 0;
font-family: 'Courier New', monospace;
font-family: 'Roboto', 'Courier New', monospace;
}}
.footer {{
background: #f8f9fa;
background: #D3CDBF;
padding: 30px;
text-align: center;
font-size: 14px;
color: #6c757d;
border-top: 1px solid #e9ecef;
font-size: 0.875rem;
color: #1A2536;
border-top: 1px solid rgba(26, 37, 54, 0.1);
}}
.warning {{
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
background: rgba(212, 160, 23, 0.1);
border: 1px solid #D4A017;
color: #2E2E2E;
padding: 16px;
border-radius: 8px;
border-radius: 4px;
margin: 24px 0;
}}
.warning strong {{
color: #491217;
color: #1A2536;
}}
.expiry-info {{
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
background: rgba(74, 122, 125, 0.1);
border: 1px solid #4A7A7D;
color: #1A2536;
padding: 12px;
border-radius: 6px;
border-radius: 4px;
margin: 16px 0;
font-size: 14px;
font-size: 0.875rem;
text-align: center;
}}
</style>
@ -277,44 +287,75 @@ EMAIL_TEMPLATES = {
<title>Password Reset</title>
<style>
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;
color: #333;
color: #2E2E2E;
margin: 0;
padding: 0;
background-color: #f5f5f5;
background-color: #D3CDBF;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: white;
border-radius: 12px;
background-color: #FFFFFF;
border-radius: 4px;
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 {{
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
background: linear-gradient(135deg, #4A7A7D 0%, #1A2536 100%);
color: #D3CDBF;
padding: 40px 30px;
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 {{
display: inline-block;
background: #ff6b6b;
color: white;
background: #D4A017;
color: #FFFFFF;
padding: 16px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
border-radius: 4px;
font-weight: 500;
margin: 24px 0;
font-size: 1rem;
transition: background-color 0.3s ease;
}}
.button:hover {{
background: rgba(212, 160, 23, 0.8);
}}
.footer {{
background: #f8f9fa;
background: #D3CDBF;
padding: 30px;
text-align: center;
font-size: 14px;
color: #6c757d;
font-size: 0.875rem;
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>
</head>
@ -332,11 +373,14 @@ EMAIL_TEMPLATES = {
<a href="{reset_link}" class="button">Reset Password</a>
</div>
<p>This link will expire in 1 hour for security reasons.</p>
<p>If you didn't request a password reset, please ignore this email and your password will remain unchanged.</p>
<div class="security-note">
<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 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>
</div>
</div>

View File

@ -1,8 +1,11 @@
from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks # type: ignore
from fastapi.middleware.cors import CORSMiddleware # type: ignore
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials # type: ignore
from fastapi.exceptions import RequestValidationError # type: ignore
from fastapi.responses import JSONResponse, StreamingResponse# type: ignore
from fastapi.staticfiles import StaticFiles # type: ignore
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY # type: ignore
import uvicorn # type: ignore
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta, UTC
@ -52,6 +55,9 @@ from device_manager import DeviceManager
# Import Pydantic models
# =============================
from models import (
# API
LoginRequest,
# User models
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse,
@ -62,7 +68,7 @@ from models import (
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
# Supporting models
Location, MFARequest, MFAVerifyRequest, ResendVerificationRequest, Skill, WorkExperience, Education,
Location, MFARequest, MFAData, MFARequestResponse, MFAVerifyRequest, ResendVerificationRequest, Skill, WorkExperience, Education,
# Email
EmailVerificationRequest
@ -107,7 +113,7 @@ async def lifespan(app: FastAPI):
yield # Application is running
except Exception as e:
logger.error(f"Failed to start application: {e}")
logger.error(f"Failed to start application: {e}")
raise
finally:
@ -144,29 +150,27 @@ app.add_middleware(
# Security
security = HTTPBearer()
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
if JWT_SECRET_KEY is None:
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "")
if JWT_SECRET_KEY == "":
raise ValueError("JWT_SECRET_KEY environment variable is not set")
ALGORITHM = "HS256"
# ============================
# Debug data type failures
# ============================
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
print("Validation error:", exc.errors())
return JSONResponse(
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
content=json.dumps({"detail": exc.errors()}),
)
# ============================
# Authentication Utilities
# ============================
# Request/Response Models
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
class CreateCandidateRequest(BaseModel):
email: EmailStr
@ -258,7 +262,7 @@ async def verify_token_with_blacklist(credentials: HTTPAuthorizationCredentials
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
except Exception as e:
logger.error(f"Token verification error: {e}")
logger.error(f"Token verification error: {e}")
raise HTTPException(status_code=401, detail="Token verification failed")
async def get_current_user(
@ -280,7 +284,7 @@ async def get_current_user(
raise HTTPException(status_code=404, detail="User not found")
except Exception as e:
logger.error(f"Error getting current user: {e}")
logger.error(f"Error getting current user: {e}")
raise HTTPException(status_code=404, detail="User not found")
# ============================
@ -377,95 +381,6 @@ api_router = APIRouter(prefix="/api/1.0")
# Authentication Endpoints
# ============================
@api_router.post("/auth/login")
async def login(
request: LoginRequest,
http_request: Request,
database: RedisDatabase = Depends(get_database)
):
"""Login with device detection and MFA"""
try:
# Initialize managers
auth_manager = AuthenticationManager(database)
device_manager = DeviceManager(database)
# Parse device information
device_info = device_manager.parse_device_info(http_request)
device_id = device_info["device_id"]
# Verify credentials
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(
request.login,
request.password
)
if not is_valid or not user_data:
logger.warning(f"⚠️ Failed login attempt for: {request.login}")
return JSONResponse(
status_code=401,
content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
)
# Check if device is trusted
is_trusted = await device_manager.is_trusted_device(user_data["id"], device_id)
if not is_trusted:
# New device detected - require MFA
logger.info(f"🔐 New device detected for {request.login}, MFA required")
return create_success_response({
"mfaRequired": True,
"deviceId": device_id,
"deviceName": device_info["device_name"],
"message": "New device detected. Please verify your identity via email."
})
# Trusted device - proceed with normal login
await device_manager.update_device_last_used(user_data["id"], device_id)
await auth_manager.update_last_login(user_data["id"])
# Create tokens
access_token = create_access_token(data={"sub": user_data["id"]})
refresh_token = create_access_token(
data={"sub": user_data["id"], "type": "refresh"},
expires_delta=timedelta(days=30)
)
# Get user object
user = None
if user_data["type"] == "candidate":
candidate_data = await database.get_candidate(user_data["id"])
if candidate_data:
user = Candidate.model_validate(candidate_data)
elif user_data["type"] == "employer":
employer_data = await database.get_employer(user_data["id"])
if employer_data:
user = Employer.model_validate(employer_data)
if not user:
return JSONResponse(
status_code=404,
content=create_error_response("USER_NOT_FOUND", "User profile not found")
)
# Create response
auth_response = AuthResponse(
accessToken=access_token,
refreshToken=refresh_token,
user=user,
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=24)).timestamp())
)
logger.info(f"🔑 User {request.login} logged in successfully from trusted device")
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(f"❌ Login error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
)
@api_router.post("/auth/logout")
async def logout(
access_token: str = Body(..., alias="accessToken"),
@ -489,7 +404,7 @@ async def logout(
content=create_error_response("INVALID_TOKEN", "Invalid refresh token")
)
except jwt.PyJWTError as e:
logger.warning(f"Invalid refresh token during logout: {e}")
logger.warning(f"⚠️ Invalid refresh token during logout: {e}")
return JSONResponse(
status_code=401,
content=create_error_response("INVALID_TOKEN", "Invalid refresh token")
@ -543,9 +458,9 @@ async def logout(
)
logger.info(f"🔒 Blacklisted access token for user {user_id}")
else:
logger.warning(f"Access token user mismatch during logout: {access_user_id} != {user_id}")
logger.warning(f"⚠️ Access token user mismatch during logout: {access_user_id} != {user_id}")
except jwt.PyJWTError as e:
logger.warning(f"Invalid access token during logout (non-critical): {e}")
logger.warning(f"⚠️ Invalid access token during logout (non-critical): {e}")
# Don't fail logout if access token is invalid
# Optional: Revoke all tokens for this user (for "logout from all devices")
@ -567,7 +482,7 @@ async def logout(
})
except Exception as e:
logger.error(f"⚠️ Logout error: {e}")
logger.error(f" Logout error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("LOGOUT_ERROR", str(e))
@ -595,7 +510,7 @@ async def logout_all_devices(
})
except Exception as e:
logger.error(f"⚠️ Logout all devices error: {e}")
logger.error(f" Logout all devices error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("LOGOUT_ALL_ERROR", str(e))
@ -653,7 +568,7 @@ async def refresh_token_endpoint(
content=create_error_response("INVALID_TOKEN", "Invalid refresh token")
)
except Exception as e:
logger.error(f"Token refresh error: {e}")
logger.error(f"Token refresh error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("REFRESH_ERROR", str(e))
@ -1120,23 +1035,34 @@ async def request_mfa(
mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code
# Store MFA code
await database.store_mfa_code(request.email, mfa_code, request.device_id)
# Get user name for email
user_name = "User"
email = None
if user_data["type"] == "candidate":
candidate_data = await database.get_candidate(user_data["id"])
if candidate_data:
user_name = candidate_data.get("fullName", "User")
email = candidate_data.get("email", None)
elif user_data["type"] == "employer":
employer_data = await database.get_employer(user_data["id"])
if employer_data:
user_name = employer_data.get("companyName", "User")
email = employer_data.get("email", None)
if not email:
return JSONResponse(
status_code=400,
content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA")
)
# Store MFA code
await database.store_mfa_code(email, mfa_code, request.device_id)
logger.info(f"🔐 MFA code generated for {email} on device {request.device_id}")
# Send MFA code via email
background_tasks.add_task(
email_service.send_mfa_email,
request.email,
email,
mfa_code,
request.device_name,
user_name
@ -1144,11 +1070,18 @@ async def request_mfa(
logger.info(f"🔐 MFA requested for {request.email} from new device {request.device_name}")
return create_success_response({
"mfaRequired": True,
"message": "MFA code sent to your email address",
"deviceId": request.device_id
})
mfa_data = MFAData(
email=request.email,
device_id=request.device_id,
device_name=request.device_name,
mfaCode=mfa_code
)
mfa_response = MFARequestResponse(
mfa_required=True,
message="MFA code sent to your email address",
mfa_data=mfa_data
)
return create_success_response(mfa_response)
except Exception as e:
logger.error(f"❌ MFA request error: {e}")
@ -1157,50 +1090,226 @@ async def request_mfa(
content=create_error_response("MFA_REQUEST_FAILED", "Failed to process MFA request")
)
@api_router.post("/auth/login")
async def login(
request: LoginRequest,
http_request: Request,
background_tasks: BackgroundTasks,
database: RedisDatabase = Depends(get_database)
):
"""login with automatic MFA email sending for new devices"""
try:
# Initialize managers
auth_manager = AuthenticationManager(database)
device_manager = DeviceManager(database)
# Parse device information
device_info = device_manager.parse_device_info(http_request)
device_id = device_info["device_id"]
# Verify credentials first
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(
request.login,
request.password
)
if not is_valid or not user_data:
logger.warning(f"⚠️ Failed login attempt for: {request.login}")
return JSONResponse(
status_code=401,
content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
)
# Check if device is trusted
is_trusted = await device_manager.is_trusted_device(user_data["id"], device_id)
if not is_trusted:
# New device detected - automatically send MFA email
logger.info(f"🔐 New device detected for {request.login}, sending MFA email")
# Generate MFA code
mfa_code = f"{secrets.randbelow(1000000):06d}" # 6-digit code
# Get user name and details for email
user_name = "User"
email = None
if user_data["type"] == "candidate":
candidate_data = await database.get_candidate(user_data["id"])
if candidate_data:
user_name = candidate_data.get("full_name", "User")
email = candidate_data.get("email", None)
elif user_data["type"] == "employer":
employer_data = await database.get_employer(user_data["id"])
if employer_data:
user_name = employer_data.get("company_name", "User")
email = employer_data.get("email", None)
if not email:
return JSONResponse(
status_code=400,
content=create_error_response("EMAIL_NOT_FOUND", "User email not found for MFA")
)
# Store MFA code
await database.store_mfa_code(email, mfa_code, device_id)
# Ensure email is lowercase
# Get IP address for security info
ip_address = http_request.client.host if http_request.client else "Unknown"
# Send MFA code via email in background
background_tasks.add_task(
email_service.send_mfa_email,
email,
mfa_code,
device_info["device_name"],
user_name,
ip_address
)
# Log security event
await database.log_security_event(
user_data["id"],
"mfa_request",
{
"device_id": device_id,
"device_name": device_info["device_name"],
"ip_address": ip_address,
"user_agent": device_info.get("user_agent", ""),
"auto_sent": True
}
)
logger.info(f"🔐 MFA code automatically sent to {request.login} for device {device_info['device_name']}")
mfa_response = MFARequestResponse(
mfa_required=True,
mfa_data=MFAData(
message="New device detected. We've sent a security code to your email address.",
email=email,
device_id=device_id,
device_name=device_info["device_name"],
code_sent=mfa_code
)
)
return create_success_response(mfa_response.model_dump(by_alias=True, exclude_unset=True))
# Trusted device - proceed with normal login
await device_manager.update_device_last_used(user_data["id"], device_id)
await auth_manager.update_last_login(user_data["id"])
# Create tokens
access_token = create_access_token(data={"sub": user_data["id"]})
refresh_token = create_access_token(
data={"sub": user_data["id"], "type": "refresh"},
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS)
)
# Get user object
user = None
if user_data["type"] == "candidate":
candidate_data = await database.get_candidate(user_data["id"])
if candidate_data:
user = Candidate.model_validate(candidate_data)
elif user_data["type"] == "employer":
employer_data = await database.get_employer(user_data["id"])
if employer_data:
user = Employer.model_validate(employer_data)
if not user:
return JSONResponse(
status_code=404,
content=create_error_response("USER_NOT_FOUND", "User profile not found")
)
# Log successful login from trusted device
await database.log_security_event(
user_data["id"],
"login",
{
"device_id": device_id,
"device_name": device_info["device_name"],
"ip_address": http_request.client.host if http_request.client else "Unknown",
"trusted_device": True
}
)
# Create response
auth_response = AuthResponse(
accessToken=access_token,
refreshToken=refresh_token,
user=user,
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp())
)
logger.info(f"🔑 User {request.login} logged in successfully from trusted device")
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"❌ Login error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("LOGIN_ERROR", "An error occurred during login")
)
@api_router.post("/auth/mfa/verify")
async def verify_mfa(
request: MFAVerifyRequest,
http_request: Request,
database: RedisDatabase = Depends(get_database)
):
"""Verify MFA code and complete login"""
"""Verify MFA code and complete login with error handling"""
try:
# Get MFA data
mfa_data = await database.get_mfa_code(request.email, request.device_id)
if not mfa_data:
logger.warning(f"⚠️ No MFA session found for {request.email} on device {request.device_id}")
return JSONResponse(
status_code=400,
content=create_error_response("INVALID_MFA", "Invalid or expired MFA code")
status_code=404,
content=create_error_response("NO_MFA_SESSION", "No active MFA session found. Please try logging in again.")
)
if mfa_data.get("verified"):
return JSONResponse(
status_code=400,
content=create_error_response("ALREADY_VERIFIED", "MFA code already used")
content=create_error_response("ALREADY_VERIFIED", "This MFA code has already been used. Please login again.")
)
# Check expiration
expires_at = datetime.fromisoformat(mfa_data["expires_at"])
if datetime.now(timezone.utc) > expires_at:
# Clean up expired MFA session
await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}")
return JSONResponse(
status_code=400,
content=create_error_response("MFA_EXPIRED", "MFA code has expired")
content=create_error_response("MFA_EXPIRED", "MFA code has expired. Please try logging in again.")
)
# Check attempts
if mfa_data.get("attempts", 0) >= 5:
current_attempts = mfa_data.get("attempts", 0)
if current_attempts >= 5:
# Clean up after too many attempts
await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}")
return JSONResponse(
status_code=429,
content=create_error_response("TOO_MANY_ATTEMPTS", "Too many MFA attempts")
content=create_error_response("TOO_MANY_ATTEMPTS", "Too many incorrect attempts. Please try logging in again.")
)
# Verify code
if mfa_data["code"] != request.code:
await database.increment_mfa_attempts(request.email, request.device_id)
remaining_attempts = 5 - (current_attempts + 1)
return JSONResponse(
status_code=400,
content=create_error_response("INVALID_CODE", "Invalid MFA code")
content=create_error_response(
"INVALID_CODE",
f"Invalid MFA code. {remaining_attempts} attempts remaining."
)
)
# Mark as verified
@ -1223,6 +1332,7 @@ async def verify_mfa(
request.device_id,
device_info
)
logger.info(f"🔒 Device {request.device_id} added to trusted devices for user {user_data['id']}")
# Update last login
auth_manager = AuthenticationManager(database)
@ -1232,7 +1342,7 @@ async def verify_mfa(
access_token = create_access_token(data={"sub": user_data["id"]})
refresh_token = create_access_token(
data={"sub": user_data["id"], "type": "refresh"},
expires_delta=timedelta(days=30)
expires_delta=timedelta(days=SecurityConfig.REFRESH_TOKEN_EXPIRY_DAYS)
)
# Get user object
@ -1252,12 +1362,38 @@ async def verify_mfa(
content=create_error_response("USER_NOT_FOUND", "User profile not found")
)
# Log successful MFA verification and login
await database.log_security_event(
user_data["id"],
"mfa_verify_success",
{
"device_id": request.device_id,
"ip_address": http_request.client.host if http_request.client else "Unknown",
"device_remembered": request.remember_device,
"attempts_used": current_attempts + 1
}
)
await database.log_security_event(
user_data["id"],
"login",
{
"device_id": request.device_id,
"ip_address": http_request.client.host if http_request.client else "Unknown",
"mfa_verified": True,
"new_device": True
}
)
# Clean up MFA session
await database.redis.delete(f"mfa_code:{request.email.lower()}:{request.device_id}")
# Create response
auth_response = AuthResponse(
accessToken=access_token,
refreshToken=refresh_token,
access_token=access_token,
refresh_token=refresh_token,
user=user,
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=24)).timestamp())
expires_at=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp())
)
logger.info(f"✅ MFA verified and login completed for {request.email}")
@ -1265,103 +1401,13 @@ async def verify_mfa(
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"❌ MFA verification error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("MFA_VERIFICATION_FAILED", "Failed to verify MFA")
)
@api_router.post("/candidates")
async def create_candidate(
request: CreateCandidateRequest,
database: RedisDatabase = Depends(get_database)
):
"""Create a new candidate with secure password handling and duplicate checking"""
try:
# Initialize authentication manager
auth_manager = AuthenticationManager(database)
# Check if user already exists
user_exists, conflict_field = await auth_manager.check_user_exists(
request.email,
request.username
)
if user_exists and not conflict_field:
raise ValueError("User already exists with this email or username, but conflict_field is not set")
if user_exists and conflict_field:
logger.warning(f"⚠️ Attempted to create user with existing {conflict_field}: {getattr(request, conflict_field)}")
return JSONResponse(
status_code=409,
content=create_error_response(
"USER_EXISTS",
f"A user with this {conflict_field} already exists"
)
)
# Generate candidate ID
candidate_id = str(uuid.uuid4())
current_time = datetime.now(timezone.utc)
# Prepare candidate data
candidate_data = {
"id": candidate_id,
"userType": "candidate",
"email": request.email,
"username": request.username,
"firstName": request.firstName,
"lastName": request.lastName,
"fullName": f"{request.firstName} {request.lastName}",
"phone": request.phone,
"createdAt": current_time.isoformat(),
"updatedAt": current_time.isoformat(),
"status": "active",
}
# Create candidate object and validate
candidate = Candidate.model_validate(candidate_data)
# Create authentication record with hashed password
await auth_manager.create_user_authentication(candidate_id, request.password)
# Store candidate in database
await database.set_candidate(candidate.id, candidate.model_dump())
# Add to users for auth lookup (by email and username)
user_auth_data = {
"id": candidate.id,
"type": "candidate",
"email": candidate.email,
"username": request.username
}
# Store user lookup records
await database.set_user(candidate.email, user_auth_data) # By email
await database.set_user(request.username, user_auth_data) # By username
await database.set_user_by_id(candidate.id, user_auth_data) # By ID
logger.info(f"✅ Created candidate: {candidate.email} (ID: {candidate.id})")
# Return candidate data (excluding sensitive information)
response_data = candidate.model_dump(by_alias=True, exclude_unset=True)
# Remove any sensitive fields from response if needed
return create_success_response(response_data)
except ValueError as ve:
logger.warning(f"⚠️ Validation error creating candidate: {ve}")
return JSONResponse(
status_code=400,
content=create_error_response("VALIDATION_ERROR", str(ve))
)
except Exception as e:
logger.error(f"❌ Candidate creation error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("CREATION_FAILED", "Failed to create candidate account")
)
@api_router.get("/candidates/{username}")
async def get_candidate(
username: str = Path(...),
@ -1392,7 +1438,7 @@ async def get_candidate(
return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(f"Get candidate error: {e}")
logger.error(f"Get candidate error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
@ -1434,7 +1480,7 @@ async def update_candidate(
return create_success_response(updated_candidate.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(f"Update candidate error: {e}")
logger.error(f"Update candidate error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("UPDATE_FAILED", str(e))
@ -1472,7 +1518,7 @@ async def get_candidates(
return create_success_response(paginated_response)
except Exception as e:
logger.error(f"Get candidates error: {e}")
logger.error(f"Get candidates error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("FETCH_FAILED", str(e))
@ -1521,114 +1567,12 @@ async def search_candidates(
return create_success_response(paginated_response)
except Exception as e:
logger.error(f"Search candidates error: {e}")
logger.error(f"Search candidates error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("SEARCH_FAILED", str(e))
)
# ============================
# Employer Endpoints
# ============================
@api_router.post("/employers")
async def create_employer(
request: CreateEmployerRequest,
database: RedisDatabase = Depends(get_database)
):
"""Create a new employer with secure password handling and duplicate checking"""
try:
# Initialize authentication manager
auth_manager = AuthenticationManager(database)
# Check if user already exists
user_exists, conflict_field = await auth_manager.check_user_exists(
request.email,
request.username
)
if user_exists and not conflict_field:
raise ValueError("User already exists with this email or username, but conflict_field is not set")
if user_exists and conflict_field:
logger.warning(f"⚠️ Attempted to create employer with existing {conflict_field}: {getattr(request, conflict_field)}")
return JSONResponse(
status_code=409,
content=create_error_response(
"USER_EXISTS",
f"A user with this {conflict_field} already exists"
)
)
# Generate employer ID
employer_id = str(uuid.uuid4())
current_time = datetime.now(timezone.utc)
# Prepare employer data
employer_data = {
"id": employer_id,
"email": request.email,
"companyName": request.companyName,
"industry": request.industry,
"companySize": request.companySize,
"companyDescription": request.companyDescription,
"websiteUrl": request.websiteUrl,
"phone": request.phone,
"createdAt": current_time.isoformat(),
"updatedAt": current_time.isoformat(),
"status": "active",
"userType": "employer",
"location": {
"city": "",
"country": "",
"remote": False
},
"socialLinks": []
}
# Create employer object and validate
employer = Employer.model_validate(employer_data)
# Create authentication record with hashed password
await auth_manager.create_user_authentication(employer_id, request.password)
# Store employer in database
await database.set_employer(employer.id, employer.model_dump())
# Add to users for auth lookup
user_auth_data = {
"id": employer.id,
"type": "employer",
"email": employer.email,
"username": request.username
}
# Store user lookup records
await database.set_user(employer.email, user_auth_data) # By email
await database.set_user(request.username, user_auth_data) # By username
await database.set_user_by_id(employer.id, user_auth_data) # By ID
logger.info(f"✅ Created employer: {employer.email} (ID: {employer.id})")
# Return employer data (excluding sensitive information)
response_data = employer.model_dump(by_alias=True, exclude_unset=True)
return create_success_response(response_data)
except ValueError as ve:
logger.warning(f"⚠️ Validation error creating employer: {ve}")
return JSONResponse(
status_code=400,
content=create_error_response("VALIDATION_ERROR", str(ve))
)
except Exception as e:
logger.error(f"❌ Employer creation error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("CREATION_FAILED", "Failed to create employer account")
)
# ============================
# Password Reset Endpoints
# ============================
@ -1738,7 +1682,7 @@ async def create_job(
return create_success_response(job.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(f"Job creation error: {e}")
logger.error(f"Job creation error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("CREATION_FAILED", str(e))
@ -1766,7 +1710,7 @@ async def get_job(
return create_success_response(job.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(f"Get job error: {e}")
logger.error(f"Get job error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
@ -1803,7 +1747,7 @@ async def get_jobs(
return create_success_response(paginated_response)
except Exception as e:
logger.error(f"Get jobs error: {e}")
logger.error(f"Get jobs error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("FETCH_FAILED", str(e))
@ -1848,7 +1792,7 @@ async def search_jobs(
return create_success_response(paginated_response)
except Exception as e:
logger.error(f"Search jobs error: {e}")
logger.error(f"Search jobs error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("SEARCH_FAILED", str(e))
@ -1868,7 +1812,7 @@ async def get_chat_statistics(
stats = await database.get_chat_statistics()
return create_success_response(stats)
except Exception as e:
logger.error(f"Get chat statistics error: {e}")
logger.error(f"Get chat statistics error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("STATS_ERROR", str(e))
@ -1900,7 +1844,7 @@ async def get_candidate_chat_summary(
return create_success_response(summary)
except Exception as e:
logger.error(f"Get candidate chat summary error: {e}")
logger.error(f"Get candidate chat summary error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("SUMMARY_ERROR", str(e))
@ -1936,7 +1880,7 @@ async def archive_chat_session(
})
except Exception as e:
logger.error(f"Archive chat session error: {e}")
logger.error(f"Archive chat session error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("ARCHIVE_ERROR", str(e))
@ -2026,7 +1970,7 @@ async def create_chat_session(
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"Chat session creation error: {e}")
logger.error(f"Chat session creation error: {e}")
logger.info(json.dumps(session_data, indent=2))
return JSONResponse(
status_code=400,
@ -2137,7 +2081,7 @@ async def post_chat_session_message_stream(
await database.set_chat_session(final_message.session_id, chat_session_data)
except Exception as e:
logger.error(f"Failed to save AI message to database: {e}")
logger.error(f"Failed to save AI message to database: {e}")
return StreamingResponse(
message_stream_generator(),
@ -2151,7 +2095,7 @@ async def post_chat_session_message_stream(
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"Chat message streaming error: {e}")
logger.error(f"Chat message streaming error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("STREAMING_ERROR", str(e))
@ -2184,7 +2128,7 @@ async def get_chat_session_messages(
message = ChatMessage.model_validate(msg_data)
messages_list.append(message)
except Exception as e:
logger.warning(f"Failed to validate message: {e}")
logger.warning(f"⚠️ Failed to validate message: {e}")
continue
# Sort by timestamp (oldest first for chat history)
@ -2204,7 +2148,7 @@ async def get_chat_session_messages(
return create_success_response(paginated_response)
except Exception as e:
logger.error(f"Get chat messages error: {e}")
logger.error(f"Get chat messages error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
@ -2410,8 +2354,8 @@ async def get_candidate_chat_sessions(
sessions_list.append(session)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"Failed to validate session ({index}): {e}")
logger.error(f"Session data: {session_data}")
logger.error(f"Failed to validate session ({index}): {e}")
logger.error(f"Session data: {session_data}")
continue
# Sort by last activity (most recent first)
@ -2439,7 +2383,7 @@ async def get_candidate_chat_sessions(
})
except Exception as e:
logger.error(f"Get candidate chat sessions error: {e}")
logger.error(f"Get candidate chat sessions error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
@ -2605,7 +2549,7 @@ async def health_check():
except RuntimeError as e:
return {"status": "shutting_down", "message": str(e)}
except Exception as e:
logger.error(f"Health check failed: {e}")
logger.error(f"Health check failed: {e}")
return {"status": "error", "message": str(e)}
@api_router.get("/redis/stats")
@ -2661,7 +2605,7 @@ async def log_requests(request: Request, call_next):
logger.info(f"Response status: {response.status_code}, Path: {request.url.path}, Method: {request.method}")
return response
except Exception as e:
logger.error(f"Error processing request: {str(e)}, Path: {request.url.path}, Method: {request.method}")
logger.error(f"Error processing request: {str(e)}, Path: {request.url.path}, Method: {request.method}")
return JSONResponse(status_code=400, content={"detail": "Invalid HTTP request"})
# ============================
@ -2725,6 +2669,18 @@ async def root():
"health": f"{defines.api_prefix}/health"
}
async def periodic_verification_cleanup():
"""Background task to periodically clean up expired verification tokens"""
try:
database = db_manager.get_database()
cleaned_count = await database.cleanup_expired_verification_tokens()
if cleaned_count > 0:
logger.info(f"🧹 Periodic cleanup: removed {cleaned_count} expired verification tokens")
except Exception as e:
logger.error(f"❌ Error in periodic verification cleanup: {e}")
if __name__ == "__main__":
host = defines.host
port = defines.port

View File

@ -1,9 +1,15 @@
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 datetime import datetime, date, UTC
from enum import Enum
import uuid
from auth_utils import (
AuthenticationManager,
validate_password_strength,
sanitize_login_input,
SecurityConfig
)
# Generic type variable
T = TypeVar('T')
@ -190,24 +196,62 @@ class SortOrder(str, Enum):
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
# ============================
class EmailVerificationRequest(BaseModel):
token: str
class MFARequest(BaseModel):
email: EmailStr
username: str
password: str
device_id: str
device_name: str
device_id: str = Field(..., alias="deviceId")
device_name: str = Field(..., alias="deviceName")
model_config = {
"populate_by_name": True, # Allow both field names and aliases
}
class MFAVerifyRequest(BaseModel):
email: EmailStr
code: str
device_id: str
remember_device: bool = False
device_id: str = Field(..., alias="deviceId")
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):
email: EmailStr