Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

22 changes: 22 additions & 0 deletions src/main/java/org/patinanetwork/patchats/auth/AuthProperties.java
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);
}
41 changes: 41 additions & 0 deletions src/main/java/org/patinanetwork/patchats/auth/TokenGenerator.java
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,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")
Comment thread
RandyJDean marked this conversation as resolved.
.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.
*
* <p>Returns {@link Optional#empty()} for unknown, already-consumed, <em>and</em> 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<String> 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();
Comment thread
RandyJDean marked this conversation as resolved.
}
}
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. */
Comment thread
RandyJDean marked this conversation as resolved.
@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"));
}
}