Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b929ac7a | ||
|
|
4ad2e44e9b | ||
|
|
2149c29a32 | ||
|
|
048d6b3679 | ||
|
|
2a422f638f | ||
|
|
0eb5348e4b | ||
|
|
b19e9682a8 | ||
|
|
8f3ad77b69 | ||
|
|
620aae179b |
@@ -67,6 +67,8 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
|
||||
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
||||
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
||||
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
||||
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
|
||||
| `HTTPS_PORT` | веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в `docker-compose.public.yml` — 8443 → 443: `https://mail.<домен>`); 0 — только HTTP на `server.port` | 0 |
|
||||
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
|
||||
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
|
||||
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
||||
@@ -83,6 +85,22 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
|
||||
| `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` |
|
||||
| `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` |
|
||||
| `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — |
|
||||
| `LOGIN_GUARD` | защита от перебора паролей (см. ниже) | `true` |
|
||||
| `LOGIN_GUARD_MAX_PER_IP`, `LOGIN_GUARD_MAX_PER_ACCOUNT` | неверных паролей за окно до блокировки: с одного IP; на один ящик с любых IP | `10`, `20` |
|
||||
| `LOGIN_GUARD_WINDOW`, `LOGIN_GUARD_BLOCK`, `LOGIN_GUARD_MAX_BLOCK` | окно подсчёта; первая блокировка; потолок (каждая следующая блокировка того же IP вдвое дольше) | `15m`, `15m`, `24h` |
|
||||
| `LOGIN_GUARD_TRUSTED_IPS` | IP через запятую, которые не блокируются по IP (ящик всё равно считается) | — |
|
||||
|
||||
### Защита от перебора паролей
|
||||
|
||||
Один счётчик на всё, где проверяется пароль: `AUTH` в SMTP (25/587/465), `LOGIN`/`AUTHENTICATE` в IMAP, вход в веб-почту, смена пароля.
|
||||
|
||||
- **По IP:** 10 неверных паролей за 15 минут → IP заблокирован на 15 минут, следующая блокировка — 30 минут, потом час… до суток. Сутки тишины — счёт сначала.
|
||||
- **По ящику:** 20 неверных паролей на один ящик с разных IP (распределённый перебор) → ящик закрыт на 15 минут, **кроме** IP, с которых в него уже входили успешно (30 дней) — так перебором нельзя запереть владельца из его же почтовой программы.
|
||||
- Пока блокировка — пароль **не проверяется** вовсе: SMTP `421 4.7.0` и обрыв, IMAP `* BYE` + `NO [UNAVAILABLE]`, веб `429` с `Retry-After`. Неверный пароль отвечается через секунду; после 3 неверных в одном соединении SMTP и IMAP его закрывают.
|
||||
- «Нет такого ящика» и «неверный пароль» снаружи не отличить, оба считаются.
|
||||
- В логе: `login guard: IP … blocked for 15 min after 10 failed logins (block #1)`.
|
||||
- `X-Forwarded-For` веб-входа принимается только от прокси в частной сети (Caddy Jambotron приходит через docker/WireGuard); с публичного адреса заголовок отбрасывается — подменой IP лимит не обойти.
|
||||
- Счётчики в памяти: перезапуск их сбрасывает.
|
||||
|
||||
## Разработка
|
||||
|
||||
@@ -164,7 +182,8 @@ DNS до первого запуска:
|
||||
- [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
|
||||
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
|
||||
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
|
||||
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча).
|
||||
- [x] Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»).
|
||||
- [ ] Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта.
|
||||
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
|
||||
- [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5).
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The web client and API over HTTPS on the mail host itself (mailserver.https.port, in public compose 8443 →
|
||||
* published 443): https://mail.jambocheck.com opens the web mail, no second name through Jambotron's Caddy.
|
||||
* Same certificate as SMTP/IMAP (Let's Encrypt from the acme service, reloaded when renewed). The plain HTTP port
|
||||
* (8080) stays for a reverse proxy inside the network (staging: domain in Jambotron).
|
||||
*/
|
||||
@Configuration
|
||||
public class HttpsConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnExpression("${mailserver.https.port:0} > 0 and ${mailserver.tls.enabled:true}")
|
||||
public HttpsServer httpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls,
|
||||
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.https.port}") int port) {
|
||||
return new HttpsServer(handler, tls, host, port);
|
||||
}
|
||||
|
||||
public static class HttpsServer implements SmartLifecycle {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HttpsServer.class);
|
||||
|
||||
private final HttpHandler handler;
|
||||
private final MailServerConfig.SmtpTlsContext tls;
|
||||
private final String host;
|
||||
private final int port;
|
||||
private volatile DisposableServer server;
|
||||
|
||||
HttpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, String host, int port) {
|
||||
this.handler = handler;
|
||||
this.tls = tls;
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
server = HttpServer.create()
|
||||
.host(host)
|
||||
.port(port)
|
||||
.secure(spec -> spec.sslContext(tls.context()))
|
||||
// HSTS: Spring Security adds it for HTTPS requests by itself
|
||||
.handle(new ReactorHttpHandlerAdapter(handler))
|
||||
.bindNow(Duration.ofSeconds(30));
|
||||
log.info("HTTPS (web client + API) listening on {}:{}", host, server.port());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
DisposableServer current = server;
|
||||
if (current != null) {
|
||||
current.disposeNow(Duration.ofSeconds(10));
|
||||
server = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
return server != null;
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server == null ? -1 : server.port();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import com.mailserver.imap.DbImapStore;
|
||||
import com.mailserver.imap.ImapServer;
|
||||
import com.mailserver.imap.ImapStore;
|
||||
@@ -32,7 +33,7 @@ public class ImapConfig {
|
||||
|
||||
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||
@ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls,
|
||||
public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard,
|
||||
@Value("${mailserver.imap.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.imap.port:3143}") int port,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@@ -41,12 +42,12 @@ public class ImapConfig {
|
||||
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
|
||||
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
|
||||
return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false,
|
||||
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store);
|
||||
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
|
||||
}
|
||||
|
||||
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||
@ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}")
|
||||
public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls,
|
||||
public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard,
|
||||
@Value("${mailserver.imap.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.imaps.port}") int port,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@@ -54,6 +55,6 @@ public class ImapConfig {
|
||||
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
|
||||
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
|
||||
return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true,
|
||||
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store);
|
||||
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.web.server.adapter.ForwardedHeaderTransformer;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Brute-force protection (one guard for SMTP, IMAP and the web) and whose X-Forwarded-* headers to believe. */
|
||||
@Configuration
|
||||
public class LoginGuardConfig {
|
||||
|
||||
@Bean
|
||||
public LoginGuard loginGuard(@Value("${mailserver.login-guard.enabled:true}") boolean enabled,
|
||||
@Value("${mailserver.login-guard.max-per-ip:10}") int maxPerIp,
|
||||
@Value("${mailserver.login-guard.max-per-account:20}") int maxPerAccount,
|
||||
@Value("${mailserver.login-guard.window:15m}") Duration window,
|
||||
@Value("${mailserver.login-guard.block:15m}") Duration block,
|
||||
@Value("${mailserver.login-guard.max-block:24h}") Duration maxBlock,
|
||||
@Value("${mailserver.login-guard.known-ip-ttl:30d}") Duration knownIpTtl,
|
||||
@Value("${mailserver.login-guard.trusted-ips:}") String trustedIps,
|
||||
@Value("${mailserver.login-guard.failure-delay:1s}") Duration failureDelay) {
|
||||
Set<String> trusted = Arrays.stream(trustedIps.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toUnmodifiableSet());
|
||||
return new LoginGuard(new LoginGuard.Settings(enabled, maxPerIp, maxPerAccount, window, block, maxBlock,
|
||||
knownIpTtl, trusted, failureDelay));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces Boot's transformer (server.forward-headers-strategy=framework): X-Forwarded-* / Forwarded are believed
|
||||
* only from a proxy in a private network (Jambotron's Caddy comes over the docker network / WireGuard). A request
|
||||
* straight from the internet (the VM's public address) has them removed — otherwise anyone could put a new
|
||||
* X-Forwarded-For into every login attempt and never hit the per-IP limit.
|
||||
*/
|
||||
@Bean(name = "forwardedHeaderTransformer")
|
||||
public ForwardedHeaderTransformer forwardedHeaderTransformer() {
|
||||
return new TrustedProxyHeaderTransformer();
|
||||
}
|
||||
|
||||
static final class TrustedProxyHeaderTransformer extends ForwardedHeaderTransformer {
|
||||
|
||||
private final ForwardedHeaderTransformer stripOnly = new ForwardedHeaderTransformer();
|
||||
|
||||
TrustedProxyHeaderTransformer() {
|
||||
stripOnly.setRemoveOnly(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerHttpRequest apply(ServerHttpRequest request) {
|
||||
return privatePeer(request.getRemoteAddress()) ? super.apply(request) : stripOnly.apply(request);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7. */
|
||||
static boolean privatePeer(InetSocketAddress peer) {
|
||||
if (peer == null || peer.getAddress() == null) {
|
||||
return false;
|
||||
}
|
||||
InetAddress a = peer.getAddress();
|
||||
if (a.isLoopbackAddress() || a.isSiteLocalAddress() || a.isLinkLocalAddress()) {
|
||||
return true;
|
||||
}
|
||||
byte[] b = a.getAddress();
|
||||
return b.length == 16 && (b[0] & 0xfe) == 0xfc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first
|
||||
* mailbox — {@code mailserver.admin.email}, or admin@<first domain>. No role in the database: the list lives in
|
||||
* the configuration, so a stolen mailbox can not make itself an administrator.
|
||||
*/
|
||||
@Component
|
||||
public class MailAdmins {
|
||||
|
||||
private final Set<String> admins;
|
||||
|
||||
public MailAdmins(@Value("${mailserver.admins:}") String admins,
|
||||
@Value("${mailserver.admin.email:}") String firstMailbox,
|
||||
MailDomains domains) {
|
||||
String list = !admins.isBlank() ? admins
|
||||
: !firstMailbox.isBlank() ? firstMailbox
|
||||
: "admin@" + domains.domains().iterator().next();
|
||||
this.admins = Arrays.stream(list.split(","))
|
||||
.map(a -> a.trim().toLowerCase(Locale.ROOT))
|
||||
.filter(a -> !a.isEmpty())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public boolean isAdmin(String email) {
|
||||
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||
import com.mailserver.smtp.MailRouter;
|
||||
import com.mailserver.smtp.SmtpServer;
|
||||
@@ -39,7 +40,7 @@ public class MailServerConfig {
|
||||
|
||||
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls,
|
||||
public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
|
||||
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.smtp.port:3025}") int port,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@@ -47,20 +48,20 @@ public class MailServerConfig {
|
||||
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
|
||||
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
|
||||
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout,
|
||||
tls.context(), false), router);
|
||||
tls.context(), false), router, guard);
|
||||
}
|
||||
|
||||
/** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
|
||||
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}")
|
||||
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls,
|
||||
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
|
||||
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.smtps.port}") int port,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
|
||||
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
|
||||
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
|
||||
tls.context(), true), router);
|
||||
tls.context(), true), router, guard);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.mailserver.config.MailAdmins;
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.LoginRequest;
|
||||
@@ -10,11 +11,16 @@ import com.mailserver.dto.ProfileUpdateRequest;
|
||||
import com.mailserver.dto.RegisterRequest;
|
||||
import com.mailserver.service.MailService;
|
||||
import com.mailserver.service.UserService;
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import com.mailserver.util.JwtUtils;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -25,6 +31,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -34,6 +42,10 @@ import java.util.Set;
|
||||
@RequestMapping("/api")
|
||||
public class AuthenticationController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthenticationController.class);
|
||||
/** Marker for "no such mailbox or wrong password": both look the same from outside. */
|
||||
private static final User NO_USER = new User();
|
||||
|
||||
/** Service addresses nobody can register (RFC 2142 and ours). */
|
||||
static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster",
|
||||
"root", "admin", "noreply", "no-reply", "security", "support");
|
||||
@@ -43,9 +55,14 @@ public class AuthenticationController {
|
||||
private final JwtUtils jwtUtils;
|
||||
private final MailDomains domains;
|
||||
private final boolean registrationEnabled;
|
||||
private final LoginGuard guard;
|
||||
private final MailAdmins admins;
|
||||
|
||||
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
|
||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
|
||||
LoginGuard guard, MailAdmins admins) {
|
||||
this.guard = guard;
|
||||
this.admins = admins;
|
||||
this.userService = userService;
|
||||
this.mailService = mailService;
|
||||
this.jwtUtils = jwtUtils;
|
||||
@@ -60,23 +77,34 @@ public class AuthenticationController {
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) {
|
||||
return requestMono.flatMap(request ->
|
||||
userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT))
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials")))
|
||||
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono, ServerHttpRequest http) {
|
||||
String ip = clientIp(http);
|
||||
return requestMono.flatMap(request -> {
|
||||
String email = request.getEmail().trim().toLowerCase(Locale.ROOT);
|
||||
Duration wait = guard.retryAfter(ip, email);
|
||||
if (!wait.isZero()) {
|
||||
log.warn("WEB {}: login as {} refused, blocked for {}", ip, email, LoginGuard.human(wait));
|
||||
return Mono.just(tooMany(wait));
|
||||
}
|
||||
return userService.findByEmail(email)
|
||||
.map(user -> userService.checkPassword(user, request.getPassword()) ? user : NO_USER)
|
||||
.onErrorResume(IllegalArgumentException.class, e -> Mono.just(NO_USER))
|
||||
.defaultIfEmpty(NO_USER)
|
||||
.flatMap(user -> {
|
||||
if (user == NO_USER) {
|
||||
guard.failure(ip, email);
|
||||
log.warn("WEB {}: authentication failed for {}", ip, email);
|
||||
Mono<ResponseEntity<?>> answer = Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
|
||||
Duration delay = guard.failureDelay();
|
||||
return delay.isZero() ? answer : answer.delayElement(delay);
|
||||
}
|
||||
guard.success(ip, email);
|
||||
if (!user.isActive()) {
|
||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
|
||||
}
|
||||
if (!userService.checkPassword(user, request.getPassword())) {
|
||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
|
||||
}
|
||||
return Mono.just(ResponseEntity.ok(token(user)));
|
||||
})
|
||||
.onErrorResume(IllegalArgumentException.class, e ->
|
||||
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")))
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** New mailbox in one of our domains; logs in right away (the answer carries the token). */
|
||||
@@ -116,16 +144,46 @@ public class AuthenticationController {
|
||||
}
|
||||
|
||||
@PutMapping("/me/password")
|
||||
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body) {
|
||||
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body,
|
||||
ServerHttpRequest http) {
|
||||
String ip = clientIp(http);
|
||||
return user(auth).zipWith(body)
|
||||
.flatMap(pair -> userService.changePassword(pair.getT1(), pair.getT2().currentPassword(), pair.getT2().newPassword()))
|
||||
.flatMap(pair -> {
|
||||
User user = pair.getT1();
|
||||
// Someone with a stolen session must not guess the current password either
|
||||
Duration wait = guard.retryAfter(ip, user.getEmail());
|
||||
if (!wait.isZero()) {
|
||||
return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Too many failed attempts, try again in " + LoginGuard.human(wait)));
|
||||
}
|
||||
if (!userService.checkPassword(user, pair.getT2().currentPassword())) {
|
||||
guard.failure(ip, user.getEmail());
|
||||
}
|
||||
return userService.changePassword(user, pair.getT2().currentPassword(), pair.getT2().newPassword());
|
||||
})
|
||||
.thenReturn(Map.of("message", "Password changed"));
|
||||
}
|
||||
|
||||
private static ResponseEntity<?> tooMany(Duration wait) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header(HttpHeaders.RETRY_AFTER, String.valueOf(Math.max(1, (wait.toMillis() + 999) / 1000)))
|
||||
.body(Map.of("error", "Too many failed logins, try again in " + LoginGuard.human(wait)));
|
||||
}
|
||||
|
||||
/** Client address; behind Caddy it comes from X-Forwarded-For (server.forward-headers-strategy=framework). */
|
||||
static String clientIp(ServerHttpRequest http) {
|
||||
InetSocketAddress address = http.getRemoteAddress();
|
||||
if (address == null) {
|
||||
return "unknown";
|
||||
}
|
||||
return address.getAddress() != null ? address.getAddress().getHostAddress() : address.getHostString();
|
||||
}
|
||||
|
||||
private Mono<ProfileResponse> profile(User user) {
|
||||
return mailService.usedBytes(user.getId())
|
||||
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
|
||||
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt()));
|
||||
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt(),
|
||||
admins.isAdmin(user.getEmail())));
|
||||
}
|
||||
|
||||
private Mono<User> user(Mono<Authentication> auth) {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.mailserver.config.MailAdmins;
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.service.MailService;
|
||||
import com.mailserver.service.UserService;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
|
||||
* support@ — registration stays closed), new password, switch off. A generated password is returned once, in the
|
||||
* answer — it is not logged.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/mailboxes")
|
||||
public class MailboxAdminController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
|
||||
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?");
|
||||
private static final int MIN_PASSWORD = 10;
|
||||
|
||||
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin,
|
||||
long usedBytes, long quotaBytes, Instant createdAt) {
|
||||
}
|
||||
|
||||
/** @param password empty — the server generates one and returns it once */
|
||||
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
|
||||
@Size(max = 200) String password) {
|
||||
}
|
||||
|
||||
public record PasswordRequest(@Size(max = 200) String password) {
|
||||
}
|
||||
|
||||
public record ActiveRequest(boolean active) {
|
||||
}
|
||||
|
||||
/** @param password the generated password, or null when the administrator chose it */
|
||||
public record Created(MailboxView mailbox, String password) {
|
||||
}
|
||||
|
||||
private final UserService users;
|
||||
private final MailService mail;
|
||||
private final MailDomains domains;
|
||||
private final MailAdmins admins;
|
||||
|
||||
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins) {
|
||||
this.users = users;
|
||||
this.mail = mail;
|
||||
this.domains = domains;
|
||||
this.admins = admins;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Flux<MailboxView> list(Mono<Authentication> auth) {
|
||||
return admin(auth).thenMany(users.all())
|
||||
.concatMap(this::view)
|
||||
.sort(Comparator.comparing(MailboxView::email));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
|
||||
return admin(auth).then(body).flatMap(request -> {
|
||||
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
|
||||
int at = email.lastIndexOf('@');
|
||||
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
|
||||
return Mono.error(bad("Not a mailbox address: " + email));
|
||||
}
|
||||
if (!domains.isLocal(email)) {
|
||||
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
|
||||
}
|
||||
String chosen = request.password() == null ? "" : request.password();
|
||||
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
|
||||
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
|
||||
}
|
||||
String password = chosen.isEmpty() ? randomPassword() : chosen;
|
||||
return users.register(email, password, request.displayName())
|
||||
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))
|
||||
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
|
||||
.flatMap(this::view)
|
||||
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
|
||||
});
|
||||
}
|
||||
|
||||
/** New password: the given one, or a generated one returned once. */
|
||||
@PostMapping("/{id}/password")
|
||||
public Mono<Created> password(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<PasswordRequest> body) {
|
||||
return admin(auth).then(body.defaultIfEmpty(new PasswordRequest(null))).flatMap(request -> {
|
||||
String chosen = request.password() == null ? "" : request.password();
|
||||
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
|
||||
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
|
||||
}
|
||||
String password = chosen.isEmpty() ? randomPassword() : chosen;
|
||||
return mailbox(id)
|
||||
.flatMap(user -> users.setPassword(user, password))
|
||||
.doOnNext(user -> log.info("Password of {} set by administrator", user.getEmail()))
|
||||
.flatMap(this::view)
|
||||
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
|
||||
});
|
||||
}
|
||||
|
||||
/** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */
|
||||
@PutMapping("/{id}/active")
|
||||
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
|
||||
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
|
||||
if (!request.active() && user.getEmail().equalsIgnoreCase(me)) {
|
||||
return Mono.error(bad("You can not switch off your own mailbox"));
|
||||
}
|
||||
log.info("Mailbox {} {} by administrator", user.getEmail(), request.active() ? "switched on" : "switched off");
|
||||
return users.setActive(user, request.active());
|
||||
}))).flatMap(this::view);
|
||||
}
|
||||
|
||||
private Mono<String> admin(Mono<Authentication> auth) {
|
||||
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||
.map(Authentication::getName)
|
||||
.filter(admins::isAdmin)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Only for mail administrators")));
|
||||
}
|
||||
|
||||
private Mono<User> mailbox(Long id) {
|
||||
return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found")));
|
||||
}
|
||||
|
||||
private Mono<MailboxView> view(User user) {
|
||||
return mail.usedBytes(user.getId()).defaultIfEmpty(0L)
|
||||
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
|
||||
admins.isAdmin(user.getEmail()), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
|
||||
user.getCreatedAt()));
|
||||
}
|
||||
|
||||
private static ResponseStatusException bad(String message) {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
|
||||
static String randomPassword() {
|
||||
byte[] bytes = new byte[18];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,7 @@ package com.mailserver.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) {
|
||||
/** @param admin may manage mailboxes ({@link com.mailserver.config.MailAdmins}) */
|
||||
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt,
|
||||
boolean admin) {
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mailserver.imap;
|
||||
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
@@ -67,11 +68,17 @@ public class ImapServer {
|
||||
|
||||
private final Settings settings;
|
||||
private final ImapStore store;
|
||||
private final LoginGuard guard;
|
||||
private DisposableServer server;
|
||||
|
||||
public ImapServer(Settings settings, ImapStore store) {
|
||||
this(settings, store, LoginGuard.disabled());
|
||||
}
|
||||
|
||||
public ImapServer(Settings settings, ImapStore store, LoginGuard guard) {
|
||||
this.settings = settings;
|
||||
this.store = store;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -98,7 +105,7 @@ public class ImapServer {
|
||||
});
|
||||
ImapSession session = new ImapSession(
|
||||
new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()),
|
||||
store, remoteIp[0], settings.implicitTls());
|
||||
store, guard, remoteIp[0], settings.implicitTls());
|
||||
@SuppressWarnings("unchecked")
|
||||
Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1];
|
||||
|
||||
@@ -135,7 +142,9 @@ public class ImapServer {
|
||||
log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString());
|
||||
return Mono.just(ImapSession.line("* BYE " + e.getMessage()));
|
||||
})
|
||||
.takeUntil(response -> session.closing);
|
||||
// after the tagged line of the command that closes (LOGOUT, too many failed logins), not after
|
||||
// its first untagged "* BYE" — the tagged answer must still reach the client
|
||||
.takeUntil(response -> session.closing && !untagged(response));
|
||||
|
||||
return Flux.concat(Mono.just(session.greeting()), responses)
|
||||
.concatMap(response -> response == ImapSession.STARTTLS_READY
|
||||
@@ -164,6 +173,10 @@ public class ImapServer {
|
||||
return new String(bytes, StandardCharsets.US_ASCII).trim().toUpperCase(Locale.ROOT).equals("DONE");
|
||||
}
|
||||
|
||||
private static boolean untagged(byte[] response) {
|
||||
return response.length > 1 && response[0] == '*' && response[1] == ' ';
|
||||
}
|
||||
|
||||
private static Flux<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) {
|
||||
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mailserver.imap;
|
||||
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import com.mailserver.service.LocalDelivery;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
@@ -9,6 +10,7 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -39,6 +41,8 @@ final class ImapSession {
|
||||
private static final Logger log = LoggerFactory.getLogger(ImapSession.class);
|
||||
|
||||
private static final int MAX_BAD_COMMANDS = 30;
|
||||
/** Wrong passwords in one connection before it is closed. */
|
||||
private static final int MAX_AUTH_FAILURES = 3;
|
||||
private static final int SOURCE_CACHE = 16;
|
||||
private static final DateTimeFormatter INTERNAL_DATE =
|
||||
DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss Z", Locale.ENGLISH);
|
||||
@@ -53,6 +57,7 @@ final class ImapSession {
|
||||
|
||||
private final Settings settings;
|
||||
private final ImapStore store;
|
||||
private final LoginGuard guard;
|
||||
final String remoteIp;
|
||||
|
||||
boolean tls;
|
||||
@@ -78,8 +83,13 @@ final class ImapSession {
|
||||
volatile String idleTag;
|
||||
|
||||
ImapSession(Settings settings, ImapStore store, String remoteIp, boolean tls) {
|
||||
this(settings, store, LoginGuard.disabled(), remoteIp, tls);
|
||||
}
|
||||
|
||||
ImapSession(Settings settings, ImapStore store, LoginGuard guard, String remoteIp, boolean tls) {
|
||||
this.settings = settings;
|
||||
this.store = store;
|
||||
this.guard = guard;
|
||||
this.remoteIp = remoteIp;
|
||||
this.tls = tls;
|
||||
}
|
||||
@@ -254,7 +264,7 @@ final class ImapSession {
|
||||
out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first"));
|
||||
return;
|
||||
}
|
||||
loggedIn(tag, store.authenticate(user, password), user, out);
|
||||
checkPassword(tag, user, password, out);
|
||||
}
|
||||
|
||||
private void authenticate(String tag, ImapReader in, List<byte[]> out) {
|
||||
@@ -292,13 +302,44 @@ final class ImapSession {
|
||||
bad(tag, "Bad PLAIN response", out);
|
||||
return;
|
||||
}
|
||||
loggedIn(tag, store.authenticate(fields[1], fields[2]), fields[1], out);
|
||||
checkPassword(tag, fields[1], fields[2], out);
|
||||
}
|
||||
|
||||
/** Through the login guard: a blocked IP/mailbox gets no answer about the password and the connection closes. */
|
||||
private void checkPassword(String tag, String user, String password, List<byte[]> out) {
|
||||
Duration wait = guard.retryAfter(remoteIp, user);
|
||||
if (!wait.isZero()) {
|
||||
log.warn("IMAP {}: login as {} refused, blocked for {}", remoteIp, user, LoginGuard.human(wait));
|
||||
out.add(line("* BYE Too many failed logins, try again in " + LoginGuard.human(wait)));
|
||||
out.add(line(tag + " NO [UNAVAILABLE] Too many failed logins"));
|
||||
closing = true;
|
||||
return;
|
||||
}
|
||||
ImapStore.Account result = store.authenticate(user, password);
|
||||
if (result == null) {
|
||||
guard.failure(remoteIp, user);
|
||||
pause(guard.failureDelay());
|
||||
} else {
|
||||
guard.success(remoteIp, user);
|
||||
}
|
||||
loggedIn(tag, result, user, out);
|
||||
}
|
||||
|
||||
private static void pause(Duration delay) {
|
||||
if (delay.isZero()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(delay.toMillis());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void loggedIn(String tag, ImapStore.Account result, String user, List<byte[]> out) {
|
||||
if (result == null) {
|
||||
log.warn("IMAP {}: authentication failed for {}", remoteIp, user);
|
||||
if (++badCommands > 5) {
|
||||
if (++badCommands >= MAX_AUTH_FAILURES) {
|
||||
out.add(line("* BYE Too many failed logins"));
|
||||
closing = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.mailserver.security;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Brute-force protection for every place that checks a password: SMTP AUTH, IMAP LOGIN/AUTHENTICATE, web login,
|
||||
* password change. One instance is shared by all of them, so failures on SMTP and IMAP add up.
|
||||
* <ul>
|
||||
* <li><b>By IP:</b> {@code maxPerIp} failures within {@code window} → the IP is blocked for {@code block};
|
||||
* each next block of the same IP is twice as long, up to {@code maxBlock}.</li>
|
||||
* <li><b>By mailbox:</b> {@code maxPerAccount} failures within {@code window} from any IPs (a distributed attack on
|
||||
* one mailbox) → the mailbox is blocked for {@code block} — but not for IPs that logged in to it successfully
|
||||
* before ({@code knownIpTtl}), so an attacker cannot lock the owner's own mail program out.</li>
|
||||
* </ul>
|
||||
* While blocked the password is not checked at all (no answer to a guess) and the caller gets {@link #retryAfter}.
|
||||
* State is in memory: a restart forgets it, which is fine for minutes-long blocks.
|
||||
*/
|
||||
public class LoginGuard {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LoginGuard.class);
|
||||
|
||||
/** Upper bound of tracked keys of each kind, so a flood of random usernames/IPs cannot eat the memory. */
|
||||
static final int MAX_KEYS = 100_000;
|
||||
private static final int MAX_KNOWN_IPS_PER_ACCOUNT = 20;
|
||||
|
||||
public record Settings(boolean enabled, int maxPerIp, int maxPerAccount, Duration window, Duration block,
|
||||
Duration maxBlock, Duration knownIpTtl, Set<String> trustedIps, Duration failureDelay) {
|
||||
|
||||
public static Settings defaults() {
|
||||
return new Settings(true, 10, 20, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24),
|
||||
Duration.ofDays(30), Set.of(), Duration.ofSeconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
/** Failures in the window, current block and how many times the key was blocked in a row. */
|
||||
private static final class Counter {
|
||||
final Deque<Instant> failures = new ArrayDeque<>();
|
||||
volatile Instant blockedUntil = Instant.EPOCH;
|
||||
int strikes;
|
||||
Instant lastSeen = Instant.EPOCH;
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private final Clock clock;
|
||||
private final Map<String, Counter> byIp = new ConcurrentHashMap<>();
|
||||
private final Map<String, Counter> byAccount = new ConcurrentHashMap<>();
|
||||
/** account → IPs that logged in to it successfully, with the time of the last success. */
|
||||
private final Map<String, Map<String, Instant>> knownIps = new ConcurrentHashMap<>();
|
||||
|
||||
public LoginGuard(Settings settings) {
|
||||
this(settings, Clock.systemUTC());
|
||||
}
|
||||
|
||||
LoginGuard(Settings settings, Clock clock) {
|
||||
this.settings = settings;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** Guard that never blocks (tests, closed networks). */
|
||||
public static LoginGuard disabled() {
|
||||
Settings d = Settings.defaults();
|
||||
return new LoginGuard(new Settings(false, d.maxPerIp(), d.maxPerAccount(), d.window(), d.block(), d.maxBlock(),
|
||||
d.knownIpTtl(), Set.of(), Duration.ZERO));
|
||||
}
|
||||
|
||||
/** Pause before answering "wrong password": slows down guessing inside one connection. */
|
||||
public Duration failureDelay() {
|
||||
return settings.enabled() ? settings.failureDelay() : Duration.ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero when the attempt may go on and the password may be checked; otherwise how long the caller has to wait.
|
||||
*/
|
||||
public Duration retryAfter(String ip, String account) {
|
||||
if (!settings.enabled()) {
|
||||
return Duration.ZERO;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
Duration wait = Duration.ZERO;
|
||||
if (!trusted(ip)) {
|
||||
wait = remaining(byIp.get(ip), now);
|
||||
}
|
||||
String acc = normalize(account);
|
||||
if (acc != null && !known(acc, ip, now)) {
|
||||
Duration accountWait = remaining(byAccount.get(acc), now);
|
||||
if (accountWait.compareTo(wait) > 0) {
|
||||
wait = accountWait;
|
||||
}
|
||||
}
|
||||
return wait;
|
||||
}
|
||||
|
||||
public boolean blocked(String ip, String account) {
|
||||
return !retryAfter(ip, account).isZero();
|
||||
}
|
||||
|
||||
/** A wrong password (or an unknown mailbox). */
|
||||
public void failure(String ip, String account) {
|
||||
if (!settings.enabled()) {
|
||||
return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
if (!trusted(ip)) {
|
||||
count(byIp, ip, settings.maxPerIp(), now, "IP " + ip);
|
||||
}
|
||||
String acc = normalize(account);
|
||||
if (acc != null) {
|
||||
count(byAccount, acc, settings.maxPerAccount(), now, "mailbox " + acc);
|
||||
}
|
||||
}
|
||||
|
||||
/** A right password: the IP becomes known for the mailbox, failures of this IP are forgiven. */
|
||||
public void success(String ip, String account) {
|
||||
if (!settings.enabled()) {
|
||||
return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
String acc = normalize(account);
|
||||
if (acc != null) {
|
||||
if (knownIps.size() < MAX_KEYS || knownIps.containsKey(acc)) {
|
||||
knownIps.compute(acc, (k, ips) -> {
|
||||
Map<String, Instant> map = ips != null ? ips : new LinkedHashMap<>();
|
||||
synchronized (map) {
|
||||
map.remove(ip);
|
||||
map.put(ip, now);
|
||||
while (map.size() > MAX_KNOWN_IPS_PER_ACCOUNT) {
|
||||
map.remove(map.keySet().iterator().next());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}
|
||||
}
|
||||
byIp.computeIfPresent(ip, (k, c) -> {
|
||||
c.failures.clear();
|
||||
return c.blockedUntil.isAfter(now) ? c : null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drops what expired; runs every minute. */
|
||||
@Scheduled(fixedDelay = 60_000)
|
||||
public void sweep() {
|
||||
Instant now = clock.instant();
|
||||
Instant idle = now.minus(settings.maxBlock()).minus(settings.window());
|
||||
byIp.entrySet().removeIf(e -> stale(e.getValue(), now, idle));
|
||||
byAccount.entrySet().removeIf(e -> stale(e.getValue(), now, idle));
|
||||
Instant knownSince = now.minus(settings.knownIpTtl());
|
||||
knownIps.entrySet().removeIf(e -> {
|
||||
synchronized (e.getValue()) {
|
||||
e.getValue().values().removeIf(t -> t.isBefore(knownSince));
|
||||
return e.getValue().isEmpty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
int trackedIps() {
|
||||
return byIp.size();
|
||||
}
|
||||
|
||||
int trackedAccounts() {
|
||||
return byAccount.size();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
private void count(Map<String, Counter> map, String key, int max, Instant now, String what) {
|
||||
if (map.size() >= MAX_KEYS && !map.containsKey(key)) {
|
||||
sweep();
|
||||
if (map.size() >= MAX_KEYS) {
|
||||
log.warn("login guard: {} keys tracked, {} not counted", map.size(), what);
|
||||
return;
|
||||
}
|
||||
}
|
||||
map.compute(key, (k, existing) -> {
|
||||
Counter c = existing != null ? existing : new Counter();
|
||||
c.lastSeen = now;
|
||||
Instant from = now.minus(settings.window());
|
||||
while (!c.failures.isEmpty() && c.failures.peekFirst().isBefore(from)) {
|
||||
c.failures.pollFirst();
|
||||
}
|
||||
if (c.blockedUntil.isAfter(now)) {
|
||||
return c; // already blocked: attempts are refused before the password check anyway
|
||||
}
|
||||
// A long quiet time after the last block forgives the strikes
|
||||
if (c.strikes > 0 && c.blockedUntil.plus(settings.maxBlock()).isBefore(now)) {
|
||||
c.strikes = 0;
|
||||
}
|
||||
c.failures.addLast(now);
|
||||
if (c.failures.size() >= max) {
|
||||
Duration block = settings.block().multipliedBy(1L << Math.min(c.strikes, 20));
|
||||
if (block.compareTo(settings.maxBlock()) > 0) {
|
||||
block = settings.maxBlock();
|
||||
}
|
||||
c.strikes++;
|
||||
c.blockedUntil = now.plus(block);
|
||||
c.failures.clear();
|
||||
log.warn("login guard: {} blocked for {} after {} failed logins (block #{})",
|
||||
what, human(block), max, c.strikes);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
|
||||
private static Duration remaining(Counter c, Instant now) {
|
||||
if (c == null) {
|
||||
return Duration.ZERO;
|
||||
}
|
||||
Instant until = c.blockedUntil;
|
||||
return until.isAfter(now) ? Duration.between(now, until) : Duration.ZERO;
|
||||
}
|
||||
|
||||
private boolean known(String account, String ip, Instant now) {
|
||||
Map<String, Instant> ips = knownIps.get(account);
|
||||
if (ips == null) {
|
||||
return false;
|
||||
}
|
||||
synchronized (ips) {
|
||||
Instant at = ips.get(ip);
|
||||
return at != null && at.plus(settings.knownIpTtl()).isAfter(now);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean trusted(String ip) {
|
||||
return settings.trustedIps().contains(ip);
|
||||
}
|
||||
|
||||
private static boolean stale(Counter c, Instant now, Instant idle) {
|
||||
return !c.blockedUntil.isAfter(now) && c.lastSeen.isBefore(idle);
|
||||
}
|
||||
|
||||
private static String normalize(String account) {
|
||||
if (account == null) {
|
||||
return null;
|
||||
}
|
||||
String acc = account.trim().toLowerCase(Locale.ROOT);
|
||||
return acc.isEmpty() ? null : acc;
|
||||
}
|
||||
|
||||
/** "15 min" / "2 h" for messages to the user. */
|
||||
public static String human(Duration d) {
|
||||
long minutes = Math.max(1, (d.toSeconds() + 59) / 60);
|
||||
return minutes < 120 ? minutes + " min" : (minutes + 59) / 60 + " h";
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,25 @@ public class UserService {
|
||||
return userRepository.save(user).then();
|
||||
}
|
||||
|
||||
public Flux<User> all() {
|
||||
return userRepository.findAll();
|
||||
}
|
||||
|
||||
public Mono<User> byId(Long id) {
|
||||
return userRepository.findById(id);
|
||||
}
|
||||
|
||||
/** Administrator sets a new password (Settings → Mailboxes): no current password needed. */
|
||||
public Mono<User> setPassword(User user, String newPassword) {
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> setActive(User user, boolean active) {
|
||||
user.setActive(active);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> updateDisplayName(User user, String displayName) {
|
||||
user.setDisplayName(displayName.trim());
|
||||
return userRepository.save(user);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufUtil;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
@@ -54,6 +55,8 @@ public class SmtpServer {
|
||||
static final int MAX_LINE = 64 * 1024;
|
||||
static final int MAX_RECIPIENTS = 100;
|
||||
private static final int MAX_BAD_COMMANDS = 20;
|
||||
/** Wrong passwords in one connection before it is closed. */
|
||||
private static final int MAX_AUTH_FAILURES = 3;
|
||||
// Compared by identity: TLS starts right after exactly this reply is written
|
||||
private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n");
|
||||
|
||||
@@ -84,11 +87,17 @@ public class SmtpServer {
|
||||
|
||||
private final Settings settings;
|
||||
private final SmtpBackend backend;
|
||||
private final LoginGuard guard;
|
||||
private DisposableServer server;
|
||||
|
||||
public SmtpServer(Settings settings, SmtpBackend backend) {
|
||||
this(settings, backend, LoginGuard.disabled());
|
||||
}
|
||||
|
||||
public SmtpServer(Settings settings, SmtpBackend backend, LoginGuard guard) {
|
||||
this.settings = settings;
|
||||
this.backend = backend;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -183,6 +192,7 @@ public class SmtpServer {
|
||||
final ByteArrayOutputStream data = new ByteArrayOutputStream();
|
||||
boolean dataTooBig;
|
||||
int badCommands;
|
||||
int authFailures;
|
||||
volatile boolean closing;
|
||||
|
||||
Session(String remoteIp, boolean tls) {
|
||||
@@ -356,16 +366,32 @@ public class SmtpServer {
|
||||
|
||||
private Mono<String> checkPassword(String user, String password) {
|
||||
String username = user.trim().toLowerCase(Locale.ROOT);
|
||||
Duration wait = guard.retryAfter(remoteIp, username);
|
||||
if (!wait.isZero()) {
|
||||
// Blocked: the password is not even checked, the connection is closed
|
||||
log.warn("SMTP {}: login as {} refused, blocked for {}", remoteIp, username, LoginGuard.human(wait));
|
||||
closing = true;
|
||||
return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
|
||||
}
|
||||
return blocking(() -> backend.authenticate(username, password))
|
||||
.map(ok -> {
|
||||
.flatMap(ok -> {
|
||||
if (ok) {
|
||||
guard.success(remoteIp, username);
|
||||
authenticatedUser = username;
|
||||
log.info("SMTP {}: authenticated as {}", remoteIp, username);
|
||||
return "235 2.7.0 Authentication successful\r\n";
|
||||
return Mono.just("235 2.7.0 Authentication successful\r\n");
|
||||
}
|
||||
guard.failure(remoteIp, username);
|
||||
log.warn("SMTP {}: authentication failed for {}", remoteIp, username);
|
||||
badCommands++;
|
||||
return "535 5.7.8 Authentication credentials invalid\r\n";
|
||||
if (++authFailures >= MAX_AUTH_FAILURES) {
|
||||
closing = true;
|
||||
}
|
||||
String answer = closing
|
||||
? "535 5.7.8 Authentication credentials invalid, closing connection\r\n"
|
||||
: "535 5.7.8 Authentication credentials invalid\r\n";
|
||||
Duration delay = guard.failureDelay();
|
||||
return delay.isZero() ? Mono.just(answer) : Mono.just(answer).delayElement(delay);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ mailserver.catch-all=${MAIL_CATCH_ALL:}
|
||||
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
||||
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
||||
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
|
||||
# Who manages mailboxes (Settings → Mailboxes), comma separated; empty — the first mailbox (admin.email or admin@<first domain>)
|
||||
mailserver.admins=${MAIL_ADMINS:}
|
||||
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once
|
||||
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
|
||||
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||
@@ -47,6 +49,8 @@ mailserver.tls.enabled=${MAIL_TLS:true}
|
||||
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
|
||||
mailserver.tls.key-file=${MAIL_TLS_KEY:}
|
||||
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
|
||||
# Web client + API over HTTPS with the same certificate (public compose: 8443 → 443); 0 — only plain HTTP (server.port)
|
||||
mailserver.https.port=${HTTPS_PORT:0}
|
||||
mailserver.smtps.port=${SMTPS_PORT:0}
|
||||
|
||||
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
||||
@@ -83,3 +87,15 @@ management.endpoints.web.exposure.include=health
|
||||
management.endpoint.health.show-details=never
|
||||
|
||||
logging.level.com.mailserver=INFO
|
||||
|
||||
# Brute-force protection for SMTP AUTH, IMAP login, web login and password change (one counter for all of them):
|
||||
# MAX_PER_IP wrong passwords from one IP within WINDOW → the IP is blocked for BLOCK (each next block twice as long,
|
||||
# up to MAX_BLOCK); MAX_PER_ACCOUNT wrong passwords to one mailbox from any IPs → the mailbox is blocked for BLOCK,
|
||||
# except for IPs that logged in to it before. TRUSTED_IPS (comma-separated) are never blocked by IP
|
||||
mailserver.login-guard.enabled=${LOGIN_GUARD:true}
|
||||
mailserver.login-guard.max-per-ip=${LOGIN_GUARD_MAX_PER_IP:10}
|
||||
mailserver.login-guard.max-per-account=${LOGIN_GUARD_MAX_PER_ACCOUNT:20}
|
||||
mailserver.login-guard.window=${LOGIN_GUARD_WINDOW:15m}
|
||||
mailserver.login-guard.block=${LOGIN_GUARD_BLOCK:15m}
|
||||
mailserver.login-guard.max-block=${LOGIN_GUARD_MAX_BLOCK:24h}
|
||||
mailserver.login-guard.trusted-ips=${LOGIN_GUARD_TRUSTED_IPS:}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import com.mailserver.smtp.SmtpTls;
|
||||
import io.netty.handler.ssl.SslContextBuilder;
|
||||
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */
|
||||
class HttpsConfigTest {
|
||||
|
||||
@Test
|
||||
void servesTheWebAppOverTls(@TempDir Path dir) throws Exception {
|
||||
MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost"));
|
||||
HttpHandler handler = (request, response) -> {
|
||||
response.setStatusCode(HttpStatus.OK);
|
||||
return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance
|
||||
.wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8))));
|
||||
};
|
||||
io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
|
||||
HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0);
|
||||
server.start();
|
||||
try {
|
||||
assertThat(server.isRunning()).isTrue();
|
||||
String body = HttpClient.create()
|
||||
.secure(spec -> spec.sslContext(trustAll))
|
||||
.get().uri("https://127.0.0.1:" + server.port() + "/")
|
||||
.responseContent().aggregate().asString().block();
|
||||
assertThat(body).isEqualTo("hello https");
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
assertThat(server.isRunning()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoginGuardConfigTest {
|
||||
|
||||
private final LoginGuardConfig.TrustedProxyHeaderTransformer transformer = new LoginGuardConfig.TrustedProxyHeaderTransformer();
|
||||
|
||||
private ServerHttpRequest from(String peer) {
|
||||
return MockServerHttpRequest.post("/api/login")
|
||||
.remoteAddress(new InetSocketAddress(peer, 40000))
|
||||
.header("X-Forwarded-For", "198.51.100.9")
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedForIsBelievedFromAPrivateProxy() {
|
||||
ServerHttpRequest request = transformer.apply(from("172.18.0.5"));
|
||||
assertThat(request.getRemoteAddress().getHostString()).isEqualTo("198.51.100.9");
|
||||
assertThat(request.getURI().getScheme()).isEqualTo("https");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedForFromTheInternetIsDropped() {
|
||||
ServerHttpRequest request = transformer.apply(from("203.0.113.7"));
|
||||
assertThat(request.getRemoteAddress().getAddress().getHostAddress()).isEqualTo("203.0.113.7");
|
||||
assertThat(request.getHeaders().containsKey("X-Forwarded-For")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void privateRanges() {
|
||||
for (String ip : new String[]{"127.0.0.1", "10.30.0.13", "172.16.0.1", "192.168.1.1", "fd00::1", "::1", "fe80::1"}) {
|
||||
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isTrue();
|
||||
}
|
||||
for (String ip : new String[]{"103.109.235.154", "172.32.0.1", "2001:db8::1", "8.8.8.8"}) {
|
||||
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.LoginRequest;
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import com.mailserver.service.MailService;
|
||||
import com.mailserver.service.UserService;
|
||||
import com.mailserver.util.JwtUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AuthenticationControllerTest {
|
||||
|
||||
private final UserService users = mock(UserService.class);
|
||||
private final JwtUtils jwt = mock(JwtUtils.class);
|
||||
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
|
||||
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
|
||||
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
|
||||
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class));
|
||||
|
||||
private final User alice = new User();
|
||||
|
||||
AuthenticationControllerTest() {
|
||||
alice.setEmail("alice@x.test");
|
||||
alice.setPassword("hash");
|
||||
when(users.findByEmail("alice@x.test")).thenReturn(Mono.just(alice));
|
||||
when(users.findByEmail("ghost@x.test")).thenReturn(Mono.empty());
|
||||
when(users.checkPassword(eq(alice), anyString())).thenAnswer(i -> "secret".equals(i.getArgument(1)));
|
||||
when(jwt.generateToken(any())).thenReturn("token");
|
||||
}
|
||||
|
||||
private ResponseEntity<?> login(String ip, String email, String password) {
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setEmail(email);
|
||||
request.setPassword(password);
|
||||
MockServerHttpRequest http = MockServerHttpRequest.post("/api/login")
|
||||
.remoteAddress(new InetSocketAddress(ip, 40000)).build();
|
||||
return controller.login(Mono.just(request), http).block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
|
||||
assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
|
||||
assertThat(login("1.2.3.4", "alice@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
|
||||
login("1.2.3.4", "alice@x.test", "y");
|
||||
|
||||
ResponseEntity<?> blocked = login("1.2.3.4", "alice@x.test", "secret");
|
||||
assertThat(blocked.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
|
||||
assertThat(blocked.getHeaders().getFirst("Retry-After")).isEqualTo("900");
|
||||
assertThat(blocked.getBody()).isEqualTo(Map.of("error", "Too many failed logins, try again in 15 min"));
|
||||
|
||||
assertThat(login("5.6.7.8", "alice@x.test", "secret").getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedAttemptDoesNotCheckThePassword() {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
login("1.2.3.4", "ghost@x.test", "x");
|
||||
}
|
||||
login("1.2.3.4", "alice@x.test", "secret");
|
||||
verify(users, never()).checkPassword(any(), eq("secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.mailserver.config.MailAdmins;
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.service.MailService;
|
||||
import com.mailserver.service.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** Settings → Mailboxes: only administrators; mailboxes only in our domains; a generated password comes back once. */
|
||||
class MailboxAdminControllerTest {
|
||||
|
||||
private final UserService users = mock(UserService.class);
|
||||
private final MailService mail = mock(MailService.class);
|
||||
private final MailDomains domains = new MailDomains("jambocheck.com", "");
|
||||
private final MailAdmins admins = new MailAdmins("", "", domains);
|
||||
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins);
|
||||
|
||||
private final User admin = user(1L, "admin@jambocheck.com");
|
||||
private final User alice = user(2L, "alice@jambocheck.com");
|
||||
|
||||
MailboxAdminControllerTest() {
|
||||
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
|
||||
when(users.all()).thenReturn(Flux.just(alice, admin));
|
||||
when(users.byId(1L)).thenReturn(Mono.just(admin));
|
||||
when(users.byId(2L)).thenReturn(Mono.just(alice));
|
||||
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0))));
|
||||
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
||||
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||
User u = i.getArgument(0);
|
||||
u.setActive(i.getArgument(1));
|
||||
return Mono.just(u);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void administratorIsTheFirstMailboxByDefault() {
|
||||
assertThat(admins.isAdmin("ADMIN@jambocheck.com")).isTrue();
|
||||
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
|
||||
assertThat(new MailAdmins("ops@jambocheck.com, root@jambocheck.com", "admin@jambocheck.com", domains).isAdmin("admin@jambocheck.com")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsMailboxesSortedForAnAdministratorOnly() {
|
||||
List<MailboxAdminController.MailboxView> list = controller.list(as("admin@jambocheck.com")).collectList().block();
|
||||
assertThat(list).extracting(MailboxAdminController.MailboxView::email).containsExactly("admin@jambocheck.com", "alice@jambocheck.com");
|
||||
assertThat(list.get(0).admin()).isTrue();
|
||||
|
||||
assertThat(status(() -> controller.list(as("alice@jambocheck.com")).collectList().block())).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsReservedNamesWithAGeneratedPasswordShownOnce() {
|
||||
var created = controller.create(as("admin@jambocheck.com"),
|
||||
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block();
|
||||
|
||||
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
|
||||
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
|
||||
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
|
||||
|
||||
var chosen = controller.create(as("admin@jambocheck.com"),
|
||||
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block();
|
||||
assertThat(chosen.password()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesOtherDomainsBadAddressesShortPasswordsAndNonAdmins() {
|
||||
assertThat(status(() -> create("admin@jambocheck.com", "x@gmail.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(status(() -> create("admin@jambocheck.com", "bad address@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(status(() -> create("admin@jambocheck.com", "x@jambocheck.com", "short"))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(status(() -> create("alice@jambocheck.com", "x@jambocheck.com", ""))).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verify(users, never()).register(anyString(), anyString(), any());
|
||||
|
||||
when(users.register(eq("alice@jambocheck.com"), anyString(), any())).thenReturn(Mono.error(new IllegalArgumentException("exists")));
|
||||
assertThat(status(() -> create("admin@jambocheck.com", "alice@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void newPasswordAndSwitchOffButNotYourself() {
|
||||
var reset = controller.password(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.PasswordRequest(null))).block();
|
||||
assertThat(reset.password()).isNotBlank();
|
||||
verify(users).setPassword(alice, reset.password());
|
||||
|
||||
assertThat(controller.active(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block().active()).isFalse();
|
||||
assertThat(status(() -> controller.active(as("admin@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block()))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
private MailboxAdminController.Created create(String me, String email, String password) {
|
||||
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
|
||||
}
|
||||
|
||||
private static Mono<Authentication> as(String email) {
|
||||
return Mono.just(new UsernamePasswordAuthenticationToken(email, null, List.of()));
|
||||
}
|
||||
|
||||
private static HttpStatus status(Runnable call) {
|
||||
Throwable error = org.assertj.core.api.Assertions.catchThrowable(call::run);
|
||||
assertThat(error).isInstanceOf(ResponseStatusException.class);
|
||||
return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value());
|
||||
}
|
||||
|
||||
private static User user(Long id, String email) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setEmail(email);
|
||||
user.setPassword("hash");
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.search.SubjectTerm;
|
||||
import org.eclipse.angus.mail.imap.AppendUID;
|
||||
import org.eclipse.angus.mail.imap.IMAPFolder;
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -184,6 +185,33 @@ class ImapServerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception {
|
||||
server.stop();
|
||||
LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15),
|
||||
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO));
|
||||
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
|
||||
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
|
||||
Duration.ofMinutes(5), Duration.ofMillis(200)), store, guard);
|
||||
server.start();
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.tls("s");
|
||||
assertThat(client.send("a LOGIN alice@example.test w1")).startsWith("a NO [AUTHENTICATIONFAILED]");
|
||||
assertThat(client.send("b LOGIN alice@example.test w2")).startsWith("b NO [AUTHENTICATIONFAILED]");
|
||||
assertThat(client.untilTagged("c", "c LOGIN alice@example.test w3")).contains("* BYE Too many failed logins");
|
||||
assertThat(client.read()).isNull();
|
||||
}
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.tls("s");
|
||||
assertThat(client.send("a LOGIN alice@example.test w4")).startsWith("a NO [AUTHENTICATIONFAILED]");
|
||||
assertThat(client.untilTagged("b", "b LOGIN alice@example.test secret"))
|
||||
.anyMatch(line -> line.startsWith("* BYE Too many failed logins, try again in 15 min"))
|
||||
.anyMatch(line -> line.startsWith("b NO [UNAVAILABLE]"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.mailserver.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoginGuardTest {
|
||||
|
||||
/** Clock the test moves by hand. */
|
||||
static final class TestClock extends Clock {
|
||||
Instant now = Instant.parse("2026-11-06T12:00:00Z");
|
||||
|
||||
void plus(Duration d) {
|
||||
now = now.plus(d);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneOffset getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(java.time.ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
|
||||
private final TestClock clock = new TestClock();
|
||||
|
||||
private LoginGuard guard(int perIp, int perAccount, String... trusted) {
|
||||
return new LoginGuard(new LoginGuard.Settings(true, perIp, perAccount, Duration.ofMinutes(15),
|
||||
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(trusted), Duration.ZERO), clock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ipIsBlockedAfterTheLimitWhateverMailboxItTries() {
|
||||
LoginGuard guard = guard(3, 100);
|
||||
for (int i = 0; i < 2; i++) {
|
||||
guard.failure("1.2.3.4", "user" + i + "@x.test");
|
||||
}
|
||||
assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse();
|
||||
guard.failure("1.2.3.4", "user9@x.test");
|
||||
assertThat(guard.retryAfter("1.2.3.4", "any@x.test")).isEqualTo(Duration.ofMinutes(15));
|
||||
assertThat(guard.blocked("5.6.7.8", "any@x.test")).isFalse();
|
||||
|
||||
clock.plus(Duration.ofMinutes(15));
|
||||
assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failuresOutsideTheWindowDoNotCount() {
|
||||
LoginGuard guard = guard(3, 100);
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
clock.plus(Duration.ofMinutes(16));
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void eachNextBlockOfTheSameIpIsTwiceAsLongUpToTheMaximum() {
|
||||
LoginGuard guard = guard(2, 100);
|
||||
Duration expected = Duration.ofMinutes(15);
|
||||
for (int round = 0; round < 10; round++) {
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
Duration wait = guard.retryAfter("1.2.3.4", "b@x.test");
|
||||
assertThat(wait).isEqualTo(expected);
|
||||
clock.plus(wait);
|
||||
expected = expected.multipliedBy(2).compareTo(Duration.ofHours(24)) > 0 ? Duration.ofHours(24) : expected.multipliedBy(2);
|
||||
}
|
||||
// a quiet day forgives
|
||||
clock.plus(Duration.ofHours(25));
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
assertThat(guard.retryAfter("1.2.3.4", "b@x.test")).isEqualTo(Duration.ofMinutes(15));
|
||||
}
|
||||
|
||||
@Test
|
||||
void attemptsWhileBlockedDoNotProlongTheBlock() {
|
||||
LoginGuard guard = guard(2, 100);
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
clock.plus(Duration.ofMinutes(10));
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
assertThat(guard.retryAfter("1.2.3.4", "a@x.test")).isEqualTo(Duration.ofMinutes(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void distributedAttackBlocksTheMailboxButNotItsOwnerFromAKnownIp() {
|
||||
LoginGuard guard = guard(100, 5);
|
||||
guard.success("10.0.0.7", "Alice@X.test");
|
||||
for (int i = 0; i < 5; i++) {
|
||||
guard.failure("203.0.113." + i, "alice@x.test");
|
||||
}
|
||||
assertThat(guard.blocked("198.51.100.1", "alice@x.test")).isTrue();
|
||||
assertThat(guard.blocked("198.51.100.1", "bob@x.test")).isFalse();
|
||||
assertThat(guard.blocked("10.0.0.7", "alice@x.test")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successForgivesTheIpFailuresButNotAnActiveBlock() {
|
||||
LoginGuard guard = guard(3, 100);
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
guard.success("1.2.3.4", "a@x.test");
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isTrue();
|
||||
guard.success("1.2.3.4", "a@x.test");
|
||||
assertThat(guard.blocked("1.2.3.4", "other@x.test")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void trustedIpIsNeverBlockedByIp() {
|
||||
LoginGuard guard = guard(2, 100, "172.18.0.1");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
guard.failure("172.18.0.1", "u" + i + "@x.test");
|
||||
}
|
||||
assertThat(guard.blocked("172.18.0.1", "new@x.test")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sweepForgetsOldKeys() {
|
||||
LoginGuard guard = guard(5, 5);
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
assertThat(guard.trackedIps()).isEqualTo(1);
|
||||
assertThat(guard.trackedAccounts()).isEqualTo(1);
|
||||
clock.plus(Duration.ofHours(25).plusMinutes(16));
|
||||
guard.sweep();
|
||||
assertThat(guard.trackedIps()).isZero();
|
||||
assertThat(guard.trackedAccounts()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledGuardNeverBlocks() {
|
||||
LoginGuard guard = LoginGuard.disabled();
|
||||
for (int i = 0; i < 100; i++) {
|
||||
guard.failure("1.2.3.4", "a@x.test");
|
||||
}
|
||||
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
|
||||
assertThat(guard.failureDelay()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void humanDuration() {
|
||||
assertThat(LoginGuard.human(Duration.ofSeconds(30))).isEqualTo("1 min");
|
||||
assertThat(LoginGuard.human(Duration.ofMinutes(15))).isEqualTo("15 min");
|
||||
assertThat(LoginGuard.human(Duration.ofHours(4))).isEqualTo("4 h");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import jakarta.mail.Session;
|
||||
import jakarta.mail.Transport;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import com.mailserver.security.LoginGuard;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -158,6 +159,34 @@ class SmtpServerTest {
|
||||
assertThat(backend.messages.get(0)).contains("with ESMTPA;");
|
||||
}
|
||||
|
||||
@Test
|
||||
void threeWrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception {
|
||||
server.stop();
|
||||
LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15),
|
||||
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO));
|
||||
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, true,
|
||||
Duration.ofSeconds(30)), backend, guard);
|
||||
server.start();
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
client.readReplyLines();
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w1"))).startsWith("535");
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w2"))).startsWith("535");
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w3"))).startsWith("535").contains("closing");
|
||||
assertThat(client.read()).isNull();
|
||||
}
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
client.readReplyLines();
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0bob@example.test\0w4"))).startsWith("535");
|
||||
// 4th failure from 127.0.0.1: blocked — even the right password is not checked
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("421 4.7.0");
|
||||
assertThat(client.read()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authLoginWorksToo() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
# 465 SMTPS: mail programs, implicit TLS
|
||||
# 993 IMAPS: mail programs, implicit TLS
|
||||
# 80 only for Let's Encrypt (HTTP-01) — service "acme"
|
||||
# The web client + API (service "mail", port 8080) goes through Jambotron's Caddy under ANOTHER name
|
||||
# 443 web client + API over HTTPS, same Let's Encrypt certificate (acme): https://mail.jambocheck.com
|
||||
# The web client + API can also go through Jambotron's Caddy (plain port 8080) under ANOTHER name
|
||||
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
|
||||
#
|
||||
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
|
||||
@@ -21,6 +22,8 @@
|
||||
#
|
||||
# Mailbox admin@<domain>: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt.
|
||||
# Registration is closed: new mailboxes only by the admin. No catch-all.
|
||||
# Defaults are ours (mail.jambocheck.com / jambocheck.com) — Jambotron deploys without a .env; another domain:
|
||||
# MAIL_HOSTNAME, MAIL_DOMAINS, ACME_EMAIL in .env next to this file.
|
||||
name: reactive-mail-public
|
||||
|
||||
services:
|
||||
@@ -71,8 +74,8 @@ services:
|
||||
[ -s /tls/cert.pem ] && sleep 43200 || sleep 3600
|
||||
done
|
||||
environment:
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-postmaster@${MAIL_DOMAINS}}
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-postmaster@jambocheck.com}
|
||||
LEGO_LOG_FORMAT: text
|
||||
volumes:
|
||||
- lego:/var/lib/lego
|
||||
@@ -119,18 +122,20 @@ services:
|
||||
- "587:3025"
|
||||
- "465:3465"
|
||||
- "993:3993"
|
||||
- "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com
|
||||
expose:
|
||||
- "8080" # web client + API → webmail.<domain> in Jambotron
|
||||
- "8080" # plain HTTP, for a reverse proxy inside the network (not needed on the public address)
|
||||
environment:
|
||||
DB_HOST: db
|
||||
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||
MAIL_DOMAINS: ${MAIL_DOMAINS:?MAIL_DOMAINS, e.g. jambocheck.com}
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com}
|
||||
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
|
||||
MAIL_TLS_CERT: /tls/cert.pem
|
||||
MAIL_TLS_KEY: /tls/key.pem
|
||||
SMTPS_PORT: "3465"
|
||||
IMAPS_PORT: "3993"
|
||||
DKIM_DOMAIN: ${MAIL_DOMAINS}
|
||||
HTTPS_PORT: "8443"
|
||||
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
|
||||
DKIM_SELECTOR: mail
|
||||
DKIM_KEYS_DIR: /app/data/dkim-keys
|
||||
MAIL_CATCH_ALL: ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models';
|
||||
|
||||
/** The mail server REST API (same origin, /api). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -17,6 +17,18 @@ export class ApiService {
|
||||
return this.http.put('/api/me/password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
// mailboxes — administrators only
|
||||
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
|
||||
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password });
|
||||
}
|
||||
newMailboxPassword(id: number): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
|
||||
}
|
||||
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
||||
}
|
||||
|
||||
// folders
|
||||
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
|
||||
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
|
||||
|
||||
@@ -53,6 +53,25 @@ export interface Profile {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
createdAt: string;
|
||||
admin?: boolean; // may manage mailboxes (Settings → Mailboxes)
|
||||
}
|
||||
|
||||
/** MailboxAdminController.MailboxView */
|
||||
export interface Mailbox {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
active: boolean;
|
||||
admin: boolean;
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** password — generated by the server, shown once; null when the administrator chose it */
|
||||
export interface MailboxCreated {
|
||||
mailbox: Mailbox;
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { Mailbox, errorText, formatBytes } from '../../core/models';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
|
||||
@Component({
|
||||
selector: 'app-mailboxes-section',
|
||||
imports: [FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="glass-panel static p-5 md:p-6 xl:col-span-2" aria-labelledby="mailboxes-title">
|
||||
<h2 id="mailboxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
|
||||
</h2>
|
||||
|
||||
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
|
||||
<div><label class="synth-label" for="mb-email">Address</label>
|
||||
<input id="mb-email" name="email" class="synth-input" [(ngModel)]="email" [placeholder]="'no-reply@' + domain()" autocomplete="off" required></div>
|
||||
<div><label class="synth-label" for="mb-name">Name</label>
|
||||
<input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div>
|
||||
<div><label class="synth-label" for="mb-password">Password (empty — generate)</label>
|
||||
<input id="mb-password" name="password" type="password" class="synth-input" [(ngModel)]="password" autocomplete="new-password"></div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
|
||||
</form>
|
||||
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
||||
|
||||
@if (secret(); as s) {
|
||||
<div class="p-3 mb-4 rounded-lg border border-pink-500/40 bg-slate-900/60" role="status">
|
||||
<div class="text-xs text-slate-400 mb-1">Password of <span class="font-mono text-cyan-300">{{ s.email }}</span> — shown once, copy it now</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-pink-300 break-all select-all">{{ s.password }}</code>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="divide-y divide-cyan-500/10">
|
||||
@for (m of mailboxes(); track m.id) {
|
||||
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
|
||||
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
|
||||
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
|
||||
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
|
||||
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
|
||||
@if (m.email !== me()) {
|
||||
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
|
||||
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
|
||||
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class MailboxesSection {
|
||||
private api = inject(ApiService);
|
||||
private state = inject(MailStateService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
readonly mailboxes = signal<Mailbox[]>([]);
|
||||
readonly secret = signal<{ email: string; password: string } | null>(null);
|
||||
readonly error = signal('');
|
||||
readonly busy = signal(false);
|
||||
readonly me = computed(() => this.state.profile()?.email ?? '');
|
||||
readonly domain = computed(() => this.me().split('@')[1] ?? 'example.com');
|
||||
|
||||
email = '';
|
||||
name = '';
|
||||
password = '';
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
size(bytes: number): string {
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: e => this.error.set(errorText(e)) });
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const email = this.email.trim();
|
||||
if (!email) { this.error.set('Enter an address'); return; }
|
||||
this.error.set('');
|
||||
this.busy.set(true);
|
||||
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
|
||||
next: created => {
|
||||
this.busy.set(false);
|
||||
this.email = this.name = this.password = '';
|
||||
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
|
||||
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
|
||||
this.load();
|
||||
},
|
||||
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
newPassword(m: Mailbox): void {
|
||||
if (!confirm('New password for ' + m.email + '? The old one stops working at once.')) return;
|
||||
this.api.newMailboxPassword(m.id).subscribe({
|
||||
next: created => created.password && this.secret.set({ email: m.email, password: created.password }),
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
toggle(m: Mailbox): void {
|
||||
this.api.setMailboxActive(m.id, !m.active).subscribe({
|
||||
next: () => this.load(),
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
copy(text: string): void {
|
||||
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import { MailStateService } from '../../core/mail-state.service';
|
||||
import { errorText, formatBytes } from '../../core/models';
|
||||
import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
import { MailboxesSection } from './mailboxes.section';
|
||||
|
||||
/** Profile name, password, storage, look. */
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [DatePipe, FormsModule, MatIconModule],
|
||||
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
@@ -97,6 +98,10 @@ import { ToastService } from '../../ui/toast.service';
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (state.profile()?.admin) {
|
||||
<app-mailboxes-section />
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user