Вход через Jambotron (OAuth 2.0 code + PKCE), пароли приложений, системные ящики
continuous-integration/drone/pr Build is passing
continuous-integration/drone/pr Build is passing
- /api/oauth/start → JAMBOTRON_URL/oauth/authorize (client mail, PKCE S256); /api/oauth/callback: код меняется на сервере у Jambotron /api/oauth/token (Bearer SERVICE_TOKEN) → рабочий ящик сотрудника → сессия, ADMIN Jambotron — ADMIN. - Вход по паролю в веб — только встроенный администратор (когда вход через Jambotron включён). - Пароли приложений (V9 app_passwords): User panel → APP PASSWORDS, только SMTP/IMAP. - Системные ящики (V9 users.is_system): без входа в веб, открывают встроенные администраторы, не в USERS; Admin panel → MAILBOXES → кнопка SYSTEM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* «Sign in with Jambotron»: JAMBOTRON_URL (https://jambotron.com) + SERVICE_TOKEN (the same token Jambotron uses for
|
||||
* the service API — here it proves to Jambotron that the code is exchanged by this mail server). Both set — people log
|
||||
* in through Jambotron, web password login only for the built-in administrator.
|
||||
*/
|
||||
@Component
|
||||
public class JambotronSso {
|
||||
|
||||
private final String url;
|
||||
private final String token;
|
||||
private final String publicUrl;
|
||||
|
||||
public JambotronSso(@Value("${mailserver.jambotron.url:}") String url,
|
||||
@Value("${mailserver.service-token:}") String token,
|
||||
@Value("${mailserver.public-url:}") String publicUrl,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||
this.url = url.isBlank() ? null : url.trim().replaceAll("/+$", "");
|
||||
this.token = token.isBlank() ? null : token.trim();
|
||||
this.publicUrl = publicUrl.isBlank() ? "https://" + hostname : publicUrl.trim().replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return url != null && token != null;
|
||||
}
|
||||
|
||||
public String url() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public String token() {
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Where Jambotron sends the browser back: https://mail.jambocheck.com/api/oauth/callback */
|
||||
public String callback() {
|
||||
return publicUrl + "/api/oauth/callback";
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@ public class MailAdmins {
|
||||
return admins.contains(key) || granted.contains(key);
|
||||
}
|
||||
|
||||
/** Built-in administrators (lower case) — owners of system mailboxes. */
|
||||
public Set<String> builtIn() {
|
||||
return admins;
|
||||
}
|
||||
|
||||
/** Built-in administrator from the configuration (admin@ by default): the role can not be taken away. */
|
||||
public boolean isBuiltIn(String email) {
|
||||
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
|
||||
|
||||
@@ -53,7 +53,7 @@ public class SecurityConfig {
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
||||
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
|
||||
.pathMatchers("/api/service/**").permitAll()
|
||||
.pathMatchers("/api/service/**", "/api/oauth/start", "/api/oauth/callback").permitAll()
|
||||
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
||||
// the web client (static files, SPA routes)
|
||||
.anyExchange().permitAll()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.mailserver.service.AppPasswords;
|
||||
import com.mailserver.service.UserService;
|
||||
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.DeleteMapping;
|
||||
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.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;
|
||||
|
||||
/** User panel → APP PASSWORDS: a password per mail program (Thunderbird, phone), SMTP / IMAP only. */
|
||||
@RestController
|
||||
@RequestMapping("/api/app-passwords")
|
||||
public class AppPasswordsController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AppPasswordsController.class);
|
||||
|
||||
public record CreateRequest(@Size(max = 100) String name) {
|
||||
}
|
||||
|
||||
/** @param password shown once */
|
||||
public record Created(AppPasswords.AppPassword appPassword, String password) {
|
||||
}
|
||||
|
||||
private final AppPasswords passwords;
|
||||
private final UserService users;
|
||||
|
||||
public AppPasswordsController(AppPasswords passwords, UserService users) {
|
||||
this.passwords = passwords;
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Flux<AppPasswords.AppPassword> list(Mono<Authentication> auth) {
|
||||
return me(auth).flatMapMany(id -> passwords.list(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
|
||||
return me(auth).zipWith(body).flatMap(pair -> {
|
||||
String name = pair.getT2().name() == null || pair.getT2().name().isBlank() ? "Mail program" : pair.getT2().name().trim();
|
||||
return passwords.create(pair.getT1(), name)
|
||||
.flatMap(password -> passwords.list(pair.getT1()).last()
|
||||
.doOnNext(p -> log.info("App password '{}' created for user {}", name, pair.getT1()))
|
||||
.map(p -> new Created(p, password)));
|
||||
});
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public Mono<Void> delete(Mono<Authentication> auth, @PathVariable Long id) {
|
||||
return me(auth).flatMap(userId -> passwords.delete(userId, id))
|
||||
.flatMap(deleted -> deleted ? Mono.<Void>empty()
|
||||
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such app password")));
|
||||
}
|
||||
|
||||
private Mono<Long> me(Mono<Authentication> auth) {
|
||||
return auth.map(Authentication::getName).flatMap(users::findByEmail).map(u -> u.getId());
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,12 @@ public class AuthenticationController {
|
||||
private final boolean registrationEnabled;
|
||||
private final LoginGuard guard;
|
||||
private final MailAdmins admins;
|
||||
private final com.mailserver.config.JambotronSso sso;
|
||||
|
||||
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
|
||||
LoginGuard guard, MailAdmins admins) {
|
||||
LoginGuard guard, MailAdmins admins, com.mailserver.config.JambotronSso sso) {
|
||||
this.sso = sso;
|
||||
this.guard = guard;
|
||||
this.admins = admins;
|
||||
this.userService = userService;
|
||||
@@ -73,7 +75,8 @@ public class AuthenticationController {
|
||||
/** Public: is registration open, and in which domains mailboxes can be created. */
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> config() {
|
||||
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList());
|
||||
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList(),
|
||||
"ssoEnabled", sso.enabled());
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@@ -106,6 +109,16 @@ public class AuthenticationController {
|
||||
if (!user.isActive()) {
|
||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
|
||||
}
|
||||
// With «Sign in with Jambotron» the web password is only for the built-in administrator
|
||||
// (Jambotron down); the others' passwords are for mail programs (SMTP / IMAP)
|
||||
if (sso.enabled() && !admins.isBuiltIn(user.getEmail())) {
|
||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
|
||||
"Sign in with Jambotron — the password is only for mail programs")));
|
||||
}
|
||||
if (user.isSystem()) {
|
||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
|
||||
"A system mailbox has no web login: its owners open it from their mailbox")));
|
||||
}
|
||||
return Mono.just(ResponseEntity.ok(token(user)));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,10 @@ public class MailboxAdminController {
|
||||
/** @param role ADMIN or USER; @param builtIn the administrator from the configuration — the role stays */
|
||||
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
|
||||
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt,
|
||||
String role, boolean builtIn) {
|
||||
String role, boolean builtIn, boolean system) {
|
||||
}
|
||||
|
||||
public record SystemRequest(boolean system) {
|
||||
}
|
||||
|
||||
public record RoleRequest(String role) {
|
||||
@@ -190,7 +193,7 @@ public class MailboxAdminController {
|
||||
if (!toAdmin && admins.isBuiltIn(user.getEmail())) {
|
||||
return Mono.error(bad("The built-in administrator stays ADMIN"));
|
||||
}
|
||||
if (toAdmin && user.isShared()) {
|
||||
if (toAdmin && (user.isShared() || user.isSystem())) {
|
||||
return Mono.error(bad("A shared mailbox has no login of its own — it can not be ADMIN"));
|
||||
}
|
||||
return users.setAdmin(user, toAdmin).doOnNext(u -> {
|
||||
@@ -200,6 +203,25 @@ public class MailboxAdminController {
|
||||
}))).flatMap(this::view);
|
||||
}
|
||||
|
||||
/**
|
||||
* System mailbox (no-reply@, monitor@, staging@): no web login, not in USERS; the built-in administrators
|
||||
* open it from their mailbox; programs keep its password. Off — an ordinary mailbox again.
|
||||
*/
|
||||
@PutMapping("/{id}/system")
|
||||
public Mono<MailboxView> system(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<SystemRequest> body) {
|
||||
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
|
||||
if (request.system() && (user.isShared() || admins.isAdmin(user.getEmail()))) {
|
||||
return Mono.error(bad("A shared mailbox or an ADMIN can not be a system mailbox"));
|
||||
}
|
||||
log.info("Mailbox {} system {} by administrator {}", user.getEmail(), request.system() ? "ON" : "OFF", me);
|
||||
return users.setSystem(user, request.system())
|
||||
.flatMap(u -> !request.system() ? Mono.just(u)
|
||||
: Flux.fromIterable(admins.builtIn()).concatMap(users::findOptional)
|
||||
.concatMap(owner -> access.grant(u.getId(), owner.getId(), MailboxAccess.MEMBER))
|
||||
.then(Mono.just(u)));
|
||||
}))).flatMap(this::view);
|
||||
}
|
||||
|
||||
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
|
||||
@PutMapping("/{id}/trap")
|
||||
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
|
||||
@@ -266,7 +288,7 @@ public class MailboxAdminController {
|
||||
return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
|
||||
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
|
||||
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
|
||||
user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail())));
|
||||
user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail()), user.isSystem()));
|
||||
}
|
||||
|
||||
private static ResponseStatusException bad(String message) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.mailserver.config.JambotronSso;
|
||||
import com.mailserver.config.MailAdmins;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.service.UserService;
|
||||
import com.mailserver.util.JwtUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* «Sign in with Jambotron» — OAuth 2.0 authorization code + PKCE (S256):
|
||||
* <ol>
|
||||
* <li>{@code /api/oauth/start} → browser to {@code JAMBOTRON_URL/oauth/authorize?client_id=mail&…}</li>
|
||||
* <li>Jambotron logs the person in (Google / GitHub / password), only staff get a one-time code</li>
|
||||
* <li>{@code /api/oauth/callback?code&state} → code exchanged server to server at {@code /api/oauth/token}
|
||||
* (Bearer SERVICE_TOKEN + code_verifier) → who it is, roles, their work mailbox</li>
|
||||
* <li>session of that mailbox → {@code /sso#token=…}; ADMIN of Jambotron gets role ADMIN here</li>
|
||||
* </ol>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/oauth")
|
||||
public class SsoController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SsoController.class);
|
||||
private static final Duration STATE_TTL = Duration.ofMinutes(10);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
/** What Jambotron says about the person (its /api/oauth/token). */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Identity(String sub, String username, String email, List<String> roles, String mailbox) {
|
||||
}
|
||||
|
||||
private record Pending(String verifier, Instant until) {
|
||||
}
|
||||
|
||||
private final Map<String, Pending> pending = new ConcurrentHashMap<>();
|
||||
private final JambotronSso sso;
|
||||
private final UserService users;
|
||||
private final MailAdmins admins;
|
||||
private final JwtUtils jwt;
|
||||
private final WebClient http;
|
||||
|
||||
public SsoController(JambotronSso sso, UserService users, MailAdmins admins, JwtUtils jwt, WebClient.Builder http) {
|
||||
this.sso = sso;
|
||||
this.users = users;
|
||||
this.admins = admins;
|
||||
this.jwt = jwt;
|
||||
this.http = http.build();
|
||||
}
|
||||
|
||||
@GetMapping("/start")
|
||||
public ResponseEntity<Void> start() {
|
||||
if (!sso.enabled()) {
|
||||
return back("/login?sso_error=" + enc("Sign in with Jambotron is not set up on this server"));
|
||||
}
|
||||
Instant now = Instant.now();
|
||||
pending.values().removeIf(p -> p.until().isBefore(now));
|
||||
String state = random(24);
|
||||
String verifier = random(48);
|
||||
pending.put(state, new Pending(verifier, now.plus(STATE_TTL)));
|
||||
return back(sso.url() + "/oauth/authorize?response_type=code&client_id=mail"
|
||||
+ "&redirect_uri=" + enc(sso.callback()) + "&state=" + state
|
||||
+ "&code_challenge=" + challenge(verifier) + "&code_challenge_method=S256");
|
||||
}
|
||||
|
||||
@GetMapping("/callback")
|
||||
public Mono<ResponseEntity<Void>> callback(@RequestParam(required = false) String code,
|
||||
@RequestParam(required = false) String state,
|
||||
@RequestParam(required = false) String error) {
|
||||
Pending p = state == null ? null : pending.remove(state);
|
||||
if (error != null) {
|
||||
return Mono.just(fail(error));
|
||||
}
|
||||
if (code == null || p == null || p.until().isBefore(Instant.now())) {
|
||||
return Mono.just(fail("The sign-in took too long or was not started here — try again"));
|
||||
}
|
||||
return http.post().uri(sso.url() + "/api/oauth/token")
|
||||
.header("Authorization", "Bearer " + sso.token())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("grant_type", "authorization_code", "client_id", "mail", "code", code,
|
||||
"code_verifier", p.verifier(), "redirect_uri", sso.callback()))
|
||||
.retrieve()
|
||||
.bodyToMono(Identity.class)
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.flatMap(this::session)
|
||||
.onErrorResume(WebClientResponseException.class, e -> {
|
||||
log.warn("Sign in with Jambotron: token exchange refused {} {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||
return Mono.just(fail("Jambotron did not confirm the sign-in (" + e.getStatusCode().value() + ")"));
|
||||
})
|
||||
.onErrorResume(e -> !(e instanceof SsoRefused), e -> {
|
||||
log.warn("Sign in with Jambotron failed: {}", e.toString());
|
||||
return Mono.just(fail("Jambotron is not reachable — try again later"));
|
||||
})
|
||||
.onErrorResume(SsoRefused.class, e -> Mono.just(fail(e.getMessage())));
|
||||
}
|
||||
|
||||
private Mono<ResponseEntity<Void>> session(Identity who) {
|
||||
if (who.mailbox() == null || who.mailbox().isBlank()) {
|
||||
return Mono.error(new SsoRefused("You have no work mailbox: mail is for Jambotron staff"));
|
||||
}
|
||||
boolean admin = who.roles() != null && who.roles().contains("ROLE_ADMIN");
|
||||
return users.findOptional(who.mailbox())
|
||||
.switchIfEmpty(Mono.error(new SsoRefused("Your mailbox " + who.mailbox() + " is not created yet — it appears within 10 minutes")))
|
||||
.flatMap(user -> {
|
||||
if (!user.isActive() || user.isShared() || user.isSystem()) {
|
||||
return Mono.error(new SsoRefused("Mailbox " + who.mailbox() + " is switched off"));
|
||||
}
|
||||
return admin && !user.isAdmin() ? users.setAdmin(user, true) : Mono.just(user);
|
||||
})
|
||||
.map(user -> {
|
||||
if (user.isAdmin()) {
|
||||
admins.grant(user.getEmail(), true);
|
||||
}
|
||||
log.info("WEB: {} signed in with Jambotron as {} ({})", user.getEmail(), who.username(), who.email());
|
||||
return back("/sso#token=" + enc(jwt.generateToken(user.getEmail())) + "&email=" + enc(user.getEmail()));
|
||||
});
|
||||
}
|
||||
|
||||
private static final class SsoRefused extends RuntimeException {
|
||||
SsoRefused(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static ResponseEntity<Void> fail(String message) {
|
||||
return back("/login?sso_error=" + enc(message));
|
||||
}
|
||||
|
||||
private static ResponseEntity<Void> back(String location) {
|
||||
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(location)).build();
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String random(int bytes) {
|
||||
byte[] b = new byte[bytes];
|
||||
RANDOM.nextBytes(b);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
|
||||
}
|
||||
|
||||
static String challenge(String verifier) {
|
||||
try {
|
||||
byte[] hash = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ public class User {
|
||||
@Column("is_admin")
|
||||
private boolean isAdmin = false;
|
||||
|
||||
/** System mailbox (no-reply@ …): no web login, owners open it, programs use its passwords (V9). */
|
||||
@Column("is_system")
|
||||
private boolean isSystem = false;
|
||||
|
||||
/** Shared mailbox (V7): no login of its own, members open it. */
|
||||
@Column("is_shared")
|
||||
private boolean isShared = false;
|
||||
@@ -70,6 +74,8 @@ public class User {
|
||||
public void setTrap(boolean trap) { isTrap = trap; }
|
||||
public boolean isAdmin() { return isAdmin; }
|
||||
public void setAdmin(boolean admin) { isAdmin = admin; }
|
||||
public boolean isSystem() { return isSystem; }
|
||||
public void setSystem(boolean system) { isSystem = system; }
|
||||
|
||||
public boolean isShared() { return isShared; }
|
||||
public void setShared(boolean shared) { isShared = shared; }
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** App passwords (V9): one per mail program, SMTP / IMAP only — never the web client. */
|
||||
@Service
|
||||
public class AppPasswords {
|
||||
|
||||
public record AppPassword(Long id, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {
|
||||
}
|
||||
|
||||
private final DatabaseClient db;
|
||||
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
|
||||
|
||||
public AppPasswords(DatabaseClient db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public Flux<AppPassword> list(Long userId) {
|
||||
return db.sql("SELECT id, name, created_at, last_used_at FROM app_passwords WHERE user_id = :u ORDER BY id")
|
||||
.bind("u", userId)
|
||||
.map(row -> new AppPassword(row.get("id", Long.class), row.get("name", String.class),
|
||||
row.get("created_at", LocalDateTime.class), row.get("last_used_at", LocalDateTime.class)))
|
||||
.all();
|
||||
}
|
||||
|
||||
/** @return the password itself — shown once */
|
||||
public Mono<String> create(Long userId, String name) {
|
||||
String password = MailDirectory.randomPassword();
|
||||
return db.sql("INSERT INTO app_passwords (user_id, name, hash) VALUES (:u, :n, :h)")
|
||||
.bind("u", userId).bind("n", name).bind("h", encoder.encode(password))
|
||||
.then().thenReturn(password);
|
||||
}
|
||||
|
||||
public Mono<Boolean> delete(Long userId, Long id) {
|
||||
return db.sql("DELETE FROM app_passwords WHERE id = :id AND user_id = :u").bind("id", id).bind("u", userId)
|
||||
.fetch().rowsUpdated().map(n -> n > 0);
|
||||
}
|
||||
|
||||
/** SMTP / IMAP: does the password match one of the app passwords of this login (and note the use). */
|
||||
public boolean matches(Long userId, String password) {
|
||||
Long hit = db.sql("SELECT id, hash FROM app_passwords WHERE user_id = :u").bind("u", userId)
|
||||
.map(row -> new Object[]{row.get("id", Long.class), row.get("hash", String.class)})
|
||||
.all()
|
||||
.filter(r -> encoder.matches(password, (String) r[1]))
|
||||
.map(r -> (Long) r[0])
|
||||
.next().block();
|
||||
if (hit == null) {
|
||||
return false;
|
||||
}
|
||||
db.sql("UPDATE app_passwords SET last_used_at = NOW() WHERE id = :id").bind("id", hit).then().subscribe();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -30,15 +30,18 @@ public class MailboxAccess {
|
||||
private final UserRepository users;
|
||||
private final UserService userService;
|
||||
|
||||
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) {
|
||||
private final AppPasswords appPasswords;
|
||||
|
||||
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService, AppPasswords appPasswords) {
|
||||
this.appPasswords = appPasswords;
|
||||
this.db = db;
|
||||
this.users = users;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → the mailbox the
|
||||
* session works with, or null. A shared mailbox never logs in by itself.
|
||||
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password or one of its app
|
||||
* passwords (V9) → the mailbox the session works with, or null. A shared mailbox never logs in by itself.
|
||||
*/
|
||||
public User login(String username, String password) {
|
||||
String name = username.trim().toLowerCase(Locale.ROOT);
|
||||
@@ -46,7 +49,8 @@ public class MailboxAccess {
|
||||
String mailbox = star < 0 ? name : name.substring(0, star);
|
||||
String login = star < 0 ? name : name.substring(star + 1);
|
||||
User user = users.findByEmail(login).block();
|
||||
if (user == null || !user.isActive() || user.isShared() || !userService.checkPassword(user, password)) {
|
||||
if (user == null || !user.isActive() || user.isShared()
|
||||
|| !(userService.checkPassword(user, password) || appPasswords.matches(user.getId(), password))) {
|
||||
return null;
|
||||
}
|
||||
if (mailbox.equals(login)) {
|
||||
|
||||
@@ -108,6 +108,11 @@ public class UserService {
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> setSystem(User user, boolean system) {
|
||||
user.setSystem(system);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> setTrap(User user, boolean trap) {
|
||||
user.setTrap(trap);
|
||||
return userRepository.save(user);
|
||||
|
||||
Reference in New Issue
Block a user