diff --git a/frontend/public/docs/authentication.md b/frontend/public/docs/authentication.md new file mode 100644 index 0000000..3af1a0c --- /dev/null +++ b/frontend/public/docs/authentication.md @@ -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 +``` \ No newline at end of file diff --git a/frontend/src/components/EmailVerificationComponents.tsx b/frontend/src/components/EmailVerificationComponents.tsx index 280aff1..0020f12 100644 --- a/frontend/src/components/EmailVerificationComponents.tsx +++ b/frontend/src/components/EmailVerificationComponents.tsx @@ -29,12 +29,13 @@ import { } from '@mui/icons-material'; import { useAuth } from 'hooks/AuthContext'; import { BackstoryPageProps } from './BackstoryTab'; +import { useNavigate } from 'react-router-dom'; // Email Verification Component const EmailVerificationPage = (props: BackstoryPageProps) => { - const { apiClient } = useAuth(); - const [verificationToken, setVerificationToken] = useState(''); - const [loading, setLoading] = useState(false); + const { verifyEmail, resendEmailVerification, getPendingVerificationEmail, isLoading, error } = useAuth(); + const navigate = useNavigate(); + const [verificationToken, setVerificationToken] = useState(''); const [status, setStatus] = useState<'pending' | 'success' | 'error'>('pending'); const [message, setMessage] = useState(''); const [userType, setUserType] = useState(''); @@ -57,62 +58,42 @@ const EmailVerificationPage = (props: BackstoryPageProps) => { return; } - setLoading(true); try { - const response = await fetch('/api/1.0/auth/verify-email', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ token }), - }); + const result = await verifyEmail({ token }); - const data = await response.json(); - - if (data.success) { + if (result) { setStatus('success'); - setMessage(data.data.message); - setUserType(data.data.userType); + setMessage(result.message); + setUserType(result.userType); // Redirect to login after 3 seconds setTimeout(() => { - window.location.href = '/login'; + navigate('/login'); }, 3000); } else { setStatus('error'); - setMessage(data.error?.message || 'Verification failed'); + setMessage('Email verification failed'); } } catch (error) { setStatus('error'); - setMessage('Network error occurred. Please try again.'); - } finally { - setLoading(false); + setMessage('Email verification failed'); } }; const handleResendVerification = async () => { - // This would need the email address - you might want to add an input for it - // or store it in localStorage from the registration process - try { - setLoading(true); - const response = await fetch('/api/1.0/auth/resend-verification', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: localStorage.getItem('pendingVerificationEmail') || '' - }), - }); + const email = getPendingVerificationEmail(); + if (!email) { + setMessage('No pending verification email found.'); + return; + } - const data = await response.json(); - if (data.success) { - setMessage('Verification email sent! Please check your inbox.'); + try { + const success = await resendEmailVerification(email); + if (success) { + setMessage('Verification email sent successfully!'); } } catch (error) { - setMessage('Failed to resend verification email.'); - } finally { - setLoading(false); + setMessage('Failed to resend verification email.'); } }; @@ -167,18 +148,18 @@ const EmailVerificationPage = (props: BackstoryPageProps) => { )} - {loading && ( + {isLoading && ( )} - {message && ( + {(message || error) && ( - {message} + {message || error} )} @@ -189,7 +170,7 @@ const EmailVerificationPage = (props: BackstoryPageProps) => { @@ -414,15 +390,15 @@ const MFAVerificationDialog = ({ - @@ -441,29 +417,17 @@ const RegistrationSuccessDialog = ({ email: string; userType: string; }) => { - const [resendLoading, setResendLoading] = useState(false); + const { resendEmailVerification, isLoading } = useAuth(); const [resendMessage, setResendMessage] = useState(''); - const handleResendVerification = async () => { - setResendLoading(true); + const handleResendVerification = async () => { try { - const response = await fetch('/api/1.0/auth/resend-verification', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email }), - }); - - const data = await response.json(); - setResendMessage(data.success ? - 'Verification email sent!' : - 'Failed to resend email. Please try again later.' - ); - } catch (error) { - setResendMessage('Network error. Please try again.'); - } finally { - setResendLoading(false); + const success = await resendEmailVerification(email); + if (success) { + setResendMessage('Verification email sent!'); + } + } catch (error: any) { + setResendMessage(error?.message || 'Network error. Please try again.'); } }; @@ -509,8 +473,8 @@ const RegistrationSuccessDialog = ({ @@ -524,69 +488,46 @@ const RegistrationSuccessDialog = ({ // Enhanced Login Component with MFA Support const LoginForm = () => { - const { apiClient } = useAuth(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [mfaRequired, setMfaRequired] = useState(false); - const [mfaData, setMfaData] = useState(null); + const { login, mfaResponse, isLoading, error } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); - const handleLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(''); - - try { - const response = await fetch('/api/1.0/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - login: email, - password, - }), - }); - - const data = await response.json(); - - if (data.success) { - if (data.data.mfaRequired) { - // MFA required for new device - setMfaRequired(true); - setMfaData({ - email, - deviceId: data.data.deviceId, - deviceName: data.data.deviceName, - }); - } else { - // Normal login success - handleLoginSuccess(data.data); + useEffect(() => { + if (!error) { + return; + } + /* Remove 'HTTP .*: ' from error string */ + const jsonStr = error.replace(/^[^{]*/, ''); + const data = JSON.parse(jsonStr); + setErrorMessage(data.error.message); + + }, [error]); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + + const success = await login({ + login: email, + password + }); + + console.log(`login success: ${success}`); + if (success) { + // Redirect based on user type - this could be handled in AuthContext + // or by a higher-level component that listens to auth state changes + handleLoginSuccess(); } - } 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); + handleLoginSuccess(); }; - const handleLoginSuccess = (authData: any) => { - // Store tokens - localStorage.setItem('accessToken', authData.accessToken); - localStorage.setItem('refreshToken', authData.refreshToken); - localStorage.setItem('user', JSON.stringify(authData.user)); - - // Redirect based on user type - const userType = authData.user.userType; - window.location.href = userType === 'employer' ? '/employer-dashboard' : '/candidate-dashboard'; + const handleLoginSuccess = () => { + // This could be handled by a router or parent component + // For now, just showing the pattern + console.log('Login successful - redirect to dashboard'); }; return ( @@ -612,9 +553,9 @@ const LoginForm = () => { autoComplete="current-password" /> - {error && ( + {errorMessage && ( - {error} + {errorMessage} )} @@ -622,23 +563,18 @@ const LoginForm = () => { type="submit" fullWidth variant="contained" - disabled={loading} + disabled={isLoading} sx={{ mt: 3, mb: 2 }} > - {loading ? : 'Sign In'} + {isLoading ? : 'Sign In'} - {/* MFA Dialog */} - {mfaRequired && mfaData && ( - setMfaRequired(false)} - email={mfaData.email} - deviceId={mfaData.deviceId} - deviceName={mfaData.deviceName} - onVerificationSuccess={handleMFASuccess} - /> - )} + {/* MFA Dialog */} + { }} // This will be handled by clearMFA in the dialog + onVerificationSuccess={handleMFASuccess} + /> ); } diff --git a/frontend/src/components/MFA.tsx b/frontend/src/components/MFA.tsx deleted file mode 100644 index 51cc208..0000000 --- a/frontend/src/components/MFA.tsx +++ /dev/null @@ -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(''); - - 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 ( - - - - - {status === 'pending' && ( - <> - - - Verifying Email - - - Please wait while we verify your email address... - - - )} - - {status === 'success' && ( - <> - - - Email Verified! - - - Your {userType} account has been successfully activated. - - - )} - - {status === 'error' && ( - <> - - - Verification Failed - - - We couldn't verify your email address. - - - )} - - - {loading && ( - - - - )} - - {message && ( - - {message} - - )} - - {status === 'success' && ( - - - You will be redirected to the login page in a few seconds... - - - - )} - - {status === 'error' && ( - - - - - )} - - - - ); -} - -// 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 ( - - - - - - Verify Your Identity - - - - - - - We've detected a login from a new device: {deviceName} - - - - We've sent a 6-digit verification code to: - - - {email} - - - { - 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} - /> - - - - Code expires in: {formatTime(timeLeft)} - - - - - setRememberDevice(e.target.checked)} - /> - } - label="Remember this device for 90 days" - /> - - - - If you didn't attempt to log in, please change your password immediately. - - - - - - - - - - ); -} - -// 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 ( - - - - - - Check Your Email - - - - We've sent a verification link to: - - - - {email} - - - - - Next steps: -
- 1. Check your email inbox (and spam folder) -
- 2. Click the verification link -
- 3. Your {userType} account will be activated -
-
- - {resendMessage && ( - - {resendMessage} - - )} -
- - - - - -
- ); -} - -// 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(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 ( - - setEmail(e.target.value)} - autoComplete="email" - autoFocus - /> - setPassword(e.target.value)} - autoComplete="current-password" - /> - - {error && ( - - {error} - - )} - - - - {/* MFA Dialog */} - {mfaRequired && mfaData && ( - setMfaRequired(false)} - email={mfaData.email} - deviceId={mfaData.deviceId} - deviceName={mfaData.deviceName} - onVerificationSuccess={handleMFASuccess} - /> - )} - - ); -} - -// Device Management Component -export function TrustedDevicesManager() { - const [devices, setDevices] = useState([]); - const [loading, setLoading] = useState(true); - - // This would need API endpoints to manage trusted devices - useEffect(() => { - // Load trusted devices - setLoading(false); - }, []); - - return ( - - - - - Trusted Devices - - - - Manage devices that you've marked as trusted. You won't need to verify - your identity when signing in from these devices. - - - {devices.length === 0 ? ( - - No trusted devices yet. When you log in from a new device and choose - to remember it, it will appear here. - - ) : ( - - {devices.map((device, index) => ( - - - - - {device.deviceName} - - - Added: {new Date(device.addedAt).toLocaleDateString()} - - - Last used: {new Date(device.lastUsed).toLocaleDateString()} - - - - - - ))} - - )} - - - ); -} \ No newline at end of file diff --git a/frontend/src/components/StyledMarkdown.tsx b/frontend/src/components/StyledMarkdown.tsx index cafc4ea..2a740de 100644 --- a/frontend/src/components/StyledMarkdown.tsx +++ b/frontend/src/components/StyledMarkdown.tsx @@ -27,6 +27,9 @@ const StyledMarkdown: React.FC = (props: StyledMarkdownProp const theme = useTheme(); const overrides: any = { + p: { component: (element: any) =>{ + return
{element.children}
+ }}, pre: { component: (element: any) => { const { className } = element.children.props; diff --git a/frontend/src/hooks/AuthContext.tsx b/frontend/src/hooks/AuthContext.tsx index 2128e12..bd69566 100644 --- a/frontend/src/hooks/AuthContext.tsx +++ b/frontend/src/hooks/AuthContext.tsx @@ -7,26 +7,40 @@ import { formatApiRequest, toCamelCase } from '../types/conversion'; // Types and Interfaces // ============================ -export interface AuthState { + +interface AuthState { user: Types.User | null; guest: Types.Guest | null; isAuthenticated: boolean; isLoading: boolean; isInitializing: boolean; error: string | null; + mfaResponse: Types.MFARequestResponse | null; } -export interface LoginRequest { +interface LoginRequest { login: string; // email or username password: string; } -export interface PasswordResetRequest { +interface MFAVerificationRequest { + email: string; + code: string; + deviceId: string; + rememberDevice?: boolean; +} + +interface EmailVerificationRequest { + token: string; +} + +interface ResendVerificationRequest { email: string; } -// Re-export API client types for convenience -export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client'; +interface PasswordResetRequest { + email: string; +} // ============================ // Token Storage Constants @@ -37,7 +51,8 @@ const TOKEN_STORAGE = { REFRESH_TOKEN: 'refreshToken', USER_DATA: 'userData', TOKEN_EXPIRY: 'tokenExpiry', - GUEST_DATA: 'guestData' + GUEST_DATA: 'guestData', + PENDING_VERIFICATION_EMAIL: 'pendingVerificationEmail' } as const; // ============================ @@ -195,14 +210,15 @@ function getStoredGuestData(): Types.Guest | null { // Main Authentication Hook // ============================ -export function useAuthenticationLogic() { +function useAuthenticationLogic() { const [authState, setAuthState] = useState({ user: null, guest: null, isAuthenticated: false, isLoading: false, isInitializing: true, - error: null + error: null, + mfaResponse: null, }); const [apiClient] = useState(() => new ApiClient()); @@ -242,7 +258,8 @@ export function useAuthenticationLogic() { isAuthenticated: false, isLoading: false, isInitializing: false, - error: null + error: null, + mfaResponse: null, }); return; } @@ -263,7 +280,8 @@ export function useAuthenticationLogic() { isAuthenticated: true, isLoading: false, isInitializing: false, - error: null + error: null, + mfaResponse: null }); console.log('Token refreshed successfully'); @@ -278,7 +296,8 @@ export function useAuthenticationLogic() { isAuthenticated: false, isLoading: false, isInitializing: false, - error: null + error: null, + mfaResponse: null }); } } else { @@ -291,7 +310,8 @@ export function useAuthenticationLogic() { isAuthenticated: true, isLoading: false, isInitializing: false, - error: null + error: null, + mfaResponse: null }); console.log('Restored authentication from stored tokens'); @@ -308,7 +328,8 @@ export function useAuthenticationLogic() { isAuthenticated: false, isLoading: false, isInitializing: false, - error: null + error: null, + mfaResponse: null }); } finally { initializationCompleted.current = true; @@ -348,27 +369,158 @@ export function useAuthenticationLogic() { return () => clearTimeout(refreshTimer); }, [authState.isAuthenticated, initializeAuth]); + // Enhanced login with MFA support const login = useCallback(async (loginData: LoginRequest): Promise => { + setAuthState(prev => ({ ...prev, isLoading: true, error: null, mfaResponse: null, mfaData: null })); + + try { + const result = await apiClient.login({ + login: loginData.login, + password: loginData.password, + }); + + if ('mfaRequired' in result) { + // MFA required for new device + setAuthState(prev => ({ + ...prev, + isLoading: false, + mfaResponse: result, + })); + return false; // Login not complete yet + } else { + // Normal login success + const authResponse: Types.AuthResponse = result; + storeAuthData(authResponse); + apiClient.setAuthToken(authResponse.accessToken); + + setAuthState(prev => ({ + ...prev, + user: authResponse.user, + isAuthenticated: true, + isLoading: false, + error: null, + mfaResponse: null, + })); + + console.log('Login successful'); + return true; + } + } catch (error: any) { + const errorMessage = error instanceof Error ? error.message : 'Network error occurred. Please try again.'; + setAuthState(prev => ({ + ...prev, + isLoading: false, + error: errorMessage, + mfaResponse: null, + })); + return false; + } + }, [apiClient]); + + // MFA verification + const verifyMFA = useCallback(async (mfaData: MFAVerificationRequest): Promise => { setAuthState(prev => ({ ...prev, isLoading: true, error: null })); try { - const authResponse = await apiClient.login(loginData); - - storeAuthData(authResponse); - apiClient.setAuthToken(authResponse.accessToken); + const result = await apiClient.verifyMFA(mfaData); + if (result.accessToken) { + const authResponse: Types.AuthResponse = result; + storeAuthData(authResponse); + apiClient.setAuthToken(authResponse.accessToken); + + setAuthState(prev => ({ + ...prev, + user: authResponse.user, + isAuthenticated: true, + isLoading: false, + error: null, + mfaResponse: null, + })); + + console.log('MFA verification successful'); + return true; + } + + return false; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'MFA verification failed'; + console.log(errorMessage); setAuthState(prev => ({ ...prev, - user: authResponse.user, - isAuthenticated: true, isLoading: false, - error: null + error: errorMessage })); - - console.log('Login successful'); + return false; + } + }, [apiClient]); + + // Resend MFA code + const resendMFACode = useCallback(async (email: string, deviceId: string, deviceName: string): Promise => { + 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 : 'Login failed'; + 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 => { + 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, @@ -378,6 +530,16 @@ export function useAuthenticationLogic() { } }, [apiClient]); + // Store pending verification email + const setPendingVerificationEmail = useCallback((email: string) => { + localStorage.setItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL, email); + }, []); + + // Get pending verification email + const getPendingVerificationEmail = useCallback((): string | null => { + return localStorage.getItem(TOKEN_STORAGE.PENDING_VERIFICATION_EMAIL); + }, []); + const logout = useCallback(() => { clearStoredAuth(); apiClient.clearAuthToken(); @@ -391,7 +553,8 @@ export function useAuthenticationLogic() { guest, isAuthenticated: false, isLoading: false, - error: null + error: null, + mfaResponse: null, })); console.log('User logged out'); @@ -413,13 +576,11 @@ export function useAuthenticationLogic() { const candidate = await apiClient.createCandidate(candidateData); console.log('Candidate created:', candidate); - // Auto-login after successful registration - const loginSuccess = await login({ - login: candidateData.email, - password: candidateData.password - }); + // Store email for potential verification resend + setPendingVerificationEmail(candidateData.email); - return loginSuccess; + setAuthState(prev => ({ ...prev, isLoading: false })); + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; setAuthState(prev => ({ @@ -429,7 +590,7 @@ export function useAuthenticationLogic() { })); return false; } - }, [apiClient, login]); + }, [apiClient, setPendingVerificationEmail]); const createEmployerAccount = useCallback(async (employerData: CreateEmployerRequest): Promise => { setAuthState(prev => ({ ...prev, isLoading: true, error: null })); @@ -438,12 +599,11 @@ export function useAuthenticationLogic() { const employer = await apiClient.createEmployer(employerData); console.log('Employer created:', employer); - const loginSuccess = await login({ - login: employerData.email, - password: employerData.password - }); + // Store email for potential verification resend + setPendingVerificationEmail(employerData.email); - return loginSuccess; + setAuthState(prev => ({ ...prev, isLoading: false })); + return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Account creation failed'; setAuthState(prev => ({ @@ -453,7 +613,7 @@ export function useAuthenticationLogic() { })); return false; } - }, [apiClient, login]); + }, [apiClient, setPendingVerificationEmail]); const requestPasswordReset = useCallback(async (email: string): Promise => { setAuthState(prev => ({ ...prev, isLoading: true, error: null })); @@ -507,6 +667,13 @@ export function useAuthenticationLogic() { apiClient, login, logout, + verifyMFA, + resendMFACode, + clearMFA, + verifyEmail, + resendEmailVerification, + setPendingVerificationEmail, + getPendingVerificationEmail, createCandidateAccount, createEmployerAccount, requestPasswordReset, @@ -521,7 +688,7 @@ export function useAuthenticationLogic() { const AuthContext = createContext | null>(null); -export function AuthProvider({ children }: { children: React.ReactNode }) { +function AuthProvider({ children }: { children: React.ReactNode }) { const auth = useAuthenticationLogic(); return ( @@ -531,7 +698,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); } -export function useAuth() { +function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); @@ -549,7 +716,7 @@ interface ProtectedRouteProps { requiredUserType?: Types.UserType; } -export function ProtectedRoute({ +function ProtectedRoute({ children, fallback =
Please log in to access this page.
, requiredUserType @@ -572,4 +739,14 @@ export function ProtectedRoute({ } return <>{children}; +} + +export type { + AuthState, LoginRequest, MFAVerificationRequest, EmailVerificationRequest, ResendVerificationRequest, PasswordResetRequest +} + +export type { CreateCandidateRequest, CreateEmployerRequest } from '../services/api-client'; + +export { + useAuthenticationLogic, AuthProvider, useAuth, ProtectedRoute } \ No newline at end of file diff --git a/frontend/src/pages/DocsPage.tsx b/frontend/src/pages/DocsPage.tsx index 50295d9..d4514de 100644 --- a/frontend/src/pages/DocsPage.tsx +++ b/frontend/src/pages/DocsPage.tsx @@ -142,6 +142,7 @@ const documents : DocType[] = [ { title: "BETA", route: "beta", description: "Details about the current beta version and upcoming features", icon: }, { title: "Resume Generation Architecture", route: "resume-generation", description: "Technical overview of how resumes are processed and generated", icon: }, { title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: }, + { title: "Authentication Architecture", route: "authentication.md", description: "Complete authentication architecture", icon: }, { title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: }, { title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: }, { title: "Chat Mockup", route: "mockup-chat-system", description: "Mockup of chat system", icon: }, diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 0fff683..adf0d56 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -1,5 +1,5 @@ /** - * Enhanced API Client with Streaming Support and Date Conversion + * API Client with Streaming Support and Date Conversion * * This demonstrates how to use the generated types with the conversion utilities * for seamless frontend-backend communication, including streaming responses and @@ -54,11 +54,6 @@ interface StreamingResponse { promise: Promise; } -export interface LoginRequest { - login: string; // email or username - password: string; -} - export interface CreateCandidateRequest { email: string; username: string; @@ -261,37 +256,38 @@ class ApiClient { /** * Request MFA for new device */ - async requestMFA(request: MFARequest): Promise { + async requestMFA(request: MFARequest): Promise { const response = await fetch(`${this.baseUrl}/auth/mfa/request`, { method: 'POST', headers: this.defaultHeaders, body: JSON.stringify(formatApiRequest(request)) }); - return handleApiResponse(response); + return handleApiResponse(response); } /** * Verify MFA code */ - async verifyMFA(request: MFAVerifyRequest): Promise { + async verifyMFA(request: Types.MFAVerifyRequest): Promise { + const formattedRequest = formatApiRequest(request) const response = await fetch(`${this.baseUrl}/auth/mfa/verify`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest(request)) + body: JSON.stringify(formattedRequest) }); return handleApiResponse(response); } /** - * Enhanced login with device detection + * login with device detection */ - async loginEnhanced(email: string, password: string): Promise { + async login(auth: Types.LoginRequest): Promise { const response = await fetch(`${this.baseUrl}/auth/login`, { method: 'POST', headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest({ login: email, password })) + body: JSON.stringify(formatApiRequest(auth)) }); // This could return either a full auth response or MFA request @@ -307,7 +303,7 @@ class ApiClient { /** * Logout with token revocation */ - async logoutEnhanced(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> { + async logout(accessToken: string, refreshToken: string): Promise<{ message: string; tokensRevoked: any }> { const response = await fetch(`${this.baseUrl}/auth/logout`, { method: 'POST', headers: this.defaultHeaders, @@ -495,27 +491,6 @@ class ApiClient { // ============================ // Authentication Methods // ============================ - async login(request: LoginRequest): Promise { - 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(response); - } - - async logout(accessToken: string, refreshToken: string): Promise { - const response = await fetch(`${this.baseUrl}/auth/logout`, { - method: 'POST', - headers: this.defaultHeaders, - body: JSON.stringify(formatApiRequest({ accessToken, refreshToken })) - }); - - return handleApiResponse(response); - } - async refreshToken(refreshToken: string): Promise { const response = await fetch(`${this.baseUrl}/auth/refresh`, { method: 'POST', @@ -1094,7 +1069,7 @@ class ApiClient { // ============================ -// Enhanced Request/Response Types +// Request/Response Types // ============================ export interface CreateCandidateWithVerificationRequest { @@ -1133,13 +1108,6 @@ export interface MFARequest { deviceName: string; } -export interface MFAVerifyRequest { - email: string; - code: string; - deviceId: string; - rememberDevice: boolean; -} - export interface RegistrationResponse { message: string; email: string; @@ -1152,12 +1120,6 @@ export interface EmailVerificationResponse { userType: string; } -export interface MFARequestResponse { - mfaRequired: boolean; - message: string; - deviceId?: string; -} - export interface TrustedDevice { deviceId: string; deviceName: string; @@ -1202,7 +1164,7 @@ export interface PendingVerification { /* // Registration with email verification -const apiClient = new EnhancedApiClient(); +const apiClient = new ApiClient(); try { const result = await apiClient.createCandidateWithVerification({ @@ -1226,9 +1188,9 @@ try { console.error('Registration failed:', error); } -// Enhanced login with MFA support +// login with MFA support try { - const loginResult = await apiClient.loginEnhanced('user@example.com', 'password'); + const loginResult = await apiClient.login('user@example.com', 'password'); if ('mfaRequired' in loginResult && loginResult.mfaRequired) { // Show MFA dialog diff --git a/frontend/src/types/conversion.ts b/frontend/src/types/conversion.ts index 7a74666..1ddf81c 100644 --- a/frontend/src/types/conversion.ts +++ b/frontend/src/types/conversion.ts @@ -132,7 +132,7 @@ export function formatApiRequest>(data: T): Record } } - return formatted; + return toSnakeCase(formatted); } /** diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index a87647c..37898e4 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -1,6 +1,6 @@ // Generated TypeScript types from Pydantic models // Source: src/backend/models.py -// Generated on: 2025-06-01T01:48:43.853130 +// Generated on: 2025-06-01T20:40:46.797024 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -145,6 +145,7 @@ export interface BaseUser { lastLogin?: Date; profileImage?: string; status: "active" | "inactive" | "pending" | "banned"; + isAdmin?: boolean; } export interface BaseUserWithType { @@ -160,6 +161,7 @@ export interface BaseUserWithType { lastLogin?: Date; profileImage?: string; status: "active" | "inactive" | "pending" | "banned"; + isAdmin?: boolean; userType: "candidate" | "employer" | "guest"; } @@ -176,6 +178,7 @@ export interface Candidate { lastLogin?: Date; profileImage?: string; status: "active" | "inactive" | "pending" | "banned"; + isAdmin?: boolean; userType: "candidate"; username: string; description?: string; @@ -395,6 +398,7 @@ export interface Employer { lastLogin?: Date; profileImage?: string; status: "active" | "inactive" | "pending" | "banned"; + isAdmin?: boolean; userType: "employer"; companyName: string; industry: string; @@ -539,13 +543,31 @@ export interface Location { address?: string; } -export interface MFARequest { +export interface LoginRequest { + login: string; + password: string; +} + +export interface MFAData { + message: string; + deviceId: string; + deviceName: string; + codeSent: string; email: string; +} + +export interface MFARequest { + username: string; password: string; deviceId: string; deviceName: string; } +export interface MFARequestResponse { + mfaRequired: boolean; + mfaData?: MFAData; +} + export interface MFAVerifyRequest { email: string; code: string; diff --git a/src/backend/database.py b/src/backend/database.py index 24c1d40..b364ec1 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -558,6 +558,7 @@ class RedisDatabase: async def store_mfa_code(self, email: str, code: str, device_id: str) -> bool: """Store MFA code for verification""" try: + logger.info("๐Ÿ” Storing MFA code for email: %s", email ) key = f"mfa_code:{email.lower()}:{device_id}" mfa_data = { "code": code, diff --git a/src/backend/email_service.py b/src/backend/email_service.py index 9d219fa..156adfe 100644 --- a/src/backend/email_service.py +++ b/src/backend/email_service.py @@ -1,4 +1,5 @@ import os +from typing import Tuple from logger import logger from email.mime.text import MIMEText # type: ignore from email.mime.multipart import MIMEMultipart # type: ignore @@ -10,170 +11,18 @@ import json from database import RedisDatabase class EmailService: - def __init__(self): - self.smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com") - self.smtp_port = int(os.getenv("SMTP_PORT", "587")) - self.email_user = os.getenv("EMAIL_USER", "your-app@example.com") - self.email_password = os.getenv("EMAIL_PASSWORD", "your-app-password") - self.from_name = os.getenv("FROM_NAME", "Backstory") - - async def send_verification_email(self, to_email: str, verification_token: str, user_name: str): - """Send email verification email""" - try: - verification_link = f"{os.getenv('FRONTEND_URL', 'https://backstory-beta.ketrenos.com')}/verify-email?token={verification_token}" - - subject = f"Welcome to {self.from_name} - Please verify your email" - - html_content = f""" - - - - - - Email Verification - - - -
-
-

Welcome to {self.from_name}!

-

Thanks for joining us, {user_name}

-
-
-

Please verify your email address

-

To complete your registration and start using {self.from_name}, please verify your email address by clicking the button below:

- - Verify Email Address - -

If the button doesn't work, copy and paste this link into your browser:

-

{verification_link}

- -
- Security Note: This verification link will expire in 24 hours. If you didn't create this account, please ignore this email. -
-
- -
- - - """ - - 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""" - - - - - - Security Code - - - -
-
-

๐Ÿ” Security Code

-

Hi {user_name}

-
-
-

New device login detected

-

We detected a login attempt from a new device: {device_name}

-

Please enter this security code to complete your login:

- -
{mfa_code}
- -

This code will expire in 10 minutes.

- -
- โš ๏ธ Important: If you didn't attempt to log in, please change your password immediately and contact our support team. -
-
- -
- - - """ - - await self._send_email(to_email, subject, html_content) - logger.info(f"๐Ÿ“ง MFA code sent to {to_email} for device {device_name}") - - except Exception as e: - logger.error(f"โŒ Failed to send MFA email to {to_email}: {e}") - raise - - async def _send_email(self, to_email: str, subject: str, html_content: str): - """Send email using SMTP""" - try: - # Create message - msg = MIMEMultipart('alternative') - msg['From'] = f"{self.from_name} <{self.email_user}>" - msg['To'] = to_email - msg['Subject'] = subject - - # Add HTML content - html_part = MIMEText(html_content, 'html') - msg.attach(html_part) - - # Send email - with smtplib.SMTP(self.smtp_server, self.smtp_port) as server: - server.starttls() - server.login(self.email_user, self.email_password) - text = msg.as_string() - server.sendmail(self.email_user, to_email, text) - - logger.debug(f"๐Ÿ“ง Email sent successfully to {to_email}") - - except Exception as e: - logger.error(f"โŒ SMTP error sending to {to_email}: {e}") - raise - -email_service = EmailService() - -class EnhancedEmailService: def __init__(self): # Configure these in your .env file self.smtp_server = os.getenv("SMTP_SERVER") - self.smtp_port = int(os.getenv("SMTP_PORT", "0")) - self.email_user = os.getenv("EMAIL_USER",) + self.smtp_port = int(os.getenv("SMTP_PORT", "587")) + self.email_user = os.getenv("EMAIL_USER") self.email_password = os.getenv("EMAIL_PASSWORD") self.from_name = os.getenv("FROM_NAME", "Backstory") self.app_name = os.getenv("APP_NAME", "Backstory") self.frontend_url = os.getenv("FRONTEND_URL", "https://backstory-beta.ketrenos.com") if not self.smtp_server or self.smtp_port == 0 or self.email_user is None or self.email_password is None: raise ValueError("SMTP configuration is not set in the environment variables") - + def _get_template(self, template_name: str) -> dict: """Get email template by name""" return EMAIL_TEMPLATES.get(template_name, {}) @@ -429,3 +278,9 @@ class VerificationEmailRateLimiter: async def record_email_sent(self, email: str): """Record that a verification email was sent""" await self.database.record_verification_attempt(email) + + + +email_service = EmailService() + + \ No newline at end of file diff --git a/src/backend/email_templates.py b/src/backend/email_templates.py index 03cd047..7c81b79 100644 --- a/src/backend/email_templates.py +++ b/src/backend/email_templates.py @@ -10,90 +10,93 @@ EMAIL_TEMPLATES = { Email Verification @@ -140,83 +143,90 @@ EMAIL_TEMPLATES = { Security Code @@ -277,44 +287,75 @@ EMAIL_TEMPLATES = { Password Reset @@ -332,11 +373,14 @@ EMAIL_TEMPLATES = { Reset Password -

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.

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