import CryptoJS from 'crypto-js'; import { AesUtil } from '../crypto/aes-util'; import { Base64 } from '../crypto/base64'; import { FailureResponse } from '../errors'; import { HttpClient, createFormData } from '../http-client'; import type { BaseResponse, IStorage } from '../types/common'; import type { AuthDevice, AuthIdentity, AuthLogin, AuthRegisterRequest, AuthRegisterResponse, AuthThirdPartyLogin, UpdateProfileRequest } from '../types/auth.types'; const AUTH_FIELDS = ['token', 'roles', 'username', 'profile', 'email', 'permissions']; class AuthService { private http: HttpClient; constructor(http: HttpClient) { this.http = http; } private get storage(): IStorage { return this.http.getStorage(); } // --- Auth data helpers --- private async setAuthData(data: AuthIdentity): Promise { const { token, user, permissions } = data; await this.storage.setItem('email', user?.email); await this.storage.setItem('token', token); await this.storage.setItem('roles', JSON.stringify(user?.roles || [])); await this.storage.setItem( 'profile', JSON.stringify({ ...user?.profile, fullName: user?.name, needToChangePassword: user?.needToChangePassword, firstLogin: user?.firstLogin }) ); await this.storage.setItem('permissions', permissions?.join(',')); } private async clearAuth(): Promise { await this.unsetImpersonateAuthData(); for (const param of AUTH_FIELDS) { await this.storage.removeItem(`admin_${param}`); await this.storage.removeItem(param); } } private async setImpersonateAuthData(data: AuthIdentity): Promise { for (const param of AUTH_FIELDS) { const toSaveParam = `admin_${param}`; await this.storage.setItem(toSaveParam, await this.storage.getItem(param)); await this.storage.setItem( param, ['profile', 'roles'].indexOf(param) !== -1 ? JSON.stringify(data[param]) : data[param] ); } await this.storage.setItem('impersonate', true); } private async unsetImpersonateAuthData(): Promise { for (const param of AUTH_FIELDS) { const savedParam = `admin_${param}`; await this.storage.setItem(param, await this.storage.getItem(savedParam)); await this.storage.removeItem(savedParam); } await this.storage.setItem('impersonate', false); } private async clearNeedToChangePassword(): Promise { const profile = await this.storage.getItem('profile'); await this.storage.setItem('profile', JSON.stringify({ ...JSON.parse(profile), needToChangePassword: false })); } // --- Public API --- async login(params: AuthLogin): Promise { const { username, password } = params; const url = `${this.http['apiUrl']}/auth/login`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: 'application/json' }, body: createFormData({ mail: username, password }) }); const response = await fetch(request); if (response.status === 401) { throw FailureResponse.handled('iam.error.invalid-credentials'); } if (!response.ok) { throw FailureResponse.unhandled(response.statusText); } const { responseCode, ...data } = await response.json(); if (responseCode !== 'ok') { throw FailureResponse.handled(responseCode); } await this.setAuthData(data); return data; } async thirdPartyLogin(params: AuthThirdPartyLogin): Promise { const { provider, payload } = params; const url = `${this.http['apiUrl']}/auth/third-party-login`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: 'application/json' }, body: createFormData({ provider, payload: JSON.stringify(payload) }) }); const response = await fetch(request); if (response.status === 200) { const body = await response.json(); if (body.responseCode === 'iam.error.user-not-active') { throw FailureResponse.handled('iam.error.user-not-active'); } const { responseCode, ...data } = body; if (responseCode !== 'ok') { throw FailureResponse.handled(responseCode); } await this.setAuthData(data); return data; } else if (response.status === 401) { throw FailureResponse.handled('iam.error.invalid-credentials'); } else { throw FailureResponse.unhandled(response.statusText); } } async validateToken(token: string): Promise { if (!token) { await this.clearAuth(); throw FailureResponse.unhandled('Token variable not found in storage'); } const url = `${this.http['apiUrl']}/auth/token-login`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: 'application/json' }, body: createFormData({ token }) }); const response = await fetch(request); const body = await response.json(); if (body.responseCode !== 'ok') { await this.clearAuth(); throw FailureResponse.handled(body.responseCode); } await this.setAuthData(body); return body; } async logout(): Promise { await this.clearAuth(); } async checkAuth(): Promise { const token = await this.getToken(); return this.validateToken(token); } async freshToken(): Promise { await this.checkAuth(); return this.getToken(); } async getToken(): Promise { const token = await this.http.getToken(); if (!token) throw FailureResponse.handled('iam.error.unauthorized'); return token; } async getHeaders(): Promise { const token = await this.http.getToken(); const authHeaders = token && token?.length > 0 ? { Authorization: `Bearer ${token}` } : ({} as any); return new Headers({ 'Content-Type': 'application/json', Accept: 'application/json', ...authHeaders }); } async getPermissions(): Promise { const permissions = await this.storage.getItem('permissions'); if (!permissions) throw FailureResponse.handled('iam.error.unauthorized'); return (permissions?.split(',') || []).filter((p) => p); } async getRoles(): Promise { const roles = await this.storage.getItem('roles'); if (!roles) throw FailureResponse.handled('iam.error.unauthorized'); return ((JSON.parse(roles || '[]') as string[]) || []).filter((r) => r); } async getIdentity(): Promise { const profile = JSON.parse((await this.storage.getItem('profile')) || '{}'); const roles = JSON.parse((await this.storage.getItem('roles')) || '[]'); const email = (await this.storage.getItem('email')) || ''; if (email === '') { throw FailureResponse.handled('iam.error.unauthorized'); } return { ...profile, email, fullName: profile?.full_name || profile?.name, roles: roles.map((role: string) => ({ id: role, name: role })) }; } async impersonate(id: string | number): Promise { const token = await this.http.getToken(); const url = `${this.http['apiUrl']}/auth/impersonate`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Authorization: `Bearer ${token}` }, body: createFormData({ id }) }); const response = await fetch(request); if (response.status === 401) { throw FailureResponse.handled('iam.error.not-authorized'); } if (!response.ok) { throw FailureResponse.unhandled(response.statusText); } const { responseCode, ...data } = await response.json(); if (responseCode !== 'ok') { throw FailureResponse.handled(responseCode); } await this.setImpersonateAuthData(data); await this.setAuthData(data); return data; } async stopImpersonate(): Promise { await this.unsetImpersonateAuthData(); } async isImpersonating(): Promise { const impersonate = await this.storage.getItem('impersonate'); return impersonate?.toString() === 'true'; } async registerDevice(code: string): Promise { const url = `${this.http['apiUrl']}/auth/register-device`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) }); const response = await fetch(request); if (!response.ok) { throw FailureResponse.unhandled(response.statusText); } const { responseCode, ...data } = await response.json(); if (responseCode !== 'ok') { throw FailureResponse.handled(responseCode); } await this.storage.setItem('deviceCode', data?.device?.code); await this.storage.setItem('deviceSecret', data?.device?.secret); return data.device; } async getDevice(code: string): Promise { const deviceCode = await this.storage.getItem('deviceCode'); const deviceSecret = await this.storage.getItem('deviceSecret'); if (!deviceCode || deviceCode !== code) { return this.registerDevice(code); } return { code: deviceCode, secret: deviceSecret }; } async pinLogin(deviceCode: string, pin: string): Promise { const aesUtil = new AesUtil(128, 1000); const iv = CryptoJS.lib.WordArray.random(128 / 8).toString(CryptoJS.enc.Hex); const { secret } = await this.getDevice(deviceCode); const salt = CryptoJS.lib.WordArray.random(128 / 8).toString(CryptoJS.enc.Hex); const ciphertext = aesUtil.encrypt(salt, iv, secret, pin); const aesPassword = iv + '::' + salt + '::' + ciphertext; const password = Base64.btoa(aesPassword); const url = `${this.http['apiUrl']}/auth/pin-login`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ deviceCode, encryptedPin: password }) }); const response = await fetch(request); if (response.status < 200 || response.status >= 300) { throw new Error(response.statusText || 'Authorization error'); } const { responseCode, ...data } = await response.json(); if (responseCode !== 'ok') { throw new Error(`Error: ${responseCode}`); } await this.setAuthData(data); return data; } async resetPin(userId: string, pinCode: string): Promise { const token = await this.http.getToken(); const url = `${this.http['apiUrl']}/auth/reset-pin`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: 'application/json', Authorization: `Bearer ${token}` }, body: createFormData({ userId, pinCode }) }); const response = await fetch(request); if (response.status < 200 || response.status >= 300) { throw new Error(response.statusText || 'Authorization error'); } const { responseCode, ...data } = await response.json(); if (responseCode !== 'ok') { throw new Error(`Error: ${responseCode}`); } await this.setAuthData(data); return data; } async register(data: AuthRegisterRequest): Promise { const url = `${this.http['apiUrl']}/auth/register`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: createFormData(data) }); const response = await fetch(request); if (response.status < 200 || response.status > 400) { throw FailureResponse.unhandled(response.statusText); } const { responseCode, ...rest } = await response.json(); if (responseCode !== 'ok') { throw FailureResponse.handled(responseCode); } return { responseCode, ...rest }; } async activate(activationCode: string): Promise { const url = `${this.http['apiUrl']}/auth/activate`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: createFormData({ activationCode }) }); const response = await fetch(request); if (response.status === 200) { return response.json(); } else if (response.status === 400) { throw new Error('iam.error.bad-activation-code'); } else { throw new Error(response.statusText); } } async changePassword(currentPassword: string, password: string): Promise { const token = await this.getToken(); const url = `${this.http['apiUrl']}/auth/change-password`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Authorization: `Bearer ${token}` }, body: createFormData({ currentPassword, password }) }); const response = await fetch(request); if (response.status === 200) { await this.clearNeedToChangePassword(); return response.json(); } else if (response.status === 401 || response.status === 403) { throw FailureResponse.handled('iam.error.unauthorized'); } else { throw FailureResponse.unhandled(response.statusText); } } async recover(email: string): Promise { const url = `${this.http['apiUrl']}/auth/recover`; const request = new Request(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: createFormData({ email }) }); const response = await fetch(request); if (response.status === 200) { return response.json(); } else if (response.status === 400) { throw FailureResponse.handled('iam.error.unauthorized'); } else { throw FailureResponse.unhandled(response.statusText); } } async updateProfile(profileData: UpdateProfileRequest): Promise { const headers = await this.getHeaders(); const url = `${this.http['apiUrl']}/auth/update-profile`; const request = new Request(url, { method: 'POST', headers, body: JSON.stringify({ data: profileData }) }); const response = await fetch(request); if (response.status === 200) { return response.json(); } else { throw FailureResponse.unhandled(response.statusText); } } } export { AUTH_FIELDS, AuthService };