Merge remote-tracking branch 'origin/bans' into bans
# Conflicts: # frontend/src/app/app.routes.ts
This commit is contained in:
commit
43430cfbef
|
|
@ -27,14 +27,14 @@ dependencies {
|
||||||
implementation(project(":open_api"))
|
implementation(project(":open_api"))
|
||||||
implementation(project(":database"))
|
implementation(project(":database"))
|
||||||
implementation(project(":frontend"))
|
implementation(project(":frontend"))
|
||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
|
||||||
annotationProcessor("org.projectlombok:lombok")
|
annotationProcessor("org.projectlombok:lombok")
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
|
||||||
implementation("com.mysql:mysql-connector-j:8.0.32")
|
implementation("com.mysql:mysql-connector-j:8.0.32")
|
||||||
implementation("org.mybatis:mybatis:3.5.13")
|
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-configuration-processor")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-hateoas")
|
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")
|
implementation("org.springframework.security:spring-security-oauth2-jose")
|
||||||
|
|
||||||
//AOP
|
//AOP
|
||||||
|
|
@ -43,6 +43,8 @@ dependencies {
|
||||||
implementation("org.springframework:spring-aop")
|
implementation("org.springframework:spring-aop")
|
||||||
implementation("org.springframework:spring-aspects")
|
implementation("org.springframework:spring-aspects")
|
||||||
|
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.compileJava {
|
tasks.compileJava {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||||
public class AltitudeWebApplication {
|
public class AltitudeWebApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(AltitudeWebApplication.class, args);
|
|
||||||
Connection.initDatabases();
|
Connection.initDatabases();
|
||||||
|
SpringApplication.run(AltitudeWebApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
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;
|
||||||
|
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.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(PermissionClaimDto.USER.getValue())
|
||||||
|
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||||
|
.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;
|
package com.alttd.altitudeweb.controllers.application;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.AppealsApi;
|
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.AppealResponseDto;
|
||||||
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
||||||
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package com.alttd.altitudeweb.controllers.history;
|
package com.alttd.altitudeweb.controllers.history;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.HistoryApi;
|
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.HistoryCountDto;
|
||||||
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
|
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
|
||||||
import com.alttd.altitudeweb.setup.Connection;
|
import com.alttd.altitudeweb.setup.Connection;
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
package com.alttd.altitudeweb.controllers.login;
|
package com.alttd.altitudeweb.controllers.login;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.LoginApi;
|
import com.alttd.altitudeweb.api.LoginApi;
|
||||||
import com.alttd.altitudeweb.controllers.limits.RateLimit;
|
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||||
import com.nimbusds.jose.jwk.JWK;
|
import com.alttd.altitudeweb.database.Databases;
|
||||||
import com.nimbusds.jose.jwk.JWKSet;
|
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
|
||||||
import com.nimbusds.jose.jwk.RSAKey;
|
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
import com.alttd.altitudeweb.setup.Connection;
|
||||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
|
||||||
import com.nimbusds.jose.proc.SecurityContext;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
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.JwtClaimsSet;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
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.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.*;
|
||||||
import java.util.Optional;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
@ -36,8 +31,11 @@ import java.util.concurrent.TimeUnit;
|
||||||
@RestController
|
@RestController
|
||||||
public class LoginController implements LoginApi {
|
public class LoginController implements LoginApi {
|
||||||
|
|
||||||
private final KeyPairService keyPairService;
|
private final JwtEncoder jwtEncoder;
|
||||||
private final String loginSecret =System.getenv("LOGIN_SECRET") ;
|
|
||||||
|
@Value("${login.secret:#{null}}")
|
||||||
|
private String loginSecret;
|
||||||
|
|
||||||
private record CacheEntry(UUID uuid, Instant expiry) {}
|
private record CacheEntry(UUID uuid, Instant expiry) {}
|
||||||
|
|
||||||
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||||
|
|
@ -57,15 +55,16 @@ public class LoginController implements LoginApi {
|
||||||
try {
|
try {
|
||||||
uuidFromString = UUID.fromString(uuid);
|
uuidFromString = UUID.fromString(uuid);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return new ResponseEntity<>(HttpStatusCode.valueOf(400));
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authorization == null || !authorization.startsWith("SECRET ")) {
|
if (authorization == null || !authorization.startsWith("SECRET ")) {
|
||||||
return new ResponseEntity<>(HttpStatusCode.valueOf(403));
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
String secret = authorization.substring("SECRET ".length());
|
String secret = authorization.substring("SECRET ".length());
|
||||||
if (!isValidSecret(secret)) {
|
if (!isValidSecret(secret)) {
|
||||||
throw new ResponseStatusException(HttpStatusCode.valueOf(401), "Invalid secret");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<String> key = cache.entrySet().stream()
|
Optional<String> key = cache.entrySet().stream()
|
||||||
|
|
@ -84,14 +83,22 @@ public class LoginController implements LoginApi {
|
||||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
|
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
|
||||||
@Override
|
@Override
|
||||||
public ResponseEntity<String> login(String code) {
|
public ResponseEntity<String> login(String code) {
|
||||||
if ( code == null) {
|
CacheEntry cacheEntry1 = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
|
||||||
return new ResponseEntity<>(HttpStatusCode.valueOf(400));
|
cache.put("23232323", cacheEntry1);
|
||||||
|
if (code == null) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
CacheEntry cacheEntry = cache.get(code);
|
CacheEntry cacheEntry = cache.get(code);
|
||||||
if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) {
|
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) {
|
private String generateLoginCode(UUID uuid) {
|
||||||
|
|
@ -117,35 +124,49 @@ public class LoginController implements LoginApi {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!loginSecret.equals(secret)) {
|
if (!loginSecret.equals(secret)) {
|
||||||
log.info("Received invalid secret {}", secret);
|
log.info("Received invalid secret attempt");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getJWTToken(UUID uuid) {
|
private String generateToken(UUID uuid) {
|
||||||
JwtEncoder jwtEncoder = jwtEncoder();
|
|
||||||
|
|
||||||
Instant now = Instant.now();
|
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));
|
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
|
||||||
|
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
|
||||||
|
List<PermissionClaimDto> 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(PermissionClaimDto.USER);
|
||||||
|
if (privilegedUser != null) {
|
||||||
|
privilegedUser.getPermissions().forEach(permission -> {
|
||||||
|
try {
|
||||||
|
claimList.add(PermissionClaimDto.valueOf(permission));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("Received invalid permission claim: {}", permission);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||||
.issuer("altitudeweb")
|
.issuer("altitudeweb")
|
||||||
|
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
|
||||||
.issuedAt(now)
|
.issuedAt(now)
|
||||||
.expiresAt(expiryTime)
|
.expiresAt(expiryTime)
|
||||||
.subject("user")
|
.subject(uuid.toString())
|
||||||
.claim("uuid", uuid.toString())
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
|
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;
|
package com.alttd.altitudeweb.controllers.team;
|
||||||
|
|
||||||
import com.alttd.altitudeweb.api.TeamApi;
|
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.setup.Connection;
|
||||||
import com.alttd.altitudeweb.database.Databases;
|
import com.alttd.altitudeweb.database.Databases;
|
||||||
import com.alttd.altitudeweb.database.luckperms.Player;
|
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;
|
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.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
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.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
@ -26,8 +26,8 @@ public class RateLimitAspect {
|
||||||
private final InMemoryRateLimiterService rateLimiterService;
|
private final InMemoryRateLimiterService rateLimiterService;
|
||||||
|
|
||||||
@Around("""
|
@Around("""
|
||||||
@annotation(com.alttd.altitudeweb.controllers.limits.RateLimit)
|
@annotation(com.alttd.altitudeweb.services.limits.RateLimit)
|
||||||
|| @within(com.alttd.altitudeweb.controllers.limits.RateLimit)""")
|
|| @within(com.alttd.altitudeweb.services.limits.RateLimit)""")
|
||||||
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
|
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||||
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
if (requestAttributes == null) {
|
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.Duration;
|
||||||
import java.time.Instant;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> permissions;
|
||||||
|
}
|
||||||
|
|
@ -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<PrivilegedUser> 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<String> 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);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
||||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.ibatis.session.SqlSession;
|
import org.apache.ibatis.session.SqlSession;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
|
@ -21,11 +22,13 @@ public class InitializeWebDb {
|
||||||
.runQuery(SqlSession -> {
|
.runQuery(SqlSession -> {
|
||||||
createSettingsTable(SqlSession);
|
createSettingsTable(SqlSession);
|
||||||
createKeyTable(SqlSession);
|
createKeyTable(SqlSession);
|
||||||
|
createPrivilegedUsersTable(SqlSession);
|
||||||
|
createPrivilegesTable(SqlSession);
|
||||||
});
|
});
|
||||||
log.debug("Initialized WebDb");
|
log.debug("Initialized WebDb");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void createSettingsTable(SqlSession sqlSession) {
|
private static void createSettingsTable(@NotNull SqlSession sqlSession) {
|
||||||
String query = """
|
String query = """
|
||||||
CREATE TABLE IF NOT EXISTS db_connection_settings
|
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 = """
|
String query = """
|
||||||
CREATE TABLE IF NOT EXISTS key_pair (
|
CREATE TABLE IF NOT EXISTS key_pair (
|
||||||
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,12 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@angular/cdk": "^19.2.18",
|
||||||
"@angular/common": "^19.2.0",
|
"@angular/common": "^19.2.0",
|
||||||
"@angular/compiler": "^19.2.0",
|
"@angular/compiler": "^19.2.0",
|
||||||
"@angular/core": "^19.2.0",
|
"@angular/core": "^19.2.0",
|
||||||
"@angular/forms": "^19.2.0",
|
"@angular/forms": "^19.2.0",
|
||||||
|
"@angular/material": "^19.2.18",
|
||||||
"@angular/platform-browser": "^19.2.0",
|
"@angular/platform-browser": "^19.2.0",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
"@angular/router": "^19.2.0",
|
"@angular/router": "^19.2.0",
|
||||||
|
|
|
||||||
|
|
@ -98,17 +98,13 @@ export const routes: Routes = [
|
||||||
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
|
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'nicknames',
|
path: 'forms/:form',
|
||||||
loadComponent: () => import('./nicknames/nicknames.component').then(m => m.NicknamesComponent)
|
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'nickgenerator',
|
path: 'forms',
|
||||||
loadComponent: () => import('./nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
|
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'community',
|
|
||||||
loadComponent: () => import('./community/community.component').then(m => m.CommunityComponent)
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
5
frontend/src/app/forms/form_type.ts
Normal file
5
frontend/src/app/forms/form_type.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum FormType {
|
||||||
|
APPEAL = 'appeal',
|
||||||
|
STAFF_APPLICATION = 'staff_application',
|
||||||
|
CONTACT = 'contact'
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,13 @@
|
||||||
<h1>{{ formTitle }}</h1>
|
<h1>{{ formTitle }}</h1>
|
||||||
</div>
|
</div>
|
||||||
</app-header>
|
</app-header>
|
||||||
<!-- TODO add form styling in this div-->
|
<ng-container *ngIf="!type">
|
||||||
|
<ng-container *ngFor="let formType of FormType | keyvalue">
|
||||||
|
<button mat-raised-button (click)="setFormType(formType.value)">
|
||||||
|
{{ formType }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
<div>
|
<div>
|
||||||
<ng-content select="[form-content]"></ng-content>
|
<ng-content select="[form-content]"></ng-content>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,68 @@
|
||||||
import {Component, Input} from '@angular/core';
|
import {Component, Input, OnInit} from '@angular/core';
|
||||||
import {HeaderComponent} from '../header/header.component';
|
import {HeaderComponent} from '../header/header.component';
|
||||||
|
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({
|
@Component({
|
||||||
selector: 'app-forms',
|
selector: 'app-forms',
|
||||||
imports: [
|
imports: [
|
||||||
HeaderComponent
|
HeaderComponent,
|
||||||
|
NgIf,
|
||||||
|
NgForOf,
|
||||||
|
MatButton,
|
||||||
|
KeyValuePipe
|
||||||
],
|
],
|
||||||
templateUrl: './forms.component.html',
|
templateUrl: './forms.component.html',
|
||||||
styleUrl: './forms.component.scss'
|
styleUrl: './forms.component.scss'
|
||||||
})
|
})
|
||||||
export class FormsComponent {
|
export class FormsComponent implements OnInit {
|
||||||
@Input() formTitle: string = 'Form';
|
@Input() formTitle: string = 'Form';
|
||||||
@Input() currentPage: string = 'forms';
|
@Input() currentPage: string = 'forms';
|
||||||
|
|
||||||
|
public type: FormType | undefined;
|
||||||
|
|
||||||
|
constructor(private authService: AuthService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
) {
|
||||||
|
this.route.paramMap.subscribe(async params => {
|
||||||
|
const code = params.get('code');
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
this.authService.login(code).subscribe();
|
||||||
|
} else if (!this.authService.checkAuthStatus()) {
|
||||||
|
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||||
|
width: '400px',
|
||||||
|
disableClose: true
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly FormType = FormType;
|
||||||
|
protected readonly Object = Object;
|
||||||
|
|
||||||
|
public setFormType(formType: FormType) {
|
||||||
|
this.type = formType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
frontend/src/app/login/login.component.html
Normal file
18
frontend/src/app/login/login.component.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<h2 mat-dialog-title>Login</h2>
|
||||||
|
<div mat-dialog-content>
|
||||||
|
<form [formGroup]="loginForm">
|
||||||
|
<mat-form-field appearance="fill" style="width: 100%">
|
||||||
|
<mat-label>Enter your code</mat-label>
|
||||||
|
<input matInput formControlName="code" type="text">
|
||||||
|
<mat-error *ngIf="formHasError()">
|
||||||
|
Code is required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div mat-dialog-actions align="end">
|
||||||
|
<button mat-button (click)="onCancel()">Cancel</button>
|
||||||
|
<button mat-flat-button color="primary" (click)="onSubmit()" [disabled]="!loginForm.valid">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
13
frontend/src/app/login/login.component.scss
Normal file
13
frontend/src/app/login/login.component.scss
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-dialog-actions {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
23
frontend/src/app/login/login.component.spec.ts
Normal file
23
frontend/src/app/login/login.component.spec.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { LoginComponent } from './login.component';
|
||||||
|
|
||||||
|
describe('LoginComponent', () => {
|
||||||
|
let component: LoginComponent;
|
||||||
|
let fixture: ComponentFixture<LoginComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [LoginComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(LoginComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
71
frontend/src/app/login/login.component.ts
Normal file
71
frontend/src/app/login/login.component.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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 {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
import {AuthService} from '../services/auth.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<LoginDialogComponent>,
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private authService: AuthService,
|
||||||
|
private snackBar: MatSnackBar
|
||||||
|
) {
|
||||||
|
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.authService.login(this.loginForm.value.code).subscribe({
|
||||||
|
next: (jwt) => {
|
||||||
|
this.dialogRef.close(jwt);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loginForm.get('code')?.setErrors({
|
||||||
|
invalid: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
@import '@angular/material/prebuilt-themes/azure-blue.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--white: #FFFFFF;
|
--white: #FFFFFF;
|
||||||
--black: #282828;
|
--black: #282828;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ info:
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
servers:
|
servers:
|
||||||
- url: https://alttd.com/api/v3
|
- url: https://alttd.com/api/v3
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
PermissionClaim:
|
||||||
|
$ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim'
|
||||||
tags:
|
tags:
|
||||||
- name: history
|
- name: history
|
||||||
description: Retrieves punishment 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