From f86f3c2e6354ede9a8c8a972e2db1fc438b246dd Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 28 Sep 2026 13:07:40 +0200 Subject: [PATCH] feat(dev): add the local profile with generated keys and a dev user Profile "local" starts without keys: LocalKeysEnvironmentPostProcessor makes a new signing key per start and refuses to run next to prod. A Flyway callback in db/local adds dev@localhost as PLATFORM_ADMIN with a linked Steam ID. --- readme.md | 25 +++++++- .../LocalKeysEnvironmentPostProcessor.java | 55 +++++++++++++++++ src/main/resources/META-INF/spring.factories | 1 + src/main/resources/application-local.yml | 18 ++++++ src/main/resources/db/local/afterMigrate.sql | 15 +++++ ...LocalKeysEnvironmentPostProcessorTest.java | 51 ++++++++++++++++ .../configuration/LocalProfileTest.java | 60 ++++++++++++++++++ .../configuration/LocalSeedFlywayTest.java | 61 +++++++++++++++++++ 8 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/main/java/auth_service/configuration/LocalKeysEnvironmentPostProcessor.java create mode 100644 src/main/resources/META-INF/spring.factories create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/db/local/afterMigrate.sql create mode 100644 src/test/java/auth_service/configuration/LocalKeysEnvironmentPostProcessorTest.java create mode 100644 src/test/java/auth_service/configuration/LocalProfileTest.java create mode 100644 src/test/java/auth_service/configuration/LocalSeedFlywayTest.java diff --git a/readme.md b/readme.md index fd13f17..a7a416f 100644 --- a/readme.md +++ b/readme.md @@ -204,7 +204,30 @@ days is `UPDATE users SET deleted_at = NULL` (see the Deployment README). ## Run locally -Needs Java 25 and Docker. +### Local development without keys (profile `local`) + +The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs +Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each +other, then start them in this order (Core also starts without Auth and finds it later): + +1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local` + (IDE: active profile `local`). Port 8080. +2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090. +3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173` + (`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development + values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`. +4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8 + games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers. + +In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this +profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools +restart), log in again. Never use `local` on a server: the dev user's password is public. The +Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). The invite code for more +users is `local`. What Core mocks and how to change the recorded answers: Core readme, "Run locally". + +### With your own keys (profile `dev`) + +Needs Java 25, Docker and your own keys. ```bash ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev diff --git a/src/main/java/auth_service/configuration/LocalKeysEnvironmentPostProcessor.java b/src/main/java/auth_service/configuration/LocalKeysEnvironmentPostProcessor.java new file mode 100644 index 0000000..e83ebb1 --- /dev/null +++ b/src/main/java/auth_service/configuration/LocalKeysEnvironmentPostProcessor.java @@ -0,0 +1,55 @@ +package auth_service.configuration; + +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; + +/** + * Only in the local profile: a new Ed25519 key pair and kid at each start, so no signing key is in the repo. A new + * kid lets Core's JWKS reload find the new key by itself. + */ +public class LocalKeysEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + static final String LOCAL_PROFILE = "local"; + private static final String PROD_PROFILE = "prod"; + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + if (!environment.matchesProfiles(LOCAL_PROFILE)) { + return; + } + // local also seeds an admin with a public password and opens CORS to localhost. + if (environment.matchesProfiles(PROD_PROFILE)) { + throw new IllegalStateException("The local profile must never run together with prod"); + } + KeyPair keys = generateKeys(); + Base64.Encoder base64 = Base64.getEncoder(); + environment.getPropertySources().addFirst(new MapPropertySource("localKeys", Map.of( + "security.jwt.private-key", base64.encodeToString(keys.getPrivate().getEncoded()), + "security.jwt.public-key", base64.encodeToString(keys.getPublic().getEncoded()), + "security.jwt.kid", LOCAL_PROFILE + "-" + UUID.randomUUID()))); + } + + // After the config files, which decide the active profiles. + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private static KeyPair generateKeys() { + try { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..6f42df5 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.EnvironmentPostProcessor=auth_service.configuration.LocalKeysEnvironmentPostProcessor diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..7eb4059 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,18 @@ +# Local development without keys or third parties: start with the profile "local" (alone). Postgres comes from +# compose.yaml, the signing keys from LocalKeysEnvironmentPostProcessor. See the readme, "Run locally". +spring: + flyway: + locations: classpath:db/migration,classpath:db/local + +app: + cors: + allowed-origins: "http://localhost:5173" + auth: + # Plain http; Safari drops Secure cookies on localhost. + cookie-secure: false + core: + internal-url: "http://localhost:8090" + +auth: + registration: + invitation-code: local diff --git a/src/main/resources/db/local/afterMigrate.sql b/src/main/resources/db/local/afterMigrate.sql new file mode 100644 index 0000000..3dd666a --- /dev/null +++ b/src/main/resources/db/local/afterMigrate.sql @@ -0,0 +1,15 @@ +-- Local profile only. A Flyway callback, not a migration: it runs after every migrate and leaves no history row, +-- so the same database still starts with "dev". dev@localhost / password123, PLATFORM_ADMIN, with the Steam ID of +-- Core's recorded answers. Display name and Steam ID are unique: when another user has one, the dev user comes +-- without it. +INSERT INTO users (email, password, enabled, display_name, steam_id, steam_id_verified, role) +SELECT 'dev@localhost', '$2a$10$3FApyf15qVEvFLEvl/d7aecSMZlslQdXiYlDgbOa4DfGWu4l0VCY.', true, + CASE WHEN NOT EXISTS (SELECT 1 FROM users WHERE lower(display_name) = 'dev') THEN 'Dev' END, + CASE WHEN NOT EXISTS (SELECT 1 FROM users WHERE steam_id = 76561198000000001) THEN 76561198000000001 END, + NOT EXISTS (SELECT 1 FROM users WHERE steam_id = 76561198000000001), + 'PLATFORM_ADMIN' +ON CONFLICT DO NOTHING; + +INSERT INTO user_settings (user_id) +SELECT id FROM users WHERE email = 'dev@localhost' +ON CONFLICT DO NOTHING; diff --git a/src/test/java/auth_service/configuration/LocalKeysEnvironmentPostProcessorTest.java b/src/test/java/auth_service/configuration/LocalKeysEnvironmentPostProcessorTest.java new file mode 100644 index 0000000..8be671d --- /dev/null +++ b/src/test/java/auth_service/configuration/LocalKeysEnvironmentPostProcessorTest.java @@ -0,0 +1,51 @@ +package auth_service.configuration; + +import auth_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.mock.env.MockEnvironment; + +import java.security.KeyFactory; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LocalKeysEnvironmentPostProcessorTest extends AbstractUnitTest { + + private final LocalKeysEnvironmentPostProcessor processor = new LocalKeysEnvironmentPostProcessor(); + + @Test + void localProfile_GetsAFreshKeyPairAndKid() throws Exception { + MockEnvironment local = new MockEnvironment(); + local.setActiveProfiles("local"); + + processor.postProcessEnvironment(local, new SpringApplication()); + + assertThat(local.getProperty("security.jwt.kid")).startsWith("local-"); + byte[] privateKey = Base64.getDecoder().decode(local.getProperty("security.jwt.private-key")); + assertThat(KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(privateKey))).isNotNull(); + assertThat(local.getProperty("security.jwt.public-key")).isNotBlank(); + } + + @Test + void otherProfiles_KeepTheirConfiguredKeys() { + MockEnvironment prod = new MockEnvironment().withProperty("security.jwt.kid", "tbp-auth-1"); + prod.setActiveProfiles("prod"); + + processor.postProcessEnvironment(prod, new SpringApplication()); + + assertThat(prod.getProperty("security.jwt.kid")).isEqualTo("tbp-auth-1"); + assertThat(prod.getProperty("security.jwt.private-key")).isNull(); + } + + @Test + void localTogetherWithProd_RefusesToStart() { + MockEnvironment both = new MockEnvironment(); + both.setActiveProfiles("prod", "local"); + + assertThatThrownBy(() -> processor.postProcessEnvironment(both, new SpringApplication())) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/auth_service/configuration/LocalProfileTest.java b/src/test/java/auth_service/configuration/LocalProfileTest.java new file mode 100644 index 0000000..1d0362c --- /dev/null +++ b/src/test/java/auth_service/configuration/LocalProfileTest.java @@ -0,0 +1,60 @@ +package auth_service.configuration; + +import auth_service.configuration.security.ClientHeaderFilter; +import auth_service.shared.api.AuthCookies; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; + +import static org.hamcrest.Matchers.startsWith; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The committed local profile: it starts without keys, and the seeded dev user logs in. + */ +@Tag("IntegrationTest") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.account-cleanup.cron=-") +@AutoConfigureMockMvc +@ActiveProfiles("local") +class LocalProfileTest { + + // Its own database, not the one of AbstractIntegrationTest: the seeded admin would stay there for every other + // test class. + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17-alpine"); + + static { + postgres.start(); + } + + @Autowired + private MockMvc mockMvc; + + @Test + void devUser_LogsInAsAdminWithItsSteamId() throws Exception { + Cookie session = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"dev@localhost\",\"password\":\"password123\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE); + + mockMvc.perform(get("/api/users/me").cookie(session)) + .andExpect(jsonPath("$.role").value("PLATFORM_ADMIN")) + .andExpect(jsonPath("$.steamId").value("76561198000000001")) + .andExpect(jsonPath("$.steamIdVerified").value(true)); + + mockMvc.perform(get("/.well-known/jwks.json")) + .andExpect(jsonPath("$.keys[0].kid", startsWith("local-"))); + } +} diff --git a/src/test/java/auth_service/configuration/LocalSeedFlywayTest.java b/src/test/java/auth_service/configuration/LocalSeedFlywayTest.java new file mode 100644 index 0000000..1d8957e --- /dev/null +++ b/src/test/java/auth_service/configuration/LocalSeedFlywayTest.java @@ -0,0 +1,61 @@ +package auth_service.configuration; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * The dev and local profiles share the Postgres of compose.yaml (readme, "Run locally"). + */ +@Tag("IntegrationTest") +class LocalSeedFlywayTest { + + private static final String DEFAULT_LOCATIONS = "classpath:db/migration"; + private static final String LOCAL_LOCATIONS = "classpath:db/migration,classpath:db/local"; + + @Test + void devAfterLocal_StartsOnTheSameDatabase() { + try (PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17-alpine")) { + postgres.start(); + flyway(postgres, LOCAL_LOCATIONS).migrate(); + + assertThatCode(() -> flyway(postgres, DEFAULT_LOCATIONS).migrate()).doesNotThrowAnyException(); + } + } + + @Test + void localOnADatabaseWithAUserNamedDev_StillAddsTheDevUser() throws Exception { + try (PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17-alpine")) { + postgres.start(); + flyway(postgres, DEFAULT_LOCATIONS).migrate(); + try (Connection connection = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()); + Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO users (email, password, display_name) VALUES ('me@example.com', 'x', 'dev')"); + + flyway(postgres, LOCAL_LOCATIONS).migrate(); + + ResultSet rows = statement.executeQuery("SELECT count(*) FROM users WHERE email = 'dev@localhost'"); + rows.next(); + assertThat(rows.getInt(1)).isEqualTo(1); + } + } + } + + // Spring Boot's defaults for this repo (application.yml): baseline-on-migrate, validate on migrate. + private static Flyway flyway(PostgreSQLContainer postgres, String locations) { + return Flyway.configure() + .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()) + .locations(locations.split(",")) + .baselineOnMigrate(true) + .load(); + } +}