magic link for reset password and admin support

This commit is contained in:
2025-12-20 18:58:12 -05:00
parent 7d5b3f8f69
commit 74b71ea10a
11 changed files with 384 additions and 98 deletions

View File

@@ -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();
}
}

View File

@@ -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 well 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));
}
}

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -41,6 +41,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
}
// Already authenticated? dont 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);

View File

@@ -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());

View File

@@ -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);
}

View File

@@ -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 arent 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 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.
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 = """
Heres your secure sign-in link (expires in 30 minutes):
%s
If you didnt 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 didnt 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) {

View File

@@ -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);

View File

@@ -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