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..fbb5f3c --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/AuthService.java @@ -0,0 +1,76 @@ +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.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.auth.TokenGenerator.GeneratedToken; +import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository; +import org.springframework.stereotype.Service; + +/** + * Orchestrates the magic-link flow: issuing links (request) and exchanging them for a member (verify). Magic links only + * sign in existing members — the sign-up form is the sole creator of member rows — and requesting a link never leaks + * whether an account exists. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + + private final MagicLinkTokenRepository tokens; + private final MemberRepo 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. Being rate-limited surfaces as + * a visible 429 — the limiter runs before the member-existence check, so the 429 is registration-blind and + * reveals nothing. Unregistered emails are skipped silently (the same generic success as a real send), so account + * existence stays unobservable. + * + * @throws TooManyLinkRequestsException when the per-email or per-IP budget is exhausted + */ + 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); + throw new TooManyLinkRequestsException(); + } + if (members.getMemberByEmail(email).isEmpty()) { + log.info("Skipping magic-link request for unregistered email {}", email); + return; + } + final GeneratedToken token = tokenGenerator.generate(); + tokens.deleteByEmail(email); + tokens.insertToken( + UUID.randomUUID(), email, token.hash(), clock.instant().plus(properties.getMagicLinkTtl())); + emailComposer.send(email, token.raw()); + } + + /** + * Atomically consumes the presented token and resolves the member behind it. + * + * @throws InvalidMagicLinkException when the token is unknown, already used, or expired — or when the member no + * longer exists (deleted between send and click); the message stays generic either way + */ + public Member verify(final String rawToken) { + final String email = tokens.consumeAndReturnEmail(TokenGenerator.hash(rawToken), clock.instant()) + .orElseThrow(InvalidMagicLinkException::new); + return members.getMemberByEmail(email).orElseThrow(InvalidMagicLinkException::new); + } + + /** + * Lowercases and trims before any lookup or token write. {@code MemberRepo.getMemberByEmail} matches the column + * exactly, so this is the single place email casing is reconciled — every path through this service must use it. + */ + 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..c8f760d --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiter.java @@ -0,0 +1,51 @@ +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. If the IP budget denies after + * the email budget consumed, the email token is returned — a request blocked by one limit must not silently drain + * the other. + */ + public boolean tryAcquire(final String email, final String clientIp) { + final Bucket emailBucket = emailBuckets.getUnchecked(email); + final Bucket ipBucket = ipBuckets.getUnchecked(clientIp); + if (!emailBucket.tryConsume(1)) { + return false; + } + if (ipBucket.tryConsume(1)) { + return true; + } + emailBucket.addTokens(1); + return false; + } + + 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/TooManyLinkRequestsException.java b/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java new file mode 100644 index 0000000..f68f84a --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/auth/TooManyLinkRequestsException.java @@ -0,0 +1,12 @@ +package org.patinanetwork.patchats.auth; + +/** + * Thrown when the request-link rate limit is hit. Maps to HTTP 429 with a friendly message. The limiter runs before the + * member-existence check, so the 429 is registration-blind — it reveals nothing about whether the email has an account. + */ +public class TooManyLinkRequestsException extends RuntimeException { + + public TooManyLinkRequestsException() { + super("Too many sign-in requests. Please wait a few minutes and try again."); + } +} 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..c573c5a --- /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.api.member.db.models.Member; + +/** + * The signed-in member as seen by the frontend. Members always have a complete profile (the sign-up form is the only + * way one is created), so {@code name} is always present. {@code isAdmin} is always false until the admin domain lands. + */ +public record SessionResponse(String id, String name, String email, boolean isAdmin) { + + public static SessionResponse of(final Member member) { + final String name = "%s %s".formatted(member.getFirstName(), member.getLastName()); + return new SessionResponse(member.getId().toString(), name, member.getEmail(), false); + } +} 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 7501ad8..a296418 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,8 @@ package org.patinanetwork.patchats.common.web; import java.util.stream.Collectors; +import org.patinanetwork.patchats.auth.InvalidMagicLinkException; +import org.patinanetwork.patchats.auth.TooManyLinkRequestsException; import org.patinanetwork.patchats.common.dto.ApiResponder; import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException; import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException; @@ -34,6 +36,16 @@ public ResponseEntity> handleMemberDuplicate(final MemberDupl return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); } + @ExceptionHandler(InvalidMagicLinkException.class) + public ResponseEntity> handleInvalidMagicLink(final InvalidMagicLinkException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(TooManyLinkRequestsException.class) + public ResponseEntity> handleTooManyLinkRequests(final TooManyLinkRequestsException ex) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).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..638c713 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/AuthServiceTest.java @@ -0,0 +1,124 @@ +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.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +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.api.member.db.models.Member; +import org.patinanetwork.patchats.api.member.db.repos.MemberRepo; +import org.patinanetwork.patchats.auth.repo.MagicLinkTokenRepository; + +class AuthServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-03T12:00:00Z"); + + private final MagicLinkTokenRepository tokens = mock(MagicLinkTokenRepository.class); + private final MemberRepo members = mock(MemberRepo.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); + when(members.getMemberByEmail("ann@example.com")).thenReturn(Optional.of(member("ann@example.com"))); + + 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).insertToken(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 requestLinkSurfacesRateLimitAs429() { + when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(false); + + assertThrows(TooManyLinkRequestsException.class, () -> authService.requestLink("ann@example.com", "10.0.0.1")); + + verifyNoInteractions(tokens, emailComposer); + } + + @Test + void requestLinkSkipsUnregisteredEmailSilently() { + when(rateLimiter.tryAcquire(anyString(), anyString())).thenReturn(true); + when(members.getMemberByEmail("stranger@example.com")).thenReturn(Optional.empty()); + + authService.requestLink("stranger@example.com", "10.0.0.1"); + + verifyNoInteractions(tokens, emailComposer); + } + + @Test + void verifyRejectsUnknownOrSpentToken() { + when(tokens.consumeAndReturnEmail(anyString(), any())).thenReturn(Optional.empty()); + + assertThrows(InvalidMagicLinkException.class, () -> authService.verify("bogus")); + } + + @Test + void verifyReturnsTheMemberBehindTheToken() { + final Member existing = member("ann@example.com"); + when(tokens.consumeAndReturnEmail(TokenGenerator.hash("raw-token"), NOW)) + .thenReturn(Optional.of("ann@example.com")); + when(members.getMemberByEmail("ann@example.com")).thenReturn(Optional.of(existing)); + + assertEquals(existing, authService.verify("raw-token")); + } + + @Test + void verifyRejectsTokenWhoseMemberNoLongerExists() { + when(tokens.consumeAndReturnEmail(TokenGenerator.hash("raw-token"), NOW)) + .thenReturn(Optional.of("gone@example.com")); + when(members.getMemberByEmail("gone@example.com")).thenReturn(Optional.empty()); + + assertThrows(InvalidMagicLinkException.class, () -> authService.verify("raw-token")); + } + + private static Member member(final String email) { + return Member.builder() + .id(UUID.randomUUID()) + .email(email) + .firstName("Ann") + .lastName("Example") + .build(); + } +} 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..4395fe5 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/auth/RequestLinkRateLimiterTest.java @@ -0,0 +1,49 @@ +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")); + } + + @Test + void ipDenialDoesNotBurnTheEmailBudget() { + // Exhaust the IP budget using other emails. + for (int i = 0; i < 10; i++) { + rateLimiter.tryAcquire("user" + i + "@example.com", "10.0.0.9"); + } + // Denied by IP — but ann's email budget must be untouched... + assertFalse(rateLimiter.tryAcquire("ann@example.com", "10.0.0.9")); + // ...so all 3 of her requests still succeed from a fresh IP. + for (int i = 0; i < 3; i++) { + assertTrue(rateLimiter.tryAcquire("ann@example.com", "10.0.0.1"), "request " + (i + 1)); + } + } +}