From e49fcf1721dc74aece66dfe09ccc545b761e7663 Mon Sep 17 00:00:00 2001 From: Randy Dean Date: Mon, 6 Jul 2026 12:22:26 -0400 Subject: [PATCH] =?UTF-8?q?Auth:=20core=20building=20blocks=20=E2=80=94=20?= =?UTF-8?q?token=20generation,=20repositories,=20clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TokenGenerator mints 256-bit base64url tokens and stores only SHA-256 hex digests - JdbcClient repositories (first runtime DB access in the repo): magic_link_tokens CRUD with an atomic consume, and the auth domain's minimal view of members incl. race-safe shell insert - Injectable UTC Clock so expiry logic is testable Co-Authored-By: Claude Fable 5 --- .../api/auth/security/SecurityConfig.java | 39 ------------- .../patchats/auth/AuthProperties.java | 22 +++++++ .../patchats/auth/TokenGenerator.java | 41 +++++++++++++ .../auth/repo/MagicLinkTokenRepository.java | 48 ++++++++++++++++ .../patchats/auth/repo/MemberAccount.java | 15 +++++ .../auth/repo/MemberAccountRepository.java | 57 +++++++++++++++++++ .../patchats/common/config/ClockConfig.java | 15 +++++ .../patchats/auth/TokenGeneratorTest.java | 42 ++++++++++++++ 8 files changed, 240 insertions(+), 39 deletions(-) delete mode 100644 src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccount.java create mode 100644 src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java create mode 100644 src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java create mode 100644 src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java diff --git a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java b/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java deleted file mode 100644 index 3b9bab1..0000000 --- a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.patinanetwork.patchats.api.auth.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -public class SecurityConfig { - - /** - * Default/production chain. Sending email is admin-only. - * - *

NOTE: authentication (OAuth2 login / roles) is not yet wired, so this rule currently fails closed — every - * caller is denied because no security context is populated. Once the auth domain authenticates admins, the rule - * begins to discriminate admins from other users. Other endpoints remain open for now, matching prior behaviour. - */ - @Bean - @Profile("!dev") - SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { - return http.csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/api/email/**") - .hasRole("ADMIN") - .anyRequest() - .permitAll()) - .build(); - } - - /** Local-dev chain: everything is permitted so the email endpoint can be exercised without auth. */ - @Bean - @Profile("dev") - SecurityFilterChain devSecurityFilterChain(final HttpSecurity http) throws Exception { - return http.csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) - .build(); - } -} diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java new file mode 100644 index 0000000..64f774e --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java @@ -0,0 +1,22 @@ +package org.patinanetwork.patchats.auth; + +import java.time.Duration; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Auth configuration, bound from {@code app.auth.*}. */ +@ConfigurationProperties(prefix = "app.auth") +@Getter +@Setter +public class AuthProperties { + + /** Public origin of the SPA; magic links point at {@code /auth/verify?token=...}. */ + private String baseUrl; + + /** Whether the session cookie carries the {@code Secure} flag. Off only for plain-HTTP dev. */ + private boolean cookieSecure = true; + + /** How long an emailed magic link stays valid. */ + private Duration magicLinkTtl = Duration.ofMinutes(15); +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java new file mode 100644 index 0000000..c449a62 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java @@ -0,0 +1,41 @@ +package org.patinanetwork.patchats.auth; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; +import org.springframework.stereotype.Component; + +/** + * Mints the opaque magic-link tokens. The raw token goes into the emailed link and is never persisted; only its SHA-256 + * hex digest is stored, so a database leak cannot be replayed as a login. + */ +@Component +public class TokenGenerator { + + private static final int TOKEN_BYTES = 32; + + private final SecureRandom secureRandom = new SecureRandom(); + + /** A freshly minted token: {@code raw} for the email link, {@code hash} for the database. */ + public record GeneratedToken(String raw, String hash) {} + + public GeneratedToken generate() { + final byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + final String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + return new GeneratedToken(raw, hash(raw)); + } + + /** SHA-256 hex digest of a raw token, used to look up what the client presents. */ + public static String hash(final String rawToken) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(rawToken.getBytes(StandardCharsets.UTF_8))); + } catch (final NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java new file mode 100644 index 0000000..4e8ea62 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java @@ -0,0 +1,48 @@ +package org.patinanetwork.patchats.auth.repo; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** Plain-SQL access to {@code magic_link_tokens}. Only token hashes ever touch this table. */ +@Repository +@RequiredArgsConstructor +public class MagicLinkTokenRepository { + + private final JdbcClient jdbc; + + /** Invalidates every outstanding link for an email; called before issuing a new one. */ + public void deleteByEmail(final String email) { + jdbc.sql("DELETE FROM magic_link_tokens WHERE email = :email") + .param("email", email) + .update(); + } + + public void insert(final UUID id, final String email, final String tokenHash, final Instant expiresAt) { + jdbc.sql("INSERT INTO magic_link_tokens (id, email, token_hash, expires_at)" + + " VALUES (:id, :email, :tokenHash, :expiresAt)") + .param("id", id) + .param("email", email) + .param("tokenHash", tokenHash) + .param("expiresAt", expiresAt.atOffset(ZoneOffset.UTC)) + .update(); + } + + /** + * Atomically consumes an unexpired, unused token and returns its email. The single UPDATE guarantees a token can + * only ever log in one caller, even under concurrent requests. + */ + public Optional consume(final String tokenHash, final Instant now) { + return jdbc.sql("UPDATE magic_link_tokens SET consumed_at = :now" + + " WHERE token_hash = :tokenHash AND consumed_at IS NULL AND expires_at > :now" + + " RETURNING email") + .param("tokenHash", tokenHash) + .param("now", now.atOffset(ZoneOffset.UTC)) + .query(String.class) + .optional(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccount.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccount.java new file mode 100644 index 0000000..00bf53d --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccount.java @@ -0,0 +1,15 @@ +package org.patinanetwork.patchats.auth.repo; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * The auth domain's minimal view of a member: just enough to identify who logged in. A "shell" account (created by + * verifying a magic link before filling in the sign-up form) has no name and no {@code profileCompletedAt}. + */ +public record MemberAccount(UUID id, String email, String fullName, OffsetDateTime profileCompletedAt) { + + public boolean profileCompleted() { + return profileCompletedAt != null; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java new file mode 100644 index 0000000..7b67c8b --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java @@ -0,0 +1,57 @@ +package org.patinanetwork.patchats.auth.repo; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** + * The auth domain's plain-SQL view of {@code members}. Lives inside {@code auth} until a second consumer needs member + * data (the same graduation rule the frontend uses for shared code). + */ +@Repository +@RequiredArgsConstructor +public class MemberAccountRepository { + + private static final String SELECT = "SELECT id, email, full_name, profile_completed_at FROM members"; + + private final JdbcClient jdbc; + + /** Case-insensitive lookup: legacy rows may hold mixed-case emails while auth normalizes to lowercase. */ + public Optional findByEmail(final String email) { + return jdbc.sql(SELECT + " WHERE LOWER(email) = :email") + .param("email", email) + .query(MemberAccountRepository::map) + .optional(); + } + + public Optional findById(final UUID id) { + return jdbc.sql(SELECT + " WHERE id = :id") + .param("id", id) + .query(MemberAccountRepository::map) + .optional(); + } + + /** + * Creates a shell account for a first-time login. {@code ON CONFLICT DO NOTHING} makes this safe against a + * concurrent insert of the same email; callers re-select afterwards. + */ + public void insertShell(final UUID id, final String email) { + jdbc.sql("INSERT INTO members (id, email) VALUES (:id, :email) ON CONFLICT (email) DO NOTHING") + .param("id", id) + .param("email", email) + .update(); + } + + private static MemberAccount map(final ResultSet rs, final int rowNum) throws SQLException { + return new MemberAccount( + rs.getObject("id", UUID.class), + rs.getString("email"), + rs.getString("full_name"), + rs.getObject("profile_completed_at", OffsetDateTime.class)); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java new file mode 100644 index 0000000..83de3bc --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/config/ClockConfig.java @@ -0,0 +1,15 @@ +package org.patinanetwork.patchats.common.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Provides the single injectable {@link Clock} so services never call {@code Instant.now()} directly. */ +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemUTC(); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java new file mode 100644 index 0000000..f68a6d2 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/TokenGeneratorTest.java @@ -0,0 +1,42 @@ +package org.patinanetwork.patchats.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken; + +class TokenGeneratorTest { + + private final TokenGenerator generator = new TokenGenerator(); + + @Test + void rawTokenIsUrlSafeAnd256Bits() { + final GeneratedToken token = generator.generate(); + + // 32 bytes base64url without padding -> 43 chars, no characters needing URL encoding. + assertEquals(43, token.raw().length()); + assertTrue(token.raw().matches("[A-Za-z0-9_-]+")); + } + + @Test + void hashMatchesSha256HexOfRaw() { + final GeneratedToken token = generator.generate(); + + assertEquals(TokenGenerator.hash(token.raw()), token.hash()); + assertEquals(64, token.hash().length()); + assertTrue(token.hash().matches("[0-9a-f]+")); + } + + @Test + void generatedTokensAreUnique() { + assertNotEquals(generator.generate().raw(), generator.generate().raw()); + } + + @Test + void hashIsDeterministic() { + assertEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abc")); + assertNotEquals(TokenGenerator.hash("abc"), TokenGenerator.hash("abd")); + } +}