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:
parent
07646e8c42
commit
32a454c034
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
115
frontend/src/app/services/auth.service.ts
Normal file
115
frontend/src/app/services/auth.service.ts
Normal 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 || [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" ]
|
||||
Loading…
Reference in New Issue
Block a user