Public Access
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.
This commit is contained in:
1 parent
32d4f40eec
commit
f86f3c2e63
8 files changed
+285
-1
No files matched your search
@@ -204,7 +204,30 @@ days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
|
|||||||
|
|
||||||
## Run locally
|
## 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
|
```bash
|
||||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
org.springframework.boot.EnvironmentPostProcessor=auth_service.configuration.LocalKeysEnvironmentPostProcessor
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user