mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
beta sign up form sends email with magic token.
This commit is contained in:
@@ -1,16 +1,22 @@
|
|||||||
package group.goforward.battlbuilder.configuration;
|
package group.goforward.battlbuilder.configuration;
|
||||||
|
|
||||||
import org.springframework.cache.CacheManager;
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
public class CacheConfig {
|
public class CacheConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
// Simple in-memory cache for dev/local
|
// Must match the @Cacheable value(s) used in controllers/services.
|
||||||
return new ConcurrentMapCacheManager("gunbuilderProducts");
|
// ProductV1Controller uses: "gunbuilderProductsV1"
|
||||||
|
return new ConcurrentMapCacheManager(
|
||||||
|
"gunbuilderProductsV1",
|
||||||
|
"gunbuilderProducts" // keep if anything else still references it
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,19 @@ package group.goforward.battlbuilder.controllers;
|
|||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import group.goforward.battlbuilder.repos.UserRepository;
|
import group.goforward.battlbuilder.repos.UserRepository;
|
||||||
import group.goforward.battlbuilder.security.JwtService;
|
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.AuthResponse;
|
||||||
|
import group.goforward.battlbuilder.web.dto.auth.BetaSignupRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
|
import group.goforward.battlbuilder.web.dto.auth.LoginRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.auth.RegisterRequest;
|
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.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -22,17 +26,24 @@ public class AuthController {
|
|||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
|
private final BetaAuthService betaAuthService;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
UserRepository users,
|
UserRepository users,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
JwtService jwtService
|
JwtService jwtService,
|
||||||
|
BetaAuthService betaAuthService
|
||||||
) {
|
) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
|
this.betaAuthService = betaAuthService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Standard Auth
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||||
String email = request.getEmail().trim().toLowerCase();
|
String email = request.getEmail().trim().toLowerCase();
|
||||||
@@ -44,7 +55,6 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
User user = new User();
|
User user = new User();
|
||||||
// Let DB generate id
|
|
||||||
user.setUuid(UUID.randomUUID());
|
user.setUuid(UUID.randomUUID());
|
||||||
user.setEmail(email);
|
user.setEmail(email);
|
||||||
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||||
@@ -58,14 +68,14 @@ public class AuthController {
|
|||||||
|
|
||||||
String token = jwtService.generateToken(user);
|
String token = jwtService.generateToken(user);
|
||||||
|
|
||||||
AuthResponse response = new AuthResponse(
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.CREATED)
|
||||||
|
.body(new AuthResponse(
|
||||||
token,
|
token,
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
user.getDisplayName(),
|
user.getDisplayName(),
|
||||||
user.getRole()
|
user.getRole()
|
||||||
);
|
));
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -76,11 +86,15 @@ public class AuthController {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (user == null || !user.getIsActive()) {
|
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())) {
|
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());
|
user.setLastLoginAt(OffsetDateTime.now());
|
||||||
@@ -90,13 +104,50 @@ public class AuthController {
|
|||||||
|
|
||||||
String token = jwtService.generateToken(user);
|
String token = jwtService.generateToken(user);
|
||||||
|
|
||||||
AuthResponse response = new AuthResponse(
|
return ResponseEntity.ok(
|
||||||
|
new AuthResponse(
|
||||||
token,
|
token,
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
user.getDisplayName(),
|
user.getDisplayName(),
|
||||||
user.getRole()
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -25,8 +25,8 @@ public class User {
|
|||||||
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
@NotNull
|
// password can be null for magic-link / beta users
|
||||||
@Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "password_hash", length = Integer.MAX_VALUE, nullable = true)
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
|
|
||||||
@Column(name = "display_name", length = Integer.MAX_VALUE)
|
@Column(name = "display_name", length = Integer.MAX_VALUE)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import java.time.temporal.ChronoUnit;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class JwtService {
|
public class JwtService {
|
||||||
@@ -31,18 +33,19 @@ public class JwtService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String generateToken(User user) {
|
public String generateToken(User user) {
|
||||||
Instant now = Instant.now();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES);
|
claims.put("email", user.getEmail());
|
||||||
|
claims.put("role", user.getRole());
|
||||||
|
|
||||||
|
if (user.getDisplayName() != null) {
|
||||||
|
claims.put("displayName", user.getDisplayName());
|
||||||
|
}
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setSubject(user.getUuid().toString())
|
.setClaims(claims)
|
||||||
.setIssuedAt(Date.from(now))
|
.setSubject(user.getEmail())
|
||||||
.setExpiration(Date.from(expiry))
|
.setIssuedAt(new Date())
|
||||||
.addClaims(Map.of(
|
.setExpiration(Date.from(Instant.now().plus(Duration.ofDays(7))))
|
||||||
"email", user.getEmail(),
|
|
||||||
"role", user.getRole(),
|
|
||||||
"displayName", user.getDisplayName()
|
|
||||||
))
|
|
||||||
.signWith(key, SignatureAlgorithm.HS256)
|
.signWith(key, SignatureAlgorithm.HS256)
|
||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 aren’t 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 didn’t 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -47,3 +47,7 @@ minio.public-base-url=https://minio.dev.gofwd.group
|
|||||||
|
|
||||||
# --- Feature toggles ---
|
# --- 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
|
||||||
Reference in New Issue
Block a user