Add Telegram auth flow to backoffice

This commit is contained in:
sdarbinyan
2026-06-21 01:41:58 +04:00
parent 09e8465577
commit fb570a32f5
18 changed files with 1353 additions and 12 deletions

View 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('')}`;
}
}

View File

@@ -1,4 +1,5 @@
export * from './api.service';
export * from './auth.service';
export * from './validation.service';
export * from './toast.service';
export * from './language.service';