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.
This commit is contained in:
2025-05-30 23:41:13 +02:00
parent 20dcebbab9
commit 07646e8c42
26 changed files with 572 additions and 59 deletions
+5 -3
View File
@@ -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 {
@@ -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);
}
}
@@ -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;
}
}
@@ -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<SecurityContext> 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();
}
}
@@ -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;
@@ -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;
@@ -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<String, CacheEntry> 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<String> 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<String> 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<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
List<PermissionClaim> 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<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwkSource);
}
}
@@ -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;
@@ -1,4 +1,4 @@
package com.alttd.altitudeweb.controllers.limits;
package com.alttd.altitudeweb.services.limits;
import org.springframework.stereotype.Service;
@@ -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;
@@ -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) {
@@ -1,4 +1,4 @@
package com.alttd.altitudeweb.controllers.limits;
package com.alttd.altitudeweb.services.limits;
import java.time.Duration;
import java.time.Instant;
@@ -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);
}
}
}