feat(auth): add 2FA with OTP, backup codes and trusted devices

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-24 17:25:00 +00:00
parent b001b94e37
commit 1839285ac3
26 changed files with 2402 additions and 312 deletions

View File

@@ -28,6 +28,31 @@ const STORAGE_KEYS = {
AUTH_MODE: "auth_mode",
};
/**
* Returnează cheia localStorage pentru tokenul de trusted device.
* Cheia e per-user și per-server pentru izolare corectă.
* Exemplu: "trusted_device_MARIUS M_romfast"
*/
const _getTrustedDeviceKey = (username, serverId) => {
const base = `trusted_device_${(username || "unknown").toUpperCase()}`;
return serverId ? `${base}_${serverId}` : base;
};
/**
* Caută orice token de trusted device, indiferent de username sau server.
* Backend validează oricum username-ul corect.
*/
const _findAnyTrustedDeviceToken = () => {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("trusted_device_")) {
const val = localStorage.getItem(key);
if (val) return val;
}
}
return null;
};
/**
* Factory function to create an auth store with the provided API service
* @param {Object} apiService - Axios instance configured for the app's API
@@ -69,6 +94,18 @@ export function createAuthStore(apiService, options = {}) {
// Allows URL bookmark to pre-select a server (e.g., /login?server=romfast)
const preselectedServerId = ref(null);
// State - 2FA (pasul 2 din fluxul de login cu email)
const otpEmail = ref(""); // Email complet — trimis la /auth/verify-2fa-code
const otpMaskedEmail = ref(""); // Email mascat pentru display ("m***@romfast.ro")
const is2FALoading = ref(false); // Loading state pt butonul "Verifică"
const resendCountdown = ref(0); // Countdown 60s pt butonul "Retrimite codul"
let _resendTimer = null; // Interval timer intern (nu ref — nu e nevoie de reactivitate)
// State - pending server ID pentru 2FA în contextul server switch
// Login() returnează requires_2fa înainte de a actualiza selectedServerId,
// deci verify2FA() trebuie să folosească pendingServerId pentru server-ul corect
const pendingServerId = ref(null);
// Getters
const isAuthenticated = computed(() => !!accessToken.value);
const currentUser = computed(() => user.value);
@@ -217,8 +254,30 @@ export function createAuthStore(apiService, options = {}) {
payload.server_id = credentials.server_id;
}
// Trusted device: trimitem tokenul stocat local pentru posibil skip 2FA
// Fallback cross-server: dacă nu există token pentru serverul curent, căutăm orice token al utilizatorului
const tdKey = _getTrustedDeviceKey(credentials.username, credentials.server_id);
const storedTrustedToken = localStorage.getItem(tdKey) || _findAnyTrustedDeviceToken();
if (storedTrustedToken) {
payload.trusted_device_token = storedTrustedToken;
}
const response = await apiService.post("/auth/login", payload);
const { access_token, refresh_token, user: userData } = response.data;
const responseData = response.data;
// 2FA: backend cere verificare cod pe email
if (responseData.requires_2fa === true) {
otpEmail.value = responseData.email;
otpMaskedEmail.value = responseData.masked_email;
// Salvăm server_id pending — verify2FA() îl va folosi în loc de selectedServerId
pendingServerId.value = credentials.server_id || null;
loginStep.value = "2fa";
_startResendCountdown();
return { success: true, requires_2fa: true, masked_email: responseData.masked_email };
}
// Flow normal — extragem tokens
const { access_token, refresh_token, user: userData } = responseData;
// IMPORTANT: Update selectedServerId BEFORE user.value to ensure
// the companies store watch uses the correct server ID for localStorage key
@@ -273,6 +332,13 @@ export function createAuthStore(apiService, options = {}) {
availableServers.value = [];
// Note: Don't clear selectedServerId - keep it for next login pre-selection
// Reset 2FA state
otpEmail.value = "";
otpMaskedEmail.value = "";
is2FALoading.value = false;
pendingServerId.value = null;
_stopResendCountdown();
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(STORAGE_KEYS.USER);
@@ -342,6 +408,13 @@ export function createAuthStore(apiService, options = {}) {
availableServers.value = [];
error.value = null;
// Keep selectedServerId from localStorage for pre-selection
// Reset 2FA state
otpEmail.value = "";
otpMaskedEmail.value = "";
is2FALoading.value = false;
pendingServerId.value = null;
_stopResendCountdown();
};
/**
@@ -370,6 +443,188 @@ export function createAuthStore(apiService, options = {}) {
preselectedServerId.value = serverId;
};
// -------------------------------------------------------------------------
// 2FA HELPERS — countdown timer
// -------------------------------------------------------------------------
const _startResendCountdown = () => {
resendCountdown.value = 60;
_stopResendCountdown(); // Curăță orice timer anterior
_resendTimer = setInterval(() => {
if (resendCountdown.value > 0) {
resendCountdown.value--;
} else {
_stopResendCountdown();
}
}, 1000);
};
const _stopResendCountdown = () => {
if (_resendTimer) {
clearInterval(_resendTimer);
_resendTimer = null;
}
resendCountdown.value = 0;
};
// -------------------------------------------------------------------------
// 2FA ACTIONS
// -------------------------------------------------------------------------
/**
* Verifică codul OTP introdus de utilizator (pasul 2 al 2FA).
* Dacă codul este valid, stochează JWT tokens și completează autentificarea.
*
* @param {Object} params - {code: "483921", trustDevice?: false}
* @returns {Promise<{success: boolean, error?: string}>}
*/
const verify2FA = async ({ code, trustDevice = false }) => {
is2FALoading.value = true;
error.value = null;
try {
const payload = {
code: code.trim(),
email: otpEmail.value,
trust_device: trustDevice,
};
// Adăugăm server_id — folosim pendingServerId dacă există (server switch context)
const effectiveServerId = pendingServerId.value || selectedServerId.value;
if (effectiveServerId) {
payload.server_id = effectiveServerId;
}
const response = await apiService.post("/auth/verify-2fa-code", payload);
const { access_token, refresh_token, user: userData, trusted_device_token } = response.data;
// IMPORTANT: Commit pendingServerId -> selectedServerId BEFORE user (pattern existent)
if (effectiveServerId) {
selectedServerId.value = effectiveServerId;
localStorage.setItem(STORAGE_KEYS.LAST_SERVER_ID, effectiveServerId);
}
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token);
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
// Salvează tokenul de trusted device dacă utilizatorul a bifat "Ține minte"
if (trusted_device_token && userData?.username) {
const tdKey = _getTrustedDeviceKey(userData.username, effectiveServerId);
localStorage.setItem(tdKey, trusted_device_token);
}
// Curăță starea 2FA
otpEmail.value = "";
otpMaskedEmail.value = "";
pendingServerId.value = null;
_stopResendCountdown();
loginStep.value = "complete";
const backupCodes = response.data.backup_codes;
return { success: true, backup_codes: backupCodes };
} catch (err) {
error.value = err.response?.data?.detail || "Verificare eșuată. Încercați din nou.";
return { success: false, error: error.value };
} finally {
is2FALoading.value = false;
}
};
/**
* Retrimite codul OTP pe email (butonul "Retrimite codul").
* Resetează countdown-ul de 60 secunde.
*
* @returns {Promise<{success: boolean, error?: string}>}
*/
const resendOTP = async () => {
// Nu permite retrimis dacă countdown-ul nu a ajuns la 0
if (resendCountdown.value > 0) {
return { success: false, error: `Așteptați ${resendCountdown.value} secunde` };
}
error.value = null;
try {
const payload = { email: otpEmail.value };
const effectiveServerId = pendingServerId.value || selectedServerId.value;
if (effectiveServerId) {
payload.server_id = effectiveServerId;
}
await apiService.post("/auth/resend-2fa-code", payload);
// Resetăm countdown-ul
_startResendCountdown();
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Nu s-a putut retrimite codul.";
return { success: false, error: error.value };
}
};
/**
* Verifică un cod de backup (fallback când emailul nu sosește).
* @param {Object} params - {code: "AB3K9PQR", serverId?: "romfast"}
* @returns {Promise<{success: boolean, error?: string}>}
*/
const verifyBackupCode = async ({ code, serverId, trustDevice = false }) => {
is2FALoading.value = true;
error.value = null;
try {
const payload = {
code: code.trim().toUpperCase(),
email: otpEmail.value,
trust_device: trustDevice,
};
const effectiveServerId = serverId || pendingServerId.value || selectedServerId.value;
if (effectiveServerId) payload.server_id = effectiveServerId;
const response = await apiService.post("/auth/verify-backup-code", payload);
const { access_token, refresh_token, user: userData, trusted_device_token } = response.data;
if (effectiveServerId) {
selectedServerId.value = effectiveServerId;
localStorage.setItem(STORAGE_KEYS.LAST_SERVER_ID, effectiveServerId);
}
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token);
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
if (trusted_device_token && userData?.username) {
const tdKey = _getTrustedDeviceKey(userData.username, effectiveServerId);
localStorage.setItem(tdKey, trusted_device_token);
}
otpEmail.value = "";
otpMaskedEmail.value = "";
pendingServerId.value = null;
_stopResendCountdown();
loginStep.value = "complete";
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Cod de recuperare invalid.";
return { success: false, error: error.value };
} finally {
is2FALoading.value = false;
}
};
/**
* Switch to a different server without full logout (US-007)
* Re-authenticates the current user on the new server.
@@ -425,6 +680,13 @@ export function createAuthStore(apiService, options = {}) {
isAuthenticating, // Flag pentru a preveni 401 redirect în timpul login/server-switch
preselectedServerId, // US-004: URL bookmark pre-selection
// State - 2FA
otpEmail,
otpMaskedEmail,
is2FALoading,
resendCountdown,
pendingServerId,
// Getters
isAuthenticated,
currentUser,
@@ -454,6 +716,11 @@ export function createAuthStore(apiService, options = {}) {
// Actions - Server switch (US-007)
switchServer,
// Actions - 2FA
verify2FA,
resendOTP,
verifyBackupCode,
};
});
}