-
Notifications
You must be signed in to change notification settings - Fork 0
Auth: core building blocks — token generation, repositories, clock #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 07-09-auth_add_magic-link_session_migrations_and_runtime_datasource
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <baseUrl>/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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would be good to have a more descriptive name than just insert. insertMagicLinkToken? |
||
| 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<String> 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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just a note that this will show the same behavior for emails that don't exist and emails that have already consumed a token (empty return value). this is probably fine as long as we don't just always assume these functions' return values to be not existent emails There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. actually now i see here how returning empty for all of these relates to the whole "hiding the email existence" security principle, makes more sense now. i would add to the javadoc here to explicitly say why an empty value is returned for all error scenarios, it's not clear from just this function. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also, function should probably be named "consumeAndReturnEmail" or something. the name sounds like it just uses the token. |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know that this is required to get things working, but this stuff should be in Allison's domain/work. |
||
|
|
||
| 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<MemberAccount> findByEmail(final String email) { | ||
| return jdbc.sql(SELECT + " WHERE LOWER(email) = :email") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i know that we do normalize the email on the |
||
| .param("email", email) | ||
|
Check failure on line 27 in src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java
|
||
| .query(MemberAccountRepository::map) | ||
| .optional(); | ||
| } | ||
|
|
||
| public Optional<MemberAccount> 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)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this needed? |
||
| @Configuration | ||
| public class ClockConfig { | ||
|
|
||
| @Bean | ||
| public Clock clock() { | ||
| return Clock.systemUTC(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NTS: double check what library we standardize on for SQL