feat: add bootstrap and SMTP onboarding
This commit is contained in:
@@ -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