Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
315402edc8 | ||
|
|
7e09282a88 | ||
|
|
01c1e5504a |
@@ -10,13 +10,15 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first
|
* 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
|
* mailbox — {@code mailserver.admin.email}, or admin@<first domain>; plus mailboxes Jambotron marks as admin
|
||||||
* the configuration, so a stolen mailbox can not make itself an administrator.
|
* through the service API ({@code users.is_admin}, V8 — every Jambotron ADMIN). A mailbox can not make itself an
|
||||||
|
* administrator: only the configuration or the service token.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class MailAdmins {
|
public class MailAdmins {
|
||||||
|
|
||||||
private final Set<String> admins;
|
private final Set<String> admins;
|
||||||
|
private final Set<String> granted = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public MailAdmins(@Value("${mailserver.admins:}") String admins,
|
public MailAdmins(@Value("${mailserver.admins:}") String admins,
|
||||||
@Value("${mailserver.admin.email:}") String firstMailbox,
|
@Value("${mailserver.admin.email:}") String firstMailbox,
|
||||||
@@ -31,6 +33,20 @@ public class MailAdmins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isAdmin(String email) {
|
public boolean isAdmin(String email) {
|
||||||
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
|
if (email == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String key = email.toLowerCase(Locale.ROOT);
|
||||||
|
return admins.contains(key) || granted.contains(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Given / taken by Jambotron (users.is_admin) — loaded at start and kept here on every change. */
|
||||||
|
public void grant(String email, boolean admin) {
|
||||||
|
String key = email.toLowerCase(Locale.ROOT);
|
||||||
|
if (admin) {
|
||||||
|
granted.add(key);
|
||||||
|
} else {
|
||||||
|
granted.remove(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import com.mailserver.repository.UserRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** At start: mailboxes Jambotron made administrators (users.is_admin) → {@link MailAdmins}. */
|
||||||
|
@Component
|
||||||
|
public class MailAdminsLoader {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MailAdminsLoader.class);
|
||||||
|
|
||||||
|
private final UserRepository users;
|
||||||
|
private final MailAdmins admins;
|
||||||
|
|
||||||
|
public MailAdminsLoader(UserRepository users, MailAdmins admins) {
|
||||||
|
this.users = users;
|
||||||
|
this.admins = admins;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void load() {
|
||||||
|
users.findAll().filter(user -> user.isAdmin())
|
||||||
|
.doOnNext(user -> admins.grant(user.getEmail(), true))
|
||||||
|
.count()
|
||||||
|
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n),
|
||||||
|
e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,11 @@ public class ServiceController {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(ServiceController.class);
|
private static final Logger log = LoggerFactory.getLogger(ServiceController.class);
|
||||||
|
|
||||||
/** @param displayName null — unchanged (new account: the name before @) */
|
/** @param displayName null — unchanged (new account: the name before @) */
|
||||||
public record AccountRequest(@Size(max = 100) String displayName, Boolean active) {
|
/** @param admin mail administrator (Jambotron ADMIN); null — leave as it is */
|
||||||
|
public record AccountRequest(@Size(max = 100) String displayName, Boolean active, Boolean admin) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record AccountView(String email, String displayName, boolean active, List<String> lists) {
|
public record AccountView(String email, String displayName, boolean active, boolean admin, List<String> lists) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param password generated, only when the account was just created or got a new password */
|
/** @param password generated, only when the account was just created or got a new password */
|
||||||
@@ -59,10 +60,12 @@ public class ServiceController {
|
|||||||
private final MailLists lists;
|
private final MailLists lists;
|
||||||
private final MailDirectory directory;
|
private final MailDirectory directory;
|
||||||
private final MailboxAccess access;
|
private final MailboxAccess access;
|
||||||
|
private final com.mailserver.config.MailAdmins admins;
|
||||||
|
|
||||||
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
|
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
|
||||||
MailDirectory directory, MailboxAccess access) {
|
MailDirectory directory, MailboxAccess access, com.mailserver.config.MailAdmins admins) {
|
||||||
this.access = access;
|
this.access = access;
|
||||||
|
this.admins = admins;
|
||||||
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
|
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.lists = lists;
|
this.lists = lists;
|
||||||
@@ -84,13 +87,14 @@ public class ServiceController {
|
|||||||
@PathVariable String email, @RequestBody Mono<AccountRequest> body) {
|
@PathVariable String email, @RequestBody Mono<AccountRequest> body) {
|
||||||
check(auth);
|
check(auth);
|
||||||
String address = directory.address(email);
|
String address = directory.address(email);
|
||||||
return body.defaultIfEmpty(new AccountRequest(null, null)).flatMap(request -> users.findOptional(address)
|
return body.defaultIfEmpty(new AccountRequest(null, null, null)).flatMap(request -> users.findOptional(address)
|
||||||
.flatMap(user -> update(user, request).flatMap(this::view)
|
.flatMap(user -> update(user, request).flatMap(this::view)
|
||||||
.map(view -> ResponseEntity.ok(new AccountResult(view, null))))
|
.map(view -> ResponseEntity.ok(new AccountResult(view, null))))
|
||||||
.switchIfEmpty(Mono.defer(() -> {
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
String password = MailDirectory.randomPassword();
|
String password = MailDirectory.randomPassword();
|
||||||
return directory.createMailbox(address, request.displayName(), password)
|
return directory.createMailbox(address, request.displayName(), password)
|
||||||
.flatMap(user -> Boolean.FALSE.equals(request.active()) ? update(user, request) : Mono.just(user))
|
.flatMap(user -> Boolean.FALSE.equals(request.active()) || Boolean.TRUE.equals(request.admin())
|
||||||
|
? update(user, request) : Mono.just(user))
|
||||||
.doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail()))
|
.doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail()))
|
||||||
.flatMap(this::view)
|
.flatMap(this::view)
|
||||||
.map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password)));
|
.map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password)));
|
||||||
@@ -152,8 +156,14 @@ public class ServiceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Mono<User> update(User user, AccountRequest request) {
|
private Mono<User> update(User user, AccountRequest request) {
|
||||||
Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
|
Mono<User> renamed = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
|
||||||
: users.updateDisplayName(user, request.displayName());
|
: users.updateDisplayName(user, request.displayName());
|
||||||
|
Mono<User> named = request.admin() == null || request.admin() == user.isAdmin() ? renamed
|
||||||
|
: renamed.flatMap(u -> users.setAdmin(u, request.admin()))
|
||||||
|
.doOnNext(u -> {
|
||||||
|
admins.grant(u.getEmail(), u.isAdmin());
|
||||||
|
log.info("Mailbox {} {} mail administrator by Jambotron", u.getEmail(), u.isAdmin() ? "made" : "is no longer");
|
||||||
|
});
|
||||||
if (request.active() == null || request.active() == user.isActive()) {
|
if (request.active() == null || request.active() == user.isActive()) {
|
||||||
return named;
|
return named;
|
||||||
}
|
}
|
||||||
@@ -166,7 +176,7 @@ public class ServiceController {
|
|||||||
|
|
||||||
private Mono<AccountView> view(User user) {
|
private Mono<AccountView> view(User user) {
|
||||||
return lists.listsOf(user.getId()).collectList()
|
return lists.listsOf(user.getId()).collectList()
|
||||||
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), in));
|
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), admins.isAdmin(user.getEmail()), in));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 404 when the API is off, 401 for a wrong token (constant-time compare). */
|
/** 404 when the API is off, 401 for a wrong token (constant-time compare). */
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public class User {
|
|||||||
@Column("is_trap")
|
@Column("is_trap")
|
||||||
private boolean isTrap = false;
|
private boolean isTrap = false;
|
||||||
|
|
||||||
|
/** Mail administrator given by Jambotron through the service API (V8); plus mailserver.admins. */
|
||||||
|
@Column("is_admin")
|
||||||
|
private boolean isAdmin = false;
|
||||||
|
|
||||||
/** Shared mailbox (V7): no login of its own, members open it. */
|
/** Shared mailbox (V7): no login of its own, members open it. */
|
||||||
@Column("is_shared")
|
@Column("is_shared")
|
||||||
private boolean isShared = false;
|
private boolean isShared = false;
|
||||||
@@ -64,6 +68,8 @@ public class User {
|
|||||||
|
|
||||||
public boolean isTrap() { return isTrap; }
|
public boolean isTrap() { return isTrap; }
|
||||||
public void setTrap(boolean trap) { isTrap = trap; }
|
public void setTrap(boolean trap) { isTrap = trap; }
|
||||||
|
public boolean isAdmin() { return isAdmin; }
|
||||||
|
public void setAdmin(boolean admin) { isAdmin = admin; }
|
||||||
|
|
||||||
public boolean isShared() { return isShared; }
|
public boolean isShared() { return isShared; }
|
||||||
public void setShared(boolean shared) { isShared = shared; }
|
public void setShared(boolean shared) { isShared = shared; }
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ public class UserService {
|
|||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Mono<User> setAdmin(User user, boolean admin) {
|
||||||
|
user.setAdmin(admin);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<User> setTrap(User user, boolean trap) {
|
public Mono<User> setTrap(User user, boolean trap) {
|
||||||
user.setTrap(trap);
|
user.setTrap(trap);
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Mail administrator given by Jambotron (service API): every ADMIN of Jambotron manages mailboxes here too.
|
||||||
|
-- Only the service token sets it — a mailbox can not make itself an administrator. Plus mailserver.admins.
|
||||||
|
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -34,6 +34,7 @@ class ServiceControllerTest {
|
|||||||
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
|
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
|
||||||
private final MailDirectory directory = new MailDirectory(users, lists, domains, access);
|
private final MailDirectory directory = new MailDirectory(users, lists, domains, access);
|
||||||
private final User existing = user(7L, "jambotronpw@jambocheck.com");
|
private final User existing = user(7L, "jambotronpw@jambocheck.com");
|
||||||
|
private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("", "", domains);
|
||||||
|
|
||||||
ServiceControllerTest() {
|
ServiceControllerTest() {
|
||||||
when(users.findOptional(anyString())).thenReturn(Mono.empty());
|
when(users.findOptional(anyString())).thenReturn(Mono.empty());
|
||||||
@@ -49,10 +50,30 @@ class ServiceControllerTest {
|
|||||||
return Mono.just(u);
|
return Mono.just(u);
|
||||||
});
|
});
|
||||||
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
||||||
|
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||||
|
User u = i.getArgument(0);
|
||||||
|
u.setAdmin(i.getArgument(1));
|
||||||
|
return Mono.just(u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jambotron ADMIN → mail administrator (Settings → MAILBOXES), and back
|
||||||
|
@Test
|
||||||
|
void jambotronMakesAMailboxAdministratorAndTakesItBack() {
|
||||||
|
assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse();
|
||||||
|
var made = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
||||||
|
Mono.just(new ServiceController.AccountRequest(null, true, true))).block();
|
||||||
|
assertThat(made.getBody().account().admin()).isTrue();
|
||||||
|
assertThat(admins.isAdmin("JambotronPW@jambocheck.com")).isTrue();
|
||||||
|
|
||||||
|
api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
||||||
|
Mono.just(new ServiceController.AccountRequest(null, true, false))).block();
|
||||||
|
assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse();
|
||||||
|
assertThat(admins.isAdmin("admin@jambocheck.com")).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ServiceController api(String token) {
|
private ServiceController api(String token) {
|
||||||
return new ServiceController(token, users, lists, directory, access);
|
return new ServiceController(token, users, lists, directory, access, admins);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,12 +88,12 @@ class ServiceControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void newAccountGetsPasswordOnceExistingDoesNot() {
|
void newAccountGetsPasswordOnceExistingDoesNot() {
|
||||||
var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com",
|
var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com",
|
||||||
Mono.just(new ServiceController.AccountRequest("Moder", true))).block();
|
Mono.just(new ServiceController.AccountRequest("Moder", true, null))).block();
|
||||||
assertThat(created.getStatusCode().value()).isEqualTo(201);
|
assertThat(created.getStatusCode().value()).isEqualTo(201);
|
||||||
assertThat(created.getBody().password()).hasSizeGreaterThan(20);
|
assertThat(created.getBody().password()).hasSizeGreaterThan(20);
|
||||||
|
|
||||||
var again = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
var again = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
||||||
Mono.just(new ServiceController.AccountRequest(null, null))).block();
|
Mono.just(new ServiceController.AccountRequest(null, null, null))).block();
|
||||||
assertThat(again.getStatusCode().value()).isEqualTo(200);
|
assertThat(again.getStatusCode().value()).isEqualTo(200);
|
||||||
assertThat(again.getBody().password()).isNull();
|
assertThat(again.getBody().password()).isNull();
|
||||||
verify(lists, never()).removeEverywhere(anyLong());
|
verify(lists, never()).removeEverywhere(anyLong());
|
||||||
@@ -83,7 +104,7 @@ class ServiceControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void switchedOffLeavesEveryList() {
|
void switchedOffLeavesEveryList() {
|
||||||
var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
|
||||||
Mono.just(new ServiceController.AccountRequest(null, false))).block();
|
Mono.just(new ServiceController.AccountRequest(null, false, null))).block();
|
||||||
assertThat(off.getBody().account().active()).isFalse();
|
assertThat(off.getBody().account().active()).isFalse();
|
||||||
verify(lists).removeEverywhere(7L);
|
verify(lists).removeEverywhere(7L);
|
||||||
verify(access).removeEverywhere(7L);
|
verify(access).removeEverywhere(7L);
|
||||||
|
|||||||
Reference in New Issue
Block a user