mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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
|
||||
|
||||
+26
-13
@@ -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() {
|
||||
|
||||
+30
@@ -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)
|
||||
|
||||
+58
-1
@@ -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
|
||||
|
||||
+23
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user