From 7ffe19712e813c63162ff3033e486788ec4f3031 Mon Sep 17 00:00:00 2001 From: LuffyNoNika Date: Tue, 28 Oct 2025 16:18:47 +0100 Subject: [PATCH] Enhancement: case insensitive login (#1549) - Allow login with case-insensitive username. - Ensure the displayed username in the UI is always the canonical DB username. - Added canonicalUsername to AuthUser and persisted it. - Updated auth.service to: - decode JWT and extract userId - fetch canonical username via /api/v2/ui/users/{id} - hydrate session and localStorage with canonicalUsername - handle autoLogin logic when reloading - Updated header.component to display canonicalUsername, not the typed value. - Added jwt-payload.model for typed JWT decoding. --- .../menus/action-menu/action-menu.model.ts | 4 + src/app/core/_models/auth-user.model.ts | 8 +- src/app/core/_models/jwt-payload.model.ts | 19 ++ src/app/core/_services/access/auth.service.ts | 303 ++++++++++++++---- src/app/layout/header/header.component.ts | 62 +++- 5 files changed, 319 insertions(+), 77 deletions(-) create mode 100644 src/app/core/_models/jwt-payload.model.ts diff --git a/src/app/core/_components/menus/action-menu/action-menu.model.ts b/src/app/core/_components/menus/action-menu/action-menu.model.ts index 70c189b70..98d056923 100644 --- a/src/app/core/_components/menus/action-menu/action-menu.model.ts +++ b/src/app/core/_components/menus/action-menu/action-menu.model.ts @@ -10,4 +10,8 @@ export interface ActionMenuItem { red?: boolean; routerLink?: string[]; external?: boolean; + + showAddButton?: boolean; + routerLinkAdd?: string[]; + tooltipAddButton?: string; } diff --git a/src/app/core/_models/auth-user.model.ts b/src/app/core/_models/auth-user.model.ts index 9e9676535..f9cde34bd 100644 --- a/src/app/core/_models/auth-user.model.ts +++ b/src/app/core/_models/auth-user.model.ts @@ -5,9 +5,10 @@ * @prop _username Name of user */ export interface AuthData { - _expires: Date; + _expires: Date | string; _token: string; - _username: string; + userId: number; + canonicalUsername: string; } /** @@ -18,7 +19,8 @@ export class AuthUser implements AuthData { constructor( public _token: string, public _expires: Date, - public _username: string + public userId: number, + public canonicalUsername: string ) {} /** diff --git a/src/app/core/_models/jwt-payload.model.ts b/src/app/core/_models/jwt-payload.model.ts new file mode 100644 index 000000000..8feb96775 --- /dev/null +++ b/src/app/core/_models/jwt-payload.model.ts @@ -0,0 +1,19 @@ +import { BaseModel } from '@models/base.model'; + +/** + * Interface for access group defining user access to agents + * @extends BaseModel + * @prop groupName Name of access group + * @prop userMembers Users which are members of the group + * @prop agentMembers Agents which are members of the group + */ + +export interface JwtPayload extends BaseModel { + userId?: number; + sub?: number | string; + username?: string; + name?: string; + user?: string; + exp?: number; + [k: string]: unknown; +} diff --git a/src/app/core/_services/access/auth.service.ts b/src/app/core/_services/access/auth.service.ts index c7a939827..d6d469db2 100644 --- a/src/app/core/_services/access/auth.service.ts +++ b/src/app/core/_services/access/auth.service.ts @@ -1,13 +1,14 @@ import { Buffer } from 'buffer'; import { BehaviorSubject, Observable, ReplaySubject, Subject, switchMap, throwError } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { EventEmitter, Injectable, Injector, Output } from '@angular/core'; import { Router } from '@angular/router'; import { AuthData, AuthUser } from '@models/auth-user.model'; +import { JwtPayload } from '@models/jwt-payload.model'; import { LoginRedirectService } from '@services/access/login-redirect.service'; import { PermissionService } from '@services/permission/permission.service'; @@ -19,12 +20,14 @@ export interface AuthResponseData { expires: number; } +type StoredAuthData = Omit & { _expires: string | Date }; + @Injectable({ providedIn: 'root' }) export class AuthService { static readonly STORAGE_KEY = 'userData'; - user = new BehaviorSubject(null); - userId!: string; + user = new BehaviorSubject(null); + userId: number | null = null; @Output() authChanged: EventEmitter = new EventEmitter(); isAuthenticated = false; @@ -32,7 +35,7 @@ export class AuthService { isLogged = this.logged.asObservable(); redirectUrl = ''; private userLoggedIn = new Subject(); - private tokenExpiration: NodeJS.Timeout | null; + private tokenExpiration: ReturnType | null = null; private endpoint = '/auth'; constructor( @@ -52,25 +55,60 @@ export class AuthService { * Auto-login user, if there is a token in localStorage */ autoLogin() { - const userData: AuthData = this.storage.getItem(AuthService.STORAGE_KEY); - if (!userData) { - return; - } + const raw = this.storage.getItem(AuthService.STORAGE_KEY); + if (!raw) return; + + const userData = raw as StoredAuthData; + + const token: string = userData._token; + const expires: Date = userData._expires instanceof Date ? userData._expires : new Date(userData._expires); + + const userId = typeof userData.userId === 'number' ? userData.userId : (this.getUserId(token) ?? 0); + + const canonicalUsername: string = userData.canonicalUsername ?? this.getCanonicalUsernameFromJwt(token) ?? ''; - const loadedUser = new AuthUser(userData._token, new Date(userData._expires), userData._username); - if (loadedUser.token) { + const loadedUser = new AuthUser(token, expires, userId, canonicalUsername); + + if (loadedUser._token) { this.user.next(loadedUser); - const tokenExpiration = new Date(userData._expires).getTime() - new Date().getTime(); - // this.autologOut(tokenExpiration); + this._authUser$.next(loadedUser); + + if (!loadedUser.canonicalUsername && loadedUser.userId) { + this.fetchCanonicalUsername(loadedUser.userId, loadedUser._token).subscribe({ + next: (canonicalFromApi) => { + const canonical = canonicalFromApi ?? ''; + + const updated: AuthData = { + _token: loadedUser._token, + _expires: loadedUser._expires, + userId: loadedUser.userId, + canonicalUsername: canonical + }; + + this.storage.setItem(AuthService.STORAGE_KEY, updated, 0); + this.user.next(updated); + this._authUser$.next( + new AuthUser( + updated._token, + updated._expires instanceof Date ? updated._expires : new Date(updated._expires), + updated.userId, + updated.canonicalUsername + ) + ); + }, + error: (err) => { + console.warn('Failed to fetch canonical username on autoLogin', err); + } + }); + } + + const tokenExpiration = expires.getTime() - Date.now(); this.getRefreshToken(tokenExpiration); - // Load permissions after restoring user const permissionService = this.injector.get(PermissionService); permissionService.loadPermissions().subscribe({ next: () => {}, - error: (err) => { - console.error('Failed to load permissions on autoLogin:', err); - } + error: (err) => console.error('Failed to load permissions on autoLogin:', err) }); } } @@ -89,35 +127,136 @@ export class AuthService { .pipe( catchError(this.handleError), switchMap((resData) => { - this.handleAuthentication(resData.token, +resData.expires, username); - this.isAuthenticated = true; - this.userAuthChanged(true); - const permissionService = this.injector.get(PermissionService); - return permissionService.loadPermissions(); + const token = resData.token; + const expires = +resData.expires; + + const canonicalFromJwt = this.getCanonicalUsernameFromJwt(token); + if (canonicalFromJwt) { + this.handleAuthentication(token, expires, canonicalFromJwt); + this.isAuthenticated = true; + this.userAuthChanged(true); + return this.injector.get(PermissionService).loadPermissions(); + } + + const payload = this.decodeJwt(token); + const rawUid = payload?.userId ?? payload?.sub; + const uid = typeof rawUid === 'string' ? Number(rawUid) : typeof rawUid === 'number' ? rawUid : null; + + if (uid == null || !Number.isFinite(uid)) { + console.warn('Could not extract userId from token. Falling back to form username.'); + this.handleAuthentication(token, expires, username); + this.isAuthenticated = true; + this.userAuthChanged(true); + return this.injector.get(PermissionService).loadPermissions(); + } + + return this.fetchCanonicalUsername(uid, token).pipe( + tap((canonicalFromApi /* string | null */) => { + const canonical = canonicalFromApi ?? username; + this.handleAuthentication(token, expires, canonical); + this.isAuthenticated = true; + this.userAuthChanged(true); + }), + switchMap(() => this.injector.get(PermissionService).loadPermissions()), + catchError((err) => { + console.warn('Failed to fetch canonical username from /ui/users/{id}, using form value', err); + this.handleAuthentication(token, expires, username); + this.isAuthenticated = true; + this.userAuthChanged(true); + return this.injector.get(PermissionService).loadPermissions(); + }) + ); }), tap(() => { const redirectService = this.injector.get(LoginRedirectService); const redirectUrl = this.redirectUrl; - redirectService.handlePostLoginRedirect(this.userId, redirectUrl); + const uid = this.getUserId(this.token); + if (uid != null) { + redirectService.handlePostLoginRedirect(String(uid), redirectUrl); + } else { + console.warn('No userId available for post-login redirect'); + } this.redirectUrl = ''; }) ); } get token(): string { - const userData: AuthData = this.storage.getItem(AuthService.STORAGE_KEY); + const userData: AuthData | null = this.storage.getItem(AuthService.STORAGE_KEY); return userData ? userData._token : 'notoken'; } - private getUserId(token: string) { - if (token == 'notoken') { - return false; - } else { - const b64string = Buffer.from(token.split('.')[1], 'base64'); - return JSON.parse(b64string.toString()).userId; + private _authUser$ = new BehaviorSubject(null); + authUser$ = this._authUser$.asObservable(); + + private decodeJwt(token: string): T | null { + if (!token || token === 'notoken') return null; + try { + const b64 = token.split('.')[1]; + const norm = b64.replace(/-/g, '+').replace(/_/g, '/'); + const json = Buffer.from(norm, 'base64').toString('utf8'); + return JSON.parse(json) as T; + } catch { + return null; } } + private getUserId(token: string): number | null { + const p = this.decodeJwt(token); + if (!p) return null; + + let id: number | null = null; + + if (typeof p.userId === 'number') { + id = p.userId; + } else if (typeof p.sub === 'number') { + id = p.sub; + } else if (typeof p.sub === 'string') { + const n = Number(p.sub); + id = Number.isFinite(n) ? n : null; + } + + return id; + } + + private getCanonicalUsernameFromJwt(token: string): string | null { + const p = this.decodeJwt(token); + return p?.username ?? p?.name ?? p?.user ?? null; + } + + private fetchCanonicalUsername(userId: number, token: string) { + const base = this.cs.getEndpoint(); + const url = `${base}/ui/users/${userId}`; + + const headers = new HttpHeaders({ + Authorization: `Bearer ${token}`, + Accept: 'application/json' + }); + + return this.http + .get<{ + data: { + id: number | string; + attributes: { name: string }; + }; + }>(url, { headers }) + .pipe(map((resp) => resp?.data?.attributes?.name ?? null)); + } + + get canonicalUsername(): string | null { + const u = this._authUser$.value; + if (u?.canonicalUsername) return u.canonicalUsername; + + const stored: AuthData | null = this.storage.getItem(AuthService.STORAGE_KEY); + if (stored?._token) return this.getCanonicalUsernameFromJwt(stored._token); + return null; + } + + canonicalUsername$ = this.authUser$.pipe( + map((u) => u?.canonicalUsername ?? this.canonicalUsername), + distinctUntilChanged() + ); + setUserLoggedIn(userLoggedIn: boolean) { this.userLoggedIn.next(userLoggedIn); } @@ -135,30 +274,53 @@ export class AuthService { getRefreshToken(expirationDuration: number) { this.tokenExpiration = setTimeout(() => { - const userData: AuthData = this.storage.getItem(AuthService.STORAGE_KEY); - return this.http - .post(this.cs.getEndpoint() + this.endpoint + '/refresh', { - headers: new HttpHeaders({ - Authorization: `Bearer ${userData._token}` - }) + const userData = this.storage.getItem(AuthService.STORAGE_KEY); + if (!userData) { + this.logOut(); + return; + } + + this.http + .post(this.cs.getEndpoint() + this.endpoint + '/refresh', null, { + headers: new HttpHeaders({ Authorization: `Bearer ${userData._token}` }) }) .pipe( - tap((response: AuthResponseData) => { - if (response && response.token) { - userData._token = response.token; - userData._expires = new Date(response.expires * 1000); - - this.storage.setItem(AuthService.STORAGE_KEY, userData, 0); + tap((response) => { + if (response?.token) { + const updatedUser: AuthData = { + _token: response.token, + _expires: new Date(response.expires * 1000), + userId: userData.userId, + canonicalUsername: userData.canonicalUsername + }; + this.user.next(updatedUser); + this.storage.setItem(AuthService.STORAGE_KEY, updatedUser, 0); + + const ms = + updatedUser._expires instanceof Date + ? updatedUser._expires.getTime() - Date.now() + : new Date(updatedUser._expires).getTime() - Date.now(); + this.getRefreshToken(ms); } else { this.logOut(); } + }), + catchError((err) => { + console.error('Refresh failed:', err); + this.logOut(); + return throwError(() => err); }) - ); + ) + .subscribe(); }, expirationDuration); } refreshToken() { - const userData: AuthData = this.storage.getItem(AuthService.STORAGE_KEY); + const userData: AuthData | null = this.storage.getItem(AuthService.STORAGE_KEY); + if (!userData) { + this.logOut(); + return throwError(() => new Error('No user in storage')); + } return this.http .post(this.cs.getEndpoint() + this.endpoint + '/refresh', null, { @@ -169,21 +331,34 @@ export class AuthService { .pipe( tap((response: AuthResponseData) => { if (response && response.token) { + const expires = new Date(response.expires * 1000); + const updatedUser: AuthData = { _token: response.token, - _expires: new Date(response.expires * 1000), - _username: userData._username + _expires: expires, + userId: userData.userId, + canonicalUsername: userData.canonicalUsername }; - - // Update BOTH in-memory and local storage this.user.next(updatedUser); this.storage.setItem(AuthService.STORAGE_KEY, updatedUser, 0); + + const authUser = new AuthUser( + updatedUser._token, + expires, + updatedUser.userId, + updatedUser.canonicalUsername + ); + this._authUser$.next(authUser); + + const ms = expires.getTime() - Date.now(); + this.getRefreshToken(ms); } else { this.logOut(); } }), catchError((error) => { - console.error('An error occurred:', error); + console.error('An error occurred while refreshing token:', error); + this.logOut(); return throwError(() => error); }) ); @@ -213,27 +388,33 @@ export class AuthService { } private userAuthChanged(status: boolean) { - this.authChanged.emit(status); // Raise changed event + this.authChanged.emit(status); } - handleAuthentication(token: string, expires: number, username: string) { - const userData = { - _token: token, - _expires: new Date(expires * 1000), - _username: username - }; + private handleAuthentication(token: string, expiresEpochSec: number, usernameFromForm: string) { + const expires = new Date(expiresEpochSec * 1000); + + const userId = this.getUserId(token) ?? 0; + const canonicalUsername = this.getCanonicalUsernameFromJwt(token) ?? usernameFromForm; + this.userId = userId; - this.user.next(userData); + const loadedUser = new AuthUser(token, expires, userId, canonicalUsername); + this.user.next(loadedUser); + this._authUser$.next(loadedUser); this.logged.next(true); this.isAuthenticated = true; - this.storage.setItem(AuthService.STORAGE_KEY, userData, 0); - this.userId = this.getUserId(token); - - this.autologOut(expires); // Epoch time + const userData: AuthData = { + _token: token, + _expires: expires, + userId, + canonicalUsername + }; this.storage.setItem(AuthService.STORAGE_KEY, userData, 0); - this.userId = this.getUserId(token); + + const tokenExpiration = expires.getTime() - Date.now(); + this.getRefreshToken(tokenExpiration); } private handleError(errorRes: HttpErrorResponse) { diff --git a/src/app/layout/header/header.component.ts b/src/app/layout/header/header.component.ts index 3e227ddd7..703990aa5 100644 --- a/src/app/layout/header/header.component.ts +++ b/src/app/layout/header/header.component.ts @@ -62,7 +62,7 @@ export class HeaderComponent implements OnInit, OnDestroy { this.subscriptions.push( this.authService.user.subscribe((user: AuthUser) => { if (user) { - this.username = user._username; + this.username = user.canonicalUsername; } }) ); @@ -149,7 +149,10 @@ export class HeaderComponent implements OnInit, OnDestroy { const agentActions = [ { label: HeaderMenuLabel.SHOW_AGENTS, - routerLink: ['agents', 'show-agents'] + routerLink: ['agents', 'show-agents'], + showAddButton: true, + routerLinkAdd: ['agents', 'new-agent'], + tooltipAddButton: 'New Agent' } ]; @@ -158,7 +161,10 @@ export class HeaderComponent implements OnInit, OnDestroy { if (canReadAgentStats) { agentActions.push({ label: HeaderMenuLabel.AGENT_STATUS, - routerLink: ['agents', 'agent-status'] + routerLink: ['agents', 'agent-status'], + showAddButton: false, + routerLinkAdd: [], + tooltipAddButton: '' }); } @@ -183,15 +189,24 @@ export class HeaderComponent implements OnInit, OnDestroy { const taskActions = [ { label: HeaderMenuLabel.SHOW_TASKS, - routerLink: ['tasks', 'show-tasks'] + routerLink: ['tasks', 'show-tasks'], + showAddButton: true, + routerLinkAdd: ['tasks', 'new-task'], + tooltipAddButton: 'New Task' }, { label: HeaderMenuLabel.PRECONFIGURED_TASKS, - routerLink: ['tasks', 'preconfigured-tasks'] + routerLink: ['tasks', 'preconfigured-tasks'], + showAddButton: true, + routerLinkAdd: ['tasks', 'new-preconfigured-tasks'], + tooltipAddButton: 'New Preconfigured Task' }, { label: HeaderMenuLabel.SUPERTASKS, - routerLink: ['tasks', 'supertasks'] + routerLink: ['tasks', 'supertasks'], + showAddButton: true, + routerLinkAdd: ['tasks', 'new-supertasks'], + tooltipAddButton: 'New Supertask' }, { label: HeaderMenuLabel.IMPORT_SUPERTASK, @@ -228,11 +243,17 @@ export class HeaderComponent implements OnInit, OnDestroy { const actions = [ { label: HeaderMenuLabel.SHOW_HASHLISTS, - routerLink: ['hashlists', 'hashlist'] + routerLink: ['hashlists', 'hashlist'], + showAddButton: true, + routerLinkAdd: ['hashlists', 'new-hashlist'], + tooltipAddButton: 'New Hashlist' }, { label: HeaderMenuLabel.SUPERHASHLISTS, - routerLink: ['hashlists', 'superhashlist'] + routerLink: ['hashlists', 'superhashlist'], + showAddButton: true, + routerLinkAdd: ['hashlists', 'new-superhashlist'], + tooltipAddButton: 'New Superhashlist' } ]; @@ -241,11 +262,17 @@ export class HeaderComponent implements OnInit, OnDestroy { actions.push( { label: HeaderMenuLabel.SEARCH_HASH, - routerLink: ['hashlists', 'search-hash'] + routerLink: ['hashlists', 'search-hash'], + showAddButton: false, + routerLinkAdd: [], + tooltipAddButton: '' }, { label: HeaderMenuLabel.SHOW_CRACKS, - routerLink: ['hashlists', 'show-cracks'] + routerLink: ['hashlists', 'show-cracks'], + showAddButton: false, + routerLinkAdd: [], + tooltipAddButton: '' } ); } @@ -274,15 +301,24 @@ export class HeaderComponent implements OnInit, OnDestroy { [ { label: HeaderMenuLabel.WORDLISTS, - routerLink: ['files', 'wordlist'] + routerLink: ['files', 'wordlist'], + showAddButton: true, + routerLinkAdd: ['files', 'wordlist', 'new-wordlist'], + tooltipAddButton: 'New Wordlist' }, { label: HeaderMenuLabel.RULES, - routerLink: ['files', 'rules'] + routerLink: ['files', 'rules'], + showAddButton: true, + routerLinkAdd: ['files', 'rules', 'new-rule'], + tooltipAddButton: 'New Rule' }, { label: HeaderMenuLabel.OTHER, - routerLink: ['files', 'other'] + routerLink: ['files', 'other'], + showAddButton: true, + routerLinkAdd: ['files', 'other', 'new-other'], + tooltipAddButton: 'New File' } ] ]