Add Telegram auth flow to backoffice
This commit is contained in:
492
src/app/services/auth.service.ts
Normal file
492
src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { AuthSession, AuthStatus, QrLoginSession, QrPollResult } from '../models/auth.model';
|
||||
|
||||
const SESSION_REFRESH_FALLBACK_MS = 60 * 60 * 1000;
|
||||
const AUTHENTICATED_SESSION_STORAGE_KEY = 'userauth_session_id';
|
||||
const LEGACY_WEB_SESSION_TOKEN_PREFIX = 'websession:';
|
||||
|
||||
interface QrCreateResponse {
|
||||
token?: string;
|
||||
url?: string;
|
||||
deeplink?: string;
|
||||
deepLink?: string;
|
||||
telegramUrl?: string;
|
||||
qrUrl?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authApiBaseUrl = this.trimTrailingSlash(
|
||||
(environment as Record<string, unknown>)['authApiUrl'] as string || ''
|
||||
);
|
||||
private readonly userSessionApiBaseUrl = this.trimTrailingSlash(
|
||||
(environment as Record<string, unknown>)['userSessionApiUrl'] as string || environment.apiUrl || ''
|
||||
);
|
||||
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unknown');
|
||||
private readonly showLoginSignal = signal(false);
|
||||
private sessionRefreshTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
readonly session = this.sessionSignal.asReadonly();
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||
|
||||
constructor() {
|
||||
this.checkSession();
|
||||
}
|
||||
|
||||
checkSession(): void {
|
||||
this.statusSignal.set('checking');
|
||||
|
||||
this.checkSessionOnce().subscribe((session) => {
|
||||
if (session?.active) {
|
||||
this.activateSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearAuthState('unauthenticated');
|
||||
});
|
||||
}
|
||||
|
||||
checkSessionOnce(): Observable<AuthSession | null> {
|
||||
return this.http
|
||||
.get<Record<string, unknown>>(this.buildAuthUrl('/userauth/session'), {
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(
|
||||
map((response) => this.normalizeSession(response)),
|
||||
catchError(() => this.checkStoredLegacySession())
|
||||
);
|
||||
}
|
||||
|
||||
createQrSession(): Observable<QrLoginSession> {
|
||||
return this.createUserauthQrSession().pipe(
|
||||
catchError(() => this.createLegacyWebSession())
|
||||
);
|
||||
}
|
||||
|
||||
pollQrStatus(token: string): Observable<QrPollResult> {
|
||||
if (token.startsWith(LEGACY_WEB_SESSION_TOKEN_PREFIX)) {
|
||||
return this.pollLegacyWebSession(token.slice(LEGACY_WEB_SESSION_TOKEN_PREFIX.length));
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<Record<string, unknown>>(
|
||||
this.buildAuthUrl(`/userauth/qr/poll?token=${encodeURIComponent(token)}`),
|
||||
{ withCredentials: true }
|
||||
)
|
||||
.pipe(
|
||||
map((response) => this.normalizeQrPoll(response)),
|
||||
catchError(() => of({ status: 'error' as const }))
|
||||
);
|
||||
}
|
||||
|
||||
completeLogin(session?: AuthSession | null): Observable<void> {
|
||||
const activeSession = session?.active ? session : null;
|
||||
|
||||
if (activeSession) {
|
||||
this.activateSession(activeSession);
|
||||
}
|
||||
|
||||
return this.syncSessionAfterLogin(activeSession?.sessionId).pipe(
|
||||
tap(() => {
|
||||
if (activeSession) {
|
||||
this.hideLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkSession();
|
||||
}),
|
||||
map(() => void 0)
|
||||
);
|
||||
}
|
||||
|
||||
requestLogin(): void {
|
||||
this.showLoginSignal.set(true);
|
||||
}
|
||||
|
||||
hideLogin(): void {
|
||||
this.showLoginSignal.set(false);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
const legacySessionId = this.readStoredAuthenticatedSessionId();
|
||||
|
||||
this.http
|
||||
.post(this.buildAuthUrl('/userauth/logout'), {}, { withCredentials: true })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe(() => {
|
||||
if (legacySessionId) {
|
||||
this.deleteLegacyWebSession(legacySessionId).subscribe();
|
||||
}
|
||||
|
||||
this.clearAuthState('unauthenticated');
|
||||
this.requestLogin();
|
||||
});
|
||||
}
|
||||
|
||||
private createUserauthQrSession(): Observable<QrLoginSession> {
|
||||
return this.http
|
||||
.post<QrCreateResponse>(
|
||||
this.buildAuthUrl('/userauth/qr/create'),
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const token = response?.token?.trim() ?? '';
|
||||
const url = response?.url ?? response?.deeplink ?? response?.deepLink ?? response?.telegramUrl ?? '';
|
||||
|
||||
if (!token || !url) {
|
||||
throw new Error('Invalid QR create response');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
url,
|
||||
qrUrl: response.qrUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private createLegacyWebSession(): Observable<QrLoginSession> {
|
||||
const webSessionId = this.generateGuid();
|
||||
|
||||
return this.http
|
||||
.post<Record<string, unknown>>(
|
||||
this.buildAuthUrl('/users/sessions'),
|
||||
{ webSessionID: webSessionId },
|
||||
{
|
||||
headers: { WebSessionID: webSessionId },
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const sessionId = this.extractSessionId(response, webSessionId);
|
||||
|
||||
return {
|
||||
token: `${LEGACY_WEB_SESSION_TOKEN_PREFIX}${sessionId}`,
|
||||
url: this.getTelegramLoginUrl(sessionId)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private pollLegacyWebSession(sessionId: string): Observable<QrPollResult> {
|
||||
if (!sessionId) {
|
||||
return of({ status: 'error' });
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<Record<string, unknown>>(this.buildAuthUrl(`/users/sessions/${encodeURIComponent(sessionId)}`), {
|
||||
headers: { WebSessionID: sessionId },
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const session = this.normalizeSession(response, sessionId);
|
||||
|
||||
return session?.active
|
||||
? { status: 'confirmed' as const, session }
|
||||
: { status: 'pending' as const };
|
||||
}),
|
||||
catchError(() => of({ status: 'error' as const }))
|
||||
);
|
||||
}
|
||||
|
||||
private checkStoredLegacySession(): Observable<AuthSession | null> {
|
||||
const storedSessionId = this.readStoredAuthenticatedSessionId();
|
||||
|
||||
if (!storedSessionId) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.pollLegacyWebSession(storedSessionId).pipe(
|
||||
map((result) => result.status === 'confirmed' ? result.session ?? null : null)
|
||||
);
|
||||
}
|
||||
|
||||
private deleteLegacyWebSession(sessionId: string): Observable<unknown> {
|
||||
return this.http
|
||||
.delete(this.buildAuthUrl(`/users/sessions/${encodeURIComponent(sessionId)}`), {
|
||||
headers: { WebSessionID: sessionId },
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
private syncSessionAfterLogin(sessionId?: string): Observable<void> {
|
||||
if (!sessionId) {
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.post(this.buildUserSessionUrl(`/usersession/${encodeURIComponent(sessionId)}`), {}, {
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of(null)),
|
||||
map(() => void 0)
|
||||
);
|
||||
}
|
||||
|
||||
private activateSession(session: AuthSession): void {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.storeAuthenticatedSessionId(session.sessionId);
|
||||
this.hideLogin();
|
||||
this.scheduleSessionRefresh(session.expiresAt);
|
||||
}
|
||||
|
||||
private clearAuthState(status: AuthStatus): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set(status);
|
||||
this.clearStoredAuthenticatedSessionId();
|
||||
this.clearSessionRefresh();
|
||||
}
|
||||
|
||||
private storeAuthenticatedSessionId(sessionId: string): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(AUTHENTICATED_SESSION_STORAGE_KEY, sessionId);
|
||||
}
|
||||
|
||||
private clearStoredAuthenticatedSessionId(): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(AUTHENTICATED_SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private readStoredAuthenticatedSessionId(): string | null {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem(AUTHENTICATED_SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private scheduleSessionRefresh(expiresAt: string): void {
|
||||
this.clearSessionRefresh();
|
||||
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
const refreshInMs = Number.isFinite(expiresAtMs)
|
||||
? Math.max(expiresAtMs - Date.now() - 60_000, 30_000)
|
||||
: SESSION_REFRESH_FALLBACK_MS;
|
||||
|
||||
this.sessionRefreshTimer = setTimeout(() => {
|
||||
this.checkSession();
|
||||
}, refreshInMs);
|
||||
}
|
||||
|
||||
private clearSessionRefresh(): void {
|
||||
if (this.sessionRefreshTimer) {
|
||||
clearTimeout(this.sessionRefreshTimer);
|
||||
this.sessionRefreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeQrPoll(response: Record<string, unknown>): QrPollResult {
|
||||
const status = this.readString(this.readFirst(response, ['status', 'Status'])) ?? 'pending';
|
||||
|
||||
if (status === 'confirmed') {
|
||||
const rawSession = this.asRecord(this.readFirst(response, ['session', 'Session'])) ?? response;
|
||||
return {
|
||||
status: 'confirmed',
|
||||
session: this.normalizeSession(rawSession)
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'expired') {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
return { status: 'pending' };
|
||||
}
|
||||
|
||||
private normalizeSession(response: Record<string, unknown> | null, fallbackSessionId?: string): AuthSession | null {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionSource = this.asRecord(this.readFirst(response, ['session', 'Session'])) ?? response;
|
||||
const user = this.asRecord(this.readFirst(sessionSource, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? sessionSource;
|
||||
const sessionId = this.extractSessionId(sessionSource, fallbackSessionId);
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = this.readFirst(sessionSource, [
|
||||
'active',
|
||||
'Active',
|
||||
'authenticated',
|
||||
'Authenticated',
|
||||
'status',
|
||||
'Status',
|
||||
'loggedIn',
|
||||
'LoggedIn'
|
||||
]);
|
||||
const active = status === undefined ? true : this.isActiveStatus(status);
|
||||
const username = this.readString(this.readFirst(user, ['username', 'Username']))
|
||||
?? this.readString(this.readFirst(sessionSource, ['username', 'Username']));
|
||||
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName']));
|
||||
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName']));
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
const displayName = this.readString(this.readFirst(sessionSource, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||
?? username
|
||||
?? fullName
|
||||
?? 'Telegram User';
|
||||
const telegramUserId = this.readNumber(this.readFirst(sessionSource, [
|
||||
'telegramUserId',
|
||||
'telegramUserID',
|
||||
'TelegramUserID',
|
||||
'userId',
|
||||
'userID',
|
||||
'UserID'
|
||||
])) ?? this.readNumber(this.readFirst(user, ['telegramUserId', 'telegramUserID', 'id', 'ID']));
|
||||
const expiresAt = this.readString(this.readFirst(sessionSource, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
|
||||
?? new Date(Date.now() + SESSION_REFRESH_FALLBACK_MS).toISOString();
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
telegramUserId,
|
||||
username,
|
||||
displayName,
|
||||
active,
|
||||
expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId?: string): string {
|
||||
if (!response) {
|
||||
return fallbackSessionId ?? '';
|
||||
}
|
||||
|
||||
return this.readString(this.readFirst(response, [
|
||||
'sessionId',
|
||||
'SessionId',
|
||||
'sessionID',
|
||||
'SessionID',
|
||||
'webSessionID',
|
||||
'WebSessionID',
|
||||
'webSessionId',
|
||||
'userSessionId',
|
||||
'userSessionID',
|
||||
'id',
|
||||
'ID'
|
||||
])) ?? fallbackSessionId ?? '';
|
||||
}
|
||||
|
||||
private buildAuthUrl(path: string): string {
|
||||
return `${this.authApiBaseUrl}${this.withLeadingSlash(path)}`;
|
||||
}
|
||||
|
||||
private buildUserSessionUrl(path: string): string {
|
||||
return `${this.userSessionApiBaseUrl}${this.withLeadingSlash(path)}`;
|
||||
}
|
||||
|
||||
private withLeadingSlash(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
|
||||
private trimTrailingSlash(value: string): string {
|
||||
return value.endsWith('/') ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
private readFirst(source: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
return source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private readString(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
private isActiveStatus(status: unknown): boolean {
|
||||
if (status === true || status === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof status !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
private getTelegramLoginUrl(sessionId: string): string {
|
||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||
return `https://t.me/${botUsername}?start=${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
private generateGuid(): string {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0'));
|
||||
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './api.service';
|
||||
export * from './auth.service';
|
||||
export * from './validation.service';
|
||||
export * from './toast.service';
|
||||
export * from './language.service';
|
||||
|
||||
Reference in New Issue
Block a user