From 07646e8c4247dea3fd7841b45ab0b1bb209afa8e Mon Sep 17 00:00:00 2001 From: Teriuihi Date: Fri, 30 May 2025 23:41:13 +0200 Subject: [PATCH 1/2] Implement enhanced login functionality with JWT, role-based permissions, and frontend integration Added JWT-based login dialog with form validation and secure token handling on the frontend. Updated backend with role-based access control, privilege management, and refined security configurations. Extended database schema for user privileges and permissions. --- backend/build.gradle.kts | 8 +- .../altitudeweb/AltitudeWebApplication.java | 2 +- .../altitudeweb/config/PermissionClaim.java | 16 +++ .../altitudeweb/config/SecurityConfig.java | 65 +++++++++++ .../application/AppealController.java | 2 +- .../history/HistoryApiController.java | 2 +- .../controllers/login/LoginController.java | 101 ++++++++++------- .../controllers/team/TeamApiController.java | 2 +- .../limits/InMemoryRateLimiterService.java | 2 +- .../limits/RateLimit.java | 2 +- .../limits/RateLimitAspect.java | 6 +- .../limits/RequestCounter.java | 2 +- .../services/user/UserDetailsServiceImpl.java | 27 +++++ .../database/web_db/PrivilegedUser.java | 16 +++ .../database/web_db/PrivilegedUserMapper.java | 102 ++++++++++++++++++ .../altitudeweb/setup/InitializeWebDb.java | 40 ++++++- frontend/package.json | 2 + frontend/src/app/app.routes.ts | 10 +- frontend/src/app/forms/form_type.ts | 5 + frontend/src/app/forms/forms.component.html | 8 +- frontend/src/app/forms/forms.component.ts | 73 ++++++++++++- frontend/src/app/login/login.component.html | 18 ++++ frontend/src/app/login/login.component.scss | 13 +++ .../src/app/login/login.component.spec.ts | 23 ++++ frontend/src/app/login/login.component.ts | 82 ++++++++++++++ frontend/src/styles.scss | 2 + 26 files changed, 572 insertions(+), 59 deletions(-) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java create mode 100644 backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java rename backend/src/main/java/com/alttd/altitudeweb/{controllers => services}/limits/InMemoryRateLimiterService.java (95%) rename backend/src/main/java/com/alttd/altitudeweb/{controllers => services}/limits/RateLimit.java (90%) rename backend/src/main/java/com/alttd/altitudeweb/{controllers => services}/limits/RateLimitAspect.java (94%) rename backend/src/main/java/com/alttd/altitudeweb/{controllers => services}/limits/RequestCounter.java (97%) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/services/user/UserDetailsServiceImpl.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java create mode 100644 frontend/src/app/forms/form_type.ts create mode 100644 frontend/src/app/login/login.component.html create mode 100644 frontend/src/app/login/login.component.scss create mode 100644 frontend/src/app/login/login.component.spec.ts create mode 100644 frontend/src/app/login/login.component.ts diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 78cc40c..67f6178 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -27,14 +27,14 @@ dependencies { implementation(project(":open_api")) implementation(project(":database")) implementation(project(":frontend")) - implementation("org.springframework.boot:spring-boot-starter-web") annotationProcessor("org.projectlombok:lombok") - testImplementation("org.springframework.boot:spring-boot-starter-test") implementation("com.mysql:mysql-connector-j:8.0.32") implementation("org.mybatis:mybatis:3.5.13") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-configuration-processor") implementation("org.springframework.boot:spring-boot-starter-hateoas") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.security:spring-security-oauth2-resource-server") implementation("org.springframework.security:spring-security-oauth2-jose") //AOP @@ -43,6 +43,8 @@ dependencies { implementation("org.springframework:spring-aop") implementation("org.springframework:spring-aspects") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.springframework.boot:spring-boot-starter-test") } tasks.compileJava { diff --git a/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java b/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java index 48c93fa..6829afb 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java +++ b/backend/src/main/java/com/alttd/altitudeweb/AltitudeWebApplication.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.EnableAspectJAutoProxy; public class AltitudeWebApplication { public static void main(String[] args) { - SpringApplication.run(AltitudeWebApplication.class, args); Connection.initDatabases(); + SpringApplication.run(AltitudeWebApplication.class, args); } } diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java b/backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java new file mode 100644 index 0000000..84ad57e --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..b806237 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -0,0 +1,65 @@ +package com.alttd.altitudeweb.config; + +import com.alttd.altitudeweb.controllers.login.KeyPairService; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +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; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.web.SecurityFilterChain; + +import java.security.KeyPair; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final KeyPairService keyPairService; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .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()) + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .build(); + } + + @Bean + public JwtEncoder jwtEncoder() { + KeyPair keyPair = keyPairService.getJwtSigningKeyPair(); + JWK jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey((RSAPrivateKey) keyPair.getPrivate()) + .build(); + JWKSource jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk)); + return new NimbusJwtEncoder(jwkSource); + } + + @Bean + public JwtDecoder jwtDecoder() { + KeyPair keyPair = keyPairService.getJwtSigningKeyPair(); + return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build(); + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/application/AppealController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/application/AppealController.java index f1e672a..044ddcf 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/application/AppealController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/application/AppealController.java @@ -1,7 +1,7 @@ package com.alttd.altitudeweb.controllers.application; import com.alttd.altitudeweb.api.AppealsApi; -import com.alttd.altitudeweb.controllers.limits.RateLimit; +import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.model.AppealResponseDto; import com.alttd.altitudeweb.model.DiscordAppealDto; import com.alttd.altitudeweb.model.MinecraftAppealDto; diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java index 359e2df..afa1547 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java @@ -1,7 +1,7 @@ package com.alttd.altitudeweb.controllers.history; import com.alttd.altitudeweb.api.HistoryApi; -import com.alttd.altitudeweb.controllers.limits.RateLimit; +import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.model.HistoryCountDto; import com.alttd.altitudeweb.model.PunishmentHistoryListDto; import com.alttd.altitudeweb.setup.Connection; 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 8a6f527..29709c1 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,32 +1,29 @@ package com.alttd.altitudeweb.controllers.login; import com.alttd.altitudeweb.api.LoginApi; -import com.alttd.altitudeweb.controllers.limits.RateLimit; -import com.nimbusds.jose.jwk.JWK; -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jose.jwk.source.ImmutableJWKSet; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.SecurityContext; +import com.alttd.altitudeweb.config.PermissionClaim; +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; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatusCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.Scheduled; +import com.alttd.altitudeweb.services.limits.RateLimit; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.security.KeyPair; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; import java.time.Instant; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; @@ -36,8 +33,11 @@ import java.util.concurrent.TimeUnit; @RestController public class LoginController implements LoginApi { - private final KeyPairService keyPairService; - private final String loginSecret =System.getenv("LOGIN_SECRET") ; + private final JwtEncoder jwtEncoder; + + @Value("${login.secret:#{null}}") + private String loginSecret; + private record CacheEntry(UUID uuid, Instant expiry) {} private static final ConcurrentMap cache = new ConcurrentHashMap<>(); @@ -57,15 +57,16 @@ public class LoginController implements LoginApi { try { uuidFromString = UUID.fromString(uuid); } catch (IllegalArgumentException e) { - return new ResponseEntity<>(HttpStatusCode.valueOf(400)); + return ResponseEntity.badRequest().build(); } + if (authorization == null || !authorization.startsWith("SECRET ")) { - return new ResponseEntity<>(HttpStatusCode.valueOf(403)); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } String secret = authorization.substring("SECRET ".length()); if (!isValidSecret(secret)) { - throw new ResponseStatusException(HttpStatusCode.valueOf(401), "Invalid secret"); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid secret"); } Optional key = cache.entrySet().stream() @@ -84,14 +85,22 @@ public class LoginController implements LoginApi { @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login") @Override public ResponseEntity login(String code) { - if ( code == null) { - return new ResponseEntity<>(HttpStatusCode.valueOf(400)); + CacheEntry cacheEntry1 = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1))); + cache.put("23232323", cacheEntry1); + if (code == null) { + return ResponseEntity.badRequest().build(); } + CacheEntry cacheEntry = cache.get(code); if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) { - return new ResponseEntity<>(HttpStatusCode.valueOf(403)); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - return ResponseEntity.ok().body(getJWTToken(cacheEntry.uuid)); + + String token = generateToken(cacheEntry.uuid); + + cache.remove(code); + + return ResponseEntity.ok(token); } private String generateLoginCode(UUID uuid) { @@ -117,35 +126,49 @@ public class LoginController implements LoginApi { return false; } if (!loginSecret.equals(secret)) { - log.info("Received invalid secret {}", secret); + log.info("Received invalid secret attempt"); return false; } return true; } - private String getJWTToken(UUID uuid) { - JwtEncoder jwtEncoder = jwtEncoder(); - + private String generateToken(UUID uuid) { Instant now = Instant.now(); + //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<>(); + Connection.getConnection(Databases.DEFAULT) + .runQuery(sqlSession -> { + try { + PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class) + .getUserByUuid(uuid.toString()); + privilegedUserCompletableFuture.complete(privilegedUser); + } catch (Exception e) { + log.error("Failed to load user by uuid", e); + privilegedUserCompletableFuture.completeExceptionally(e); + } + }); + PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join(); + claimList.add(PermissionClaim.USER); + if (privilegedUser != null) { + privilegedUser.getPermissions().forEach(permission -> { + try { + claimList.add(PermissionClaim.valueOf(permission)); + } catch (IllegalArgumentException e) { + log.warn("Received invalid permission claim: {}", permission); + } + }); + } JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("altitudeweb") + .claim("authorities", claimList.stream().map(PermissionClaim::getClaim).toList()) .issuedAt(now) .expiresAt(expiryTime) - .subject("user") - .claim("uuid", uuid.toString()) + .subject(uuid.toString()) .build(); return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); } - - private JwtEncoder jwtEncoder() { - KeyPair keyPair = keyPairService.getJwtSigningKeyPair(); - JWK jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) - .privateKey((RSAPrivateKey) keyPair.getPrivate()) - .build(); - JWKSource jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk)); - return new NimbusJwtEncoder(jwkSource); - } } diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java index 3f69878..a7287e8 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/team/TeamApiController.java @@ -1,7 +1,7 @@ package com.alttd.altitudeweb.controllers.team; import com.alttd.altitudeweb.api.TeamApi; -import com.alttd.altitudeweb.controllers.limits.RateLimit; +import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.setup.Connection; import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.luckperms.Player; diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/InMemoryRateLimiterService.java b/backend/src/main/java/com/alttd/altitudeweb/services/limits/InMemoryRateLimiterService.java similarity index 95% rename from backend/src/main/java/com/alttd/altitudeweb/controllers/limits/InMemoryRateLimiterService.java rename to backend/src/main/java/com/alttd/altitudeweb/services/limits/InMemoryRateLimiterService.java index a6c27e6..4ef8969 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/InMemoryRateLimiterService.java +++ b/backend/src/main/java/com/alttd/altitudeweb/services/limits/InMemoryRateLimiterService.java @@ -1,4 +1,4 @@ -package com.alttd.altitudeweb.controllers.limits; +package com.alttd.altitudeweb.services.limits; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimit.java b/backend/src/main/java/com/alttd/altitudeweb/services/limits/RateLimit.java similarity index 90% rename from backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimit.java rename to backend/src/main/java/com/alttd/altitudeweb/services/limits/RateLimit.java index 020a6c5..5c47045 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimit.java +++ b/backend/src/main/java/com/alttd/altitudeweb/services/limits/RateLimit.java @@ -1,4 +1,4 @@ -package com.alttd.altitudeweb.controllers.limits; +package com.alttd.altitudeweb.services.limits; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimitAspect.java b/backend/src/main/java/com/alttd/altitudeweb/services/limits/RateLimitAspect.java similarity index 94% rename from backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimitAspect.java rename to backend/src/main/java/com/alttd/altitudeweb/services/limits/RateLimitAspect.java index f4a4381..ba0d939 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RateLimitAspect.java +++ b/backend/src/main/java/com/alttd/altitudeweb/services/limits/RateLimitAspect.java @@ -1,4 +1,4 @@ -package com.alttd.altitudeweb.controllers.limits; +package com.alttd.altitudeweb.services.limits; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -26,8 +26,8 @@ public class RateLimitAspect { private final InMemoryRateLimiterService rateLimiterService; @Around(""" - @annotation(com.alttd.altitudeweb.controllers.limits.RateLimit) - || @within(com.alttd.altitudeweb.controllers.limits.RateLimit)""") + @annotation(com.alttd.altitudeweb.services.limits.RateLimit) + || @within(com.alttd.altitudeweb.services.limits.RateLimit)""") public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable { ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (requestAttributes == null) { diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RequestCounter.java b/backend/src/main/java/com/alttd/altitudeweb/services/limits/RequestCounter.java similarity index 97% rename from backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RequestCounter.java rename to backend/src/main/java/com/alttd/altitudeweb/services/limits/RequestCounter.java index 4448367..84b89c8 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/limits/RequestCounter.java +++ b/backend/src/main/java/com/alttd/altitudeweb/services/limits/RequestCounter.java @@ -1,4 +1,4 @@ -package com.alttd.altitudeweb.controllers.limits; +package com.alttd.altitudeweb.services.limits; import java.time.Duration; import java.time.Instant; diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/user/UserDetailsServiceImpl.java b/backend/src/main/java/com/alttd/altitudeweb/services/user/UserDetailsServiceImpl.java new file mode 100644 index 0000000..1711dfb --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/services/user/UserDetailsServiceImpl.java @@ -0,0 +1,27 @@ +package com.alttd.altitudeweb.services.user; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + @Override + public UserDetails loadUserByUsername(String uuid) throws UsernameNotFoundException { + try { + //Validate uuid + UUID.fromString(uuid); + return new User(uuid, "", Collections.emptyList()); + } catch (IllegalArgumentException e) { + throw new UsernameNotFoundException("Invalid UUID format: " + uuid); + } + } +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java new file mode 100644 index 0000000..138ad98 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java @@ -0,0 +1,16 @@ +package com.alttd.altitudeweb.database.web_db; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PrivilegedUser { + private int id; + private String uuid; + private List permissions; +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java new file mode 100644 index 0000000..4bcad5d --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUserMapper.java @@ -0,0 +1,102 @@ +package com.alttd.altitudeweb.database.web_db; + +import org.apache.ibatis.annotations.*; + +import java.util.List; + +public interface PrivilegedUserMapper { + + /** + * Retrieves a user by their UUID along with their permissions + * @param uuid The UUID of the user to retrieve + * @return The PrivilegedUser with their permissions, or null if not found + */ + @Select(""" + SELECT privileged_users.id, privileged_users.uuid, privileges.privileges as permission + FROM privileged_users + LEFT JOIN privileges ON privileged_users.id = privileges.user_id + WHERE privileged_users.uuid = #{uuid} + """) + @Results({ + @Result(property = "id", column = "id"), + @Result(property = "uuid", column = "uuid"), + @Result(property = "permissions", column = "id", javaType = List.class, + many = @Many(select = "getPermissionsForUser")) + }) + PrivilegedUser getUserByUuid(@Param("uuid") String uuid); + + /** + * Retrieves all privileged users with their permissions + * @return List of all privileged users with their permissions + */ + @Select(""" + SELECT id, uuid + FROM privileged_users + """) + @Results({ + @Result(property = "id", column = "id"), + @Result(property = "uuid", column = "uuid"), + @Result(property = "permissions", column = "id", javaType = List.class, + many = @Many(select = "getPermissionsForUser")) + }) + List getAllUsers(); + + /** + * Gets all permissions for a specific user + * @param userId The ID of the user + * @return List of permission strings + */ + @Select(""" + SELECT privileges + FROM privileges + WHERE user_id = #{userId} + """) + List getPermissionsForUser(@Param("userId") int userId); + + /** + * Adds a new privileged user + * @param user The PrivilegedUser object to add + * @return The number of rows affected + */ + @Insert(""" + INSERT INTO privileged_users (uuid) + VALUES (#{user.uuid}) + """) + @Options(useGeneratedKeys = true, keyProperty = "user.id", keyColumn = "id") + int addUser(@Param("user") PrivilegedUser user); + + /** + * Deletes a privileged user by their UUID + * @param uuid The UUID of the user to delete + * @return The number of rows affected + */ + @Delete(""" + DELETE FROM privileged_users + WHERE uuid = #{uuid} + """) + int deleteUserByUuid(@Param("uuid") String uuid); + + /** + * Adds a permission to a user + * @param userId The ID of the user + * @param permission The permission to add + * @return The number of rows affected + */ + @Insert(""" + INSERT INTO privileges (user_id, privileges) + VALUES (#{userId}, #{permission}) + """) + int addPermissionToUser(@Param("userId") int userId, @Param("permission") String permission); + + /** + * Removes a permission from a user + * @param userId The ID of the user + * @param permission The permission to remove + * @return The number of rows affected + */ + @Delete(""" + DELETE FROM privileges + WHERE user_id = #{userId} AND privileges = #{permission} + """) + int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission); +} diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java index f75a0fe..7a6dfe5 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java @@ -5,6 +5,7 @@ import com.alttd.altitudeweb.database.web_db.KeyPairMapper; import com.alttd.altitudeweb.database.web_db.SettingsMapper; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.session.SqlSession; +import org.jetbrains.annotations.NotNull; import java.sql.SQLException; import java.sql.Statement; @@ -21,11 +22,13 @@ public class InitializeWebDb { .runQuery(SqlSession -> { createSettingsTable(SqlSession); createKeyTable(SqlSession); + createPrivilegedUsersTable(SqlSession); + createPrivilegesTable(SqlSession); }); log.debug("Initialized WebDb"); } - private static void createSettingsTable(SqlSession sqlSession) { + private static void createSettingsTable(@NotNull SqlSession sqlSession) { String query = """ CREATE TABLE IF NOT EXISTS db_connection_settings ( @@ -45,7 +48,7 @@ public class InitializeWebDb { } } - private static void createKeyTable(SqlSession sqlSession) { + private static void createKeyTable(@NotNull SqlSession sqlSession) { String query = """ CREATE TABLE IF NOT EXISTS key_pair ( id int NOT NULL AUTO_INCREMENT PRIMARY KEY, @@ -61,4 +64,37 @@ public class InitializeWebDb { } } + private static void createPrivilegedUsersTable(@NotNull SqlSession sqlSession) { + String query = """ + CREATE TABLE IF NOT EXISTS privileged_users ( + id int NOT NULL AUTO_INCREMENT PRIMARY KEY, + uuid VARCHAR(36) NOT NULL + ); + """; + try (Statement statement = sqlSession.getConnection().createStatement()) { + statement.execute(query); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private static void createPrivilegesTable(@NotNull SqlSession sqlSession) { + String query = """ + CREATE TABLE IF NOT EXISTS privileges ( + id int NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id int NOT NULL, + privileges VARCHAR(36) NOT NULL, + CONSTRAINT fk_privileges_user FOREIGN KEY (user_id) + REFERENCES privileged_users(id) + ON DELETE CASCADE + ON UPDATE CASCADE + ); + """; + try (Statement statement = sqlSession.getConnection().createStatement()) { + statement.execute(query); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + } diff --git a/frontend/package.json b/frontend/package.json index 21157d6..618ec7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,10 +13,12 @@ }, "private": true, "dependencies": { + "@angular/cdk": "^19.2.18", "@angular/common": "^19.2.0", "@angular/compiler": "^19.2.0", "@angular/core": "^19.2.0", "@angular/forms": "^19.2.0", + "@angular/material": "^19.2.18", "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index a06354c..1ac56b8 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -96,7 +96,15 @@ export const routes: Routes = [ { path: 'staffpowers', loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent) - } + }, + { + path: 'forms/:form', + loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent) + }, + { + path: 'forms', + loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent) + }, ]; diff --git a/frontend/src/app/forms/form_type.ts b/frontend/src/app/forms/form_type.ts new file mode 100644 index 0000000..1f8b097 --- /dev/null +++ b/frontend/src/app/forms/form_type.ts @@ -0,0 +1,5 @@ +export enum FormType { + APPEAL = 'appeal', + STAFF_APPLICATION = 'staff_application', + CONTACT = 'contact' +} diff --git a/frontend/src/app/forms/forms.component.html b/frontend/src/app/forms/forms.component.html index 9f04ed1..a8b4ffd 100644 --- a/frontend/src/app/forms/forms.component.html +++ b/frontend/src/app/forms/forms.component.html @@ -5,7 +5,13 @@

{{ formTitle }}

- + + + + +
diff --git a/frontend/src/app/forms/forms.component.ts b/frontend/src/app/forms/forms.component.ts index fffed0e..13b57b8 100644 --- a/frontend/src/app/forms/forms.component.ts +++ b/frontend/src/app/forms/forms.component.ts @@ -1,15 +1,82 @@ -import {Component, Input} from '@angular/core'; +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'; @Component({ selector: 'app-forms', imports: [ - HeaderComponent + HeaderComponent, + NgIf, + NgForOf, + MatButton, + KeyValuePipe ], templateUrl: './forms.component.html', styleUrl: './forms.component.scss' }) -export class FormsComponent { +export class FormsComponent implements OnInit { @Input() formTitle: string = 'Form'; @Input() currentPage: string = 'forms'; + + public type: FormType | undefined; + + constructor(private loginService: LoginService, + private dialog: MatDialog, + private route: ActivatedRoute, + ) { + this.route.paramMap.subscribe(async params => { + const code = params.get('code'); + + if (code) { + this.loginService.login(code).subscribe(jwt => this.saveJwt(jwt as JsonWebKey)); //TODO handle error + } else { + const dialogRef = this.dialog.open(LoginDialogComponent, { + width: '400px', + disableClose: true + }); + + dialogRef.afterClosed().subscribe(jwt => { + this.saveJwt(jwt as JsonWebKey) + }); + } + }); + } + + ngOnInit() { + this.route.paramMap.subscribe(params => { + switch (params.get('form')) { + case FormType.APPEAL: + this.type = FormType.APPEAL; + this.currentPage = 'appeal'; + break; + default: + throw new Error("Invalid type"); + } + }); + } + + 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; + + public setFormType(formType: FormType) { + this.type = formType; + } } diff --git a/frontend/src/app/login/login.component.html b/frontend/src/app/login/login.component.html new file mode 100644 index 0000000..3c8865a --- /dev/null +++ b/frontend/src/app/login/login.component.html @@ -0,0 +1,18 @@ +

Login

+
+
+ + Enter your code + + + Code is required + + +
+
+
+ + +
diff --git a/frontend/src/app/login/login.component.scss b/frontend/src/app/login/login.component.scss new file mode 100644 index 0000000..fb13066 --- /dev/null +++ b/frontend/src/app/login/login.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; + width: 100%; + max-width: 400px; +} + +.mat-dialog-content { + padding-top: 10px; +} + +.mat-dialog-actions { + padding: 16px 0; +} diff --git a/frontend/src/app/login/login.component.spec.ts b/frontend/src/app/login/login.component.spec.ts new file mode 100644 index 0000000..18f3685 --- /dev/null +++ b/frontend/src/app/login/login.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoginComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/login/login.component.ts b/frontend/src/app/login/login.component.ts new file mode 100644 index 0000000..ba54163 --- /dev/null +++ b/frontend/src/app/login/login.component.ts @@ -0,0 +1,82 @@ +import {Component} from '@angular/core'; +import {MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle} from '@angular/material/dialog'; +import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +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'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ + ReactiveFormsModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + NgIf + ], + templateUrl: './login.component.html', + styleUrl: './login.component.scss' +}) +export class LoginDialogComponent { + public loginForm: FormGroup; + + constructor( + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private loginService: LoginService, + private snackBar: MatSnackBar, + private cookieService: CookieService + ) { + this.loginForm = this.fb.group({ + code: ['', [ + Validators.required, + Validators.minLength(8), + Validators.maxLength(8), + Validators.pattern('^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$') + ]] + }); + } + + onCancel(): void { + this.dialogRef.close(); + } + + onSubmit(): void { + if (!this.loginForm.valid) { + this.snackBar.open('Invalid code', '', {duration: 2000}); + return; + } + this.snackBar.open('Logging in...', '', {duration: 2000}); + this.loginService.login(this.loginForm.value.code).subscribe({ + next: (jwt) => { + this.saveJwt(jwt as JsonWebKey); + this.dialogRef.close(jwt); + }, + error: () => { + this.loginForm.get('code')?.setErrors({ + invalid: true + }); + } + }); + } + + 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/styles.scss b/frontend/src/styles.scss index 25a5181..032cac3 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1,3 +1,5 @@ +@import '@angular/material/prebuilt-themes/azure-blue.css'; + :root { --white: #FFFFFF; --black: #282828; From 32a454c034098e1fd53dc28d41258e47a56eb113 Mon Sep 17 00:00:00 2001 From: Teriuihi Date: Sat, 21 Jun 2025 23:15:46 +0200 Subject: [PATCH 2/2] 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. --- .../altitudeweb/config/PermissionClaim.java | 16 --- .../altitudeweb/config/SecurityConfig.java | 6 +- .../controllers/login/LoginController.java | 12 +- frontend/src/app/forms/forms.component.ts | 24 +--- frontend/src/app/login/login.component.ts | 19 +-- frontend/src/app/services/auth.service.ts | 115 ++++++++++++++++++ open_api/src/main/resources/api.yml | 4 + .../schemas/permissions/permissions.yml | 8 ++ 8 files changed, 144 insertions(+), 60 deletions(-) delete mode 100644 backend/src/main/java/com/alttd/altitudeweb/config/PermissionClaim.java create mode 100644 frontend/src/app/services/auth.service.ts create mode 100644 open_api/src/main/resources/schemas/permissions/permissions.yml 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" ]