Implementing MFA

This commit is contained in:
James Ketr 2025-05-31 19:25:04 -07:00
parent 9b320366ce
commit 35701d9719
15 changed files with 5100 additions and 357 deletions

View File

@ -190,6 +190,9 @@ RUN pip install "redis[hiredis]>=4.5.0"
# New backend implementation
RUN pip install fastapi uvicorn "python-jose[cryptography]" bcrypt python-multipart
# Needed for email verification
RUN pip install pyyaml user-agents cryptography
# Automatic type conversion pydantic -> typescript
RUN pip install pydantic typing-inspect jinja2
RUN apt-get update \

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
IconButton,
Dialog,

View File

@ -0,0 +1,711 @@
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 { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
// Email Verification Component
const EmailVerificationPage = (props: BackstoryPageProps) => {
const { apiClient } = useAuth();
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>('');
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
const MFAVerificationDialog = ({
open,
onClose,
email,
deviceId,
deviceName,
onVerificationSuccess
}: {
open: boolean;
onClose: () => void;
email: string;
deviceId: string;
deviceName: string;
onVerificationSuccess: (authData: any) => void;
}) => {
const { apiClient } = useAuth();
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
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
const 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
const LoginForm = () => {
const { apiClient } = 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 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
const 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>
);
}
export { EmailVerificationPage, MFAVerificationDialog, TrustedDevicesManager, RegistrationSuccessDialog, LoginForm };

View File

@ -0,0 +1,742 @@
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -55,6 +55,9 @@ import { BackstoryLogo } from 'components/ui/BackstoryLogo';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { Navigate, useNavigate } from 'react-router-dom';
import { LoginForm } from "components/EmailVerificationComponents";
import { CandidateRegistrationForm, EmployerRegistrationForm } from "components/RegistrationForms";
type UserRegistrationType = 'candidate' | 'employer';
interface LoginRequest {
@ -412,327 +415,329 @@ const LoginPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
)}
{tabValue === 0 && (
<Box component="form" onSubmit={handleLogin}>
<Typography variant="h5" gutterBottom>
Sign In
</Typography>
<LoginForm />
// <Box component="form" onSubmit={handleLogin}>
// <Typography variant="h5" gutterBottom>
// Sign In
// </Typography>
<TextField
fullWidth
label="Username or Email"
type="text"
value={loginForm.login}
onChange={handleLoginChange}
margin="normal"
required
disabled={loading}
variant="outlined"
placeholder="Enter username or email"
/>
// <TextField
// fullWidth
// label="Username or Email"
// type="text"
// value={loginForm.login}
// onChange={handleLoginChange}
// margin="normal"
// required
// disabled={loading}
// variant="outlined"
// placeholder="Enter username or email"
// />
<TextField
fullWidth
label="Password"
type={showLoginPassword ? 'text' : 'password'}
value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
autoComplete='current-password'
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={toggleLoginPasswordVisibility}
edge="end"
disabled={loading}
>
{showLoginPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
// <TextField
// fullWidth
// label="Password"
// type={showLoginPassword ? 'text' : 'password'}
// value={loginForm.password}
// onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
// margin="normal"
// required
// disabled={loading}
// variant="outlined"
// autoComplete='current-password'
// slotProps={{
// input: {
// endAdornment: (
// <InputAdornment position="end">
// <IconButton
// aria-label="toggle password visibility"
// onClick={toggleLoginPasswordVisibility}
// edge="end"
// disabled={loading}
// >
// {showLoginPassword ? <VisibilityOff /> : <Visibility />}
// </IconButton>
// </InputAdornment>
// )
// }
// }}
// />
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Person />}
>
{loading ? 'Signing In...' : 'Sign In'}
</Button>
</Box>
// <Button
// type="submit"
// fullWidth
// variant="contained"
// sx={{ mt: 3, mb: 2 }}
// disabled={loading}
// startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <Person />}
// >
// {loading ? 'Signing In...' : 'Sign In'}
// </Button>
// </Box>
)}
{tabValue === 1 && (
<Box component="form" onSubmit={handleRegister}>
<Typography variant="h5" gutterBottom>
Create Account
</Typography>
<CandidateRegistrationForm />
// <Box component="form" onSubmit={handleRegister}>
// <Typography variant="h5" gutterBottom>
// Create Account
// </Typography>
{/* User Type Selection */}
<FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
<FormLabel component="legend" sx={{ mb: 2 }}>
<Typography variant="h6">Select Account Type</Typography>
</FormLabel>
<RadioGroup
value={registerForm.userType}
onChange={handleUserTypeChange}
sx={{ gap: 1 }}
>
{(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
const info = getUserTypeInfo(userType);
return (
<FormControlLabel
key={userType}
value={userType}
disabled={loading || userType === 'employer'}
control={<Radio />}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
{info.icon}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
{info.title}
{userType === 'employer' && (
<Chip
label="Coming Soon"
size="small"
color="warning"
sx={{ ml: 1 }}
/>
)}
</Typography>
<Typography variant="body2" color="text.secondary">
{info.description}
</Typography>
</Box>
</Box>
}
sx={{
border: '1px solid',
borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
borderRadius: 1,
p: 1,
m: 0,
bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
'&:hover': {
bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
},
opacity: userType === 'employer' ? 0.6 : 1
}}
/>
);
})}
</RadioGroup>
</FormControl>
// {/* User Type Selection */}
// <FormControl component="fieldset" sx={{ mb: 3, width: '100%' }}>
// <FormLabel component="legend" sx={{ mb: 2 }}>
// <Typography variant="h6">Select Account Type</Typography>
// </FormLabel>
// <RadioGroup
// value={registerForm.userType}
// onChange={handleUserTypeChange}
// sx={{ gap: 1 }}
// >
// {(['candidate', 'employer'] as UserRegistrationType[]).map((userType) => {
// const info = getUserTypeInfo(userType);
// return (
// <FormControlLabel
// key={userType}
// value={userType}
// disabled={loading || userType === 'employer'}
// control={<Radio />}
// label={
// <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
// {info.icon}
// <Box>
// <Typography variant="subtitle1" sx={{ fontWeight: 'medium' }}>
// {info.title}
// {userType === 'employer' && (
// <Chip
// label="Coming Soon"
// size="small"
// color="warning"
// sx={{ ml: 1 }}
// />
// )}
// </Typography>
// <Typography variant="body2" color="text.secondary">
// {info.description}
// </Typography>
// </Box>
// </Box>
// }
// sx={{
// border: '1px solid',
// borderColor: registerForm.userType === userType ? 'primary.main' : 'divider',
// borderRadius: 1,
// p: 1,
// m: 0,
// bgcolor: registerForm.userType === userType ? 'primary.50' : 'transparent',
// '&:hover': {
// bgcolor: userType === 'employer' ? 'grey.100' : 'action.hover'
// },
// opacity: userType === 'employer' ? 0.6 : 1
// }}
// />
// );
// })}
// </RadioGroup>
// </FormControl>
{/* Employer Placeholder */}
{registerForm.userType === 'employer' && (
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom>
Employer Registration Coming Soon
</Typography>
<Typography variant="body2">
We're currently building our employer features. If you're interested in posting jobs
and finding talent, please contact our support team at support@backstory.com for
early access.
</Typography>
</Alert>
)}
// {/* Employer Placeholder */}
// {registerForm.userType === 'employer' && (
// <Alert severity="info" sx={{ mb: 3 }}>
// <Typography variant="h6" gutterBottom>
// Employer Registration Coming Soon
// </Typography>
// <Typography variant="body2">
// We're currently building our employer features. If you're interested in posting jobs
// and finding talent, please contact our support team at support@backstory.com for
// early access.
// </Typography>
// </Alert>
// )}
{/* Basic Information Fields */}
{registerForm.userType !== 'employer' && (
<>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="First Name"
value={registerForm.firstName}
onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
required
disabled={loading}
variant="outlined"
/>
</Grid>
// {/* Basic Information Fields */}
// {registerForm.userType !== 'employer' && (
// <>
// <Grid container spacing={2} sx={{ mb: 2 }}>
// <Grid size={{ xs: 12, sm: 6 }}>
// <TextField
// fullWidth
// label="First Name"
// value={registerForm.firstName}
// onChange={(e) => setRegisterForm({ ...registerForm, firstName: e.target.value })}
// required
// disabled={loading}
// variant="outlined"
// />
// </Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Last Name"
value={registerForm.lastName}
onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
required
disabled={loading}
variant="outlined"
/>
</Grid>
</Grid>
// <Grid size={{ xs: 12, sm: 6 }}>
// <TextField
// fullWidth
// label="Last Name"
// value={registerForm.lastName}
// onChange={(e) => setRegisterForm({ ...registerForm, lastName: e.target.value })}
// required
// disabled={loading}
// variant="outlined"
// />
// </Grid>
// </Grid>
<TextField
fullWidth
label="Username"
value={registerForm.username}
onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
/>
// <TextField
// fullWidth
// label="Username"
// value={registerForm.username}
// onChange={(e) => setRegisterForm({ ...registerForm, username: e.target.value })}
// margin="normal"
// required
// disabled={loading}
// variant="outlined"
// />
<TextField
fullWidth
label="Email"
type="email"
value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
/>
// <TextField
// fullWidth
// label="Email"
// type="email"
// value={registerForm.email}
// onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })}
// margin="normal"
// required
// disabled={loading}
// variant="outlined"
// />
{/* Conditional fields based on user type */}
{registerForm.userType === 'candidate' && (
<>
<PhoneInput
label="Phone (Optional)"
placeholder="Enter phone number"
defaultCountry='US'
value={registerForm.phone}
disabled={loading}
onChange={(v) => setPhone(v as E164Number)}
/>
// {/* Conditional fields based on user type */}
// {registerForm.userType === 'candidate' && (
// <>
// <PhoneInput
// label="Phone (Optional)"
// placeholder="Enter phone number"
// defaultCountry='US'
// value={registerForm.phone}
// disabled={loading}
// onChange={(v) => setPhone(v as E164Number)}
// />
<LocationInput
value={location}
onChange={handleLocationChange}
showCity
helperText="Include your city for more specific job matches"
/>
</>
)}
// <LocationInput
// value={location}
// onChange={handleLocationChange}
// showCity
// helperText="Include your city for more specific job matches"
// />
// </>
// )}
<TextField
fullWidth
label="Password"
type={showRegisterPassword ? 'text' : 'password'}
value={registerForm.password}
onChange={(e) => handlePasswordChange(e.target.value)}
margin="normal"
required
disabled={loading}
variant="outlined"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={toggleRegisterPasswordVisibility}
edge="end"
disabled={loading}
>
{showRegisterPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
// <TextField
// fullWidth
// label="Password"
// type={showRegisterPassword ? 'text' : 'password'}
// value={registerForm.password}
// onChange={(e) => handlePasswordChange(e.target.value)}
// margin="normal"
// required
// disabled={loading}
// variant="outlined"
// slotProps={{
// input: {
// endAdornment: (
// <InputAdornment position="end">
// <IconButton
// aria-label="toggle password visibility"
// onClick={toggleRegisterPasswordVisibility}
// edge="end"
// disabled={loading}
// >
// {showRegisterPassword ? <VisibilityOff /> : <Visibility />}
// </IconButton>
// </InputAdornment>
// )
// }
// }}
// />
{/* Password Requirements */}
{registerForm.password.length > 0 && (
<Box sx={{ mt: 1, mb: 1 }}>
<Button
onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
size="small"
sx={{ mb: 1 }}
>
Password Requirements
</Button>
<Collapse in={showPasswordRequirements}>
<Paper variant="outlined" sx={{ p: 2 }}>
<List dense>
{passwordRequirements.map((requirement, index) => (
<ListItem key={index} sx={{ py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
{requirement.met ? (
<CheckCircle color="success" fontSize="small" />
) : (
<Cancel color="error" fontSize="small" />
)}
</ListItemIcon>
<ListItemText
primary={requirement.label}
sx={{
'& .MuiListItemText-primary': {
fontSize: '0.875rem',
color: requirement.met ? 'success.main' : 'error.main'
}
}}
/>
</ListItem>
))}
</List>
</Paper>
</Collapse>
</Box>
)}
// {/* Password Requirements */}
// {registerForm.password.length > 0 && (
// <Box sx={{ mt: 1, mb: 1 }}>
// <Button
// onClick={() => setShowPasswordRequirements(!showPasswordRequirements)}
// startIcon={showPasswordRequirements ? <ExpandLess /> : <ExpandMore />}
// size="small"
// sx={{ mb: 1 }}
// >
// Password Requirements
// </Button>
// <Collapse in={showPasswordRequirements}>
// <Paper variant="outlined" sx={{ p: 2 }}>
// <List dense>
// {passwordRequirements.map((requirement, index) => (
// <ListItem key={index} sx={{ py: 0.5 }}>
// <ListItemIcon sx={{ minWidth: 36 }}>
// {requirement.met ? (
// <CheckCircle color="success" fontSize="small" />
// ) : (
// <Cancel color="error" fontSize="small" />
// )}
// </ListItemIcon>
// <ListItemText
// primary={requirement.label}
// sx={{
// '& .MuiListItemText-primary': {
// fontSize: '0.875rem',
// color: requirement.met ? 'success.main' : 'error.main'
// }
// }}
// />
// </ListItem>
// ))}
// </List>
// </Paper>
// </Collapse>
// </Box>
// )}
<TextField
fullWidth
label="Confirm Password"
type={showConfirmPassword ? 'text' : 'password'}
value={registerForm.confirmPassword}
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
margin="normal"
required
disabled={loading}
variant="outlined"
error={hasPasswordMatchError}
helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle confirm password visibility"
onClick={toggleConfirmPasswordVisibility}
edge="end"
disabled={loading}
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
// <TextField
// fullWidth
// label="Confirm Password"
// type={showConfirmPassword ? 'text' : 'password'}
// value={registerForm.confirmPassword}
// onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })}
// margin="normal"
// required
// disabled={loading}
// variant="outlined"
// error={hasPasswordMatchError}
// helperText={hasPasswordMatchError ? 'Passwords do not match' : ''}
// slotProps={{
// input: {
// endAdornment: (
// <InputAdornment position="end">
// <IconButton
// aria-label="toggle confirm password visibility"
// onClick={toggleConfirmPasswordVisibility}
// edge="end"
// disabled={loading}
// >
// {showConfirmPassword ? <VisibilityOff /> : <Visibility />}
// </IconButton>
// </InputAdornment>
// )
// }
// }}
// />
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
>
{loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
</Button>
</>
)}
</Box>
// <Button
// type="submit"
// fullWidth
// variant="contained"
// sx={{ mt: 3, mb: 2 }}
// disabled={loading || hasPasswordMatchError || !passwordRequirements.every(req => req.met)}
// startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PersonAdd />}
// >
// {loading ? 'Creating Account...' : `Create ${getUserTypeInfo(registerForm.userType).title} Account`}
// </Button>
// </>
// )}
// </Box>
)}
</Paper>
</Container>

View File

@ -1,34 +0,0 @@
import React from 'react';
const RegisterPage = () => {
return (
<pre>
+------------------------------------------------------+
| BACKSTORY [Logo] Home |
+------------------------------------------------------+
| |
| Create Your Candidate Account |
| |
| [ ] Email |
| [ ] Password |
| [ ] Confirm Password |
| |
| [ ] I agree to the Terms & Privacy Policy |
| |
| [Create Account] |
| |
| Already have an account? [Login] |
| |
| --- or --- |
| |
| [Continue with Google] |
| [Continue with LinkedIn] |
| |
+------------------------------------------------------+
</pre>
);
};
export {
RegisterPage
};

View File

@ -148,7 +148,7 @@ class ApiClient {
}
// ============================
// Enhanced Response Handlers with Date Conversion
// Response Handlers with Date Conversion
// ============================
/**
@ -202,6 +202,296 @@ class ApiClient {
return extractedData;
}
/**
* Create candidate with email verification
*/
async createCandidateWithVerification(
candidate: CreateCandidateWithVerificationRequest
): Promise<RegistrationResponse> {
const response = await fetch(`${this.baseUrl}/candidates`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(candidate))
});
return handleApiResponse<RegistrationResponse>(response);
}
/**
* Create employer with email verification
*/
async createEmployerWithVerification(
employer: CreateEmployerWithVerificationRequest
): Promise<RegistrationResponse> {
const response = await fetch(`${this.baseUrl}/employers`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(employer))
});
return handleApiResponse<RegistrationResponse>(response);
}
/**
* Verify email address
*/
async verifyEmail(request: EmailVerificationRequest): Promise<EmailVerificationResponse> {
const response = await fetch(`${this.baseUrl}/auth/verify-email`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<EmailVerificationResponse>(response);
}
/**
* Resend verification email
*/
async resendVerificationEmail(request: ResendVerificationRequest): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/auth/resend-verification`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<{ message: string }>(response);
}
/**
* Request MFA for new device
*/
async requestMFA(request: MFARequest): Promise<MFARequestResponse> {
const response = await fetch(`${this.baseUrl}/auth/mfa/request`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<MFARequestResponse>(response);
}
/**
* Verify MFA code
*/
async verifyMFA(request: MFAVerifyRequest): Promise<Types.AuthResponse> {
const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<Types.AuthResponse>(response);
}
/**
* Enhanced login with device detection
*/
async loginEnhanced(email: string, password: string): Promise<Types.AuthResponse | MFARequestResponse> {
const response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ login: email, password }))
});
// This could return either a full auth response or MFA request
const data = await response.json();
if (!response.ok) {
throw new Error(data.error?.message || 'Login failed');
}
return data.data;
}
/**
* Logout with token revocation
*/
async logoutEnhanced(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> {
const response = await fetch(`${this.baseUrl}/auth/logout`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({
accessToken,
refreshToken
}))
});
return handleApiResponse<{ message: string; tokensRevoked: any }>(response);
}
/**
* Logout from all devices
*/
async logoutAllDevices(): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/auth/logout-all`, {
method: 'POST',
headers: this.defaultHeaders
});
return handleApiResponse<{ message: string }>(response);
}
// ============================
// Device Management Methods
// ============================
/**
* Get trusted devices for current user
*/
async getTrustedDevices(): Promise<TrustedDevice[]> {
const response = await fetch(`${this.baseUrl}/auth/trusted-devices`, {
headers: this.defaultHeaders
});
return handleApiResponse<TrustedDevice[]>(response);
}
/**
* Remove trusted device
*/
async removeTrustedDevice(deviceId: string): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/auth/trusted-devices/${deviceId}`, {
method: 'DELETE',
headers: this.defaultHeaders
});
return handleApiResponse<{ message: string }>(response);
}
/**
* Get security log for current user
*/
async getSecurityLog(days: number = 7): Promise<SecurityEvent[]> {
const response = await fetch(`${this.baseUrl}/auth/security-log?days=${days}`, {
headers: this.defaultHeaders
});
return handleApiResponse<SecurityEvent[]>(response);
}
// ============================
// Admin Methods (if user has admin role)
// ============================
/**
* Get pending user verifications (admin only)
*/
async getPendingVerifications(): Promise<PendingVerification[]> {
const response = await fetch(`${this.baseUrl}/admin/pending-verifications`, {
headers: this.defaultHeaders
});
return handleApiResponse<PendingVerification[]>(response);
}
/**
* Manually verify user (admin only)
*/
async manuallyVerifyUser(userId: string, reason: string): Promise<{ message: string }> {
const response = await fetch(`${this.baseUrl}/admin/verify-user/${userId}`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest({ reason }))
});
return handleApiResponse<{ message: string }>(response);
}
/**
* Get user security events (admin only)
*/
async getUserSecurityEvents(userId: string, days: number = 30): Promise<SecurityEvent[]> {
const response = await fetch(`${this.baseUrl}/admin/users/${userId}/security-events?days=${days}`, {
headers: this.defaultHeaders
});
return handleApiResponse<SecurityEvent[]>(response);
}
// ============================
// Utility Methods
// ============================
/**
* Generate device fingerprint for MFA
*/
generateDeviceFingerprint(): string {
// Create a basic device fingerprint
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
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 +
// (navigator.platform || '') +
(navigator.cookieEnabled ? '1' : '0');
// Create a hash-like string from the fingerprint
let hash = 0;
for (let i = 0; i < fingerprint.length; i++) {
const char = fingerprint.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16).slice(0, 16);
}
/**
* Get device name from user agent
*/
getDeviceName(): string {
const ua = navigator.userAgent;
const browser = ua.includes('Chrome') ? 'Chrome' :
ua.includes('Firefox') ? 'Firefox' :
ua.includes('Safari') ? 'Safari' :
ua.includes('Edge') ? 'Edge' : 'Browser';
const os = ua.includes('Windows') ? 'Windows' :
ua.includes('Mac') ? 'macOS' :
ua.includes('Linux') ? 'Linux' :
ua.includes('Android') ? 'Android' :
ua.includes('iOS') ? 'iOS' : 'Unknown OS';
return `${browser} on ${os}`;
}
/**
* Check if email verification is pending
*/
isEmailVerificationPending(): boolean {
return localStorage.getItem('pendingEmailVerification') === 'true';
}
/**
* Set email verification pending status
*/
setPendingEmailVerification(email: string, pending: boolean = true): void {
if (pending) {
localStorage.setItem('pendingEmailVerification', 'true');
localStorage.setItem('pendingVerificationEmail', email);
} else {
localStorage.removeItem('pendingEmailVerification');
localStorage.removeItem('pendingVerificationEmail');
}
}
/**
* Get pending verification email
*/
getPendingVerificationEmail(): string | null {
return localStorage.getItem('pendingVerificationEmail');
}
// ============================
// Authentication Methods
// ============================
@ -802,6 +1092,222 @@ class ApiClient {
}
}
// ============================
// Enhanced Request/Response Types
// ============================
export interface CreateCandidateWithVerificationRequest {
email: string;
username: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
}
export interface CreateEmployerWithVerificationRequest {
email: string;
username: string;
password: string;
companyName: string;
industry: string;
companySize: string;
companyDescription: string;
websiteUrl?: string;
phone?: string;
}
export interface EmailVerificationRequest {
token: string;
}
export interface ResendVerificationRequest {
email: string;
}
export interface MFARequest {
email: string;
password: string;
deviceId: string;
deviceName: string;
}
export interface MFAVerifyRequest {
email: string;
code: string;
deviceId: string;
rememberDevice: boolean;
}
export interface RegistrationResponse {
message: string;
email: string;
verificationRequired: boolean;
}
export interface EmailVerificationResponse {
message: string;
accountActivated: boolean;
userType: string;
}
export interface MFARequestResponse {
mfaRequired: boolean;
message: string;
deviceId?: string;
}
export interface TrustedDevice {
deviceId: string;
deviceName: string;
browser: string;
browserVersion: string;
os: string;
osVersion: string;
addedAt: string;
lastUsed: string;
ipAddress: string;
}
// ============================
// Additional Types
// ============================
export interface SecurityEvent {
timestamp: string;
userId: string;
eventType: 'login' | 'logout' | 'mfa_request' | 'mfa_verify' | 'password_change' | 'email_verify' | 'device_add' | 'device_remove';
details: {
ipAddress?: string;
deviceName?: string;
success?: boolean;
failureReason?: string;
[key: string]: any;
};
}
export interface PendingVerification {
id: string;
email: string;
userType: 'candidate' | 'employer';
createdAt: string;
expiresAt: string;
attempts: number;
}
// ============================
// Usage Examples
// ============================
/*
// Registration with email verification
const apiClient = new EnhancedApiClient();
try {
const result = await apiClient.createCandidateWithVerification({
email: 'user@example.com',
username: 'johndoe',
password: 'SecurePassword123!',
firstName: 'John',
lastName: 'Doe',
phone: '+1234567890'
});
console.log(result.message); // "Registration successful! Please check your email..."
// Set pending verification status
apiClient.setPendingEmailVerification(result.email);
// Show success dialog to user
showRegistrationSuccessDialog(result);
} catch (error) {
console.error('Registration failed:', error);
}
// Enhanced login with MFA support
try {
const loginResult = await apiClient.loginEnhanced('user@example.com', 'password');
if ('mfaRequired' in loginResult && loginResult.mfaRequired) {
// Show MFA dialog
showMFADialog({
email: 'user@example.com',
deviceId: loginResult.deviceId!,
deviceName: loginResult.message || 'Unknown device'
});
} else {
// Normal login success
const authData = loginResult as Types.AuthResponse;
handleLoginSuccess(authData);
}
} catch (error) {
console.error('Login failed:', error);
}
// Email verification
try {
const verificationResult = await apiClient.verifyEmail({
token: 'verification-token-from-email'
});
console.log(verificationResult.message); // "Email verified successfully!"
// Clear pending verification
apiClient.setPendingEmailVerification('', false);
// Redirect to login
window.location.href = '/login';
} catch (error) {
console.error('Email verification failed:', error);
}
// MFA verification
try {
const mfaResult = await apiClient.verifyMFA({
email: 'user@example.com',
code: '123456',
deviceId: 'device-fingerprint',
rememberDevice: true
});
// Handle successful login
handleLoginSuccess(mfaResult);
} catch (error) {
console.error('MFA verification failed:', error);
}
// Device management
try {
const devices = await apiClient.getTrustedDevices();
devices.forEach(device => {
console.log(`Device: ${device.deviceName}, Last used: ${device.lastUsed}`);
});
// Remove a device
await apiClient.removeTrustedDevice('device-id-to-remove');
} catch (error) {
console.error('Device management failed:', error);
}
// Security log
try {
const securityEvents = await apiClient.getSecurityLog(30); // Last 30 days
securityEvents.forEach(event => {
console.log(`${event.timestamp}: ${event.eventType} from ${event.details.deviceName}`);
});
} catch (error) {
console.error('Failed to load security log:', error);
}
*/
// ============================
// React Hooks for Streaming with Date Conversion
// ============================

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// Source: src/backend/models.py
// Generated on: 2025-05-31T18:20:52.253576
// Generated on: 2025-06-01T01:48:43.853130
// DO NOT EDIT MANUALLY - This file is auto-generated
// ============================
@ -378,6 +378,10 @@ export interface Education {
location?: Location;
}
export interface EmailVerificationRequest {
token: string;
}
export interface Employer {
id?: string;
email: string;
@ -535,6 +539,20 @@ export interface Location {
address?: string;
}
export interface MFARequest {
email: string;
password: string;
deviceId: string;
deviceName: string;
}
export interface MFAVerifyRequest {
email: string;
code: string;
deviceId: string;
rememberDevice?: boolean;
}
export interface MessageReaction {
userId: string;
reaction: string;
@ -609,6 +627,10 @@ export interface RefreshToken {
revokedReason?: string;
}
export interface ResendVerificationRequest {
email: string;
}
export interface RetrievalParameters {
searchType: "similarity" | "mmr" | "hybrid" | "keyword";
topK: number;

View File

@ -348,7 +348,141 @@ class RedisDatabase:
"""Delete job"""
key = f"{self.KEY_PREFIXES['jobs']}{job_id}"
await self.redis.delete(key)
# MFA and Email Verification operations
async def store_email_verification_token(self, email: str, token: str, user_type: str, user_data: dict) -> bool:
"""Store email verification token with user data"""
try:
key = f"email_verification:{token}"
verification_data = {
"email": email.lower(),
"user_type": user_type,
"user_data": user_data,
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
"verified": False
}
# Store with 24 hour expiration
await self.redis.setex(
key,
24 * 60 * 60, # 24 hours in seconds
json.dumps(verification_data, default=str)
)
logger.debug(f"📧 Stored email verification token for {email}")
return True
except Exception as e:
logger.error(f"❌ Error storing email verification token: {e}")
return False
async def get_email_verification_token(self, token: str) -> Optional[Dict[str, Any]]:
"""Retrieve email verification token data"""
try:
key = f"email_verification:{token}"
data = await self.redis.get(key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error(f"❌ Error retrieving email verification token: {e}")
return None
async def mark_email_verified(self, token: str) -> bool:
"""Mark email verification token as used"""
try:
key = f"email_verification:{token}"
token_data = await self.get_email_verification_token(token)
if token_data:
token_data["verified"] = True
token_data["verified_at"] = datetime.now(timezone.utc).isoformat()
await self.redis.setex(
key,
24 * 60 * 60, # Keep for remaining TTL
json.dumps(token_data, default=str)
)
return True
return False
except Exception as e:
logger.error(f"❌ Error marking email verified: {e}")
return False
async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool:
"""Store MFA code for verification"""
try:
key = f"mfa_code:{email.lower()}:{device_id}"
mfa_data = {
"code": code,
"email": email.lower(),
"device_id": device_id,
"expires_at": (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
"attempts": 0,
"verified": False
}
# Store with 10 minute expiration
await self.redis.setex(
key,
10 * 60, # 10 minutes in seconds
json.dumps(mfa_data, default=str)
)
logger.debug(f"🔐 Stored MFA code for {email}")
return True
except Exception as e:
logger.error(f"❌ Error storing MFA code: {e}")
return False
async def get_mfa_code(self, email: str, device_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve MFA code data"""
try:
key = f"mfa_code:{email.lower()}:{device_id}"
data = await self.redis.get(key)
if data:
return json.loads(data)
return None
except Exception as e:
logger.error(f"❌ Error retrieving MFA code: {e}")
return None
async def increment_mfa_attempts(self, email: str, device_id: str) -> int:
"""Increment MFA verification attempts"""
try:
key = f"mfa_code:{email.lower()}:{device_id}"
mfa_data = await self.get_mfa_code(email, device_id)
if mfa_data:
mfa_data["attempts"] += 1
await self.redis.setex(
key,
10 * 60, # Keep original TTL
json.dumps(mfa_data, default=str)
)
return mfa_data["attempts"]
return 0
except Exception as e:
logger.error(f"❌ Error incrementing MFA attempts: {e}")
return 0
async def mark_mfa_verified(self, email: str, device_id: str) -> bool:
"""Mark MFA code as verified"""
try:
key = f"mfa_code:{email.lower()}:{device_id}"
mfa_data = await self.get_mfa_code(email, device_id)
if mfa_data:
mfa_data["verified"] = True
mfa_data["verified_at"] = datetime.now(timezone.utc).isoformat()
await self.redis.setex(
key,
10 * 60, # Keep for remaining TTL
json.dumps(mfa_data, default=str)
)
return True
return False
except Exception as e:
logger.error(f"❌ Error marking MFA verified: {e}")
return False
# Job Applications operations
async def get_job_application(self, application_id: str) -> Optional[Dict]:
"""Get job application by ID"""

View File

@ -0,0 +1,86 @@
from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks # type: ignore
from database import RedisDatabase
import hashlib
from logger import logger
from datetime import datetime, timezone
from user_agents import parse # type: ignore
import json
class DeviceManager:
def __init__(self, database: RedisDatabase):
self.database = database
def generate_device_fingerprint(self, request: Request) -> str:
"""Generate device fingerprint from request"""
user_agent = request.headers.get("user-agent", "")
ip_address = request.client.host if request.client else "unknown"
accept_language = request.headers.get("accept-language", "")
# Create fingerprint
fingerprint_data = f"{user_agent}|{accept_language}"
fingerprint = hashlib.sha256(fingerprint_data.encode()).hexdigest()[:16]
return fingerprint
def parse_device_info(self, request: Request) -> dict:
"""Parse device information from request"""
user_agent_string = request.headers.get("user-agent", "")
user_agent = parse(user_agent_string)
return {
"device_id": self.generate_device_fingerprint(request),
"device_name": f"{user_agent.browser.family} on {user_agent.os.family}",
"browser": user_agent.browser.family,
"browser_version": user_agent.browser.version_string,
"os": user_agent.os.family,
"os_version": user_agent.os.version_string,
"ip_address": request.client.host if request.client else "unknown",
"user_agent": user_agent_string
}
async def is_trusted_device(self, user_id: str, device_id: str) -> bool:
"""Check if device is trusted for user"""
try:
key = f"trusted_device:{user_id}:{device_id}"
exists = await self.database.redis.exists(key)
return exists > 0
except Exception as e:
logger.error(f"Error checking trusted device: {e}")
return False
async def add_trusted_device(self, user_id: str, device_id: str, device_info: dict):
"""Add device to trusted devices"""
try:
key = f"trusted_device:{user_id}:{device_id}"
device_data = {
**device_info,
"added_at": datetime.now(timezone.utc).isoformat(),
"last_used": datetime.now(timezone.utc).isoformat()
}
# Store for 90 days
await self.database.redis.setex(
key,
90 * 24 * 60 * 60, # 90 days in seconds
json.dumps(device_data, default=str)
)
logger.info(f"🔒 Added trusted device {device_id} for user {user_id}")
except Exception as e:
logger.error(f"Error adding trusted device: {e}")
async def update_device_last_used(self, user_id: str, device_id: str):
"""Update last used timestamp for device"""
try:
key = f"trusted_device:{user_id}:{device_id}"
device_data = await self.database.redis.get(key)
if device_data:
device_info = json.loads(device_data)
device_info["last_used"] = datetime.now(timezone.utc).isoformat()
await self.database.redis.setex(
key,
90 * 24 * 60 * 60, # Reset 90 day expiry
json.dumps(device_info, default=str)
)
except Exception as e:
logger.error(f"Error updating device last used: {e}")

View File

@ -0,0 +1,367 @@
import os
from logger import logger
from email.mime.text import MIMEText # type: ignore
from email.mime.multipart import MIMEMultipart # type: ignore
import smtplib
import asyncio
from email_templates import EMAIL_TEMPLATES
from datetime import datetime, timezone, timedelta
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):
self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com")
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.email_user = os.getenv("EMAIL_USER", "your-app@example.com")
self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password")
self.from_name = os.getenv("FROM_NAME", "Backstory")
self.app_name = os.getenv("APP_NAME", "Backstory")
self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com")
def _get_template(self, template_name: str) -> dict:
"""Get email template by name"""
return EMAIL_TEMPLATES.get(template_name, {})
def _format_template(self, template: str, **kwargs) -> str:
"""Format template with provided variables"""
return template.format(
app_name=self.app_name,
from_name=self.from_name,
frontend_url=self.frontend_url,
**kwargs
)
async def send_verification_email(
self,
to_email: str,
verification_token: str,
user_name: str,
user_type: str = "user"
):
"""Send email verification email using template"""
try:
template = self._get_template("verification")
verification_link = f"{self.frontend_url}/verify-email?token={verification_token}"
subject = self._format_template(
template["subject"],
user_name=user_name,
to_email=to_email
)
html_content = self._format_template(
template["html"],
user_name=user_name,
user_type=user_type,
to_email=to_email,
verification_link=verification_link
)
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,
ip_address: str = "Unknown"
):
"""Send MFA code email using template"""
try:
template = self._get_template("mfa")
login_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
subject = self._format_template(template["subject"])
html_content = self._format_template(
template["html"],
user_name=user_name,
device_name=device_name,
ip_address=ip_address,
login_time=login_time,
mfa_code=mfa_code,
to_email=to_email
)
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_password_reset_email(
self,
to_email: str,
reset_token: str,
user_name: str
):
"""Send password reset email using template"""
try:
template = self._get_template("password_reset")
reset_link = f"{self.frontend_url}/reset-password?token={reset_token}"
subject = self._format_template(template["subject"])
html_content = self._format_template(
template["html"],
user_name=user_name,
reset_link=reset_link,
to_email=to_email
)
await self._send_email(to_email, subject, html_content)
logger.info(f"📧 Password reset email sent to {to_email}")
except Exception as e:
logger.error(f"❌ Failed to send password reset email to {to_email}: {e}")
raise
async def _send_email(self, to_email: str, subject: str, html_content: str):
"""Send email using SMTP with improved error handling"""
try:
# Create message
msg = MIMEMultipart('alternative')
msg['From'] = f"{self.from_name} <{self.email_user}>"
msg['To'] = to_email
msg['Subject'] = subject
msg['Reply-To'] = self.email_user
# Add HTML content
html_part = MIMEText(html_content, 'html', 'utf-8')
msg.attach(html_part)
# Send email with connection pooling and retry logic
max_retries = 3
for attempt in range(max_retries):
try:
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)
break # Success, exit retry loop
except smtplib.SMTPException as e:
if attempt == max_retries - 1: # Last attempt
raise
logger.warning(f"⚠️ SMTP attempt {attempt + 1} failed, retrying: {e}")
await asyncio.sleep(1) # Wait before retry
logger.debug(f"📧 Email sent successfully to {to_email}")
except Exception as e:
logger.error(f"❌ SMTP error sending to {to_email}: {e}")
raise
class EmailRateLimiter:
def __init__(self, database: RedisDatabase):
self.database = database
async def can_send_email(self, email: str, email_type: str, limit: int = 5, window_minutes: int = 60) -> bool:
"""Check if email can be sent based on rate limiting"""
try:
key = f"email_rate_limit:{email_type}:{email.lower()}"
current_time = datetime.now(timezone.utc)
window_start = current_time - timedelta(minutes=window_minutes)
# Get current count
count_data = await self.database.redis.get(key)
if not count_data:
# First email, allow it
await self._record_email_sent(key, current_time, window_minutes)
return True
email_records = json.loads(count_data)
# Filter out old records
recent_records = [
record for record in email_records
if datetime.fromisoformat(record) > window_start
]
if len(recent_records) >= limit:
logger.warning(f"⚠️ Email rate limit exceeded for {email} ({email_type})")
return False
# Add current email to records
recent_records.append(current_time.isoformat())
await self.database.redis.setex(
key,
window_minutes * 60,
json.dumps(recent_records)
)
return True
except Exception as e:
logger.error(f"❌ Error checking email rate limit: {e}")
# On error, allow the email to be safe
return True
async def _record_email_sent(self, key: str, timestamp: datetime, ttl_minutes: int):
"""Record that an email was sent"""
await self.database.redis.setex(
key,
ttl_minutes * 60,
json.dumps([timestamp.isoformat()])
)

View File

@ -0,0 +1,347 @@
EMAIL_TEMPLATES = {
"verification": {
"subject": "Welcome to {app_name} - Please verify your email",
"html": """
<!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;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}}
.container {{
max-width: 600px;
margin: 0 auto;
background-color: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-top: 40px;
margin-bottom: 40px;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 30px;
text-align: center;
}}
.header h1 {{
margin: 0 0 10px 0;
font-size: 28px;
font-weight: 600;
}}
.header p {{
margin: 0;
opacity: 0.9;
font-size: 16px;
}}
.content {{
padding: 40px 30px;
}}
.content h2 {{
margin: 0 0 20px 0;
color: #333;
font-size: 24px;
}}
.button {{
display: inline-block;
background: #667eea;
color: white;
padding: 16px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 24px 0;
font-size: 16px;
transition: background-color 0.3s ease;
}}
.button:hover {{
background: #5a6fd8;
}}
.link-text {{
word-break: break-all;
color: #667eea;
background-color: #f8f9ff;
padding: 12px;
border-radius: 6px;
font-family: monospace;
font-size: 14px;
margin: 16px 0;
}}
.footer {{
background: #f8f9fa;
padding: 30px;
text-align: center;
font-size: 14px;
color: #6c757d;
border-top: 1px solid #e9ecef;
}}
.security-note {{
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 16px;
border-radius: 8px;
margin: 24px 0;
color: #856404;
}}
.security-note strong {{
color: #664d03;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to {app_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 {app_name}, please verify your email address by clicking the button below:</p>
<div style="text-align: center; margin: 32px 0;">
<a href="{verification_link}" class="button">Verify Email Address</a>
</div>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<div class="link-text">{verification_link}</div>
<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 and the account will be automatically deleted.
</div>
</div>
<div class="footer">
<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>
</body>
</html>
"""
},
"mfa": {
"subject": "Security Code for {app_name}",
"html": """
<!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;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 30px;
text-align: center;
}}
.header h1 {{
margin: 0 0 10px 0;
font-size: 28px;
font-weight: 600;
}}
.content {{
padding: 40px 30px;
}}
.device-info {{
background: #e3f2fd;
border: 1px solid #2196f3;
padding: 16px;
border-radius: 8px;
margin: 20px 0;
color: #1565c0;
}}
.code {{
background: #f8f9fa;
border: 3px solid #667eea;
padding: 24px;
text-align: center;
font-size: 36px;
font-weight: bold;
letter-spacing: 12px;
color: #667eea;
border-radius: 12px;
margin: 32px 0;
font-family: 'Courier New', monospace;
}}
.footer {{
background: #f8f9fa;
padding: 30px;
text-align: center;
font-size: 14px;
color: #6c757d;
border-top: 1px solid #e9ecef;
}}
.warning {{
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 16px;
border-radius: 8px;
margin: 24px 0;
}}
.warning strong {{
color: #491217;
}}
.expiry-info {{
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 12px;
border-radius: 6px;
margin: 16px 0;
font-size: 14px;
text-align: center;
}}
</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>
<div class="device-info">
<strong>Device Details:</strong><br>
<strong>Name:</strong> {device_name}<br>
<strong>IP Address:</strong> {ip_address}<br>
<strong>Time:</strong> {login_time}
</div>
<p>Please enter this security code to complete your login:</p>
<div class="code">{mfa_code}</div>
<div class="expiry-info">
This code will expire in 10 minutes
</div>
<div class="warning">
<strong> Important Security Notice:</strong><br>
If you didn't attempt to log in from this device, please:
<ul style="margin: 12px 0; padding-left: 20px;">
<li>Change your password immediately</li>
<li>Review your account activity</li>
<li>Contact our support team</li>
</ul>
Never share this code with anyone, including {app_name} support.
</div>
</div>
<div class="footer">
<p><strong>This email was sent to:</strong> {to_email}</p>
<p>For security questions, please contact our support team immediately.</p>
<p>&copy; 2024 {app_name}. All rights reserved.</p>
</div>
</div>
</body>
</html>
"""
},
"password_reset": {
"subject": "Reset your {app_name} password",
"html": """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Password Reset</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}}
.container {{
max-width: 600px;
margin: 40px auto;
background-color: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}}
.header {{
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
padding: 40px 30px;
text-align: center;
}}
.content {{ padding: 40px 30px; }}
.button {{
display: inline-block;
background: #ff6b6b;
color: white;
padding: 16px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 24px 0;
}}
.footer {{
background: #f8f9fa;
padding: 30px;
text-align: center;
font-size: 14px;
color: #6c757d;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔑 Password Reset</h1>
<p>Reset your password for {app_name}</p>
</div>
<div class="content">
<h2>Reset your password</h2>
<p>We received a request to reset your password. Click the button below to create a new password:</p>
<div style="text-align: center; margin: 32px 0;">
<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>
<div class="footer">
<p>This email was sent to {to_email}</p>
<p>&copy; 2024 {app_name}. All rights reserved.</p>
</div>
</div>
</body>
</html>
"""
}
}

View File

@ -1,4 +1,4 @@
from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request # type: ignore
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.responses import JSONResponse, StreamingResponse# type: ignore
@ -26,6 +26,8 @@ from pydantic import BaseModel, EmailStr, field_validator # type: ignore
from prometheus_client import Summary # type: ignore
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
from prometheus_client import CollectorRegistry, Counter # type: ignore
import secrets
import os
# =============================
# Import custom modules
@ -43,6 +45,8 @@ from database import RedisDatabase, redis_manager, DatabaseManager
from metrics import Metrics
from llm_manager import llm_manager
import entities
from email_service import email_service
from device_manager import DeviceManager
# =============================
# Import Pydantic models
@ -58,7 +62,10 @@ from models import (
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
# Supporting models
Location, Skill, WorkExperience, Education
Location, MFARequest, MFAVerifyRequest, ResendVerificationRequest, Skill, WorkExperience, Education,
# Email
EmailVerificationRequest
)
@ -369,14 +376,20 @@ api_router = APIRouter(prefix="/api/1.0")
# ============================
@api_router.post("/auth/login")
async def login(
async def enhanced_login(
request: LoginRequest,
http_request: Request,
database: RedisDatabase = Depends(get_database)
):
"""Secure login endpoint with password verification"""
"""Enhanced login with device detection and MFA"""
try:
# Initialize authentication manager
# 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(
@ -391,33 +404,42 @@ async def login(
content=create_error_response("AUTH_FAILED", error_message or "Invalid credentials")
)
# Update last login timestamp
# 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"])
logger.info(f"🔑 User {request.login} logged in successfully")
# 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)
expires_delta=timedelta(days=30)
)
# Get user object based on type
# Get user object
user = None
if user_data["type"] == "candidate":
logger.info(f"🔑 User {request.login} is a candidate")
candidate_data = await database.get_candidate(user_data["id"])
if candidate_data:
user = Candidate.model_validate(candidate_data)
elif user_data["type"] == "employer":
logger.info(f"🔑 User {request.login} is an employer")
employer_data = await database.get_employer(user_data["id"])
if employer_data:
user = Employer.model_validate(employer_data)
user = Employer.model_validate(employer_data)
if not user:
logger.error(f"❌ User object not found for {user_data['id']}")
return JSONResponse(
status_code=404,
content=create_error_response("USER_NOT_FOUND", "User profile not found")
@ -428,17 +450,20 @@ async def login(
accessToken=access_token,
refreshToken=refresh_token,
user=user,
expiresAt=int((datetime.now(timezone.utc) + timedelta(hours=SecurityConfig.TOKEN_EXPIRY_HOURS)).timestamp())
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}")
logger.error(f"Enhanced 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"),
@ -635,7 +660,505 @@ async def refresh_token_endpoint(
# ============================
# Candidate Endpoints
# ============================
@api_router.post("/candidates")
async def create_candidate_with_verification(
request: CreateCandidateRequest,
background_tasks: BackgroundTasks,
database: RedisDatabase = Depends(get_database)
):
"""Create a new candidate with email verification"""
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 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 data (but don't activate yet)
candidate_id = str(uuid.uuid4())
current_time = datetime.now(timezone.utc)
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": "pending", # Not active until email verified
}
# Generate verification token
verification_token = secrets.token_urlsafe(32)
# Store verification token with user data
await database.store_email_verification_token(
request.email,
verification_token,
"candidate",
{
"candidate_data": candidate_data,
"password": request.password, # Store temporarily for verification
"username": request.username
}
)
# Send verification email in background
background_tasks.add_task(
email_service.send_verification_email,
request.email,
verification_token,
f"{request.firstName} {request.lastName}"
)
logger.info(f"✅ Candidate registration initiated for: {request.email}")
return create_success_response({
"message": "Registration successful! Please check your email to verify your account.",
"email": request.email,
"verificationRequired": True
})
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.post("/employers")
async def create_employer_with_verification(
request: CreateEmployerRequest,
background_tasks: BackgroundTasks,
database: RedisDatabase = Depends(get_database)
):
"""Create a new employer with email verification"""
try:
# Similar to candidate creation but for employer
auth_manager = AuthenticationManager(database)
user_exists, conflict_field = await auth_manager.check_user_exists(
request.email,
request.username
)
if user_exists and conflict_field:
return JSONResponse(
status_code=409,
content=create_error_response(
"USER_EXISTS",
f"A user with this {conflict_field} already exists"
)
)
employer_id = str(uuid.uuid4())
current_time = datetime.now(timezone.utc)
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": "pending", # Not active until verified
"userType": "employer",
"location": {
"city": "",
"country": "",
"remote": False
},
"socialLinks": []
}
verification_token = secrets.token_urlsafe(32)
await database.store_email_verification_token(
request.email,
verification_token,
"employer",
{
"employer_data": employer_data,
"password": request.password,
"username": request.username
}
)
background_tasks.add_task(
email_service.send_verification_email,
request.email,
verification_token,
request.companyName
)
logger.info(f"✅ Employer registration initiated for: {request.email}")
return create_success_response({
"message": "Registration successful! Please check your email to verify your account.",
"email": request.email,
"verificationRequired": True
})
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")
)
@api_router.post("/auth/verify-email")
async def verify_email(
request: EmailVerificationRequest,
database: RedisDatabase = Depends(get_database)
):
"""Verify email address and activate account"""
try:
# Get verification data
verification_data = await database.get_email_verification_token(request.token)
if not verification_data:
return JSONResponse(
status_code=400,
content=create_error_response("INVALID_TOKEN", "Invalid or expired verification token")
)
if verification_data.get("verified"):
return JSONResponse(
status_code=400,
content=create_error_response("ALREADY_VERIFIED", "Email already verified")
)
# Check expiration
expires_at = datetime.fromisoformat(verification_data["expires_at"])
if datetime.now(timezone.utc) > expires_at:
return JSONResponse(
status_code=400,
content=create_error_response("TOKEN_EXPIRED", "Verification token has expired")
)
# Extract user data
user_type = verification_data["user_type"]
user_data_container = verification_data["user_data"]
if user_type == "candidate":
candidate_data = user_data_container["candidate_data"]
password = user_data_container["password"]
username = user_data_container["username"]
# Activate candidate
candidate_data["status"] = "active"
candidate = Candidate.model_validate(candidate_data)
# Create authentication record
auth_manager = AuthenticationManager(database)
await auth_manager.create_user_authentication(candidate.id, password)
# Store in database
await database.set_candidate(candidate.id, candidate.model_dump())
# Add user lookup records
user_auth_data = {
"id": candidate.id,
"type": "candidate",
"email": candidate.email,
"username": username
}
await database.set_user(candidate.email, user_auth_data)
await database.set_user(username, user_auth_data)
await database.set_user_by_id(candidate.id, user_auth_data)
elif user_type == "employer":
employer_data = user_data_container["employer_data"]
password = user_data_container["password"]
username = user_data_container["username"]
# Activate employer
employer_data["status"] = "active"
employer = Employer.model_validate(employer_data)
# Create authentication record
auth_manager = AuthenticationManager(database)
await auth_manager.create_user_authentication(employer.id, password)
# Store in database
await database.set_employer(employer.id, employer.model_dump())
# Add user lookup records
user_auth_data = {
"id": employer.id,
"type": "employer",
"email": employer.email,
"username": username
}
await database.set_user(employer.email, user_auth_data)
await database.set_user(username, user_auth_data)
await database.set_user_by_id(employer.id, user_auth_data)
# Mark as verified
await database.mark_email_verified(request.token)
logger.info(f"✅ Email verified and account activated for: {verification_data['email']}")
return create_success_response({
"message": "Email verified successfully! Your account is now active.",
"accountActivated": True,
"userType": user_type
})
except Exception as e:
logger.error(f"❌ Email verification error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("VERIFICATION_FAILED", "Failed to verify email")
)
@api_router.post("/auth/resend-verification")
async def resend_verification_email(
request: ResendVerificationRequest,
background_tasks: BackgroundTasks,
database: RedisDatabase = Depends(get_database)
):
"""Resend verification email"""
try:
# Check if user exists and is pending
user_data = await database.get_user(request.email)
if user_data:
return JSONResponse(
status_code=400,
content=create_error_response("ALREADY_VERIFIED", "Account is already verified")
)
# Look for pending verification
# This would require scanning verification tokens (implement if needed)
# For now, return a generic success message
return create_success_response({
"message": "If your email is in our system and pending verification, a new verification email has been sent."
})
except Exception as e:
logger.error(f"❌ Resend verification error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("RESEND_FAILED", "Failed to resend verification email")
)
@api_router.post("/auth/mfa/request")
async def request_mfa(
request: MFARequest,
background_tasks: BackgroundTasks,
http_request: Request,
database: RedisDatabase = Depends(get_database)
):
"""Request MFA for login from new device"""
try:
# Verify credentials first
auth_manager = AuthenticationManager(database)
is_valid, user_data, error_message = await auth_manager.verify_user_credentials(
request.email,
request.password
)
if not is_valid or not user_data:
return JSONResponse(
status_code=401,
content=create_error_response("AUTH_FAILED", "Invalid credentials")
)
# Check if device is trusted
device_manager = DeviceManager(database)
device_info = device_manager.parse_device_info(http_request)
is_trusted = await device_manager.is_trusted_device(user_data["id"], request.device_id)
if is_trusted:
# Device is trusted, proceed with normal login
await device_manager.update_device_last_used(user_data["id"], request.device_id)
return create_success_response({
"mfaRequired": False,
"message": "Device is trusted, proceed with login"
})
# Generate MFA code
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"
if user_data["type"] == "candidate":
candidate_data = await database.get_candidate(user_data["id"])
if candidate_data:
user_name = candidate_data.get("fullName", "User")
elif user_data["type"] == "employer":
employer_data = await database.get_employer(user_data["id"])
if employer_data:
user_name = employer_data.get("companyName", "User")
# Send MFA code via email
background_tasks.add_task(
email_service.send_mfa_email,
request.email,
mfa_code,
request.device_name,
user_name
)
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
})
except Exception as e:
logger.error(f"❌ MFA request error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("MFA_REQUEST_FAILED", "Failed to process MFA request")
)
@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"""
try:
# Get MFA data
mfa_data = await database.get_mfa_code(request.email, request.device_id)
if not mfa_data:
return JSONResponse(
status_code=400,
content=create_error_response("INVALID_MFA", "Invalid or expired MFA code")
)
if mfa_data.get("verified"):
return JSONResponse(
status_code=400,
content=create_error_response("ALREADY_VERIFIED", "MFA code already used")
)
# Check expiration
expires_at = datetime.fromisoformat(mfa_data["expires_at"])
if datetime.now(timezone.utc) > expires_at:
return JSONResponse(
status_code=400,
content=create_error_response("MFA_EXPIRED", "MFA code has expired")
)
# Check attempts
if mfa_data.get("attempts", 0) >= 5:
return JSONResponse(
status_code=429,
content=create_error_response("TOO_MANY_ATTEMPTS", "Too many MFA attempts")
)
# Verify code
if mfa_data["code"] != request.code:
await database.increment_mfa_attempts(request.email, request.device_id)
return JSONResponse(
status_code=400,
content=create_error_response("INVALID_CODE", "Invalid MFA code")
)
# Mark as verified
await database.mark_mfa_verified(request.email, request.device_id)
# Get user data
user_data = await database.get_user(request.email)
if not user_data:
return JSONResponse(
status_code=404,
content=create_error_response("USER_NOT_FOUND", "User not found")
)
# Add device to trusted devices if requested
if request.remember_device:
device_manager = DeviceManager(database)
device_info = device_manager.parse_device_info(http_request)
await device_manager.add_trusted_device(
user_data["id"],
request.device_id,
device_info
)
# Update last login
auth_manager = AuthenticationManager(database)
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"✅ MFA verified and login completed for {request.email}")
return create_success_response(auth_response.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
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,

View File

@ -189,6 +189,29 @@ class SortOrder(str, Enum):
ASC = "asc"
DESC = "desc"
# ============================
# MFA Models
# ============================
class EmailVerificationRequest(BaseModel):
token: str
class MFARequest(BaseModel):
email: EmailStr
password: str
device_id: str
device_name: str
class MFAVerifyRequest(BaseModel):
email: EmailStr
code: str
device_id: str
remember_device: bool = False
class ResendVerificationRequest(BaseModel):
email: EmailStr
# ============================
# Supporting Models
# ============================