feat: add bootstrap and SMTP onboarding

This commit is contained in:
sechmachine
2026-08-14 23:46:09 +07:00
parent 4b37f8fd05
commit bc70db1d0d
23 changed files with 986 additions and 0 deletions
@@ -0,0 +1,74 @@
# Test Evidence: Atomic first administrator bootstrap
- **Test type:** Integration
- **Requirement IDs:** `ACC-001ACC-004, SEC-001SEC-002, GOV-013`
- **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001`
- **Test class/method:** `com.lab.labtimesheet.BootstrapIntegrationTest`
- **Implementation commit:** `this milestone commit`
## Protected behavior
Before initialization only bootstrap and health are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap.
## Test method
A PostgreSQL 18.4 integration test releases two Java 25 virtual-thread-safe requests onto the same service concurrently and asserts the row-locked outcomes and database state. MockMvc checks pre/post-bootstrap route exposure.
## Hand-derived expected result
Two simultaneous submissions produce one `CREATED`, one `ALREADY_INITIALIZED`, one Admin row, and one initialized singleton. Later bootstrap requests cannot create another Admin.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
```
**Observed result**
```text
BootstrapIntegrationTest.java: cannot find symbol class BootstrapService
17 compilation errors
BUILD FAILURE
```
The public bootstrap behavior did not exist.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
./mvnw test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The command used the Java 25 and OrbStack environment exports shown above.
## External-test boundaries
This test does not prove deployment-network privacy for the temporary bootstrap route. Operations must still bootstrap on a private interface before public exposure.
+75
View File
@@ -0,0 +1,75 @@
# Test Evidence: SMTP draft, test, and activation
- **Test type:** Integration
- **Requirement IDs:** `INT-001INT-008, ACC-011, SEC-001`
- **Scenario IDs:** `AC-INT-001, AC-INT-002, AC-ACC-004`
- **Test class/method:** `com.lab.labtimesheet.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted`
- **Implementation commit:** `this milestone commit`
## Protected behavior
SMTP credentials are AES-256-GCM encrypted, only a successfully tested draft can activate, and a failed test cannot alter the draft into an active configuration.
## Test method
The test persists a draft against PostgreSQL 18.4 using a deterministic test-only master key and a recording SMTP boundary. It forces send failure, inspects database state, rejects activation, then allows the probe and activates the tested draft.
## Hand-derived expected result
Ciphertext must not contain the submitted password. Failure leaves `status=DRAFT` and `tested_at=null`; activation fails. A successful test sets test provenance and permits exactly that draft to become `ACTIVE`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
```
**Observed result**
```text
SmtpAccountIntegrationTest.java: cannot find symbol class SmtpConfigurationService
SmtpAccountIntegrationTest.java: cannot find symbol class SmtpProbe
17 compilation errors
BUILD FAILURE
```
The SMTP revision and controllable delivery boundaries were absent.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
./mvnw test
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The command used the Java 25 and OrbStack environment exports shown above.
## External-test boundaries
The test intentionally does not contact Mailpit or an external SMTP server. The production adapter is compiled, while delivery semantics are exercised through the recording boundary without network or secret egress.
+4
View File
@@ -31,6 +31,10 @@
<java.version>25</java.version> <java.version>25</java.version>
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
@@ -2,8 +2,12 @@ package com.lab.labtimesheet;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.configuration.SecurityProperties;
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class)
public class LabtimesheetApplication { public class LabtimesheetApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,34 @@
package com.lab.labtimesheet.accounts;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
class BootstrapAccessFilter extends OncePerRequestFilter {
private final BootstrapService bootstrap;
BootstrapAccessFilter(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String path = request.getRequestURI();
if (!bootstrap.isInitialized() && !allowedBeforeBootstrap(path)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
chain.doFilter(request, response);
}
private static boolean allowedBeforeBootstrap(String path) {
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|| path.equals("/actuator/health") || path.startsWith("/bootstrap-assets/")
|| path.equals("/error");
}
}
@@ -0,0 +1,46 @@
package com.lab.labtimesheet.accounts;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
@Controller
@RequestMapping("/bootstrap")
class BootstrapController {
private final BootstrapService bootstrap;
BootstrapController(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@GetMapping
String form() {
requireOpen();
return "bootstrap/form";
}
@PostMapping
String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password,
Model model) {
try {
if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) {
return "redirect:/login";
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} catch (IllegalArgumentException validation) {
model.addAttribute("error", validation.getMessage());
return "bootstrap/form";
}
}
private void requireOpen() {
if (bootstrap.isInitialized()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
}
@@ -0,0 +1,82 @@
package com.lab.labtimesheet.accounts;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Locale;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class BootstrapService {
private final JdbcTemplate jdbc;
private final TransactionTemplate transactions;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(JdbcTemplate jdbc, TransactionTemplate transactions, PasswordEncoder passwords, Clock clock) {
this.jdbc = jdbc;
this.transactions = transactions;
this.passwords = passwords;
this.clock = clock;
}
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
String normalizedEmail = normalizeEmail(email);
String normalizedName = requireText(displayName, "Display name");
requirePassword(password);
return transactions.execute(status -> {
Boolean initialized = jdbc.queryForObject(
"select initialized from system_state where singleton_id = 1 for update", Boolean.class);
if (Boolean.TRUE.equals(initialized)) {
return BootstrapOutcome.ALREADY_INITIALIZED;
}
OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
Long userId = jdbc.queryForObject("""
insert into app_users
(email, display_name, password_hash, global_role, account_status, activated_at, created_at, updated_at)
values (?, ?, ?, 'ADMIN', 'ACTIVE', ?, ?, ?)
returning id
""", Long.class, normalizedEmail, normalizedName, passwords.encode(password), now, now, now);
jdbc.update("""
update system_state
set initialized = true, initialized_at = ?, bootstrap_admin_id = ?, updated_at = ?, version = version + 1
where singleton_id = 1
""", now, userId, now);
return BootstrapOutcome.CREATED;
});
}
public boolean isInitialized() {
return Boolean.TRUE.equals(jdbc.queryForObject(
"select initialized from system_state where singleton_id = 1", Boolean.class));
}
static String normalizeEmail(String email) {
return requireText(email, "Email").toLowerCase(Locale.ROOT);
}
static void requirePassword(String password) {
if (password == null || password.length() < 12 || password.length() > 128) {
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
}
}
private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " is required");
}
return value.trim();
}
public enum BootstrapOutcome {
CREATED,
ALREADY_INITIALIZED
}
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.accounts;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
class HomeController {
@GetMapping("/")
String home() {
return "home";
}
}
@@ -0,0 +1,37 @@
package com.lab.labtimesheet.accounts;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
class JdbcUserDetailsService implements UserDetailsService {
private final JdbcTemplate jdbc;
JdbcUserDetailsService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String email = BootstrapService.normalizeEmail(username);
return jdbc.query("""
select email, password_hash, global_role, account_status
from app_users where lower(btrim(email)) = ?
""", resultSet -> {
if (!resultSet.next()) {
throw new UsernameNotFoundException("Invalid credentials");
}
boolean active = "ACTIVE".equals(resultSet.getString("account_status"));
String hash = resultSet.getString("password_hash");
return User.withUsername(resultSet.getString("email"))
.password(hash == null ? "{noop}unavailable" : hash)
.roles(resultSet.getString("global_role"))
.disabled(!active)
.build();
}, email);
}
}
@@ -0,0 +1,38 @@
package com.lab.labtimesheet.accounts;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
BootstrapAccessFilter bootstrapAccessFilter(BootstrapService bootstrap) {
return new BootstrapAccessFilter(bootstrap);
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, BootstrapAccessFilter bootstrapAccessFilter)
throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/bootstrap/**", "/activate/**", "/login", "/error", "/actuator/health")
.permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(form -> form.defaultSuccessUrl("/", true))
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
.build();
}
}
@@ -0,0 +1,34 @@
package com.lab.labtimesheet.configuration;
import java.util.Properties;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.stereotype.Component;
@Component
class JavaMailSmtpProbe implements SmtpProbe {
@Override
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body) {
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(connection.host());
sender.setPort(connection.port());
sender.setUsername(connection.username());
sender.setPassword(connection.password());
Properties properties = sender.getJavaMailProperties();
if (connection.securityMode() == SmtpConfigurationService.SecurityMode.STARTTLS) {
properties.setProperty("mail.smtp.starttls.enable", "true");
properties.setProperty("mail.smtp.starttls.required", "true");
} else if (connection.securityMode() == SmtpConfigurationService.SecurityMode.TLS) {
sender.setProtocol("smtps");
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(connection.fromAddress());
message.setTo(recipient);
message.setSubject(subject);
message.setText(body);
sender.send(message);
}
}
@@ -0,0 +1,49 @@
package com.lab.labtimesheet.configuration;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
@Component
public class SecretCipher {
private static final int NONCE_BYTES = 12;
private static final int GCM_TAG_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
SecretCipher(SecurityProperties properties) {
this.key = new SecretKeySpec(properties.decodedMasterKey(), "AES");
}
EncryptedSecret encrypt(String plaintext) {
byte[] nonce = new byte[NONCE_BYTES];
random.nextBytes(nonce);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, nonce));
return new EncryptedSecret(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)), nonce, 1);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to encrypt integration secret", exception);
}
}
String decrypt(byte[] ciphertext, byte[] nonce) {
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, nonce));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Unable to decrypt integration secret", exception);
}
}
record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
}
}
@@ -0,0 +1,29 @@
package com.lab.labtimesheet.configuration;
import java.util.Base64;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("lab.security")
public class SecurityProperties {
private String masterKey;
public String getMasterKey() {
return masterKey;
}
public void setMasterKey(String masterKey) {
this.masterKey = masterKey;
}
byte[] decodedMasterKey() {
if (masterKey == null || masterKey.isBlank()) {
throw new IllegalStateException("lab.security.master-key is required");
}
byte[] decoded = Base64.getDecoder().decode(masterKey);
if (decoded.length != 32) {
throw new IllegalStateException("lab.security.master-key must decode to 256 bits");
}
return decoded;
}
}
@@ -0,0 +1,183 @@
package com.lab.labtimesheet.configuration;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class SmtpConfigurationService {
private final JdbcTemplate jdbc;
private final TransactionTemplate transactions;
private final SecretCipher secrets;
private final SmtpProbe probe;
private final Environment environment;
private final Clock clock;
SmtpConfigurationService(JdbcTemplate jdbc, TransactionTemplate transactions, SecretCipher secrets,
SmtpProbe probe, Environment environment, Clock clock) {
this.jdbc = jdbc;
this.transactions = transactions;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
}
public long saveDraft(long adminId, SmtpDraft draft) {
validate(draft);
SecretCipher.EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
OffsetDateTime now = now();
return transactions.execute(status -> {
Long existing = jdbc.query("select id from smtp_configurations where status = 'DRAFT' for update",
resultSet -> resultSet.next() ? resultSet.getLong(1) : null);
Object[] values = values(draft, password, adminId, now);
if (existing == null) {
return jdbc.queryForObject("""
insert into smtp_configurations
(status, host, port, security_mode, username, password_ciphertext, password_nonce,
secret_key_version, from_address, from_name, created_by_user_id, created_at, updated_at)
values ('DRAFT', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
returning id
""", Long.class, values);
}
jdbc.update("""
update smtp_configurations
set host = ?, port = ?, security_mode = ?, username = ?, password_ciphertext = ?,
password_nonce = ?, secret_key_version = ?, from_address = ?, from_name = ?,
tested_at = null, tested_by_user_id = null, updated_at = ?, version = version + 1
where id = ?
""", draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
now, existing);
return existing;
});
}
private static Object[] values(SmtpDraft draft, SecretCipher.EncryptedSecret password, long adminId,
OffsetDateTime now) {
return new Object[] {
draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
adminId, now, now
};
}
public void testDraft(long draftId, long adminId, String recipient) {
SmtpConnection connection = load(draftId, "DRAFT");
probe.send(connection, recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
OffsetDateTime now = now();
if (jdbc.update("""
update smtp_configurations
set tested_at = ?, tested_by_user_id = ?, updated_at = ?, version = version + 1
where id = ? and status = 'DRAFT'
""", now, adminId, now, draftId) != 1) {
throw new IllegalStateException("SMTP draft is no longer available");
}
}
public void activate(long draftId, long adminId) {
transactions.executeWithoutResult(status -> {
OffsetDateTime testedAt = jdbc.query("""
select tested_at from smtp_configurations where id = ? and status = 'DRAFT' for update
""", resultSet -> resultSet.next() ? resultSet.getObject(1, OffsetDateTime.class) : null, draftId);
if (testedAt == null) {
throw new IllegalStateException("SMTP draft must pass a test before activation");
}
OffsetDateTime now = now();
jdbc.update("""
update smtp_configurations
set status = 'RETIRED', retired_at = ?, retired_by_user_id = ?, updated_at = ?, version = version + 1
where status = 'ACTIVE'
""", now, adminId, now);
jdbc.update("""
update smtp_configurations
set status = 'ACTIVE', activated_at = ?, activated_by_user_id = ?, updated_at = ?, version = version + 1
where id = ? and status = 'DRAFT'
""", now, adminId, now, draftId);
});
}
public boolean hasActiveConfiguration() {
return jdbc.queryForObject("select exists(select 1 from smtp_configurations where status = 'ACTIVE')",
Boolean.class);
}
public SmtpConnection activeConnection() {
return jdbc.query("select id from smtp_configurations where status = 'ACTIVE'",
resultSet -> {
if (!resultSet.next()) {
throw new IllegalStateException("Active SMTP configuration is required");
}
return load(resultSet.getLong(1), "ACTIVE");
});
}
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
}
private SmtpConnection load(long id, String requiredStatus) {
return jdbc.query("""
select host, port, security_mode, username, password_ciphertext, password_nonce,
from_address, from_name
from smtp_configurations where id = ? and status = ?
""", resultSet -> {
if (!resultSet.next()) {
throw new IllegalStateException("SMTP configuration is not available");
}
byte[] ciphertext = resultSet.getBytes("password_ciphertext");
return new SmtpConnection(
resultSet.getString("host"), resultSet.getInt("port"),
SecurityMode.valueOf(resultSet.getString("security_mode")),
resultSet.getString("username"),
ciphertext == null ? null : secrets.decrypt(ciphertext, resultSet.getBytes("password_nonce")),
resultSet.getString("from_address"), resultSet.getString("from_name"));
}, id, requiredStatus);
}
private void validate(SmtpDraft draft) {
if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535
|| draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank()
|| draft.fromName() == null || draft.fromName().isBlank()) {
throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required");
}
if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) {
throw new IllegalArgumentException("SMTP username and password must be supplied together");
}
if (draft.securityMode() == SecurityMode.NONE
&& !environment.acceptsProfiles(Profiles.of("dev", "test"))) {
throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test");
}
}
private OffsetDateTime now() {
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public enum SecurityMode {
NONE,
STARTTLS,
TLS
}
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
}
@@ -0,0 +1,55 @@
package com.lab.labtimesheet.configuration;
import java.security.Principal;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
@RequestMapping("/admin/smtp")
class SmtpController {
private final SmtpConfigurationService smtp;
private final JdbcTemplate jdbc;
SmtpController(SmtpConfigurationService smtp, JdbcTemplate jdbc) {
this.smtp = smtp;
this.jdbc = jdbc;
}
@GetMapping
String form() {
return "smtp/form";
}
@PostMapping("/draft")
String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode,
@RequestParam(required = false) String username, @RequestParam(required = false) String password,
@RequestParam String fromAddress, @RequestParam String fromName, Principal principal) {
smtp.saveDraft(adminId(principal),
new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName));
return "redirect:/admin/smtp";
}
@PostMapping("/test")
String test(@RequestParam long draftId, Principal principal) {
smtp.testDraft(draftId, adminId(principal), principal.getName());
return "redirect:/admin/smtp";
}
@PostMapping("/activate")
String activate(@RequestParam long draftId, Principal principal) {
smtp.activate(draftId, adminId(principal));
return "redirect:/admin/smtp";
}
private long adminId(Principal principal) {
return jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class,
principal.getName());
}
}
@@ -0,0 +1,6 @@
package com.lab.labtimesheet.configuration;
@FunctionalInterface
public interface SmtpProbe {
void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body);
}
+4
View File
@@ -6,3 +6,7 @@ spring:
mail: mail:
host: ${LAB_SMTP_HOST:localhost} host: ${LAB_SMTP_HOST:localhost}
port: ${LAB_SMTP_PORT:1025} port: ${LAB_SMTP_PORT:1025}
lab:
security:
# Explicit non-production key; production must supply its own 256-bit key.
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Initialize Lab Timesheet</title></head>
<body>
<main>
<h1>Create the first administrator</h1>
<p th:if="${error}" th:text="${error}" role="alert"></p>
<form method="post" th:action="@{/bootstrap}">
<label>Email <input name="email" type="email" required autocomplete="email"></label>
<label>Display name <input name="displayName" required autocomplete="name"></label>
<label>Password <input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
<button type="submit">Create administrator</button>
</form>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Lab Timesheet</title></head>
<body><main><h1>Lab Timesheet</h1></main></body>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>SMTP configuration</title></head>
<body>
<main>
<h1>SMTP configuration</h1>
<form method="post" th:action="@{/admin/smtp/draft}">
<label>Host <input name="host" required></label>
<label>Port <input name="port" type="number" min="1" max="65535" required></label>
<label>Security <select name="securityMode"><option>STARTTLS</option><option>TLS</option><option>NONE</option></select></label>
<label>Username <input name="username" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="new-password"></label>
<label>From address <input name="fromAddress" type="email" required></label>
<label>From name <input name="fromName" required></label>
<button type="submit">Save draft</button>
</form>
</main>
</body>
</html>
@@ -0,0 +1,77 @@
package com.lab.labtimesheet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import com.lab.labtimesheet.accounts.BootstrapService;
import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
@Autowired
private BootstrapService bootstrapService;
@Autowired
private MockMvc mockMvc;
@Test
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health")).andExpect(status().isOk());
mockMvc.perform(get("/")).andExpect(status().isNotFound());
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
mockMvc.perform(get("/bootstrap")).andExpect(status().isNotFound());
}
@Test
void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<BootstrapOutcome>> futures = new ArrayList<>();
try (var executor = Executors.newFixedThreadPool(2)) {
for (int i = 0; i < 2; i++) {
int suffix = i;
futures.add(executor.submit(() -> {
ready.countDown();
start.await();
return bootstrapService.bootstrap(
"admin" + suffix + "@example.com", "First Admin", "correct horse battery staple");
}));
}
ready.await();
start.countDown();
}
assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder(
BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject(
"select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'",
Integer.class)).isEqualTo(1);
assertThat(bootstrapService.bootstrap(
"another@example.com", "Another", "correct horse battery staple"))
.isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED);
assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class))
.isTrue();
}
}
@@ -0,0 +1,17 @@
package com.lab.labtimesheet;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
abstract class PlatformDatabaseTestSupport {
@Autowired
protected JdbcTemplate jdbc;
@BeforeEach
void resetPlatformData() {
jdbc.execute("TRUNCATE smtp_configurations, user_action_tokens, intern_profiles, app_users RESTART IDENTITY CASCADE");
jdbc.update("insert into system_state (singleton_id) values (1)");
}
}
@@ -0,0 +1,86 @@
package com.lab.labtimesheet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.charset.StandardCharsets;
import com.lab.labtimesheet.accounts.BootstrapService;
import com.lab.labtimesheet.configuration.SmtpConfigurationService;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft;
import com.lab.labtimesheet.configuration.SmtpProbe;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ActiveProfiles;
@Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class})
@SpringBootTest
@ActiveProfiles("test")
class SmtpIntegrationTest extends PlatformDatabaseTestSupport {
@Autowired
private BootstrapService bootstrapService;
@Autowired
private SmtpConfigurationService smtpService;
@Autowired
private RecordingSmtpProbe smtpProbe;
@Test
void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() {
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
long adminId = jdbc.queryForObject("select id from app_users", Long.class);
long draftId = smtpService.saveDraft(adminId, new SmtpDraft(
"mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab"));
byte[] ciphertext = jdbc.queryForObject(
"select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId);
assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password");
assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?",
Integer.class, draftId)).isEqualTo(12);
assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?",
Integer.class, draftId)).isEqualTo(1);
smtpProbe.fail = true;
assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com"))
.isInstanceOf(IllegalStateException.class);
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId))
.isEqualTo("DRAFT");
assertThat(jdbc.queryForObject("select tested_at is null from smtp_configurations where id = ?", Boolean.class,
draftId)).isTrue();
assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class);
smtpProbe.fail = false;
smtpService.testDraft(draftId, adminId, "admin@example.com");
smtpService.activate(draftId, adminId);
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId))
.isEqualTo("ACTIVE");
}
@TestConfiguration(proxyBeanMethods = false)
static class MailProbeConfiguration {
@Bean
@Primary
RecordingSmtpProbe recordingSmtpProbe() {
return new RecordingSmtpProbe();
}
}
static final class RecordingSmtpProbe implements SmtpProbe {
private boolean fail;
@Override
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject,
String body) {
if (fail) {
throw new IllegalStateException("simulated SMTP failure");
}
}
}
}