From c4c17b3adce76cdbe08d18266a33bab599dcf293 Mon Sep 17 00:00:00 2001 From: Teriuihi Date: Sat, 24 May 2025 01:33:36 +0200 Subject: [PATCH] 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. --- backend/build.gradle.kts | 1 + .../controllers/login/KeyPairService.java | 102 ++++++++++++++ .../controllers/login/LoginController.java | 131 +++++++++++++++++- .../src/main/resources/application.properties | 1 + .../database/web_db/KeyPairEntity.java | 16 +++ .../database/web_db/KeyPairMapper.java | 15 ++ .../alttd/altitudeweb/setup/Connection.java | 1 + .../altitudeweb/setup/InitializeWebDb.java | 27 +++- open_api/src/main/resources/api.yml | 2 +- .../main/resources/schemas/login/login.yml | 6 + 10 files changed, 295 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/alttd/altitudeweb/controllers/login/KeyPairService.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairEntity.java create mode 100644 database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairMapper.java diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index a52d438..78cc40c 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -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") diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/KeyPairService.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/KeyPairService.java new file mode 100644 index 0000000..d68f940 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/KeyPairService.java @@ -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 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); + } + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java index e3fcee8..8a6f527 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java @@ -2,25 +2,150 @@ package com.alttd.altitudeweb.controllers.login; import com.alttd.altitudeweb.api.LoginApi; import com.alttd.altitudeweb.controllers.limits.RateLimit; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import 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 { + private final KeyPairService keyPairService; + private final String loginSecret =System.getenv("LOGIN_SECRET") ; + private record CacheEntry(UUID uuid, Instant expiry) {} + + private static final ConcurrentMap 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 requestLogin(String uuid) throws Exception { - throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Adding login is not yet supported"); + public ResponseEntity 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 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 login(String code) { - throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Logging in is not yet supported"); + 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 jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk)); + return new NimbusJwtEncoder(jwkSource); } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 6af4629..dd273f8 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairEntity.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairEntity.java new file mode 100644 index 0000000..b6428bb --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairEntity.java @@ -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; +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairMapper.java new file mode 100644 index 0000000..b1a2f86 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/KeyPairMapper.java @@ -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); +} diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java index 6ee74b3..c6057d8 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java @@ -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); } diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java index 5eefb0b..f75a0fe 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java @@ -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); + } + } + } diff --git a/open_api/src/main/resources/api.yml b/open_api/src/main/resources/api.yml index cbab541..050bbf2 100644 --- a/open_api/src/main/resources/api.yml +++ b/open_api/src/main/resources/api.yml @@ -40,5 +40,5 @@ paths: $ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal' /login/requestNewUserLogin/{uuid}: $ref: './schemas/login/login.yml#/RequestNewUserLogin' - /login/userLogin: + /login/userLogin/{code}: $ref: './schemas/login/login.yml#/UserLogin' diff --git a/open_api/src/main/resources/schemas/login/login.yml b/open_api/src/main/resources/schemas/login/login.yml index e6b32ed..8e15be0 100644 --- a/open_api/src/main/resources/schemas/login/login.yml +++ b/open_api/src/main/resources/schemas/login/login.yml @@ -35,6 +35,12 @@ RequestNewUserLogin: 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':