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.

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")

Copy link
Copy Markdown
Contributor

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

.param("email", email)
.update();
}

public void insert(final UUID id, final String email, final String tokenHash, final Instant expiresAt) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

@spiffyy99 spiffyy99 Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/Patina-Network/patchats/pull/46/changes#diff-1572ab6415b5680e0bfbba142f204dcc75ea3010433be4654b75056608886005R53

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i know that we do normalize the email on the AuthService side, but we shouldn't assume here that it's done in that code. We should normalize both sides if we really want to ensure case insensitivity. (i.e. lowercase and trim for both)

.param("email", email)

Check failure on line 27 in src/main/java/org/patinanetwork/patchats/auth/repo/MemberAccountRepository.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "email" 3 times.

See more on https://sonarcloud.io/project/issues?id=Patina-Network_patchats&issues=AZ9I13SQ52clM__nttBF&open=AZ9I13SQ52clM__nttBF&pullRequest=45
.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. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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"));
}
}