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:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user