beta sign up form sends email with magic token.

This commit is contained in:
2025-12-20 17:15:34 -05:00
parent 18f577c7cf
commit 7d5b3f8f69
11 changed files with 418 additions and 34 deletions

View File

@@ -1,16 +1,22 @@
package group.goforward.battlbuilder.configuration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// Simple in-memory cache for dev/local
return new ConcurrentMapCacheManager("gunbuilderProducts");
// Must match the @Cacheable value(s) used in controllers/services.
// ProductV1Controller uses: "gunbuilderProductsV1"
return new ConcurrentMapCacheManager(
"gunbuilderProductsV1",
"gunbuilderProducts" // keep if anything else still references it
);
}
}

View File

@@ -3,15 +3,19 @@ package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repos.UserRepository;
import group.goforward.battlbuilder.security.JwtService;
import group.goforward.battlbuilder.services.auth.BetaAuthService;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
import group.goforward.battlbuilder.web.dto.auth.TokenRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
@RestController
@@ -22,17 +26,24 @@ public class AuthController {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final BetaAuthService betaAuthService;
public AuthController(
UserRepository users,
PasswordEncoder passwordEncoder,
JwtService jwtService
JwtService jwtService,
BetaAuthService betaAuthService
) {
this.users = users;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.betaAuthService = betaAuthService;
}
// ---------------------------------------------------------------------
// Standard Auth
// ---------------------------------------------------------------------
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
String email = request.getEmail().trim().toLowerCase();
@@ -44,8 +55,7 @@ public class AuthController {
}
User user = new User();
// Let DB generate id
user.setUuid(UUID.randomUUID());
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
user.setDisplayName(request.getDisplayName());
@@ -58,14 +68,14 @@ public class AuthController {
String token = jwtService.generateToken(user);
AuthResponse response = new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
));
}
@PostMapping("/login")
@@ -76,11 +86,15 @@ public class AuthController {
.orElse(null);
if (user == null || !user.getIsActive()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body("Invalid credentials");
}
user.setLastLoginAt(OffsetDateTime.now());
@@ -90,13 +104,50 @@ public class AuthController {
String token = jwtService.generateToken(user);
AuthResponse response = new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
return ResponseEntity.ok(
new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
)
);
}
return ResponseEntity.ok(response);
// ---------------------------------------------------------------------
// Beta Flow
// ---------------------------------------------------------------------
@PostMapping("/beta/signup")
public ResponseEntity<Map<String, Object>> betaSignup(@RequestBody BetaSignupRequest request) {
// Always return OK to prevent email enumeration
try {
betaAuthService.signup(request.getEmail(), request.getUseCase());
} catch (Exception ignored) {
// Intentionally swallow errors here to avoid leaking state
}
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/beta/confirm")
public ResponseEntity<Map<String, Object>> betaConfirm(@RequestBody TokenRequest request) {
try {
betaAuthService.confirmEmail(request.getToken());
} catch (IllegalArgumentException ignored) {
// Token already used / invalid / expired -> return OK for UX
}
return ResponseEntity.ok(Map.of("ok", true));
}
@PostMapping("/magic/exchange")
public ResponseEntity<?> magicExchange(@RequestBody TokenRequest request) {
try {
AuthResponse auth = betaAuthService.exchangeMagicToken(request.getToken());
return ResponseEntity.ok(auth);
} catch (IllegalArgumentException e) {
// token invalid/expired/consumed
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Magic link is invalid or expired. Please request a new one.");
}
}
}

View File

@@ -0,0 +1,70 @@
package group.goforward.battlbuilder.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "auth_tokens",
indexes = {
@Index(name = "idx_auth_tokens_email", columnList = "email"),
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
}
)
public class AuthToken {
public enum TokenType {
BETA_VERIFY,
MAGIC_LOGIN
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private TokenType type;
@Column(name = "token_hash", nullable = false, length = 64)
private String tokenHash;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
@Column(name = "consumed_at")
private OffsetDateTime consumedAt;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
// getters/setters
public Long getId() { return id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public TokenType getType() { return type; }
public void setType(TokenType type) { this.type = type; }
public String getTokenHash() { return tokenHash; }
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
public OffsetDateTime getExpiresAt() { return expiresAt; }
public void setExpiresAt(OffsetDateTime expiresAt) { this.expiresAt = expiresAt; }
public OffsetDateTime getConsumedAt() { return consumedAt; }
public void setConsumedAt(OffsetDateTime consumedAt) { this.consumedAt = consumedAt; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
@Transient
public boolean isConsumed() { return consumedAt != null; }
@Transient
public boolean isExpired(OffsetDateTime now) { return expiresAt.isBefore(now); }
}

View File

@@ -25,8 +25,8 @@ public class User {
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
private String email;
@NotNull
@Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
// password can be null for magic-link / beta users
@Column(name = "password_hash", length = Integer.MAX_VALUE, nullable = true)
private String passwordHash;
@Column(name = "display_name", length = Integer.MAX_VALUE)

View File

@@ -0,0 +1,10 @@
package group.goforward.battlbuilder.repos;
import group.goforward.battlbuilder.model.AuthToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findFirstByTypeAndTokenHash(AuthToken.TokenType type, String tokenHash);
}

View File

@@ -15,6 +15,8 @@ import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import java.util.HashMap;
import java.time.Duration;
@Service
public class JwtService {
@@ -31,18 +33,19 @@ public class JwtService {
}
public String generateToken(User user) {
Instant now = Instant.now();
Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES);
Map<String, Object> claims = new HashMap<>();
claims.put("email", user.getEmail());
claims.put("role", user.getRole());
if (user.getDisplayName() != null) {
claims.put("displayName", user.getDisplayName());
}
return Jwts.builder()
.setSubject(user.getUuid().toString())
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.addClaims(Map.of(
"email", user.getEmail(),
"role", user.getRole(),
"displayName", user.getDisplayName()
))
.setClaims(claims)
.setSubject(user.getEmail())
.setIssuedAt(new Date())
.setExpiration(Date.from(Instant.now().plus(Duration.ofDays(7))))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}

View File

@@ -0,0 +1,25 @@
package group.goforward.battlbuilder.services.auth;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
public interface BetaAuthService {
/**
* Upsert a beta signup lead and send a confirmation email with a verify token/link.
* Should NOT throw to the caller for common cases (e.g. already exists).
*/
void signup(String email, String useCase);
/**
* Confirms the user's email based on a verification token.
* Should throw if token is invalid/expired (or you can choose to no-op).
*/
void confirmEmail(String token);
/**
* Exchanges a "magic link" token (or verified token) for a real JWT session.
* Returns the same AuthResponse shape as /login and /register so the Next app
* can hydrate localStorage consistently.
*/
AuthResponse exchangeMagicToken(String token);
}

View File

@@ -0,0 +1,195 @@
package group.goforward.battlbuilder.services.auth.impl;
import group.goforward.battlbuilder.model.AuthToken;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repos.AuthTokenRepository;
import group.goforward.battlbuilder.repos.UserRepository;
import group.goforward.battlbuilder.security.JwtService;
import group.goforward.battlbuilder.services.auth.BetaAuthService;
import group.goforward.battlbuilder.services.utils.EmailService;
import group.goforward.battlbuilder.web.dto.auth.AuthResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.UUID;
@Service
public class BetaAuthServiceImpl implements BetaAuthService {
private final AuthTokenRepository tokens;
private final UserRepository users;
private final JwtService jwtService;
private final EmailService emailService;
@Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl;
// a secret “pepper” so token hashes arent trivially rainbow-table-able
@Value("${app.authTokenPepper:change-me}")
private String tokenPepper;
private final SecureRandom secureRandom = new SecureRandom();
public BetaAuthServiceImpl(
AuthTokenRepository tokens,
UserRepository users,
JwtService jwtService,
EmailService emailService
) {
this.tokens = tokens;
this.users = users;
this.jwtService = jwtService;
this.emailService = emailService;
}
@Override
public void signup(String rawEmail, String useCase) {
String email = normalizeEmail(rawEmail);
// Create a verify token (24h)
String verifyToken = generateToken();
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
// URL-encode token just in case (safe + avoids weirdness if you ever change format)
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" +
URLEncoder.encode(verifyToken, StandardCharsets.UTF_8);
// Keep copy simple, not phishy
String subject = "Confirm your Battl Builders beta signup";
String body = """
You're on the list.
Confirm your email to lock in beta access:
%s
If you didnt request this, you can ignore this email.
""".formatted(confirmUrl);
System.out.println("BETA SIGNUP: sending confirm email to " + email);
System.out.println("BETA SIGNUP: confirmUrl = " + confirmUrl);
emailService.sendEmail(email, subject, body);
// TODO (optional): persist useCase to a BetaSignup table for admin dashboards/exports
// For now, you're fine leaving it out.
}
@Override
public void confirmEmail(String token) {
AuthToken authToken = consumeToken(AuthToken.TokenType.BETA_VERIFY, token);
String email = authToken.getEmail();
// Create or activate user
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) {
user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setDisplayName(null);
user.setRole("USER");
user.setIsActive(true);
user.setCreatedAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
} else if (!Boolean.TRUE.equals(user.getIsActive())) {
user.setIsActive(true);
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
}
// Send magic login link (15 min)
String magicToken = generateToken();
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(15));
String magicUrl = publicBaseUrl + "/beta/magic?token=" +
URLEncoder.encode(magicToken, StandardCharsets.UTF_8);
String subject = "Your Battl Builders sign-in link";
String body = """
You're verified. Let's get you in.
Sign in (link expires in 15 minutes):
%s
""".formatted(magicUrl);
emailService.sendEmail(email, subject, body);
}
/**
* Exchange a one-time magic link token for a JWT session.
* Returns AuthResponse so the Next AuthContext can hydrate localStorage cleanly.
*/
@Override
public AuthResponse exchangeMagicToken(String token) {
AuthToken magic = consumeToken(AuthToken.TokenType.MAGIC_LOGIN, token);
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
user.setLastLoginAt(OffsetDateTime.now());
user.incrementLoginCount();
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
String jwt = jwtService.generateToken(user);
return new AuthResponse(
jwt,
user.getEmail(),
user.getDisplayName(),
user.getRole()
);
}
// -------- helpers --------
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
AuthToken t = new AuthToken();
t.setEmail(email);
t.setType(type);
t.setTokenHash(hashToken(token));
t.setExpiresAt(expiresAt);
t.setCreatedAt(OffsetDateTime.now());
tokens.save(t);
}
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
String hash = hashToken(token);
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
OffsetDateTime now = OffsetDateTime.now();
if (t.isConsumed()) throw new IllegalArgumentException("Token already used");
if (t.isExpired(now)) throw new IllegalArgumentException("Token expired");
t.setConsumedAt(now);
tokens.save(t);
return t;
}
private String normalizeEmail(String email) {
if (email == null) throw new IllegalArgumentException("Email required");
return email.trim().toLowerCase();
}
private String generateToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes); // 64-char hex token
}
private String hashToken(String token) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hashed);
} catch (Exception e) {
throw new RuntimeException("Failed to hash token", e);
}
}
}

View File

@@ -0,0 +1,12 @@
package group.goforward.battlbuilder.web.dto.auth;
public class BetaSignupRequest {
private String email;
private String useCase;
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getUseCase() { return useCase; }
public void setUseCase(String useCase) { this.useCase = useCase; }
}

View File

@@ -0,0 +1,8 @@
package group.goforward.battlbuilder.web.dto.auth;
public class TokenRequest {
private String token;
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
}

View File

@@ -46,4 +46,8 @@ minio.bucket=battlbuilders
minio.public-base-url=https://minio.dev.gofwd.group
# --- Feature toggles ---
app.api.legacy.enabled=false
app.api.legacy.enabled=false
# Beta Email Signup and Auth
app.publicBaseUrl=http://localhost:3000
app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb