new stuff to flag off beta magic links until were ready for a email blast.

This commit is contained in:
2025-12-24 08:35:15 -05:00
parent 343b01375d
commit f9f4e95aef
6 changed files with 245 additions and 23 deletions

View File

@@ -0,0 +1,39 @@
package group.goforward.battlbuilder.cli;
import group.goforward.battlbuilder.services.auth.impl.BetaInviteService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class BetaInviteCliRunner implements CommandLineRunner {
private final BetaInviteService inviteService;
@Value("${app.beta.invite.run:false}")
private boolean run;
@Value("${app.beta.invite.limit:0}")
private int limit;
@Value("${app.beta.invite.dryRun:true}")
private boolean dryRun;
@Value("${app.beta.invite.tokenMinutes:30}")
private int tokenMinutes;
public BetaInviteCliRunner(BetaInviteService inviteService) {
this.inviteService = inviteService;
}
@Override
public void run(String... args) {
if (!run) return;
int count = inviteService.inviteAllBetaUsers(tokenMinutes, limit, dryRun);
System.out.println("✅ Beta invite runner complete. processed=" + count + " dryRun=" + dryRun);
// Exit so it behaves like a CLI command
System.exit(0);
}
}

View File

@@ -2,7 +2,9 @@ package group.goforward.battlbuilder.repos;
import group.goforward.battlbuilder.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;
@@ -17,4 +19,9 @@ public interface UserRepository extends JpaRepository<User, Integer> {
boolean existsByRole(String role);
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
List<User> findByRoleAndIsActiveFalseAndDeletedAtIsNull(String role);
@Query(value = "select * from users where role = :role and is_active = false and deleted_at is null order by created_at asc limit :limit", nativeQuery = true)
List<User> findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull(@Param("role") String role, @Param("limit") int limit);
}

View File

@@ -34,6 +34,15 @@ public class BetaAuthServiceImpl implements BetaAuthService {
@Value("${app.authTokenPepper:change-me}")
private String tokenPepper;
/**
* When true:
* - Signup captures users (role=BETA, inactive)
* - NO tokens are generated
* - NO emails are sent
*/
@Value("${app.beta.captureOnly:true}")
private boolean betaCaptureOnly;
private final SecureRandom secureRandom = new SecureRandom();
public BetaAuthServiceImpl(
@@ -51,13 +60,39 @@ public class BetaAuthServiceImpl implements BetaAuthService {
}
/**
* Send ONE email with a "confirm+login" token.
* A: Beta signup (capture lead + optionally email 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 or update a "beta lead" user record
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) {
user = new User();
user.setUuid(UUID.randomUUID());
user.setEmail(email);
// Treat beta signups as users, but not active / not verified yet
user.setRole("BETA");
user.setIsActive(false);
user.setDisplayName(null);
user.setCreatedAt(OffsetDateTime.now());
}
// Optional: stash useCase somewhere if desired
// user.setPreferences(mergeUseCase(user.getPreferences(), useCase));
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
// 🚫 Capture-only mode: do not create tokens, do not send email
if (betaCaptureOnly) return;
// --- Invite mode (later) ---
// 24h confirm token
String verifyToken = generateToken();
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
@@ -78,7 +113,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
}
/**
* B: Existing users only — request a magic login link (no signup/confirm).
* B: Existing users only — request a magic login link (no signup/confirm).
* Caller must always return OK to avoid email enumeration.
*/
@Override
@@ -89,11 +124,18 @@ public class BetaAuthServiceImpl implements BetaAuthService {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return;
if (!Boolean.TRUE.equals(user.getIsActive())) return;
boolean isBeta = "BETA".equalsIgnoreCase(user.getRole());
// 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;
// If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return;
// Allow magic link requests for:
// - active USERs, OR
// - BETA users (even if inactive), since they may not be activated yet
if (!Boolean.TRUE.equals(user.getIsActive()) && !isBeta) return;
// Optional: restrict magic links to USER/BETA only (keeps admins/mods out if desired)
if (!("USER".equalsIgnoreCase(user.getRole()) || isBeta)) return;
// 30 minute magic token
String magicToken = generateToken();
@@ -113,7 +155,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
}
/**
* Consumes BETA_VERIFY token, creates/activates user, and returns JWT immediately.
* Consumes BETA_VERIFY token, activates user, promotes BETA->USER, and returns JWT immediately.
*/
@Override
public AuthResponse confirmAndExchange(String token) {
@@ -121,6 +163,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
String email = authToken.getEmail();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
OffsetDateTime now = OffsetDateTime.now();
if (user == null) {
user = new User();
@@ -129,27 +172,27 @@ public class BetaAuthServiceImpl implements BetaAuthService {
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.setCreatedAt(now);
} else {
// Promote BETA -> USER on first successful confirm
if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER");
}
user.setIsActive(true);
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
}
user.setLastLoginAt(OffsetDateTime.now());
user.setLastLoginAt(now);
user.incrementLoginCount();
user.setUpdatedAt(OffsetDateTime.now());
user.setUpdatedAt(now);
users.save(user);
String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
}
/**
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
* Also promotes BETA->USER and activates the account on first successful login.
*/
@Override
public AuthResponse exchangeMagicToken(String token) {
@@ -158,13 +201,22 @@ public class BetaAuthServiceImpl implements BetaAuthService {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
user.setLastLoginAt(OffsetDateTime.now());
OffsetDateTime now = OffsetDateTime.now();
// Promote/activate beta users on first successful magic login
if ("BETA".equalsIgnoreCase(user.getRole())) {
user.setRole("USER");
}
if (!Boolean.TRUE.equals(user.getIsActive())) {
user.setIsActive(true);
}
user.setLastLoginAt(now);
user.incrementLoginCount();
user.setUpdatedAt(OffsetDateTime.now());
user.setUpdatedAt(now);
users.save(user);
String jwt = jwtService.generateToken(user);
return new AuthResponse(jwt, user.getEmail(), user.getDisplayName(), user.getRole());
}
@@ -179,6 +231,9 @@ public class BetaAuthServiceImpl implements BetaAuthService {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
if (user == null) return;
// If capture-only mode is enabled, do not generate tokens or send email
if (betaCaptureOnly) return;
String resetToken = generateToken();
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));

View File

@@ -0,0 +1,102 @@
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.services.utils.EmailService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
@Service
public class BetaInviteService {
private final UserRepository users;
private final AuthTokenRepository tokens;
private final EmailService emailService;
@Value("${app.publicBaseUrl:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.authTokenPepper:change-me}")
private String tokenPepper;
private final SecureRandom secureRandom = new SecureRandom();
public BetaInviteService(UserRepository users, AuthTokenRepository tokens, EmailService emailService) {
this.users = users;
this.tokens = tokens;
this.emailService = emailService;
}
public int inviteAllBetaUsers(int tokenMinutes, int limit, boolean dryRun) {
// You may need to adjust this query depending on your repo methods.
// See NOTE below if you dont have this finder.
List<User> betaUsers = (limit > 0)
? users.findTopNByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA", limit)
: users.findByRoleAndIsActiveFalseAndDeletedAtIsNull("BETA");
int sent = 0;
for (User user : betaUsers) {
String email = user.getEmail();
String magicToken = generateToken();
saveToken(email, AuthToken.TokenType.MAGIC_LOGIN, magicToken,
OffsetDateTime.now().plusMinutes(tokenMinutes));
String magicUrl = publicBaseUrl + "/beta/magic?token=" + magicToken;
String subject = "Your Battl Builders beta access link";
String body = """
Youre in.
Heres your secure sign-in link (expires in %d minutes):
%s
If you didnt request this, you can ignore this email.
""".formatted(tokenMinutes, magicUrl);
if (!dryRun) {
emailService.sendEmail(email, subject, body);
}
sent++;
}
return sent;
}
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 String generateToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
private String hashToken(String token) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hashed = md.digest((tokenPepper + ":" + token).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hashed);
} catch (Exception e) {
throw new RuntimeException("Failed to hash token", e);
}
}
}

View File

@@ -27,6 +27,10 @@ public class EmailServiceImpl implements EmailService {
@Value("${spring.mail.username}")
private String fromEmail;
// Kill switch for beta sign up(default true so dev + future prod work normally)
@Value("${app.email.outbound-enabled:true}")
private boolean outboundEnabled;
/**
* Sends an email and persists send status.
* Uses multipart=true to avoid MimeMessageHelper errors when setting text.
@@ -45,6 +49,13 @@ public class EmailServiceImpl implements EmailService {
emailRequest = emailRequestRepository.save(emailRequest);
// ✅ Capture-only mode: record it, but dont send
if (!outboundEnabled) {
emailRequest.setStatus(EmailStatus.PENDING); // <-- if you don't have this enum, use PENDING
emailRequest.setErrorMessage("Outbound email suppressed by config (app.email.outbound-enabled=false)");
return emailRequestRepository.save(emailRequest);
}
try {
MimeMessage message = mailSender.createMimeMessage();

View File

@@ -34,7 +34,6 @@ spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
#Database settings
spring.datasource.hikari.max-lifetime=600000
minio.endpoint=https://minioapi.dev.gofwd.group
@@ -55,8 +54,17 @@ app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d0
# Magic Token Duration
security.jwt.access-token-days=30
# Beta Invite Email Toggle
app.beta.captureOnly=true
app.email.outbound-enabled=false
# CLI invite runner (off by default)
app.beta.invite.run=false
app.beta.invite.limit=0 # 0 = no limit
app.beta.invite.dryRun=true # true = generate + log, but do NOT send emails
app.beta.invite.tokenMinutes=30
# Ai Enrichment Settings
ai.minConfidence=0.75
ai.openai.apiKey=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
ai.openai.model=gpt-4.1-mini
ai.openai.model=gpt-4.1-mini