MFA is working

This commit is contained in:
James Ketr 2025-06-01 13:42:32 -07:00
parent 360673e60d
commit d7a81481a2
14 changed files with 1214 additions and 1648 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import os import os
from typing import Tuple
from logger import logger from logger import logger
from email.mime.text import MIMEText # type: ignore from email.mime.text import MIMEText # type: ignore
from email.mime.multipart import MIMEMultipart # type: ignore from email.mime.multipart import MIMEMultipart # type: ignore
@ -10,163 +11,11 @@ import json
from database import RedisDatabase from database import RedisDatabase
class EmailService: 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): def __init__(self):
# Configure these in your .env file # Configure these in your .env file
self.smtp_server = os.getenv("SMTP_SERVER") self.smtp_server = os.getenv("SMTP_SERVER")
self.smtp_port = int(os.getenv("SMTP_PORT", "0")) self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.email_user = os.getenv("EMAIL_USER",) self.email_user = os.getenv("EMAIL_USER")
self.email_password = os.getenv("EMAIL_PASSWORD") self.email_password = os.getenv("EMAIL_PASSWORD")
self.from_name = os.getenv("FROM_NAME", "Backstory") self.from_name = os.getenv("FROM_NAME", "Backstory")
self.app_name = os.getenv("APP_NAME", "Backstory") self.app_name = os.getenv("APP_NAME", "Backstory")
@ -429,3 +278,9 @@ class VerificationEmailRateLimiter:
async def record_email_sent(self, email: str): async def record_email_sent(self, email: str):
"""Record that a verification email was sent""" """Record that a verification email was sent"""
await self.database.record_verification_attempt(email) await self.database.record_verification_attempt(email)
email_service = EmailService()

View File

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

View File

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

View File

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