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..2adda9e --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/repo/MagicLinkTokenRepository.java @@ -0,0 +1,52 @@ +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 insertToken(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 the email it was issued to. The single UPDATE + * guarantees a token can only ever log in one caller, even under concurrent requests. + * + *

Returns {@link Optional#empty()} for unknown, already-consumed, and expired tokens alike — the + * uniformity is deliberate: callers surface one generic "invalid or expired" outcome, so presenting tokens never + * becomes an oracle for which failure occurred or whether an email exists in the system. + */ + public Optional consumeAndReturnEmail(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/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")); + } +}