diff --git a/src/main/java/org/patinanetwork/patchats/auth/AuthService.java b/src/main/java/org/patinanetwork/patchats/auth/AuthService.java new file mode 100644 index 0000000..81129ef --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/AuthService.java @@ -0,0 +1,69 @@ +package org.patinanetwork.patchats.auth; + +import java.time.Clock; +import java.util.Locale; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken; +import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository; +import org.patinanetwork.patchats.auth.repo.MemberAccount; +import org.patinanetwork.patchats.auth.repo.MemberAccountRepository; +import org.springframework.stereotype.Service; + +/** + * Orchestrates the magic-link flow: issuing links (request) and exchanging them for a member (verify). Member rows are + * only created at verify time, so requesting a link never leaks whether an account exists. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + + private final MagicLinkTokenRepository tokens; + private final MemberAccountRepository members; + private final TokenGenerator tokenGenerator; + private final MagicLinkEmailComposer emailComposer; + private final RequestLinkRateLimiter rateLimiter; + private final AuthProperties properties; + private final Clock clock; + + /** + * Issues a fresh single-use link and invalidates any outstanding ones for the email. Silently does nothing when + * rate-limited — callers always answer with the same generic response, so neither account existence nor the limit + * is observable from outside. + */ + public void requestLink(final String rawEmail, final String clientIp) { + final String email = normalize(rawEmail); + if (!rateLimiter.tryAcquire(email, clientIp)) { + log.warn("Rate-limited magic-link request for {} from {}", email, clientIp); + return; + } + final GeneratedToken token = tokenGenerator.generate(); + tokens.deleteByEmail(email); + tokens.insert(UUID.randomUUID(), email, token.hash(), clock.instant().plus(properties.getMagicLinkTtl())); + emailComposer.send(email, token.raw()); + } + + /** + * Atomically consumes the presented token and resolves the member, creating a shell account on first login. + * + * @throws InvalidMagicLinkException when the token is unknown, already used, or expired + */ + public MemberAccount verify(final String rawToken) { + final String email = tokens.consume(TokenGenerator.hash(rawToken), clock.instant()) + .orElseThrow(InvalidMagicLinkException::new); + return members.findByEmail(email).orElseGet(() -> createShell(email)); + } + + private MemberAccount createShell(final String email) { + members.insertShell(UUID.randomUUID(), email); + // Re-select instead of trusting our insert: a concurrent verify may have won the ON CONFLICT race. + return members.findByEmail(email) + .orElseThrow(() -> new IllegalStateException("Shell member vanished for " + email)); + } + + private static String normalize(final String email) { + return email.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java b/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java new file mode 100644 index 0000000..cb35685 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/InvalidMagicLinkException.java @@ -0,0 +1,9 @@ +package org.patinanetwork.patchats.auth; + +/** Thrown when a presented magic-link token is unknown, already used, or expired. Maps to a 400 failure envelope. */ +public class InvalidMagicLinkException extends RuntimeException { + + public InvalidMagicLinkException() { + super("This sign-in link is invalid or has expired. Request a new one."); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java b/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java new file mode 100644 index 0000000..1a25430 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposer.java @@ -0,0 +1,53 @@ +package org.patinanetwork.patchats.auth; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.email.EmailSender; +import org.patinanetwork.patchats.email.OutgoingEmail; +import org.patinanetwork.patchats.email.TemplateRenderer; +import org.springframework.stereotype.Component; + +/** + * Builds and delivers the sign-in email. Goes through the {@link EmailSender} port directly (not {@code EmailService}, + * whose batch request/response shape is for admin-triggered sends), so the dev profile's logging sender prints the full + * body — including the link — to the backend console. + */ +@Component +@RequiredArgsConstructor +public class MagicLinkEmailComposer { + + private static final String SUBJECT = "Your PatChats sign-in link"; + private static final String BODY_TEMPLATE = """ + Hi, + + Click this link to sign in to PatChats: + + ${link} + + The link expires in ${ttlMinutes} minutes and can only be used once. + + If you didn't request this, you can safely ignore this email."""; + + private final TemplateRenderer renderer; + private final EmailSender sender; + private final AuthProperties properties; + + public void send(final String email, final String rawToken) { + final String baseUrl = trimTrailingSlash(properties.getBaseUrl()); + final String link = "%s/auth/verify?token=%s".formatted(baseUrl, rawToken); + final String body = renderer.render( + BODY_TEMPLATE, + Map.of( + "link", + link, + "ttlMinutes", + String.valueOf(properties.getMagicLinkTtl().toMinutes()))); + sender.send(new OutgoingEmail(List.of(email), SUBJECT, body, Optional.empty())); + } + + private static String trimTrailingSlash(final String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java b/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java new file mode 100644 index 0000000..74e37d7 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java @@ -0,0 +1,40 @@ +package org.patinanetwork.patchats.auth; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import io.github.bucket4j.Bucket; +import java.time.Duration; +import org.springframework.stereotype.Component; + +/** + * Guards the request-link endpoint against inbox flooding: a small per-email budget plus a looser per-IP budget, both + * refilling over a 15-minute window. Buckets live in memory (bounded by an expire-after-access cache), which is + * per-instance and fine for the current single-node deployment; Bucket4j's distributed backends are the upgrade path if + * that changes. + */ +@Component +public class RequestLinkRateLimiter { + + private static final int EMAIL_CAPACITY = 3; + private static final int IP_CAPACITY = 10; + private static final Duration WINDOW = Duration.ofMinutes(15); + + private final LoadingCache emailBuckets = buckets(EMAIL_CAPACITY); + private final LoadingCache ipBuckets = buckets(IP_CAPACITY); + + /** Consumes one request from both budgets; permitted only when neither is exhausted. */ + public boolean tryAcquire(final String email, final String clientIp) { + final boolean emailAllowed = emailBuckets.getUnchecked(email).tryConsume(1); + final boolean ipAllowed = ipBuckets.getUnchecked(clientIp).tryConsume(1); + return emailAllowed && ipAllowed; + } + + private static LoadingCache buckets(final int capacity) { + return CacheBuilder.newBuilder() + .expireAfterAccess(WINDOW.multipliedBy(2)) + .build(CacheLoader.from(key -> Bucket.builder() + .addLimit(limit -> limit.capacity(capacity).refillGreedy(capacity, WINDOW)) + .build())); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java b/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java new file mode 100644 index 0000000..f708c9d --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/dto/RequestLinkRequest.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** Body of {@code POST /api/auth/request-link}. */ +public record RequestLinkRequest(@NotBlank @Email String email) {} diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java b/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java new file mode 100644 index 0000000..8c2f911 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/dto/SessionResponse.java @@ -0,0 +1,15 @@ +package org.patinanetwork.patchats.auth.dto; + +import org.patinanetwork.patchats.auth.repo.MemberAccount; + +/** + * The signed-in member as seen by the frontend. {@code name} is null and {@code profileCompleted} false for a shell + * account that has not filled in the sign-up form yet. {@code isAdmin} is always false until the admin domain lands. + */ +public record SessionResponse(String id, String name, String email, boolean isAdmin, boolean profileCompleted) { + + public static SessionResponse of(final MemberAccount account) { + return new SessionResponse( + account.id().toString(), account.fullName(), account.email(), false, account.profileCompleted()); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java b/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java new file mode 100644 index 0000000..fb3856a --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/dto/VerifyRequest.java @@ -0,0 +1,6 @@ +package org.patinanetwork.patchats.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +/** Body of {@code POST /api/auth/verify}: the raw token from the emailed link. */ +public record VerifyRequest(@NotBlank String token) {} diff --git a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java index 302916b..4d8435b 100644 --- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -1,6 +1,7 @@ package org.patinanetwork.patchats.common.web; import java.util.stream.Collectors; +import org.patinanetwork.patchats.auth.InvalidMagicLinkException; import org.patinanetwork.patchats.common.dto.ApiResponder; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; @@ -21,6 +22,11 @@ public ResponseEntity> handleValidation(final MethodArgumentN return ResponseEntity.badRequest().body(ApiResponder.failure(message)); } + @ExceptionHandler(InvalidMagicLinkException.class) + public ResponseEntity> handleInvalidMagicLink(final InvalidMagicLinkException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); + } + private String formatError(final FieldError error) { return error.getField() + " " + error.getDefaultMessage(); } diff --git a/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java b/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java new file mode 100644 index 0000000..175225d --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java @@ -0,0 +1,110 @@ +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.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository; +import org.patinanetwork.patchats.auth.repo.MemberAccount; +import org.patinanetwork.patchats.auth.repo.MemberAccountRepository; + +class AuthServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-03T12:00:00Z"); + + private final MagicLinkTokenRepository tokens = mock(MagicLinkTokenRepository.class); + private final MemberAccountRepository members = mock(MemberAccountRepository.class); + private final MagicLinkEmailComposer emailComposer = mock(MagicLinkEmailComposer.class); + private final RequestLinkRateLimiter rateLimiter = mock(RequestLinkRateLimiter.class); + private final AuthProperties properties = new AuthProperties(); + + private AuthService authService; + + @BeforeEach + void setUp() { + properties.setBaseUrl("http://localhost:5173"); + authService = new AuthService( + tokens, + members, + new TokenGenerator(), + emailComposer, + rateLimiter, + properties, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void requestLinkNormalizesEmailAndStoresHashNotRaw() { + when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(true); + + authService.requestLink(" Ann@Example.COM ", "10.0.0.1"); + + verify(tokens).deleteByEmail("ann@example.com"); + final ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor expiry = ArgumentCaptor.forClass(Instant.class); + verify(tokens).insert(any(UUID.class), eq("ann@example.com"), hash.capture(), expiry.capture()); + final ArgumentCaptor raw = ArgumentCaptor.forClass(String.class); + verify(emailComposer).send(eq("ann@example.com"), raw.capture()); + + // The emailed value and the stored value must differ, and the stored one is the SHA-256 of the raw. + assertNotEquals(raw.getValue(), hash.getValue()); + assertEquals(TokenGenerator.hash(raw.getValue()), hash.getValue()); + assertEquals(NOW.plus(properties.getMagicLinkTtl()), expiry.getValue()); + } + + @Test + void requestLinkDoesNothingWhenRateLimited() { + when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(false); + + authService.requestLink("ann@example.com", "10.0.0.1"); + + verifyNoInteractions(tokens, emailComposer); + } + + @Test + void verifyRejectsUnknownOrSpentToken() { + when(tokens.consume(anyString(), any())).thenReturn(Optional.empty()); + + assertThrows(InvalidMagicLinkException.class, () -> authService.verify("bogus")); + } + + @Test + void verifyReturnsExistingMemberWithoutCreatingShell() { + final MemberAccount existing = + new MemberAccount(UUID.randomUUID(), "ann@example.com", "Ann", OffsetDateTime.now(ZoneOffset.UTC)); + when(tokens.consume(TokenGenerator.hash("raw-token"), NOW)).thenReturn(Optional.of("ann@example.com")); + when(members.findByEmail("ann@example.com")).thenReturn(Optional.of(existing)); + + assertEquals(existing, authService.verify("raw-token")); + verify(members, never()).insertShell(any(), anyString()); + } + + @Test + void verifyCreatesShellMemberOnFirstLogin() { + final MemberAccount shell = new MemberAccount(UUID.randomUUID(), "new@example.com", null, null); + when(tokens.consume(TokenGenerator.hash("raw-token"), NOW)).thenReturn(Optional.of("new@example.com")); + when(members.findByEmail("new@example.com")) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(shell)); + + assertEquals(shell, authService.verify("raw-token")); + verify(members).insertShell(any(UUID.class), eq("new@example.com")); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java b/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.java new file mode 100644 index 0000000..b36432e --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/MagicLinkEmailComposerTest.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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.email.OutgoingEmail; +import org.patinanetwork.patchats.email.TemplateRenderer; + +class MagicLinkEmailComposerTest { + + private final AtomicReference captured = new AtomicReference<>(); + private final AuthProperties properties = new AuthProperties(); + private final MagicLinkEmailComposer composer = + new MagicLinkEmailComposer(new TemplateRenderer(), captured::set, properties); + + @Test + void bodyContainsVerifyLinkAndTtl() { + properties.setBaseUrl("https://patchats.example.org"); + + composer.send("ann@example.com", "raw-token-123"); + + final OutgoingEmail email = captured.get(); + assertNotNull(email); + assertEquals(List.of("ann@example.com"), email.to()); + assertEquals("Your PatChats sign-in link", email.subject()); + assertTrue(email.body().contains("https://patchats.example.org/auth/verify?token=raw-token-123")); + assertTrue(email.body().contains("expires in 15 minutes")); + } + + @Test + void trailingSlashInBaseUrlDoesNotDoubleUp() { + properties.setBaseUrl("http://localhost:5173/"); + + composer.send("ann@example.com", "tok"); + + assertTrue(captured.get().body().contains("http://localhost:5173/auth/verify?token=tok")); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java b/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java new file mode 100644 index 0000000..de3aff8 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java @@ -0,0 +1,35 @@ +package org.patinanetwork.patchats.auth; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RequestLinkRateLimiterTest { + + private final RequestLinkRateLimiter rateLimiter = new RequestLinkRateLimiter(); + + @Test + void allowsThreeRequestsPerEmailThenDenies() { + for (int i = 0; i < 3; i++) { + assertTrue(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"), "request " + (i + 1)); + } + assertFalse(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1")); + } + + @Test + void emailBudgetsAreIndependent() { + for (int i = 0; i < 3; i++) { + rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"); + } + assertTrue(rateLimiter.tryAcquire("bob@example.com", "10.0.0.2")); + } + + @Test + void capsRequestsPerIpAcrossEmails() { + for (int i = 0; i < 10; i++) { + assertTrue(rateLimiter.tryAcquire("user" + i + "@example.com", "10.0.0.9"), "request " + (i + 1)); + } + assertFalse(rateLimiter.tryAcquire("user10@example.com", "10.0.0.9")); + } +}