Refactor permission handling and authentication services

Replaced `PermissionClaim` enum with an OpenAPI-defined schema `PermissionClaimDto` for consistency across frontend and backend. Refactored authentication flow to utilize `AuthService` on the frontend, consolidating JWT handling logic. Removed redundant methods like `saveJwt` and integrated robust permission management throughout the application.
This commit is contained in:
Teriuihi 2025-06-21 23:15:46 +02:00
parent 07646e8c42
commit 32a454c034
8 changed files with 144 additions and 60 deletions

View File

@ -1,16 +0,0 @@
package com.alttd.altitudeweb.config;
public enum PermissionClaim {
USER("SCOPE_user"),
HEAD_MOD("SCOPE_head_mod");
private String claim;
PermissionClaim(String claim) {
this.claim = claim;
}
public String getClaim() {
return this.claim;
}
}

View File

@ -1,6 +1,7 @@
package com.alttd.altitudeweb.config;
import com.alttd.altitudeweb.controllers.login.KeyPairService;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
@ -13,7 +14,6 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
@ -38,8 +38,8 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll()
.requestMatchers("/team/**", "/history/**").permitAll()
.requestMatchers("/form/**").hasAuthority(PermissionClaim.USER.getClaim())
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaim.HEAD_MOD.getClaim())
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))

View File

@ -1,10 +1,8 @@
package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.api.LoginApi;
import com.alttd.altitudeweb.config.PermissionClaim;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.setup.Connection;
@ -137,7 +135,7 @@ public class LoginController implements LoginApi {
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour)
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaim> claimList = new ArrayList<>();
List<PermissionClaimDto> claimList = new ArrayList<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
try {
@ -151,11 +149,11 @@ public class LoginController implements LoginApi {
}
});
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join();
claimList.add(PermissionClaim.USER);
claimList.add(PermissionClaimDto.USER);
if (privilegedUser != null) {
privilegedUser.getPermissions().forEach(permission -> {
try {
claimList.add(PermissionClaim.valueOf(permission));
claimList.add(PermissionClaimDto.valueOf(permission));
} catch (IllegalArgumentException e) {
log.warn("Received invalid permission claim: {}", permission);
}
@ -163,7 +161,7 @@ public class LoginController implements LoginApi {
}
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("altitudeweb")
.claim("authorities", claimList.stream().map(PermissionClaim::getClaim).toList())
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
.issuedAt(now)
.expiresAt(expiryTime)
.subject(uuid.toString())

View File

@ -1,12 +1,12 @@
import {Component, Input, OnInit} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {LoginService} from '../../api';
import {MatDialog} from '@angular/material/dialog';
import {ActivatedRoute} from '@angular/router';
import {LoginDialogComponent} from '../login/login.component';
import {KeyValuePipe, NgForOf, NgIf} from '@angular/common';
import {FormType} from './form_type';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../services/auth.service';
@Component({
selector: 'app-forms',
@ -26,7 +26,7 @@ export class FormsComponent implements OnInit {
public type: FormType | undefined;
constructor(private loginService: LoginService,
constructor(private authService: AuthService,
private dialog: MatDialog,
private route: ActivatedRoute,
) {
@ -34,16 +34,14 @@ export class FormsComponent implements OnInit {
const code = params.get('code');
if (code) {
this.loginService.login(code).subscribe(jwt => this.saveJwt(jwt as JsonWebKey)); //TODO handle error
} else {
this.authService.login(code).subscribe();
} else if (!this.authService.checkAuthStatus()) {
const dialogRef = this.dialog.open(LoginDialogComponent, {
width: '400px',
disableClose: true
});
dialogRef.afterClosed().subscribe(jwt => {
this.saveJwt(jwt as JsonWebKey)
});
dialogRef.afterClosed().subscribe();
}
});
}
@ -61,18 +59,6 @@ export class FormsComponent implements OnInit {
});
}
private saveJwt(jwt: JsonWebKey) {
const claims = this.extractJwtClaims(jwt);
const authorizations = claims?.authorizations || [];
}
private extractJwtClaims(jwt: JsonWebKey): any {
const token = jwt.toString();
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(window.atob(base64));
}
protected readonly FormType = FormType;
protected readonly Object = Object;

View File

@ -5,9 +5,8 @@ import {MatButtonModule} from '@angular/material/button';
import {MatInputModule} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import {NgIf} from '@angular/common';
import {LoginService} from '../../api';
import {MatSnackBar} from '@angular/material/snack-bar';
import {CookieService} from 'ngx-cookie-service';
import {AuthService} from '../services/auth.service';
@Component({
selector: 'app-login',
@ -31,9 +30,8 @@ export class LoginDialogComponent {
constructor(
public dialogRef: MatDialogRef<LoginDialogComponent>,
private fb: FormBuilder,
private loginService: LoginService,
private snackBar: MatSnackBar,
private cookieService: CookieService
private authService: AuthService,
private snackBar: MatSnackBar
) {
this.loginForm = this.fb.group({
code: ['', [
@ -55,9 +53,8 @@ export class LoginDialogComponent {
return;
}
this.snackBar.open('Logging in...', '', {duration: 2000});
this.loginService.login(this.loginForm.value.code).subscribe({
this.authService.login(this.loginForm.value.code).subscribe({
next: (jwt) => {
this.saveJwt(jwt as JsonWebKey);
this.dialogRef.close(jwt);
},
error: () => {
@ -68,14 +65,6 @@ export class LoginDialogComponent {
});
}
private saveJwt(jwt: JsonWebKey) {
this.cookieService.set('jwt', jwt.toString(), {
path: '/',
secure: true,
sameSite: 'Strict'
});
}
public formHasError() {
return this.loginForm.get('code')?.hasError('required');
}

View File

@ -0,0 +1,115 @@
import {Injectable} from '@angular/core';
import {LoginService} from '../../api';
import {CookieService} from 'ngx-cookie-service';
import {BehaviorSubject, Observable, throwError} from 'rxjs';
import {catchError, tap} from 'rxjs/operators';
import {MatSnackBar} from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
private userClaimsSubject = new BehaviorSubject<any>(null);
public userClaims$ = this.userClaimsSubject.asObservable();
constructor(
private loginService: LoginService,
private cookieService: CookieService,
private snackBar: MatSnackBar
) {
// Check if user is already logged in on service initialization
this.checkAuthStatus();
}
/**
* Attempt to login with the provided code
*/
public login(code: string): Observable<any> {
return this.loginService.login(code).pipe(
tap(jwt => {
this.saveJwt(jwt as JsonWebKey);
this.isAuthenticatedSubject.next(true);
}),
catchError(error => {
this.snackBar.open('Login failed', '', {duration: 2000});
return throwError(() => error);
})
);
}
/**
* Log the user out by removing the JWT
*/
public logout(): void {
this.cookieService.delete('jwt', '/');
this.isAuthenticatedSubject.next(false);
this.userClaimsSubject.next(null);
}
/**
* Check if the user is authenticated
*/
public checkAuthStatus(): boolean {
const jwt = this.getJwt();
if (jwt) {
try {
const claims = this.extractJwtClaims(jwt as JsonWebKey);
// Check if token is expired
const currentTime = Math.floor(Date.now() / 1000);
if (claims.exp && claims.exp < currentTime) {
this.logout();
return false;
}
this.userClaimsSubject.next(claims);
this.isAuthenticatedSubject.next(true);
return true;
} catch (e) {
this.logout();
}
}
return false;
}
/**
* Get the JWT from cookies
*/
public getJwt(): string | null {
return this.cookieService.check('jwt') ? this.cookieService.get('jwt') : null;
}
/**
* Save the JWT to cookies
*/
private saveJwt(jwt: JsonWebKey): void {
this.cookieService.set('jwt', jwt.toString(), {
path: '/',
secure: true,
sameSite: 'Strict'
});
const claims = this.extractJwtClaims(jwt);
this.userClaimsSubject.next(claims);
}
/**
* Extract claims from JWT
*/
private extractJwtClaims(jwt: JsonWebKey): any {
const token = jwt.toString();
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(window.atob(base64));
}
/**
* Get user authorizations from claims
*/
public getUserAuthorizations(): string[] {
const claims = this.userClaimsSubject.getValue();
return claims?.authorizations || [];
}
}

View File

@ -6,6 +6,10 @@ info:
version: 1.0.0
servers:
- url: https://alttd.com/api/v3
components:
schemas:
PermissionClaim:
$ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim'
tags:
- name: history
description: Retrieves punishment history

View File

@ -0,0 +1,8 @@
components:
schemas:
PermissionClaim:
type: string
enum: [ SCOPE_user, SCOPE_head_mod ]
description: Permission claims used for authorization
x-enum-varnames: [ USER, HEAD_MOD ]
x-enum-descriptions: [ "User permission", "Head moderator permission" ]