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':