keys);
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/MfaCodeRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/MfaCodeRequest.java
new file mode 100644
index 0000000000..c002efc6ae
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/MfaCodeRequest.java
@@ -0,0 +1,12 @@
+package stirling.software.proprietary.security.model.api.user;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+@Data
+public class MfaCodeRequest {
+
+ @Schema(description = "6-digit authentication code from your authenticator app")
+ private String code;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UsernameAndPassMfa.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UsernameAndPassMfa.java
new file mode 100644
index 0000000000..21d4336379
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UsernameAndPassMfa.java
@@ -0,0 +1,14 @@
+package stirling.software.proprietary.security.model.api.user;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class UsernameAndPassMfa extends UsernameAndPass {
+
+ @Schema(description = "6-digit authentication code from authenticator app", example = "123456")
+ private String mfaCode;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java
index a5755edf63..88165b022d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java
@@ -21,6 +21,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
+import java.util.UUID;
import java.util.stream.Collectors;
import javax.sql.DataSource;
@@ -265,14 +266,17 @@ public class DatabaseService implements DatabaseServiceInterface {
String checksum = bytesToHex(digest.digest(content));
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
- try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
+ String verifyDbUrl = "jdbc:h2:mem:backupVerify_" + UUID.randomUUID();
+ // Use a fresh in-memory database per verification to avoid leftover objects between
+ // runs.
+ try (Connection conn = DriverManager.getConnection(verifyDbUrl);
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
stmt.setString(1, backupPath.toString());
stmt.execute();
}
return true;
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
- log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
+ log.error("Backup verification failed for {}: {}", backupPath, e.getMessage());
}
return false;
}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java
new file mode 100644
index 0000000000..f8a558ecff
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/MfaService.java
@@ -0,0 +1,247 @@
+package stirling.software.proprietary.security.service;
+
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.exception.UnsupportedProviderException;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+
+/**
+ * Service for managing multi-factor authentication (MFA) settings for users.
+ *
+ * This service reads and writes MFA-related settings such as secrets, enablement flags, and
+ * last-used TOTP steps.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class MfaService {
+
+ public static final String MFA_ENABLED_KEY = "mfaEnabled";
+ public static final String MFA_SECRET_KEY = "mfaSecret";
+ public static final String MFA_LAST_USED_STEP_KEY = "mfaLastUsedStep";
+ public static final String MFA_REQUIRED_KEY = "mfaRequired";
+
+ private final UserRepository userRepository;
+ private final DatabaseServiceInterface databaseService;
+
+ /**
+ * Determines whether MFA is enabled for the given user.
+ *
+ * @param user target user
+ * @return {@code true} if MFA is enabled
+ */
+ public boolean isMfaEnabled(User user) {
+ String value = getSetting(user, MFA_ENABLED_KEY);
+ return Boolean.parseBoolean(value);
+ }
+
+ /**
+ * Retrieves the MFA secret for the given user.
+ *
+ * @param user target user
+ * @return Base32-encoded secret, or {@code null} if not set
+ */
+ public String getSecret(User user) {
+ return getSetting(user, MFA_SECRET_KEY);
+ }
+
+ /**
+ * Stores a new MFA secret and marks MFA as pending (disabled) for the user.
+ *
+ * @param user target user
+ * @param secret Base32-encoded secret to store
+ * @throws SQLException when database persistence fails
+ * @throws UnsupportedProviderException when the database provider is unsupported
+ */
+ @Transactional
+ public void setSecret(User user, String secret)
+ throws SQLException, UnsupportedProviderException {
+ User managedUser = getUserWithSettings(user);
+ Map settings = ensureSettings(managedUser);
+ settings.put(MFA_ENABLED_KEY, "false");
+ // Clear existing values and flush the removals before inserting a new secret. This keeps
+ // the (user_id, setting_key) PK satisfied even when the persistence context re-inserts the
+ // same keys within a single transaction.
+ settings.remove(MFA_SECRET_KEY);
+ settings.remove(MFA_LAST_USED_STEP_KEY);
+ if (managedUser != null && managedUser.getId() != null) {
+ userRepository.deleteSettingsByUserIdAndKeys(
+ managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
+ userRepository.flush();
+ }
+ settings.put(MFA_SECRET_KEY, secret);
+ persist(managedUser);
+ }
+
+ /**
+ * Enables MFA for the given user.
+ *
+ * @param user target user
+ * @throws SQLException when database persistence fails
+ * @throws UnsupportedProviderException when the database provider is unsupported
+ */
+ public void enableMfa(User user) throws SQLException, UnsupportedProviderException {
+ User managedUser = getUserWithSettings(user);
+ Map settings = ensureSettings(managedUser);
+ settings.put(MFA_ENABLED_KEY, "true");
+ persist(managedUser);
+ }
+
+ /**
+ * Clears any pending MFA setup data for the user.
+ *
+ * @param user target user
+ * @throws SQLException when database persistence fails
+ * @throws UnsupportedProviderException when the database provider is unsupported
+ */
+ public void clearPendingSecret(User user) throws SQLException, UnsupportedProviderException {
+ User managedUser = getUserWithSettings(user);
+ Map settings = ensureSettings(managedUser);
+ settings.put(MFA_ENABLED_KEY, "false");
+ settings.remove(MFA_SECRET_KEY);
+ settings.remove(MFA_LAST_USED_STEP_KEY);
+ persist(managedUser);
+ }
+
+ /**
+ * Disables MFA and clears stored secrets for the user.
+ *
+ * @param user target user
+ * @throws SQLException when database persistence fails
+ * @throws UnsupportedProviderException when the database provider is unsupported
+ */
+ public void disableMfa(User user) throws SQLException, UnsupportedProviderException {
+ User managedUser = getUserWithSettings(user);
+ Map settings = ensureSettings(managedUser);
+ settings.put(MFA_ENABLED_KEY, "false");
+ settings.remove(MFA_SECRET_KEY);
+ settings.remove(MFA_LAST_USED_STEP_KEY);
+ persist(managedUser);
+ }
+
+ /**
+ * Checks whether a TOTP time step has not been used before.
+ *
+ * @param user target user
+ * @param timeStep candidate TOTP time step
+ * @return {@code true} if the time step is usable
+ */
+ public boolean isTotpStepUsable(User user, long timeStep) {
+ User managedUser = getUserWithSettings(user);
+ Map settings = managedUser.getSettings();
+ if (settings == null) {
+ return true;
+ }
+ String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
+ if (lastUsed == null) {
+ return true;
+ }
+ try {
+ long lastUsedStep = Long.parseLong(lastUsed);
+ return timeStep > lastUsedStep;
+ } catch (NumberFormatException ignored) {
+ return true;
+ }
+ }
+
+ /**
+ * Marks a TOTP time step as used, preventing replay.
+ *
+ * @param user target user
+ * @param timeStep time step to mark as used
+ * @return {@code true} if the time step was marked, {@code false} if it was already used
+ * @throws SQLException when database persistence fails
+ * @throws UnsupportedProviderException when the database provider is unsupported
+ */
+ public boolean markTotpStepUsed(User user, long timeStep)
+ throws SQLException, UnsupportedProviderException {
+ User managedUser = getUserWithSettings(user);
+ Map settings = ensureSettings(managedUser);
+ String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
+ if (lastUsed != null) {
+ try {
+ long lastUsedStep = Long.parseLong(lastUsed);
+ if (timeStep <= lastUsedStep) {
+ return false;
+ }
+ } catch (NumberFormatException ignored) {
+ // treat malformed value as unused
+ }
+ }
+ settings.put(MFA_LAST_USED_STEP_KEY, Long.toString(timeStep));
+ persist(managedUser);
+ return true;
+ }
+
+ /**
+ * Determines whether MFA is required for the given user.
+ *
+ * @param user target user
+ * @return {@code true} if MFA is required
+ */
+ public boolean isMfaRequired(User user) {
+ String value = getSetting(user, MFA_REQUIRED_KEY);
+ if (value == null) {
+ value = "false";
+ }
+ log.info("MFA required for user {}: {}", user.getUsername(), value);
+ return Boolean.parseBoolean(value);
+ }
+
+ /**
+ * Sets whether MFA is required for the given user.
+ *
+ * @param user target user
+ * @param required {@code true} to require MFA
+ * @throws SQLException when database persistence fails
+ * @throws UnsupportedProviderException when the database provider is unsupported
+ */
+ public void setMfaRequired(User user, boolean required)
+ throws SQLException, UnsupportedProviderException {
+ User managedUser = getUserWithSettings(user);
+ Map settings = ensureSettings(managedUser);
+ settings.put(MFA_REQUIRED_KEY, Boolean.toString(required));
+ log.info("Set MFA required={} for user {}", required, managedUser.getUsername());
+ persist(managedUser);
+ }
+
+ private String getSetting(User user, String key) {
+ User managedUser = getUserWithSettings(user);
+ Map settings = managedUser.getSettings();
+ if (settings == null) {
+ return null;
+ }
+ return settings.get(key);
+ }
+
+ private User getUserWithSettings(User user) {
+ if (user == null || user.getId() == null) {
+ return user;
+ }
+ return userRepository.findByIdWithSettings(user.getId()).orElse(user);
+ }
+
+ private Map ensureSettings(User user) {
+ Map settings = user.getSettings();
+ if (settings == null) {
+ settings = new HashMap<>();
+ user.setSettings(settings);
+ }
+ return settings;
+ }
+
+ private void persist(User user) throws SQLException, UnsupportedProviderException {
+ userRepository.save(user);
+ databaseService.exportDatabase();
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java
new file mode 100644
index 0000000000..b20cf88dd7
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/SaveUserRequest.java
@@ -0,0 +1,50 @@
+package stirling.software.proprietary.security.service;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import stirling.software.common.model.enumeration.Role;
+import stirling.software.proprietary.model.Team;
+import stirling.software.proprietary.security.model.AuthenticationType;
+
+/**
+ * Carries all attributes required to create or update a user account, including credentials,
+ * SSO/provider details, team association, role and MFA configuration. Used by the security service
+ * layer to persist or update users.
+ *
+ * Defaults:
+ *
+ *
+ * - password: null
+ *
- ssoProviderId: null
+ *
- ssoProvider: null
+ *
- authenticationType: {@code AuthenticationType.WEB}
+ *
- teamId: null
+ *
- team: null
+ *
- role: {@code Role.USER.getRoleId()}
+ *
- firstLogin: false
+ *
- enabled: true
+ *
- requireMfa: false
+ *
- mfaEnabled: false
+ *
- mfaSecret: null
+ *
- mfaLastUsedStep: null
+ *
+ */
+@Getter
+@Builder(builderClassName = "Builder")
+public class SaveUserRequest {
+ private final String username;
+ @Builder.Default private final String password = null;
+ @Builder.Default private final String ssoProviderId = null;
+ @Builder.Default private final String ssoProvider = null;
+ @Builder.Default private final AuthenticationType authenticationType = AuthenticationType.WEB;
+ @Builder.Default private final Long teamId = null;
+ @Builder.Default private final Team team = null;
+ @Builder.Default private final String role = Role.USER.getRoleId();
+ @Builder.Default private final boolean firstLogin = false;
+ @Builder.Default private final boolean enabled = true;
+ @Builder.Default private final boolean requireMfa = false;
+ @Builder.Default private final boolean mfaEnabled = false;
+ @Builder.Default private final String mfaSecret = null;
+ @Builder.Default private final Long mfaLastUsedStep = null;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java
new file mode 100644
index 0000000000..7d64de445f
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TotpService.java
@@ -0,0 +1,159 @@
+package stirling.software.proprietary.security.service;
+
+import java.net.URLEncoder;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.Instant;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.springframework.stereotype.Service;
+
+import lombok.RequiredArgsConstructor;
+
+import stirling.software.common.model.ApplicationProperties;
+import stirling.software.proprietary.security.util.Base32Codec;
+
+/**
+ * Service for generating and validating TOTP secrets and codes for MFA authentication.
+ *
+ * This service handles secret generation, code validation across time steps, and building
+ * otpauth:// URIs to provision authenticator apps.
+ */
+@Service
+@RequiredArgsConstructor
+public class TotpService {
+
+ private static final int SECRET_LENGTH_BYTES = 20;
+ private static final int CODE_DIGITS = 6;
+ private static final int PERIOD_SECONDS = 30;
+ private static final String HMAC_ALGORITHM = "HmacSHA1";
+ private static final String DEFAULT_ISSUER = "Stirling PDF";
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private final ApplicationProperties applicationProperties;
+
+ /**
+ * Generates a new random TOTP secret encoded in Base32.
+ *
+ * @return Base32-encoded secret suitable for provisioning an authenticator app
+ */
+ public String generateSecret() {
+ byte[] secret = new byte[SECRET_LENGTH_BYTES];
+ SECURE_RANDOM.nextBytes(secret);
+ return Base32Codec.encode(secret);
+ }
+
+ /**
+ * Checks whether a submitted TOTP code is valid for the current time window.
+ *
+ * @param secret Base32-encoded shared secret
+ * @param code six-digit code supplied by the user
+ * @return {@code true} if the code is valid for the current time window
+ */
+ public boolean isValidCode(String secret, String code) {
+ return getValidTimeStep(secret, code) != null;
+ }
+
+ /**
+ * Validates a TOTP code and returns the time step it matches.
+ *
+ * @param secret Base32-encoded shared secret
+ * @param code six-digit code supplied by the user
+ * @return matching time step, or {@code null} if the code is invalid
+ */
+ public Long getValidTimeStep(String secret, String code) {
+ if (secret == null || secret.isBlank() || code == null) {
+ return null;
+ }
+
+ String normalizedCode = code.replace(" ", "");
+ if (!normalizedCode.matches("\\d{6}")) {
+ return null;
+ }
+
+ byte[] secretKey;
+ try {
+ secretKey = Base32Codec.decode(secret);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ long timeStep = Instant.now().getEpochSecond() / PERIOD_SECONDS;
+ byte[] normalizedCodeBytes = normalizedCode.getBytes(StandardCharsets.UTF_8);
+
+ for (int offset = -1; offset <= 1; offset++) {
+ long candidate = timeStep + offset;
+ String generatedCode = generateCode(secretKey, candidate);
+ if (MessageDigest.isEqual(
+ generatedCode.getBytes(StandardCharsets.UTF_8), normalizedCodeBytes)) {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Builds an otpauth:// URI for configuring TOTP in authenticator apps.
+ *
+ * @param username account identifier to embed in the label
+ * @param secret Base32-encoded secret to embed in the URI
+ * @return otpauth URI that can be encoded as a QR code
+ */
+ public String buildOtpAuthUri(String username, String secret) {
+ String issuer = resolveIssuer();
+ String label = encodeForOtpAuth(issuer + ":" + username);
+ String encodedIssuer = encodeForOtpAuth(issuer);
+
+ return "otpauth://totp/"
+ + label
+ + "?secret="
+ + secret
+ + "&issuer="
+ + encodedIssuer
+ + "&algorithm=SHA1&digits="
+ + CODE_DIGITS
+ + "&period="
+ + PERIOD_SECONDS;
+ }
+
+ private String encodeForOtpAuth(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
+ }
+
+ private String resolveIssuer() {
+ if (applicationProperties.getUi() != null) {
+ String appName = applicationProperties.getUi().getAppNameNavbar();
+ if (appName != null && !appName.isBlank()) {
+ return appName.trim();
+ }
+ }
+ return DEFAULT_ISSUER;
+ }
+
+ private String generateCode(byte[] secret, long timeStep) {
+ try {
+ ByteBuffer buffer = ByteBuffer.allocate(8);
+ buffer.putLong(timeStep);
+
+ Mac mac = Mac.getInstance(HMAC_ALGORITHM);
+ mac.init(new SecretKeySpec(secret, HMAC_ALGORITHM));
+ byte[] hash = mac.doFinal(buffer.array());
+
+ int offset = hash[hash.length - 1] & 0x0F;
+ int binary =
+ ((hash[offset] & 0x7F) << 24)
+ | ((hash[offset + 1] & 0xFF) << 16)
+ | ((hash[offset + 2] & 0xFF) << 8)
+ | (hash[offset + 3] & 0xFF);
+
+ int otp = binary % (int) Math.pow(10, CODE_DIGITS);
+ return String.format("%0" + CODE_DIGITS + "d", otp);
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to generate TOTP code", e);
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java
index 59a19c1e7c..f2bc3cc2cc 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java
@@ -1,5 +1,10 @@
package stirling.software.proprietary.security.service;
+import static stirling.software.proprietary.security.service.MfaService.MFA_ENABLED_KEY;
+import static stirling.software.proprietary.security.service.MfaService.MFA_LAST_USED_STEP_KEY;
+import static stirling.software.proprietary.security.service.MfaService.MFA_REQUIRED_KEY;
+import static stirling.software.proprietary.security.service.MfaService.MFA_SECRET_KEY;
+
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
@@ -101,7 +106,13 @@ public class UserService implements UserServiceInterface {
}
if (autoCreateUser) {
- saveUser(username, ssoProviderId, ssoProvider, type);
+ SaveUserRequest.Builder builder =
+ SaveUserRequest.builder()
+ .username(username)
+ .ssoProviderId(ssoProviderId)
+ .ssoProvider(ssoProvider)
+ .authenticationType(type);
+ saveUserCore(builder.build());
}
}
@@ -141,6 +152,14 @@ public class UserService implements UserServiceInterface {
return user;
}
+ private User saveUser(Optional user, String apiKey) {
+ if (user.isPresent()) {
+ user.get().setApiKey(apiKey);
+ return userRepository.save(user.get());
+ }
+ throw new UsernameNotFoundException("User not found");
+ }
+
public User refreshApiKeyForUser(String username) {
// reuse the add API key method for refreshing
return addApiKeyToUser(username);
@@ -179,162 +198,6 @@ public class UserService implements UserServiceInterface {
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
}
- public void saveUser(String username, AuthenticationType authenticationType)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
- }
-
- public void saveUser(
- String username,
- String ssoProviderId,
- String ssoProvider,
- AuthenticationType authenticationType)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- saveUser(
- username,
- ssoProviderId,
- ssoProvider,
- authenticationType,
- (Long) null,
- Role.USER.getRoleId());
- }
-
- private User saveUser(Optional user, String apiKey) {
- if (user.isPresent()) {
- user.get().setApiKey(apiKey);
- return userRepository.save(user.get());
- }
- throw new UsernameNotFoundException("User not found");
- }
-
- public User saveUser(
- String username, AuthenticationType authenticationType, Long teamId, String role)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- return saveUserCore(
- username, // username
- null, // password
- null, // ssoProviderId
- null, // ssoProvider
- authenticationType, // authenticationType
- teamId, // teamId
- null, // team
- role, // role
- false, // firstLogin
- true // enabled
- );
- }
-
- public User saveUser(
- String username,
- String ssoProviderId,
- String ssoProvider,
- AuthenticationType authenticationType,
- Long teamId,
- String role)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- return saveUserCore(
- username, // username
- null, // password
- ssoProviderId, // ssoProviderId
- ssoProvider, // ssoProvider
- authenticationType, // authenticationType
- teamId, // teamId
- null, // team
- role, // role
- false, // firstLogin
- true // enabled
- );
- }
-
- public User saveUser(
- String username, AuthenticationType authenticationType, Team team, String role)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- return saveUserCore(
- username, // username
- null, // password
- null, // ssoProviderId
- null, // ssoProvider
- authenticationType, // authenticationType
- null, // teamId
- team, // team
- role, // role
- false, // firstLogin
- true // enabled
- );
- }
-
- public User saveUser(String username, String password, Long teamId)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- return saveUserCore(
- username, // username
- password, // password
- null, // ssoProviderId
- null, // ssoProvider
- AuthenticationType.WEB, // authenticationType
- teamId, // teamId
- null, // team
- Role.USER.getRoleId(), // role
- false, // firstLogin
- true // enabled
- );
- }
-
- public User saveUser(
- String username, String password, Team team, String role, boolean firstLogin)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- return saveUserCore(
- username, // username
- password, // password
- null, // ssoProviderId
- null, // ssoProvider
- AuthenticationType.WEB, // authenticationType
- null, // teamId
- team, // team
- role, // role
- firstLogin, // firstLogin
- true // enabled
- );
- }
-
- public User saveUser(
- String username, String password, Long teamId, String role, boolean firstLogin)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- return saveUserCore(
- username, // username
- password, // password
- null, // ssoProviderId
- null, // ssoProvider
- AuthenticationType.WEB, // authenticationType
- teamId, // teamId
- null, // team
- role, // role
- firstLogin, // firstLogin
- true // enabled
- );
- }
-
- public void saveUser(String username, String password, Long teamId, String role)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- saveUser(username, password, teamId, role, false);
- }
-
- public void saveUser(
- String username, String password, Long teamId, boolean firstLogin, boolean enabled)
- throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- saveUserCore(
- username, // username
- password, // password
- null, // ssoProviderId
- null, // ssoProvider
- AuthenticationType.WEB, // authenticationType
- teamId, // teamId
- null, // team
- Role.USER.getRoleId(), // role
- firstLogin, // firstLogin
- enabled // enabled
- );
- }
-
public void deleteUser(String username) {
Optional userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
@@ -485,75 +348,71 @@ public class UserService implements UserServiceInterface {
}
/**
- * Core implementation for saving a user with all possible parameters. This method centralizes
- * the common logic for all saveUser variants.
+ * Core method to save a user based on the provided SaveUserRequest.
*
- * @param username Username for the new user
- * @param password Password for the user (may be null for SSO/OAuth users)
- * @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
- * @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
- * @param authenticationType Type of authentication (WEB, SSO, etc.)
- * @param teamId ID of the team to assign (may be null to use default)
- * @param team Team object to assign (takes precedence over teamId if both provided)
- * @param role Role to assign to the user
- * @param firstLogin Whether this is the user's first login
- * @param enabled Whether the user account is enabled
+ * @param request The SaveUserRequest containing user details
* @return The saved User object
- * @throws IllegalArgumentException If username is invalid or team is invalid
- * @throws SQLException If database operation fails
- * @throws UnsupportedProviderException If provider is not supported
+ * @throws IllegalArgumentException If the username is invalid
+ * @throws SQLException If a database error occurs
+ * @throws UnsupportedProviderException If an unsupported provider is specified
*/
- private User saveUserCore(
- String username,
- String password,
- String ssoProviderId,
- String ssoProvider,
- AuthenticationType authenticationType,
- Long teamId,
- Team team,
- String role,
- boolean firstLogin,
- boolean enabled)
+ public User saveUserCore(SaveUserRequest request)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
- if (!isUsernameValid(username)) {
+ if (!isUsernameValid(request.getUsername())) {
throw new IllegalArgumentException(getInvalidUsernameMessage());
}
User user = new User();
- user.setUsername(username);
+ user.setUsername(request.getUsername());
// Set password if provided
- if (password != null && !password.isEmpty()) {
- user.setPassword(passwordEncoder.encode(password));
+ if (request.getPassword() != null && !request.getPassword().isEmpty()) {
+ user.setPassword(passwordEncoder.encode(request.getPassword()));
}
// Set SSO provider details if provided
- if (ssoProviderId != null && ssoProvider != null) {
- user.setSsoProviderId(ssoProviderId);
- user.setSsoProvider(ssoProvider);
+ if (request.getSsoProviderId() != null && request.getSsoProvider() != null) {
+ user.setSsoProviderId(request.getSsoProviderId());
+ user.setSsoProvider(request.getSsoProvider());
}
// Set authentication type
- user.setAuthenticationType(authenticationType);
+ user.setAuthenticationType(request.getAuthenticationType());
// Set enabled status
- user.setEnabled(enabled);
+ user.setEnabled(request.isEnabled());
// Set first login flag
- user.setFirstLogin(firstLogin);
+ user.setFirstLogin(request.isFirstLogin());
+
+ // Set MFA requirement
+ Map settings = user.getSettings();
+ settings.put(MFA_REQUIRED_KEY, String.valueOf(request.isRequireMfa()));
+ settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled()));
+ if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) {
+ settings.put(MFA_SECRET_KEY, request.getMfaSecret());
+ } else {
+ settings.remove(MFA_SECRET_KEY);
+ }
+ if (request.getMfaLastUsedStep() != null) {
+ settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep()));
+ } else {
+ settings.remove(MFA_LAST_USED_STEP_KEY);
+ }
+ log.info(
+ "MFA required set to true for user {} {}",
+ request.getUsername(),
+ user.getSettings().toString());
// Set role (authority)
- if (role == null) {
- role = Role.USER.getRoleId();
- }
- user.addAuthority(new Authority(role, user));
+ user.addAuthority(new Authority(request.getRole(), user));
// Resolve and set team
- if (team != null) {
- user.setTeam(team);
+ if (request.getTeam() != null) {
+ user.setTeam(request.getTeam());
} else {
- user.setTeam(resolveTeam(teamId, this::getDefaultTeam));
+ user.setTeam(resolveTeam(request.getTeamId(), this::getDefaultTeam));
}
// Save user
@@ -678,6 +537,7 @@ public class UserService implements UserServiceInterface {
return null;
}
+ @Override
public boolean isCurrentUserAdmin() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -693,6 +553,7 @@ public class UserService implements UserServiceInterface {
return false;
}
+ @Override
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/util/Base32Codec.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/Base32Codec.java
new file mode 100644
index 0000000000..bef4e4ef49
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/util/Base32Codec.java
@@ -0,0 +1,96 @@
+package stirling.software.proprietary.security.util;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.Locale;
+
+/**
+ * RFC 4648 Base32 encoder/decoder for handling TOTP secrets.
+ *
+ *