Merge remote-tracking branch 'origin/bans' into bans

# Conflicts:
#	frontend/src/app/app.routes.ts
This commit is contained in:
Peter 2025-07-06 11:13:59 +02:00
commit 43430cfbef
28 changed files with 651 additions and 66 deletions

View File

@ -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 {

View File

@ -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);
} }
} }

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
} }

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}
}
} }

View File

@ -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",

View File

@ -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)
}
]; ];

View File

@ -0,0 +1,5 @@
export enum FormType {
APPEAL = 'appeal',
STAFF_APPLICATION = 'staff_application',
CONTACT = 'contact'
}

View File

@ -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>

View File

@ -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;
}
} }

View 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>

View 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;
}

View 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();
});
});

View 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');
}
}

View 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 || [];
}
}

View File

@ -1,3 +1,5 @@
@import '@angular/material/prebuilt-themes/azure-blue.css';
:root { :root {
--white: #FFFFFF; --white: #FFFFFF;
--black: #282828; --black: #282828;

View File

@ -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

View File

@ -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" ]