feat: add bootstrap and SMTP onboarding
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# Test Evidence: Atomic first administrator bootstrap
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `ACC-001–ACC-004, SEC-001–SEC-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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Test Evidence: SMTP draft, test, and activation
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `INT-001–INT-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.
|
||||
@@ -31,6 +31,10 @@
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
|
||||
@@ -2,8 +2,12 @@ package com.lab.labtimesheet;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
import com.lab.labtimesheet.configuration.SecurityProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
public class LabtimesheetApplication {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -6,3 +6,7 @@ spring:
|
||||
mail:
|
||||
host: ${LAB_SMTP_HOST:localhost}
|
||||
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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user