mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
new stuff to flag off beta magic links until were ready for a email blast.
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package group.goforward.battlbuilder.repos;
|
|||||||
|
|
||||||
import group.goforward.battlbuilder.model.User;
|
import group.goforward.battlbuilder.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -17,4 +19,9 @@ public interface UserRepository extends JpaRepository<User, Integer> {
|
|||||||
boolean existsByRole(String role);
|
boolean existsByRole(String role);
|
||||||
|
|
||||||
Optional<User> findByUuidAndDeletedAtIsNull(UUID uuid);
|
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);
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,15 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
@Value("${app.authTokenPepper:change-me}")
|
@Value("${app.authTokenPepper:change-me}")
|
||||||
private String tokenPepper;
|
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();
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
public BetaAuthServiceImpl(
|
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.
|
* The Next page will call /api/auth/beta/confirm and receive AuthResponse.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void signup(String rawEmail, String useCase) {
|
public void signup(String rawEmail, String useCase) {
|
||||||
String email = normalizeEmail(rawEmail);
|
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
|
// 24h confirm token
|
||||||
String verifyToken = generateToken();
|
String verifyToken = generateToken();
|
||||||
saveToken(email, AuthToken.TokenType.BETA_VERIFY, verifyToken, OffsetDateTime.now().plusHours(24));
|
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.
|
* Caller must always return OK to avoid email enumeration.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@@ -89,11 +124,18 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||||
if (user == null) return;
|
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 capture-only mode is enabled, do not generate tokens or send email
|
||||||
// If you want admins to also use magic links, remove this.
|
if (betaCaptureOnly) return;
|
||||||
if (!"USER".equalsIgnoreCase(user.getRole())) 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
|
// 30 minute magic token
|
||||||
String magicToken = generateToken();
|
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
|
@Override
|
||||||
public AuthResponse confirmAndExchange(String token) {
|
public AuthResponse confirmAndExchange(String token) {
|
||||||
@@ -121,6 +163,7 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
String email = authToken.getEmail();
|
String email = authToken.getEmail();
|
||||||
|
|
||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
user = new User();
|
user = new User();
|
||||||
@@ -129,27 +172,27 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
user.setDisplayName(null);
|
user.setDisplayName(null);
|
||||||
user.setRole("USER");
|
user.setRole("USER");
|
||||||
user.setIsActive(true);
|
user.setIsActive(true);
|
||||||
user.setCreatedAt(OffsetDateTime.now());
|
user.setCreatedAt(now);
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
} else {
|
||||||
users.save(user);
|
// Promote BETA -> USER on first successful confirm
|
||||||
} else if (!Boolean.TRUE.equals(user.getIsActive())) {
|
if ("BETA".equalsIgnoreCase(user.getRole())) {
|
||||||
|
user.setRole("USER");
|
||||||
|
}
|
||||||
user.setIsActive(true);
|
user.setIsActive(true);
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
|
||||||
users.save(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.setLastLoginAt(OffsetDateTime.now());
|
user.setLastLoginAt(now);
|
||||||
user.incrementLoginCount();
|
user.incrementLoginCount();
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(now);
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
String jwt = jwtService.generateToken(user);
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
|
* Consumes MAGIC_LOGIN token and returns JWT (returning users).
|
||||||
|
* Also promotes BETA->USER and activates the account on first successful login.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public AuthResponse exchangeMagicToken(String token) {
|
public AuthResponse exchangeMagicToken(String token) {
|
||||||
@@ -158,13 +201,22 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(magic.getEmail())
|
||||||
.orElseThrow(() -> new IllegalStateException("User not found for magic token"));
|
.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.incrementLoginCount();
|
||||||
user.setUpdatedAt(OffsetDateTime.now());
|
user.setUpdatedAt(now);
|
||||||
users.save(user);
|
users.save(user);
|
||||||
|
|
||||||
String jwt = jwtService.generateToken(user);
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +231,9 @@ public class BetaAuthServiceImpl implements BetaAuthService {
|
|||||||
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email).orElse(null);
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
|
|
||||||
|
// If capture-only mode is enabled, do not generate tokens or send email
|
||||||
|
if (betaCaptureOnly) return;
|
||||||
|
|
||||||
String resetToken = generateToken();
|
String resetToken = generateToken();
|
||||||
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
|
saveToken(email, AuthToken.TokenType.PASSWORD_RESET, resetToken, OffsetDateTime.now().plusMinutes(30));
|
||||||
|
|
||||||
|
|||||||
@@ -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 don’t 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 = """
|
||||||
|
You’re in.
|
||||||
|
|
||||||
|
Here’s your secure sign-in link (expires in %d minutes):
|
||||||
|
%s
|
||||||
|
|
||||||
|
If you didn’t 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ public class EmailServiceImpl implements EmailService {
|
|||||||
@Value("${spring.mail.username}")
|
@Value("${spring.mail.username}")
|
||||||
private String fromEmail;
|
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.
|
* Sends an email and persists send status.
|
||||||
* Uses multipart=true to avoid MimeMessageHelper errors when setting text.
|
* Uses multipart=true to avoid MimeMessageHelper errors when setting text.
|
||||||
@@ -45,6 +49,13 @@ public class EmailServiceImpl implements EmailService {
|
|||||||
|
|
||||||
emailRequest = emailRequestRepository.save(emailRequest);
|
emailRequest = emailRequestRepository.save(emailRequest);
|
||||||
|
|
||||||
|
// ✅ Capture-only mode: record it, but don’t 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 {
|
try {
|
||||||
MimeMessage message = mailSender.createMimeMessage();
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ spring.mail.properties.mail.smtp.starttls.enable=true
|
|||||||
spring.mail.properties.mail.smtp.starttls.required=true
|
spring.mail.properties.mail.smtp.starttls.required=true
|
||||||
|
|
||||||
#Database settings
|
#Database settings
|
||||||
|
|
||||||
spring.datasource.hikari.max-lifetime=600000
|
spring.datasource.hikari.max-lifetime=600000
|
||||||
|
|
||||||
minio.endpoint=https://minioapi.dev.gofwd.group
|
minio.endpoint=https://minioapi.dev.gofwd.group
|
||||||
@@ -55,8 +54,17 @@ app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d0
|
|||||||
# Magic Token Duration
|
# Magic Token Duration
|
||||||
security.jwt.access-token-days=30
|
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 Enrichment Settings
|
||||||
ai.minConfidence=0.75
|
ai.minConfidence=0.75
|
||||||
|
|
||||||
ai.openai.apiKey=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
|
ai.openai.apiKey=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
|
||||||
ai.openai.model=gpt-4.1-mini
|
ai.openai.model=gpt-4.1-mini
|
||||||
Reference in New Issue
Block a user