diff --git a/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java b/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java index 7e8eed6..4e5bf76 100644 --- a/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java +++ b/src/main/java/group/goforward/battlbuilder/configuration/SecurityConfig.java @@ -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(); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java b/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java index b3945c6..e5a8a59 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/AuthController.java @@ -130,13 +130,13 @@ public class AuthController { } @PostMapping("/beta/confirm") - public ResponseEntity> 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 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 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 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)); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/controllers/api/MeController.java b/src/main/java/group/goforward/battlbuilder/controllers/api/MeController.java new file mode 100644 index 0000000..bc1f8dc --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/controllers/api/MeController.java @@ -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 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 = 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 = 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 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 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 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)); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/AuthToken.java b/src/main/java/group/goforward/battlbuilder/model/AuthToken.java index b009702..0a5984c 100644 --- a/src/main/java/group/goforward/battlbuilder/model/AuthToken.java +++ b/src/main/java/group/goforward/battlbuilder/model/AuthToken.java @@ -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 diff --git a/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java b/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java index b20f7f3..e896ed3 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/UserRepository.java @@ -15,4 +15,6 @@ public interface UserRepository extends JpaRepository { Optional findByUuid(UUID uuid); boolean existsByRole(String role); + + Optional findByUuidAndDeletedAtIsNull(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java index 8c3090f..487f9d7 100644 --- a/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java +++ b/src/main/java/group/goforward/battlbuilder/security/JwtAuthenticationFilter.java @@ -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); diff --git a/src/main/java/group/goforward/battlbuilder/security/JwtService.java b/src/main/java/group/goforward/battlbuilder/security/JwtService.java index f313924..3360e19 100644 --- a/src/main/java/group/goforward/battlbuilder/security/JwtService.java +++ b/src/main/java/group/goforward/battlbuilder/security/JwtService.java @@ -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()); diff --git a/src/main/java/group/goforward/battlbuilder/services/auth/BetaAuthService.java b/src/main/java/group/goforward/battlbuilder/services/auth/BetaAuthService.java index c957475..8e71f8a 100644 --- a/src/main/java/group/goforward/battlbuilder/services/auth/BetaAuthService.java +++ b/src/main/java/group/goforward/battlbuilder/services/auth/BetaAuthService.java @@ -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); + } \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java index 99d2adf..4c9775f 100644 --- a/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/auth/impl/BetaAuthServiceImpl.java @@ -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) { diff --git a/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java b/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java index 488d15d..32b59e6 100644 --- a/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java +++ b/src/main/java/group/goforward/battlbuilder/services/utils/impl/EmailServiceImpl.java @@ -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); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index fcf1599..d045b09 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -50,4 +50,7 @@ app.api.legacy.enabled=false # Beta Email Signup and Auth app.publicBaseUrl=http://localhost:3000 -app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb \ No newline at end of file +app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb + +# Magic Token Duration +security.jwt.access-token-days=30 \ No newline at end of file