Вход через Jambotron (OAuth 2.0 code + PKCE), пароли приложений, системные ящики
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:
2026-11-07 20:57:30 +01:00
co-authored by Claude Opus 5.5
parent 910a028612
commit 99e12d33f3
26 changed files with 665 additions and 19 deletions
@@ -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);