Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику
continuous-integration/drone/pr Build is passing
continuous-integration/drone/pr Build is passing
Один LoginGuard на все проверки пароля (SMTP AUTH, IMAP LOGIN/AUTHENTICATE, вход в веб, смена пароля): 10 неверных с IP за 15 мин → блок 15 мин, дальше вдвое дольше до суток; 20 неверных на ящик с разных IP → ящик закрыт, кроме IP, с которых в него уже входили. Во время блока пароль не проверяется (SMTP 421, IMAP BYE + NO [UNAVAILABLE], веб 429 + Retry-After); неверный пароль — ответ через 1 с; 3 неверных в соединении — обрыв. X-Forwarded-For веб-входа — только от прокси в частной сети, иначе заголовок отбрасывается (подменой IP лимит не обойти). Попутно: IMAP терял тегированный ответ после "* BYE" (LOGOUT, обрыв после неудачных входов) — takeUntil теперь после тегированной строки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,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 +30,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 +41,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 +54,12 @@ public class AuthenticationController {
|
||||
private final JwtUtils jwtUtils;
|
||||
private final MailDomains domains;
|
||||
private final boolean registrationEnabled;
|
||||
private final LoginGuard guard;
|
||||
|
||||
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) {
|
||||
this.guard = guard;
|
||||
this.userService = userService;
|
||||
this.mailService = mailService;
|
||||
this.jwtUtils = jwtUtils;
|
||||
@@ -60,23 +74,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,12 +141,41 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user