diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java b/backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java deleted file mode 100644 index 84ad57e..0000000 --- a/backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java +++ /dev/null @@ -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; - } -} diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java index b806237..6fe956c 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -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())) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java index 29709c1..282980a 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java @@ -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 privilegedUserCompletableFuture = new CompletableFuture<>(); - List claimList = new ArrayList<>(); + List 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()) diff --git a/frontend/src/app/forms/forms.component.ts b/frontend/src/app/forms/forms.component.ts index 13b57b8..78ece55 100644 --- a/frontend/src/app/forms/forms.component.ts +++ b/frontend/src/app/forms/forms.component.ts @@ -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; diff --git a/frontend/src/app/login/login.component.ts b/frontend/src/app/login/login.component.ts index ba54163..c825356 100644 --- a/frontend/src/app/login/login.component.ts +++ b/frontend/src/app/login/login.component.ts @@ -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, 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'); } diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts new file mode 100644 index 0000000..17a6aeb --- /dev/null +++ b/frontend/src/app/services/auth.service.ts @@ -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(false); + public isAuthenticated$ = this.isAuthenticatedSubject.asObservable(); + + private userClaimsSubject = new BehaviorSubject(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 { + 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 || []; + } +} diff --git a/open_api/src/main/resources/api.yml b/open_api/src/main/resources/api.yml index 050bbf2..2c3b38b 100644 --- a/open_api/src/main/resources/api.yml +++ b/open_api/src/main/resources/api.yml @@ -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 diff --git a/open_api/src/main/resources/schemas/permissions/permissions.yml b/open_api/src/main/resources/schemas/permissions/permissions.yml new file mode 100644 index 0000000..16466f8 --- /dev/null +++ b/open_api/src/main/resources/schemas/permissions/permissions.yml @@ -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" ]