Compare commits

...

6 Commits

Author SHA1 Message Date
Teriuihi c4c17b3adc Add JWT-based login flow with key pair generation
Introduced a secure login flow using JWTs with dynamically generated RSA key pairs stored in the database. Updated relevant APIs, database schema, and services to support login codes, JWT encoding, and secret validation.
2025-05-24 01:33:36 +02:00
Teriuihi cf758bfe60 Add endpoints and schema for history retrieval by UUID
Introduced a new API endpoint to fetch all punishment history for a specified UUID. Updated existing schemas, controllers, and mappers to support this functionality. Adjusted login endpoints to improve request handling and streamlined frontend form setup for appeals.
2025-05-03 04:37:47 +02:00
Teriuihi 8c7ec0a237 Restrict team member query to global world scope. 2025-05-02 01:33:45 +02:00
Teriuihi 80462218a7 Update role label for head mod 2025-05-02 01:32:15 +02:00
Teriuihi 26b5f86983 Add rate limiting to LoginController endpoints
Introduced a `@RateLimit` annotation to enforce limits on the `addLogin` and `login` methods in `LoginController`. This restricts the number of requests per minute to improve security and prevent abuse.
2025-04-26 23:14:33 +02:00
Teriuihi ba6cf6d938 Add rate limiting to AppealController methods
Introduced @RateLimit annotations to enforce request limits on the AppealController. The overall controller has a global limit of 30 requests per hour, while specific methods for Discord and Minecraft appeals are limited to 3 requests per hour. This aims to prevent abuse and improve system reliability.
2025-04-26 23:13:26 +02:00
18 changed files with 429 additions and 92 deletions

View File

@ -35,6 +35,7 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
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.security:spring-security-oauth2-jose")
//AOP //AOP
implementation("org.aspectj:aspectjrt:1.9.19") implementation("org.aspectj:aspectjrt:1.9.19")

View File

@ -1,21 +1,29 @@
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.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;
import com.alttd.altitudeweb.model.UpdateMailDto; import com.alttd.altitudeweb.model.UpdateMailDto;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.concurrent.TimeUnit;
@RestController
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class AppealController implements AppealsApi { public class AppealController implements AppealsApi {
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override @Override
public ResponseEntity<MinecraftAppealDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) { public ResponseEntity<MinecraftAppealDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported"); throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
} }
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@Override @Override
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) { public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Minecraft appeals are not yet supported"); throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Minecraft appeals are not yet supported");

View File

@ -153,6 +153,24 @@ public class HistoryApiController implements HistoryApi {
return ResponseEntity.ok().body(searchResultCountCompletableFuture.join()); return ResponseEntity.ok().body(searchResultCountCompletableFuture.join());
} }
@Override
public ResponseEntity<PunishmentHistoryListDto> getAllHistoryForUUID(String uuid) {
PunishmentHistoryListDto punishmentHistoryList = new PunishmentHistoryListDto();
CompletableFuture<List<HistoryRecord>> historyRecordsCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
log.debug("Loading all history for uuid {}", uuid);
try {
List<HistoryRecord> punishments = sqlSession.getMapper(UUIDHistoryMapper.class)
.getAllHistoryForUUID(UUID.fromString(uuid));
historyRecordsCompletableFuture.complete(punishments);
} catch (Exception e) {
log.error("Failed to load all history for uuid {}", uuid, e);
historyRecordsCompletableFuture.completeExceptionally(e);
}
});
return mapPunishmentHistory(punishmentHistoryList, historyRecordsCompletableFuture);
}
@Override @Override
public ResponseEntity<PunishmentHistoryDto> getHistoryById(String type, Integer id) { public ResponseEntity<PunishmentHistoryDto> getHistoryById(String type, Integer id) {
HistoryType historyTypeEnum = HistoryType.getHistoryType(type); HistoryType historyTypeEnum = HistoryType.getHistoryType(type);

View File

@ -0,0 +1,102 @@
package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairEntity;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.concurrent.CompletableFuture;
@Service
@RequiredArgsConstructor
@Slf4j
public class KeyPairService {
private KeyPair cachedKeyPair = null;
private static final String RSA_ALGORITHM = "RSA";
private static final int RSA_KEY_SIZE = 2048;
public KeyPair getJwtSigningKeyPair() {
if (cachedKeyPair != null) {
return cachedKeyPair;
}
KeyPair keyPair = getOrCreateKeyPair();
if (keyPair != null) {
cachedKeyPair = keyPair;
return cachedKeyPair;
}
throw new IllegalStateException("Failed to generate or load key pair");
}
public KeyPair getOrCreateKeyPair() {
CompletableFuture<KeyPairEntity> keyPairFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Loading key pair");
try {
KeyPairEntity entity = sqlSession.getMapper(KeyPairMapper.class).getKeyPair();
keyPairFuture.complete(entity);
} catch (Exception e) {
log.error("Failed to key pair", e);
keyPairFuture.completeExceptionally(e);
}
});
KeyPairEntity keyPairEntity = keyPairFuture.join();
if (keyPairEntity != null) {
try {
byte[] privateKeyBytes = Base64.getDecoder().decode(keyPairEntity.getPrivateKey());
byte[] publicKeyBytes = Base64.getDecoder().decode(keyPairEntity.getPublicKey());
KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes));
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
return new KeyPair(publicKey, privateKey);
} catch (Exception e) {
log.error("Failed to load key pair from database", e);
}
}
KeyPair keyPair = generateKeyPair();
try {
KeyPairEntity entity = new KeyPairEntity();
entity.setPrivateKey(Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()));
entity.setPublicKey(Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()));
entity.setCreatedAt(Instant.now());
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Saving key pair");
try {
sqlSession.getMapper(KeyPairMapper.class).save(entity);
log.info("Generated and saved new key pair");
} catch (Exception e) {
log.error("Failed to key pair", e);
}
});
} catch (Exception e) {
log.error("Failed to save key pair to database", e);
}
return keyPair;
}
private KeyPair generateKeyPair() {
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM);
keyPairGenerator.initialize(RSA_KEY_SIZE);
return keyPairGenerator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Error generating key pair", e);
}
}
}

View File

@ -1,22 +1,151 @@
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.model.AddLoginDto; import com.alttd.altitudeweb.controllers.limits.RateLimit;
import com.alttd.altitudeweb.model.LoginDataDto; import com.nimbusds.jose.jwk.JWK;
import com.alttd.altitudeweb.model.LoginResultDto; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
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 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.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
@Slf4j
@RequiredArgsConstructor
@RestController
public class LoginController implements LoginApi { public class LoginController implements LoginApi {
@Override private final KeyPairService keyPairService;
public ResponseEntity<Void> addLogin(AddLoginDto addLoginDto) { private final String loginSecret =System.getenv("LOGIN_SECRET") ;
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Adding login is not yet supported"); private record CacheEntry(UUID uuid, Instant expiry) {}
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
private void clearExpiredCacheEntries() {
Instant now = Instant.now();
int initialCacheSize = cache.size();
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
log.info("Cleared {} expired cache entries", initialCacheSize - cache.size());
} }
@RateLimit(limit = 100, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "addLogin")
@Override @Override
public ResponseEntity<LoginResultDto> login(LoginDataDto loginDataDto) { public ResponseEntity<String> requestLogin(String authorization, String uuid) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Logging in is not yet supported"); UUID uuidFromString;
try {
uuidFromString = UUID.fromString(uuid);
} catch (IllegalArgumentException e) {
return new ResponseEntity<>(HttpStatusCode.valueOf(400));
}
if (authorization == null || !authorization.startsWith("SECRET ")) {
return new ResponseEntity<>(HttpStatusCode.valueOf(403));
}
String secret = authorization.substring("SECRET ".length());
if (!isValidSecret(secret)) {
throw new ResponseStatusException(HttpStatusCode.valueOf(401), "Invalid secret");
}
Optional<String> key = cache.entrySet().stream()
.filter(entry -> entry.getValue().uuid.equals(uuidFromString))
.map(Map.Entry::getKey)
.findFirst();
if (key.isPresent()) {
return ResponseEntity.ok(key.get());
}
String loginCode = generateLoginCode(uuidFromString);
return ResponseEntity.ok(loginCode);
}
@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 cacheEntry = cache.get(code);
if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) {
return new ResponseEntity<>(HttpStatusCode.valueOf(403));
}
return ResponseEntity.ok().body(getJWTToken(cacheEntry.uuid));
}
private String generateLoginCode(UUID uuid) {
String characters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
StringBuilder loginCode = new StringBuilder();
for (int i = 0; i < 8; i++) {
int index = (int) (Math.random() * characters.length());
loginCode.append(characters.charAt(index));
}
CacheEntry cacheEntry = new CacheEntry(uuid,
Instant.now().plusSeconds(TimeUnit.MINUTES.toSeconds(15)));
cache.put(loginCode.toString(), cacheEntry);
return loginCode.toString();
}
private boolean isValidSecret(String secret) {
if (loginSecret == null) {
log.warn("No login secret set, skipping secret validation");
return false;
}
if (loginSecret.length() < 16) {
log.warn("Login secret is too short, skipping secret validation");
return false;
}
if (!loginSecret.equals(secret)) {
log.info("Received invalid secret {}", secret);
return false;
}
return true;
}
private String getJWTToken(UUID uuid) {
JwtEncoder jwtEncoder = jwtEncoder();
Instant now = Instant.now();
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("altitudeweb")
.issuedAt(now)
.expiresAt(expiryTime)
.subject("user")
.claim("uuid", 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);
} }
} }

View File

@ -5,4 +5,5 @@ database.host=${DB_HOST:localhost}
database.user=${DB_USER:root} database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root} database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com} cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN}
logging.level.com.alttd.altitudeweb=INFO logging.level.com.alttd.altitudeweb=INFO

View File

@ -80,6 +80,10 @@ public interface UUIDHistoryMapper {
}; };
} }
default List<HistoryRecord> getAllHistoryForUUID(@NotNull UUID uuid) {
return getRecentAllHistory(uuid.toString(), "uuid", 100, 0);
}
private List<HistoryRecord> getRecent(@NotNull String tableName, @NotNull UserType userType, private List<HistoryRecord> getRecent(@NotNull String tableName, @NotNull UserType userType,
@NotNull UUID uuid, int page) { @NotNull UUID uuid, int page) {
int offset = page * PAGE_SIZE; int offset = page * PAGE_SIZE;

View File

@ -16,6 +16,7 @@ public interface TeamMemberMapper {
FROM luckperms_user_permissions AS permissions FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission = #{groupPermission} WHERE permission = #{groupPermission}
AND world = 'global'
""") """)
List<Player> getTeamMembers(@Param("groupPermission") String groupPermission); List<Player> getTeamMembers(@Param("groupPermission") String groupPermission);
} }

View File

@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database.web_db;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class KeyPairEntity {
private int id;
private String privateKey;
private String publicKey;
private Instant createdAt;
}

View File

@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.*;
public interface KeyPairMapper {
@Select("SELECT * FROM key_pair ORDER BY id DESC LIMIT 1")
KeyPairEntity getKeyPair();
@Insert("""
INSERT INTO key_pair (id, private_key, public_key, created_at)
VALUES (#{id}, #{privateKey}, #{publicKey}, #{createdAt})
""")
void save(KeyPairEntity keyPair);
}

View File

@ -87,6 +87,7 @@ public class Connection {
log.debug("Loaded default database settings {}", databaseSettings); log.debug("Loaded default database settings {}", databaseSettings);
Connection connection = new Connection(databaseSettings, addMappers); Connection connection = new Connection(databaseSettings, addMappers);
log.debug("Created default database connection {}", connection); log.debug("Created default database connection {}", connection);
connections.put(Databases.DEFAULT, connection);
return CompletableFuture.completedFuture(connection); return CompletableFuture.completedFuture(connection);
} }

View File

@ -1,6 +1,7 @@
package com.alttd.altitudeweb.setup; package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
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;
@ -12,12 +13,16 @@ import java.sql.Statement;
public class InitializeWebDb { public class InitializeWebDb {
protected static void init() { protected static void init() {
log.info("Initializing LiteBans"); log.info("Initializing WebDb");
Connection.getConnection(Databases.DEFAULT, (configuration) -> { Connection.getConnection(Databases.DEFAULT, (configuration) -> {
configuration.addMapper(SettingsMapper.class); configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
}).join() }).join()
.runQuery(InitializeWebDb::createSettingsTable); .runQuery(SqlSession -> {
log.debug("Initialized LuckPerms"); createSettingsTable(SqlSession);
createKeyTable(SqlSession);
});
log.debug("Initialized WebDb");
} }
private static void createSettingsTable(SqlSession sqlSession) { private static void createSettingsTable(SqlSession sqlSession) {
@ -40,4 +45,20 @@ public class InitializeWebDb {
} }
} }
private static void createKeyTable(SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS key_pair (
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
} }

View File

@ -1,7 +1,7 @@
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {FormsComponent} from '../forms.component'; import {FormsComponent} from '../forms.component';
import {FormBuilder, FormGroup, Validators} from '@angular/forms'; import {FormControl, FormGroup, Validators} from '@angular/forms';
import {AppealsService} from '../../../api'; import {AppealsService, MinecraftAppeal} from '../../../api';
@Component({ @Component({
selector: 'app-appeal', selector: 'app-appeal',
@ -13,22 +13,18 @@ import {AppealsService} from '../../../api';
}) })
export class AppealComponent implements OnInit { export class AppealComponent implements OnInit {
public form: FormGroup | undefined; public form: FormGroup<Appeal>;
constructor(private fb: FormBuilder, private appealApi: AppealsService) { constructor(private appealApi: AppealsService) {
this.form = new FormGroup({
username: new FormControl('', {nonNullable: true, validators: [Validators.required]}),
punishmentId: new FormControl('', {nonNullable: true, validators: [Validators.required]}),
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
});
} }
ngOnInit() { ngOnInit() {
this.initForm()
}
private initForm() {
this.form = this.fb.group({
name: ['', [Validators.required]],
punishmentId: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]],
message: ['', [Validators.required, Validators.minLength(10)]]
});
} }
public onSubmit() { public onSubmit() {
@ -37,8 +33,7 @@ export class AppealComponent implements OnInit {
return return
} }
if (this.form.valid) { if (this.form.valid) {
console.log('Form submitted:', this.form.value); this.sendForm()
// Process form submission here
} else { } else {
// Mark all fields as touched to trigger validation display // Mark all fields as touched to trigger validation display
Object.keys(this.form.controls).forEach(field => { Object.keys(this.form.controls).forEach(field => {
@ -52,12 +47,23 @@ export class AppealComponent implements OnInit {
} }
} }
private sendForm(validForm: FormGroup) { private sendForm() {
// const appeal: MinecraftAppeal = { const rawValue = this.form.getRawValue();
// const appeal: MinecraftAppeal = {
// } appeal: rawValue.appeal,
// this.appealApi.submitMinecraftAppeal() email: rawValue.email,
punishmentId: parseInt(rawValue.punishmentId),
username: rawValue.username,
uuid: ''//TODO
}
this.appealApi.submitMinecraftAppeal(appeal).subscribe()
} }
}
interface Appeal {
username: FormControl<string>;
punishmentId: FormControl<string>;
email: FormControl<string>;
appeal: FormControl<string>;
} }

View File

@ -44,7 +44,7 @@
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin" <img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
height="160" width="160" style="width: 160px;"> height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2> <h2>{{ member.name }}</h2>
<p>Admin</p> <p>Head Mod</p>
</div> </div>
</div> </div>
</section> </section>

View File

@ -28,6 +28,8 @@ paths:
$ref: './schemas/bans/bans.yml#/getTotalResultsForUserSearch' $ref: './schemas/bans/bans.yml#/getTotalResultsForUserSearch'
/history/single/{type}/{id}: /history/single/{type}/{id}:
$ref: './schemas/bans/bans.yml#/getHistoryById' $ref: './schemas/bans/bans.yml#/getHistoryById'
/history/all/{uuid}:
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
/history/total: /history/total:
$ref: './schemas/bans/bans.yml#/getTotalPunishments' $ref: './schemas/bans/bans.yml#/getTotalPunishments'
/appeal/update-mail: /appeal/update-mail:
@ -36,7 +38,7 @@ paths:
$ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal' $ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal'
/appeal/discord-appeal: /appeal/discord-appeal:
$ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal' $ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal'
/login/addUserLogin: /login/requestNewUserLogin/{uuid}:
$ref: './schemas/login/login.yml#/AddUserLogin' $ref: './schemas/login/login.yml#/RequestNewUserLogin'
/login/userLogin: /login/userLogin/{code}:
$ref: './schemas/login/login.yml#/UserLogin' $ref: './schemas/login/login.yml#/UserLogin'

View File

@ -89,7 +89,7 @@ getHistoryForUuid:
parameters: parameters:
- $ref: '#/components/parameters/UserType' - $ref: '#/components/parameters/UserType'
- $ref: '#/components/parameters/HistoryType' - $ref: '#/components/parameters/HistoryType'
- $ref: '#/components/parameters/Uuid' - $ref: '../generic/parameters.yml#/components/parameters/Uuid'
- $ref: '#/components/parameters/Page' - $ref: '#/components/parameters/Page'
responses: responses:
'200': '200':
@ -158,7 +158,7 @@ getTotalResultsForUuidSearch:
parameters: parameters:
- $ref: '#/components/parameters/UserType' - $ref: '#/components/parameters/UserType'
- $ref: '#/components/parameters/HistoryType' - $ref: '#/components/parameters/HistoryType'
- $ref: '#/components/parameters/Uuid' - $ref: '../generic/parameters.yml#/components/parameters/Uuid'
responses: responses:
'200': '200':
description: Successful operation description: Successful operation
@ -195,6 +195,22 @@ getHistoryById:
application/json: application/json:
schema: schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError' $ref: '../generic/errors.yml#/components/schemas/ApiError'
getAllHistoryForUUID:
get:
tags:
- history
summary: Gets all history for specified UUID
description: Retrieves all history for specified UUID
operationId: getAllHistoryForUUID
parameters:
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/PunishmentHistoryList'
components: components:
parameters: parameters:
HistoryType: HistoryType:
@ -212,13 +228,6 @@ components:
schema: schema:
type: string type: string
description: The (partial) username to search for description: The (partial) username to search for
Uuid:
name: uuid
in: path
required: true
schema:
type: string
description: The uuid of the desired user
UserType: UserType:
name: userType name: userType
in: path in: path

View File

@ -0,0 +1,9 @@
components:
parameters:
Uuid:
name: uuid
in: path
required: true
schema:
type: string
description: The uuid of the desired user

View File

@ -1,51 +1,61 @@
UserLogin: UserLogin:
post: get:
tags: tags:
- login - login
summary: Log in to the site summary: Log in to the site
description: Log in to the site through a code from the server description: Log in to the site through a code from the server
operationId: login operationId: login
requestBody: parameters:
required: true - $ref: '#/components/parameters/Code'
content:
application/json:
schema:
$ref: '#/components/schemas/LoginData'
responses: responses:
'200': '200':
description: Logged in description: Logged in
content: content:
application/json: application/text:
schema: schema:
$ref: '#/components/schemas/LoginResult' type: string
description: A JWT token for this user
'401': '401':
description: Login failed - Invalid credentials description: Login failed - Invalid credentials
content: content:
application/json: application/text:
schema: schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError' $ref: '../generic/errors.yml#/components/schemas/ApiError'
default: default:
description: Unexpected error description: Unexpected error
content: content:
application/json: application/text:
schema: schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError' $ref: '../generic/errors.yml#/components/schemas/ApiError'
AddUserLogin: RequestNewUserLogin:
post: get:
tags: tags:
- login - login
summary: Add a login summary: Request a login
description: Add a code, user combination that can be used to log in description: Request a code, that can be used to log in
operationId: addLogin operationId: requestLogin
requestBody: parameters:
required: true - name: Authorization
content: in: header
application/json: required: true
schema: schema:
$ref: '#/components/schemas/AddLogin' type: string
description: Secret
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
responses: responses:
'200': '200':
description: Success description: Success
content:
application/text:
schema:
type: string
description: code to log in with
'401':
description: Login failed - Invalid secret
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default: default:
description: Unexpected error description: Unexpected error
content: content:
@ -53,6 +63,14 @@ AddUserLogin:
schema: schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError' $ref: '../generic/errors.yml#/components/schemas/ApiError'
components: components:
parameters:
Code:
name: code
in: path
required: true
schema:
type: string
description: The code to log in with
schemas: schemas:
LoginData: LoginData:
type: object type: object
@ -62,35 +80,11 @@ components:
loginCode: loginCode:
type: string type: string
description: The code to log in description: The code to log in
LoginResult:
type: object
required:
- uuid
- userName
- auth
properties:
uuid:
type: string
format: uuid
description: UUID of logged in user
userName:
type: string
description: Name of the logged in user
auth:
type: string
description: Token to use along side requests
AddLogin: AddLogin:
type: object type: object
required: required:
- loginCode
- uuid - uuid
properties: properties:
auth:
type: string
description: Token to verify the sender is allowed to add logins
loginCode:
type: string
description: The code that can be logged in with
uuid: uuid:
type: string type: string
format: uuid format: uuid