Add Gmail allowed-emails allowlist

Add support for an allowlist of Google account emails for Gmail mailbox connections. Introduces ApplicationProperties.mailbox.gmail.allowedEmails, documents it in settings.yml.template, and exposes it in the admin mailbox settings UI. Server-side enforcement added to GmailOAuthService (throws 403) and GmailOAuthController now redirects the frontend to ?gmail=not-allowed on rejection. Frontend shows localized error messages and handles connect failures. Unit tests updated to cover allowlist behavior.
This commit is contained in:
Ludy87
2026-08-25 13:41:05 +02:00
parent d470ba5a63
commit ac60091c2b
9 changed files with 185 additions and 21 deletions
@@ -1431,6 +1431,9 @@ public class ApplicationProperties {
private String clientId = "";
@ToString.Exclude private String clientSecret = "";
private String redirectUri = "";
/** Google account emails allowed to connect to Gmail; empty means all accounts. */
private List<String> allowedEmails = new ArrayList<>();
}
}
@@ -164,6 +164,7 @@ mailbox:
client-id: "" # Google OAuth client ID
client-secret: "" # Google OAuth client secret; do not commit a real secret
redirect-uri: "" # Optional fixed public callback, e.g. https://pdf.example.com/api/v1/email/gmail/callback
allowed-emails: [] # Empty allows all Google accounts; otherwise only these accounts may connect
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -196,21 +197,33 @@ public class GmailOAuthController {
"Invalid Gmail OAuth callback: missing or expired code/state");
return;
}
GmailOAuthService.GmailToken token = gmailOAuthService.exchangeCode(code, redirectUri);
GmailOAuthService.GmailProfile profile = gmailOAuthService.getProfile(token);
session.removeAttribute(STATE_SESSION_KEY);
session.removeAttribute(REDIRECT_URI_SESSION_KEY);
session.setAttribute(PROFILE_SESSION_KEY, profile);
String username = (String) session.getAttribute(USER_SESSION_KEY);
if (username != null && !username.isBlank()) {
gmailOAuthService.saveConnection(username, token, profile);
try {
GmailOAuthService.GmailToken token = gmailOAuthService.exchangeCode(code, redirectUri);
GmailOAuthService.GmailProfile profile = gmailOAuthService.getProfile(token);
gmailOAuthService.ensureEmailAllowed(profile.email());
session.removeAttribute(STATE_SESSION_KEY);
session.removeAttribute(REDIRECT_URI_SESSION_KEY);
session.setAttribute(PROFILE_SESSION_KEY, profile);
String username = (String) session.getAttribute(USER_SESSION_KEY);
if (username != null && !username.isBlank()) {
gmailOAuthService.saveConnection(username, token, profile);
}
response.sendRedirect(frontendTarget("connected"));
} catch (ResponseStatusException exception) {
session.removeAttribute(STATE_SESSION_KEY);
session.removeAttribute(REDIRECT_URI_SESSION_KEY);
session.removeAttribute(PROFILE_SESSION_KEY);
log.warn("Gmail OAuth account rejected: {}", exception.getReason());
response.sendRedirect(frontendTarget("not-allowed"));
}
}
private String frontendTarget(String status) {
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
String target =
frontendUrl == null || frontendUrl.isBlank()
? "/mail?gmail=connected"
: frontendUrl.trim().replaceAll("/$", "") + "/mail?gmail=connected";
response.sendRedirect(target);
String path = "/mail?gmail=" + status;
return frontendUrl == null || frontendUrl.isBlank()
? path
: frontendUrl.trim().replaceAll("/$", "") + path;
}
private String randomState() {
@@ -17,7 +17,9 @@ import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import jakarta.servlet.http.HttpServletRequest;
@@ -25,6 +27,8 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@@ -42,6 +46,7 @@ public class GmailOAuthService {
private final ObjectMapper objectMapper;
private final GmailConnectionRepository connectionRepository;
private final ApplicationProperties applicationProperties;
// Kept replaceable for deterministic tests; production uses the standard JDK client.
private HttpClient httpClient = HttpClient.newHttpClient();
@@ -115,6 +120,7 @@ public class GmailOAuthService {
}
public void saveConnection(String username, GmailToken token, GmailProfile profile) {
ensureEmailAllowed(profile.email());
GmailConnectionEntity entity =
connectionRepository.findByUsername(username).orElseGet(GmailConnectionEntity::new);
String refreshToken = token.refreshToken();
@@ -132,6 +138,30 @@ public class GmailOAuthService {
connectionRepository.save(entity);
}
/**
* Ensures that only configured Google accounts can create or retain a mailbox connection. An
* empty allowlist intentionally permits every Google account.
*/
void ensureEmailAllowed(String email) {
List<String> allowedEmails =
applicationProperties.getMailbox().getGmail().getAllowedEmails();
boolean allowAll =
allowedEmails == null
|| allowedEmails.stream()
.allMatch(value -> value == null || value.isBlank());
boolean allowed =
allowAll
|| (email != null
&& allowedEmails.stream()
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.anyMatch(value -> value.equalsIgnoreCase(email.trim())));
if (!allowed) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This Google account is not allowed to connect");
}
}
public GmailConnection getConnection(String username) {
return connectionRepository
.findByUsername(username)
@@ -29,6 +29,8 @@ import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
@@ -39,10 +41,14 @@ class GmailOAuthServiceTest {
@Mock private HttpClient httpClient;
private GmailOAuthService service;
private ApplicationProperties applicationProperties;
@BeforeEach
void setUp() {
service = new GmailOAuthService(new ObjectMapper(), connectionRepository);
applicationProperties = new ApplicationProperties();
service =
new GmailOAuthService(
new ObjectMapper(), connectionRepository, applicationProperties);
ReflectionTestUtils.setField(service, "httpClient", httpClient);
}
@@ -139,6 +145,57 @@ class GmailOAuthServiceTest {
verify(connectionRepository).save(existing);
}
@Test
void allowsEveryGoogleAccountWhenLoginAllowlistIsEmpty() {
service.ensureEmailAllowed("anyone@example.com");
}
@Test
void allowsConfiguredGoogleAccountIgnoringCaseAndWhitespace() {
applicationProperties
.getMailbox()
.getGmail()
.setAllowedEmails(java.util.List.of(" admin@example.com "));
service.ensureEmailAllowed("ADMIN@EXAMPLE.COM");
}
@Test
void rejectsGoogleAccountOutsideLoginAllowlist() {
applicationProperties
.getMailbox()
.getGmail()
.setAllowedEmails(java.util.List.of("allowed@example.com"));
assertThatThrownBy(() -> service.ensureEmailAllowed("blocked@example.com"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
error -> {
ResponseStatusException exception = (ResponseStatusException) error;
assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(exception.getReason())
.isEqualTo("This Google account is not allowed to connect");
});
}
@Test
void doesNotPersistConnectionForDisallowedGoogleAccount() {
applicationProperties
.getMailbox()
.getGmail()
.setAllowedEmails(java.util.List.of("allowed@example.com"));
assertThatThrownBy(
() ->
service.saveConnection(
"admin",
new GmailOAuthService.GmailToken("access", "refresh", 1L),
new GmailOAuthService.GmailProfile(
"blocked@example.com", "Blocked")))
.isInstanceOf(ResponseStatusException.class);
org.mockito.Mockito.verifyNoInteractions(connectionRepository);
}
@Test
void rejectsRequestsWhenNoGmailConnectionExists() {
when(connectionRepository.findByUsername("admin")).thenReturn(Optional.empty());
@@ -3712,6 +3712,7 @@ connectAccount = "Connect account"
connectDescription = "Connect your email account to securely transfer attachments into your PDF workflow."
connectGmail = "Connect Gmail"
connectTitle = "Connect mailbox"
connectFailed = "The Gmail connection could not be started. Please try again."
copySender = "Copy sender"
copySubject = "Copy subject"
customFileType = "Enter a custom file type and press Enter"
@@ -3722,6 +3723,7 @@ displayNamePlaceholder = "e.g. Peter Example"
download = "Import"
eyebrow = "File sources"
fileTypeFilter = "Filter by file type"
gmailNotAllowed = "This Google account is not allowed to connect to this mailbox. Contact an administrator if you need access."
folders = "Mailbox"
inbox = "Inbox"
labelFilter = "Filter by labels"
@@ -568,6 +568,17 @@
font-size: 0.82rem;
line-height: 1.55;
}
.email-connect-error {
margin: 0 0 1rem;
padding: 0.7rem 0.8rem;
border: 1px solid color-mix(in srgb, var(--c-danger) 55%, var(--c-border));
border-radius: 0.35rem;
background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface));
color: var(--c-danger);
font-size: 0.78rem;
line-height: 1.45;
text-align: left;
}
.email-provider-actions {
display: grid;
gap: 0.55rem;
@@ -155,11 +155,20 @@ export default function EmailInboxPage() {
const { addFiles } = useFileHandler();
const { fileStubs } = useAllFiles();
const [searchParams, setSearchParams] = useSearchParams();
const gmailCallbackStatus = searchParams.get("gmail");
const [accountConnected, setAccountConnected] = useState(
searchParams.get("gmail") === "connected",
gmailCallbackStatus === "connected",
);
const [connectDialogOpen, setConnectDialogOpen] = useState(
searchParams.get("gmail") !== "connected",
gmailCallbackStatus !== "connected",
);
const [connectionError, setConnectionError] = useState<string | null>(
gmailCallbackStatus === "not-allowed"
? t(
"email.gmailNotAllowed",
"This Google account is not allowed to connect to this mailbox. Contact an administrator if you need access.",
)
: null,
);
const [accountEmail, setAccountEmail] = useState(DEMO_ACCOUNT.email);
const [accountProvider, setAccountProvider] = useState<Provider>(
@@ -191,7 +200,7 @@ export default function EmailInboxPage() {
const [downloadedAttachment, setDownloadedAttachment] = useState<
string | null
>(null);
const connectedFromCallback = searchParams.get("gmail") === "connected";
const connectedFromCallback = gmailCallbackStatus === "connected";
useEffect(() => {
const savedName = window.localStorage.getItem(
@@ -447,10 +456,20 @@ export default function EmailInboxPage() {
);
const connectAccount = async () => {
const { data } = await apiClient.get<{ authorizationUrl: string }>(
"/api/v1/email/gmail/connect",
);
window.location.assign(data.authorizationUrl);
setConnectionError(null);
try {
const { data } = await apiClient.get<{ authorizationUrl: string }>(
"/api/v1/email/gmail/connect",
);
window.location.assign(data.authorizationUrl);
} catch {
setConnectionError(
t(
"email.connectFailed",
"The Gmail connection could not be started. Please try again.",
),
);
}
};
const disconnectAccount = async () => {
@@ -961,6 +980,11 @@ export default function EmailInboxPage() {
"Connect your email account to securely transfer attachments into your PDF workflow.",
)}
</p>
{connectionError && (
<div className="email-connect-error" role="alert">
{connectionError}
</div>
)}
<div className="email-provider-actions">
<Button
fullWidth
@@ -15,6 +15,7 @@ interface GmailMailboxSettings {
clientId?: string;
clientSecret?: string;
redirectUri?: string;
allowedEmails?: string[];
}
interface MailboxSettingsData {
@@ -46,6 +47,7 @@ export default function AdminMailboxSection() {
"mailbox.gmail.clientId": current.gmail?.clientId ?? "",
"mailbox.gmail.clientSecret": current.gmail?.clientSecret ?? "",
"mailbox.gmail.redirectUri": current.gmail?.redirectUri ?? "",
"mailbox.gmail.allowedEmails": current.gmail?.allowedEmails ?? [],
},
}),
});
@@ -160,6 +162,27 @@ export default function AdminMailboxSection() {
}
disabled={!loginEnabled}
/>
<TextInput
label={
<Group gap="xs">
<span>Allowed Google account emails</span>
<PendingBadge show={isFieldPending("gmail.allowedEmails")} />
</Group>
}
description="Leave empty to allow all Google accounts. Separate addresses with commas."
placeholder="user@example.com, admin@example.com"
value={(gmail.allowedEmails ?? []).join(", ")}
onChange={(event) =>
updateGmail({
allowedEmails: event.currentTarget.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
disabled={!loginEnabled}
/>
</Stack>
</Paper>
</Stack>