Вход через 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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user