mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
magic link for reset password and admin support
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package group.goforward.battlbuilder.configuration;
|
||||
|
||||
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
@@ -11,49 +12,43 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(sm ->
|
||||
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
|
||||
// Auth endpoints always open
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
|
||||
// Swagger / docs
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
|
||||
// Health
|
||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
|
||||
// Public product endpoints
|
||||
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
||||
|
||||
// Everything else (for now) also open – we can tighten later
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// BCrypt is a solid default for user passwords
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(
|
||||
AuthenticationConfiguration configuration
|
||||
) throws Exception {
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
|
||||
throws Exception {
|
||||
return configuration.getAuthenticationManager();
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,13 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/beta/confirm")
|
||||
public ResponseEntity<Map<String, Object>> betaConfirm(@RequestBody TokenRequest request) {
|
||||
public ResponseEntity<?> betaConfirm(@RequestBody TokenRequest request) {
|
||||
try {
|
||||
betaAuthService.confirmEmail(request.getToken());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Token already used / invalid / expired -> return OK for UX
|
||||
return ResponseEntity.ok(betaAuthService.confirmAndExchange(request.getToken()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("Confirm link is invalid or expired. Please request a new one.");
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
@PostMapping("/magic/exchange")
|
||||
@@ -150,4 +150,40 @@ public class AuthController {
|
||||
.body("Magic link is invalid or expired. Please request a new one.");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/password/forgot")
|
||||
public ResponseEntity<?> forgotPassword(@RequestBody Map<String, String> body) {
|
||||
String email = body.getOrDefault("email", "").trim();
|
||||
|
||||
try {
|
||||
betaAuthService.sendPasswordReset(email); // name we’ll add below
|
||||
} catch (Exception ignored) {
|
||||
// swallow to avoid enumeration
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||
}
|
||||
|
||||
@PostMapping("/password/reset")
|
||||
public ResponseEntity<?> resetPassword(@RequestBody Map<String, String> body) {
|
||||
String token = body.getOrDefault("token", "").trim();
|
||||
String password = body.getOrDefault("password", "").trim();
|
||||
|
||||
betaAuthService.resetPassword(token, password);
|
||||
|
||||
return ResponseEntity.ok().body("{\"ok\":true}");
|
||||
}
|
||||
|
||||
@PostMapping("/magic")
|
||||
public ResponseEntity<?> requestMagic(@RequestBody Map<String, String> body) {
|
||||
String email = body.getOrDefault("email", "").trim();
|
||||
|
||||
try {
|
||||
betaAuthService.sendMagicLoginLink(email);
|
||||
} catch (Exception ignored) {
|
||||
// swallow to avoid enumeration
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package group.goforward.battlbuilder.controllers.api;
|
||||
|
||||
import group.goforward.battlbuilder.model.User;
|
||||
import group.goforward.battlbuilder.repos.UserRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/me")
|
||||
@CrossOrigin
|
||||
public class MeController {
|
||||
|
||||
private final UserRepository users;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public MeController(UserRepository users, PasswordEncoder passwordEncoder) {
|
||||
this.users = users;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers
|
||||
// -----------------------------
|
||||
|
||||
private Authentication requireAuth() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
// Spring may set "anonymousUser" as a principal when not logged in
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal == null || "anonymousUser".equals(principal)) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
private Optional<UUID> tryParseUuid(String s) {
|
||||
try {
|
||||
return Optional.of(UUID.fromString(s));
|
||||
} catch (Exception ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private User requireUser() {
|
||||
Authentication auth = requireAuth();
|
||||
Object principal = auth.getPrincipal();
|
||||
|
||||
// Case 1: principal is a String (we commonly set this to UUID string)
|
||||
if (principal instanceof String s) {
|
||||
// Prefer UUID lookup
|
||||
Optional<UUID> uuid = tryParseUuid(s);
|
||||
if (uuid.isPresent()) {
|
||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Fallback to email lookup
|
||||
String email = s.trim().toLowerCase();
|
||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Case 2: principal is a UserDetails (often username=email)
|
||||
if (principal instanceof UserDetails ud) {
|
||||
String username = ud.getUsername();
|
||||
if (username == null) {
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
// Try UUID first, then email
|
||||
Optional<UUID> uuid = tryParseUuid(username);
|
||||
if (uuid.isPresent()) {
|
||||
return users.findByUuidAndDeletedAtIsNull(uuid.get())
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
String email = username.trim().toLowerCase();
|
||||
return users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
// Anything else: unsupported principal type
|
||||
throw new ResponseStatusException(UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
|
||||
private Map<String, Object> toMeResponse(User user) {
|
||||
return Map.of(
|
||||
"email", user.getEmail(),
|
||||
"displayName", user.getDisplayName(),
|
||||
"role", user.getRole()
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Routes
|
||||
// -----------------------------
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> me() {
|
||||
User user = requireUser();
|
||||
return ResponseEntity.ok(toMeResponse(user));
|
||||
}
|
||||
|
||||
@PatchMapping
|
||||
public ResponseEntity<?> updateMe(@RequestBody Map<String, Object> body) {
|
||||
User user = requireUser();
|
||||
|
||||
String displayName = null;
|
||||
if (body != null && body.get("displayName") != null) {
|
||||
displayName = String.valueOf(body.get("displayName")).trim();
|
||||
}
|
||||
|
||||
if (displayName == null || displayName.isBlank()) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "displayName is required");
|
||||
}
|
||||
|
||||
user.setDisplayName(displayName);
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
return ResponseEntity.ok(toMeResponse(user));
|
||||
}
|
||||
|
||||
@PostMapping("/password")
|
||||
public ResponseEntity<?> setPassword(@RequestBody Map<String, Object> body) {
|
||||
User user = requireUser();
|
||||
|
||||
String password = null;
|
||||
if (body != null && body.get("password") != null) {
|
||||
password = String.valueOf(body.get("password"));
|
||||
}
|
||||
|
||||
if (password == null || password.length() < 8) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(password));
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import jakarta.persistence.*;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "auth_tokens",
|
||||
@Table(
|
||||
name = "auth_tokens",
|
||||
indexes = {
|
||||
@Index(name = "idx_auth_tokens_email", columnList = "email"),
|
||||
@Index(name = "idx_auth_tokens_type_hash", columnList = "type, token_hash")
|
||||
@@ -14,7 +15,8 @@ public class AuthToken {
|
||||
|
||||
public enum TokenType {
|
||||
BETA_VERIFY,
|
||||
MAGIC_LOGIN
|
||||
MAGIC_LOGIN,
|
||||
PASSWORD_RESET
|
||||
}
|
||||
|
||||
@Id
|
||||
|
||||
@@ -15,4 +15,6 @@ public interface UserRepository extends JpaRepository<User, Integer> {
|
||||
Optional<User> findByUuid(UUID uuid);
|
||||
|
||||
boolean existsByRole(String role);
|
||||
|
||||
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
|
||||
}
|
||||
@@ -41,6 +41,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already authenticated? don’t redo work
|
||||
if (SecurityContextHolder.getContext().getAuthentication() != null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7);
|
||||
|
||||
if (!jwtService.isTokenValid(token)) {
|
||||
@@ -49,30 +55,30 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
UUID userUuid = jwtService.extractUserUuid(token);
|
||||
|
||||
if (userUuid == null || SecurityContextHolder.getContext().getAuthentication() != null) {
|
||||
if (userUuid == null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
User user = userRepository.findByUuid(userUuid)
|
||||
.orElse(null);
|
||||
User user = userRepository.findByUuid(userUuid).orElse(null);
|
||||
|
||||
if (user == null || !user.getIsActive()) {
|
||||
if (user == null || !Boolean.TRUE.equals(user.getIsActive())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep authorities from your details class…
|
||||
CustomUserDetails userDetails = new CustomUserDetails(user);
|
||||
|
||||
// …but set principal to UUID string so controllers can reliably resolve "me"
|
||||
UsernamePasswordAuthenticationToken authToken =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userDetails,
|
||||
user.getUuid().toString(),
|
||||
null,
|
||||
userDetails.getAuthorities()
|
||||
);
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
|
||||
@@ -13,23 +13,22 @@ import java.security.Key;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.HashMap;
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
private final Key key;
|
||||
private final long accessTokenMinutes;
|
||||
private final long accessTokenDays;
|
||||
|
||||
public JwtService(
|
||||
@Value("${security.jwt.secret}") String secret,
|
||||
@Value("${security.jwt.access-token-minutes:60}") long accessTokenMinutes
|
||||
@Value("${security.jwt.access-token-days:30}") long accessTokenDays
|
||||
) {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes());
|
||||
this.accessTokenMinutes = accessTokenMinutes;
|
||||
this.accessTokenDays = accessTokenDays;
|
||||
}
|
||||
|
||||
public String generateToken(User user) {
|
||||
@@ -43,13 +42,14 @@ public class JwtService {
|
||||
|
||||
return Jwts.builder()
|
||||
.setClaims(claims)
|
||||
.setSubject(user.getEmail())
|
||||
.setSubject(user.getUuid().toString()) // UUID subject
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(Date.from(Instant.now().plus(Duration.ofDays(7))))
|
||||
.setExpiration(Date.from(Instant.now().plus(accessTokenDays, ChronoUnit.DAYS)))
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** Used by JwtAuthenticationFilter */
|
||||
public UUID extractUserUuid(String token) {
|
||||
Claims claims = parseClaims(token);
|
||||
return UUID.fromString(claims.getSubject());
|
||||
|
||||
@@ -11,15 +11,19 @@ public interface BetaAuthService {
|
||||
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).
|
||||
* Exchanges a "confirm" token for a real JWT session.
|
||||
* This confirms the email (one-time) AND logs the user in immediately.
|
||||
*/
|
||||
void confirmEmail(String token);
|
||||
AuthResponse confirmAndExchange(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.
|
||||
* Exchanges a "magic link" token for a real JWT session.
|
||||
* Used for returning users ("email me a sign-in link").
|
||||
*/
|
||||
AuthResponse exchangeMagicToken(String token);
|
||||
|
||||
void sendPasswordReset(String email);
|
||||
void resetPassword(String token, String newPassword);
|
||||
void sendMagicLoginLink(String email);
|
||||
|
||||
}
|
||||
@@ -9,9 +9,9 @@ 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.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
@@ -26,11 +26,11 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
private final UserRepository users;
|
||||
private final JwtService jwtService;
|
||||
private final EmailService emailService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@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;
|
||||
|
||||
@@ -40,51 +40,88 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
AuthTokenRepository tokens,
|
||||
UserRepository users,
|
||||
JwtService jwtService,
|
||||
EmailService emailService
|
||||
EmailService emailService,
|
||||
PasswordEncoder passwordEncoder
|
||||
) {
|
||||
this.tokens = tokens;
|
||||
this.users = users;
|
||||
this.jwtService = jwtService;
|
||||
this.emailService = emailService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ONE email with a "confirm+login" token.
|
||||
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
|
||||
*/
|
||||
@Override
|
||||
public void signup(String rawEmail, String useCase) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
// Create a verify token (24h)
|
||||
// 24h confirm token
|
||||
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);
|
||||
String confirmUrl = publicBaseUrl + "/beta/confirm?token=" + verifyToken;
|
||||
|
||||
// Keep copy simple, not phishy
|
||||
String subject = "Confirm your Battl Builders beta signup";
|
||||
String subject = "Your Battl Builders sign-in link";
|
||||
String body = """
|
||||
You're on the list.
|
||||
|
||||
Confirm your email to lock in beta access:
|
||||
Sign in (and confirm your email) here:
|
||||
%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.
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ B: Existing users only — request a magic login link (no signup/confirm).
|
||||
* Caller must always return OK to avoid email enumeration.
|
||||
*/
|
||||
@Override
|
||||
public void confirmEmail(String token) {
|
||||
public void sendMagicLoginLink(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
// Only send if user exists (but do NOT reveal that)
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
if (user == null) return;
|
||||
|
||||
if (!Boolean.TRUE.equals(user.getIsActive())) return;
|
||||
|
||||
// Optional: restrict magic links to normal beta users only
|
||||
// If you want admins to also use magic links, remove this.
|
||||
if (!"USER".equalsIgnoreCase(user.getRole())) return;
|
||||
|
||||
// 30 minute magic token
|
||||
String magicToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(30));
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
|
||||
|
||||
String subject = "Your Battl Builders sign-in link";
|
||||
String body = """
|
||||
Here’s your secure sign-in link (expires in 30 minutes):
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(magicUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes BETA_VERIFY token, creates/activates user, and returns JWT immediately.
|
||||
*/
|
||||
@Override
|
||||
public AuthResponse confirmAndExchange(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());
|
||||
@@ -101,27 +138,18 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
users.save(user);
|
||||
}
|
||||
|
||||
// Send magic login link (15 min)
|
||||
String magicToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken, OffsetDateTime.now().plusMinutes(15));
|
||||
user.setLastLoginAt(OffsetDateTime.now());
|
||||
user.incrementLoginCount();
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
|
||||
String magicUrl = publicBaseUrl + "/beta/magic?token=" +
|
||||
URLEncoder.encode(magicToken, StandardCharsets.UTF_8);
|
||||
String jwt = jwtService.generateToken(user);
|
||||
|
||||
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);
|
||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a one-time magic link token for a JWT session.
|
||||
* Returns AuthResponse so the Next AuthContext can hydrate localStorage cleanly.
|
||||
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
|
||||
*/
|
||||
@Override
|
||||
public AuthResponse exchangeMagicToken(String token) {
|
||||
@@ -137,15 +165,56 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
|
||||
String jwt = jwtService.generateToken(user);
|
||||
|
||||
return new AuthResponse(
|
||||
jwt,
|
||||
user.getEmail(),
|
||||
user.getDisplayName(),
|
||||
user.getRole()
|
||||
);
|
||||
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
|
||||
}
|
||||
|
||||
// -------- helpers --------
|
||||
// ---------------------------------------------------------------------
|
||||
// Password Reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void sendPasswordReset(String rawEmail) {
|
||||
String email = normalizeEmail(rawEmail);
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||
if (user == null) return;
|
||||
|
||||
String resetToken = generateToken();
|
||||
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
|
||||
|
||||
String resetUrl = publicBaseUrl + "/reset-password?token=" + resetToken;
|
||||
|
||||
String subject = "Reset your Battl Builders password";
|
||||
String body = """
|
||||
Reset your password using this link (expires in 30 minutes):
|
||||
%s
|
||||
|
||||
If you didn’t request this, you can ignore this email.
|
||||
""".formatted(resetUrl);
|
||||
|
||||
emailService.sendEmail(email, subject, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetPassword(String token, String newPassword) {
|
||||
if (newPassword == null || newPassword.trim().length() < 8) {
|
||||
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
AuthToken t = consumeToken(AuthToken.TokenType.PASSWORD_RESET, token);
|
||||
String email = t.getEmail();
|
||||
|
||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword.trim()));
|
||||
user.setUpdatedAt(OffsetDateTime.now());
|
||||
users.save(user);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private void saveToken(String email, AuthToken.TokenType type, String token, OffsetDateTime expiresAt) {
|
||||
AuthToken t = new AuthToken();
|
||||
@@ -159,6 +228,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
|
||||
private AuthToken consumeToken(AuthToken.TokenType type, String token) {
|
||||
String hash = hashToken(token);
|
||||
|
||||
AuthToken t = tokens.findFirstByTypeAndTokenHash(type, hash)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid token"));
|
||||
|
||||
@@ -180,7 +250,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
||||
private String generateToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
secureRandom.nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes); // 64-char hex token
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private String hashToken(String token) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import group.goforward.battlbuilder.model.EmailRequest;
|
||||
import group.goforward.battlbuilder.model.EmailStatus;
|
||||
import group.goforward.battlbuilder.repos.EmailRequestRepository;
|
||||
import group.goforward.battlbuilder.services.utils.EmailService;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
@@ -11,7 +12,6 @@ import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -27,29 +27,40 @@ public class EmailServiceImpl implements EmailService {
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
/**
|
||||
* Sends an email and persists send status.
|
||||
* Uses multipart=true to avoid MimeMessageHelper errors when setting text.
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public EmailRequest sendEmail(String recipient, String subject, String body) {
|
||||
// Create and save email request
|
||||
|
||||
// Persist initial request
|
||||
EmailRequest emailRequest = new EmailRequest();
|
||||
emailRequest.setRecipient(recipient);
|
||||
emailRequest.setSubject(subject);
|
||||
emailRequest.setBody(body);
|
||||
emailRequest.setStatus(EmailStatus.PENDING);
|
||||
emailRequest.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
emailRequest = emailRequestRepository.save(emailRequest);
|
||||
|
||||
try {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
|
||||
// ✅ multipart=true fixes "Not in multipart mode" errors
|
||||
MimeMessageHelper helper = new MimeMessageHelper(
|
||||
message,
|
||||
false,
|
||||
true, // multipart
|
||||
StandardCharsets.UTF_8.name()
|
||||
);
|
||||
|
||||
helper.setFrom(fromEmail);
|
||||
helper.setTo(recipient);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body, true);
|
||||
|
||||
// Plain text email (safe + deliverable)
|
||||
helper.setText(body, false);
|
||||
|
||||
mailSender.send(message);
|
||||
|
||||
|
||||
@@ -50,4 +50,7 @@ app.api.legacy.enabled=false
|
||||
|
||||
# Beta Email Signup and Auth
|
||||
app.publicBaseUrl=http://localhost:3000
|
||||
app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb
|
||||
app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb
|
||||
|
||||
# Magic Token Duration
|
||||
security.jwt.access-token-days=30
|
||||
Reference in New Issue
Block a user