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")
implementation("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springframework.boot:spring-boot-starter-hateoas")
implementation("org.springframework.security:spring-security-oauth2-jose")
//AOP
implementation("org.aspectj:aspectjrt:1.9.19")

View File

@ -1,21 +1,29 @@
package com.alttd.altitudeweb.controllers.application;
import com.alttd.altitudeweb.api.AppealsApi;
import com.alttd.altitudeweb.controllers.limits.RateLimit;
import com.alttd.altitudeweb.model.AppealResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
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 {
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override
public ResponseEntity<MinecraftAppealDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
}
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@Override
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
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());
}
@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
public ResponseEntity<PunishmentHistoryDto> getHistoryById(String type, Integer id) {
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;
import com.alttd.altitudeweb.api.LoginApi;
import com.alttd.altitudeweb.model.AddLoginDto;
import com.alttd.altitudeweb.model.LoginDataDto;
import com.alttd.altitudeweb.model.LoginResultDto;
import com.alttd.altitudeweb.controllers.limits.RateLimit;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
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 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 {
@Override
public ResponseEntity<Void> addLogin(AddLoginDto addLoginDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Adding login is not yet supported");
private final KeyPairService keyPairService;
private final String loginSecret =System.getenv("LOGIN_SECRET") ;
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
public ResponseEntity<LoginResultDto> login(LoginDataDto loginDataDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Logging in is not yet supported");
public ResponseEntity<String> requestLogin(String authorization, String uuid) {
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.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN}
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,
@NotNull UUID uuid, int page) {
int offset = page * PAGE_SIZE;

View File

@ -16,6 +16,7 @@ public interface TeamMemberMapper {
FROM luckperms_user_permissions AS permissions
INNER JOIN luckperms_players AS players ON players.uuid = permissions.uuid
WHERE permission = #{groupPermission}
AND world = 'global'
""")
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);
Connection connection = new Connection(databaseSettings, addMappers);
log.debug("Created default database connection {}", connection);
connections.put(Databases.DEFAULT, connection);
return CompletableFuture.completedFuture(connection);
}

View File

@ -1,6 +1,7 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
@ -12,12 +13,16 @@ import java.sql.Statement;
public class InitializeWebDb {
protected static void init() {
log.info("Initializing LiteBans");
log.info("Initializing WebDb");
Connection.getConnection(Databases.DEFAULT, (configuration) -> {
configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
}).join()
.runQuery(InitializeWebDb::createSettingsTable);
log.debug("Initialized LuckPerms");
.runQuery(SqlSession -> {
createSettingsTable(SqlSession);
createKeyTable(SqlSession);
});
log.debug("Initialized WebDb");
}
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 {FormsComponent} from '../forms.component';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {AppealsService} from '../../../api';
import {FormControl, FormGroup, Validators} from '@angular/forms';
import {AppealsService, MinecraftAppeal} from '../../../api';
@Component({
selector: 'app-appeal',
@ -13,22 +13,18 @@ import {AppealsService} from '../../../api';
})
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() {
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() {
@ -37,8 +33,7 @@ export class AppealComponent implements OnInit {
return
}
if (this.form.valid) {
console.log('Form submitted:', this.form.value);
// Process form submission here
this.sendForm()
} else {
// Mark all fields as touched to trigger validation display
Object.keys(this.form.controls).forEach(field => {
@ -52,12 +47,23 @@ export class AppealComponent implements OnInit {
}
}
private sendForm(validForm: FormGroup) {
// const appeal: MinecraftAppeal = {
//
// }
// this.appealApi.submitMinecraftAppeal()
private sendForm() {
const rawValue = this.form.getRawValue();
const appeal: MinecraftAppeal = {
appeal: rawValue.appeal,
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"
height="160" width="160" style="width: 160px;">
<h2>{{ member.name }}</h2>
<p>Admin</p>
<p>Head Mod</p>
</div>
</div>
</section>

View File

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

View File

@ -89,7 +89,7 @@ getHistoryForUuid:
parameters:
- $ref: '#/components/parameters/UserType'
- $ref: '#/components/parameters/HistoryType'
- $ref: '#/components/parameters/Uuid'
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
- $ref: '#/components/parameters/Page'
responses:
'200':
@ -158,7 +158,7 @@ getTotalResultsForUuidSearch:
parameters:
- $ref: '#/components/parameters/UserType'
- $ref: '#/components/parameters/HistoryType'
- $ref: '#/components/parameters/Uuid'
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
responses:
'200':
description: Successful operation
@ -195,6 +195,22 @@ getHistoryById:
application/json:
schema:
$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:
parameters:
HistoryType:
@ -212,13 +228,6 @@ components:
schema:
type: string
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:
name: userType
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:
post:
get:
tags:
- login
summary: Log in to the site
description: Log in to the site through a code from the server
operationId: login
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginData'
parameters:
- $ref: '#/components/parameters/Code'
responses:
'200':
description: Logged in
content:
application/json:
application/text:
schema:
$ref: '#/components/schemas/LoginResult'
type: string
description: A JWT token for this user
'401':
description: Login failed - Invalid credentials
content:
application/json:
application/text:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
application/text:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
AddUserLogin:
post:
RequestNewUserLogin:
get:
tags:
- login
summary: Add a login
description: Add a code, user combination that can be used to log in
operationId: addLogin
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AddLogin'
summary: Request a login
description: Request a code, that can be used to log in
operationId: requestLogin
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: Secret
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
responses:
'200':
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:
description: Unexpected error
content:
@ -53,6 +63,14 @@ AddUserLogin:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
components:
parameters:
Code:
name: code
in: path
required: true
schema:
type: string
description: The code to log in with
schemas:
LoginData:
type: object
@ -62,35 +80,11 @@ components:
loginCode:
type: string
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:
type: object
required:
- loginCode
- uuid
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:
type: string
format: uuid