From c57e859aa98603fce3b758f77640b31e345eede3 Mon Sep 17 00:00:00 2001 From: Ludy87 Date: Mon, 24 Aug 2026 18:12:13 +0200 Subject: [PATCH] Add persistent Gmail OAuth connections Introduce GmailConnectionEntity and GmailConnectionRepository to persist encrypted OAuth tokens. Extend GmailOAuthService to save/load connections, refresh access tokens, revoke grants (disconnect), and return a valid token via getValidToken. Update GmailOAuthController to remove session-token reliance, add a DELETE /api/v1/email/gmail/connection endpoint, and use the service token helpers. Include mail package in DatabaseConfig scans. Frontend: add disconnectAccount action and UI button in EmailInboxPage and adjust a placeholder text. --- .../mail/GmailConnectionEntity.java | 48 +++++++ .../mail/GmailConnectionRepository.java | 10 ++ .../mail/GmailOAuthController.java | 44 +++---- .../proprietary/mail/GmailOAuthService.java | 121 +++++++++++++++++- .../configuration/DatabaseConfig.java | 6 +- .../editor/src/core/pages/EmailInboxPage.tsx | 34 ++++- 6 files changed, 230 insertions(+), 33 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionEntity.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionRepository.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionEntity.java new file mode 100644 index 0000000000..b43f2c7c20 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionEntity.java @@ -0,0 +1,48 @@ +package stirling.software.proprietary.mail; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.integration.crypto.EncryptedStringConverter; + +/** Durable, user-owned Gmail OAuth connection. OAuth secrets are encrypted at rest. */ +@Entity +@Table(name = "gmail_oauth_connections") +@NoArgsConstructor +@Getter +@Setter +public class GmailConnectionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "username", nullable = false, unique = true, length = 255) + private String username; + + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "access_token", nullable = false, length = 4096) + private String accessToken; + + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "refresh_token", nullable = false, length = 4096) + private String refreshToken; + + @Column(name = "expires_at", nullable = false) + private long expiresAt; + + @Column(name = "email", nullable = false, length = 320) + private String email; + + @Column(name = "display_name", length = 255) + private String displayName; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionRepository.java new file mode 100644 index 0000000000..ffd5172ea9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailConnectionRepository.java @@ -0,0 +1,10 @@ +package stirling.software.proprietary.mail; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GmailConnectionRepository extends JpaRepository { + + Optional findByUsername(String username); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthController.java index 6a0810d25d..117889d3d7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthController.java @@ -10,6 +10,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +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; @@ -31,7 +32,6 @@ public class GmailOAuthController { static final String STATE_SESSION_KEY = "stirling.gmail.oauth.state"; static final String REDIRECT_URI_SESSION_KEY = "stirling.gmail.oauth.redirect-uri"; - static final String TOKEN_SESSION_KEY = "stirling.gmail.oauth.token"; static final String PROFILE_SESSION_KEY = "stirling.gmail.oauth.profile"; static final String USER_SESSION_KEY = "stirling.gmail.oauth.username"; @@ -73,9 +73,7 @@ public class GmailOAuthController { GmailOAuthService.GmailConnection connection = gmailOAuthService.getConnection(userService.getCurrentUsername()); if (profile == null && connection != null) profile = connection.profile(); - boolean tokenPresent = - (session != null && session.getAttribute(TOKEN_SESSION_KEY) != null) - || connection != null; + boolean tokenPresent = connection != null; ResponseEntity response = ResponseEntity.ok( profile == null @@ -99,6 +97,19 @@ public class GmailOAuthController { return response; } + @DeleteMapping("/api/v1/email/gmail/connection") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> disconnect(HttpServletRequest request) { + String username = userService.getCurrentUsername(); + HttpSession session = request.getSession(false); + if (session != null) { + session.removeAttribute(PROFILE_SESSION_KEY); + session.removeAttribute(USER_SESSION_KEY); + } + boolean revoked = gmailOAuthService.disconnect(username); + return ResponseEntity.ok(Map.of("disconnected", true, "googleRevoked", revoked)); + } + @GetMapping("/api/v1/email/gmail/messages") @PreAuthorize("isAuthenticated()") public ResponseEntity messages( @@ -107,7 +118,7 @@ public class GmailOAuthController { @RequestParam(required = false) String pageToken, HttpServletRequest request) throws IOException, InterruptedException { - GmailOAuthService.GmailToken token = sessionToken(request); + GmailOAuthService.GmailToken token = currentToken(); return ResponseEntity.ok(gmailOAuthService.listMessages(token, folder, types, pageToken)); } @@ -119,8 +130,7 @@ public class GmailOAuthController { HttpServletRequest request) throws IOException, InterruptedException { GmailOAuthService.GmailAttachmentData attachment = - gmailOAuthService.downloadAttachment( - sessionToken(request), messageId, attachmentId); + gmailOAuthService.downloadAttachment(currentToken(), messageId, attachmentId); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); headers.setContentDisposition( @@ -129,23 +139,8 @@ public class GmailOAuthController { attachment.data(), headers, org.springframework.http.HttpStatus.OK); } - private GmailOAuthService.GmailToken sessionToken(HttpServletRequest request) { - HttpSession session = request.getSession(false); - GmailOAuthService.GmailToken token = - session == null - ? null - : (GmailOAuthService.GmailToken) session.getAttribute(TOKEN_SESSION_KEY); - if (token == null) { - GmailOAuthService.GmailConnection connection = - gmailOAuthService.getConnection(userService.getCurrentUsername()); - token = connection == null ? null : connection.token(); - } - if (token == null) { - throw new org.springframework.web.server.ResponseStatusException( - org.springframework.http.HttpStatus.UNAUTHORIZED, - "Gmail mailbox is not connected"); - } - return token; + private GmailOAuthService.GmailToken currentToken() throws IOException, InterruptedException { + return gmailOAuthService.getValidToken(userService.getCurrentUsername()); } @GetMapping("/api/v1/email/gmail/callback") @@ -203,7 +198,6 @@ public class GmailOAuthController { GmailOAuthService.GmailProfile profile = gmailOAuthService.getProfile(token); session.removeAttribute(STATE_SESSION_KEY); session.removeAttribute(REDIRECT_URI_SESSION_KEY); - session.setAttribute(TOKEN_SESSION_KEY, token); session.setAttribute(PROFILE_SESSION_KEY, profile); String username = (String) session.getAttribute(USER_SESSION_KEY); if (username != null && !username.isBlank()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthService.java b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthService.java index 035a47be2d..c76aeb2737 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mail/GmailOAuthService.java @@ -11,11 +11,10 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; -import java.util.stream.Collectors; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -24,12 +23,14 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @Service @RequiredArgsConstructor +@Slf4j public class GmailOAuthService { private static final String AUTHORIZATION_URI = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -40,8 +41,8 @@ public class GmailOAuthService { "openid email https://www.googleapis.com/auth/gmail.readonly"; private final ObjectMapper objectMapper; + private final GmailConnectionRepository connectionRepository; private final HttpClient httpClient = HttpClient.newHttpClient(); - private final Map connections = new ConcurrentHashMap<>(); @Value("${mailbox.gmail.client-id:}") private String clientId; @@ -113,11 +114,121 @@ public class GmailOAuthService { } public void saveConnection(String username, GmailToken token, GmailProfile profile) { - connections.put(username, new GmailConnection(token, profile)); + GmailConnectionEntity entity = + connectionRepository.findByUsername(username).orElseGet(GmailConnectionEntity::new); + String refreshToken = token.refreshToken(); + if ((refreshToken == null || refreshToken.isBlank()) + && entity.getRefreshToken() != null + && !entity.getRefreshToken().isBlank()) { + refreshToken = entity.getRefreshToken(); + } + entity.setUsername(username); + entity.setAccessToken(token.accessToken()); + entity.setRefreshToken(refreshToken == null ? "" : refreshToken); + entity.setExpiresAt(token.expiresAt()); + entity.setEmail(profile.email()); + entity.setDisplayName(profile.name()); + connectionRepository.save(entity); } public GmailConnection getConnection(String username) { - return connections.get(username); + return connectionRepository + .findByUsername(username) + .map( + entity -> + new GmailConnection( + new GmailToken( + entity.getAccessToken(), + entity.getRefreshToken(), + entity.getExpiresAt()), + new GmailProfile( + entity.getEmail(), entity.getDisplayName()))) + .orElse(null); + } + + /** Removes the local connection and revokes the Google grant when possible. */ + public boolean disconnect(String username) { + GmailConnectionEntity entity = connectionRepository.findByUsername(username).orElse(null); + if (entity == null) return false; + boolean revoked = false; + try { + String revokeToken = + entity.getRefreshToken() == null || entity.getRefreshToken().isBlank() + ? entity.getAccessToken() + : entity.getRefreshToken(); + revoked = revokeToken(revokeToken); + } catch (IOException e) { + log.warn( + "Could not revoke Gmail OAuth grant for user '{}'; local connection removed", + username, + e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn( + "Gmail OAuth revoke interrupted for user '{}'; local connection removed", + username); + } finally { + connectionRepository.delete(entity); + } + return revoked; + } + + private boolean revokeToken(String token) throws IOException, InterruptedException { + HttpRequest request = + HttpRequest.newBuilder(URI.create("https://oauth2.googleapis.com/revoke")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString("token=" + encode(token))) + .build(); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() / 100 == 2 || response.statusCode() == 400; + } + + public GmailToken getValidToken(String username) throws IOException, InterruptedException { + GmailConnection connection = getConnection(username); + if (connection == null) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.UNAUTHORIZED, + "Gmail mailbox is not connected"); + } + GmailToken token = connection.token(); + if (token.expiresAt() > System.currentTimeMillis() + 60_000L) { + return token; + } + if (token.refreshToken() == null || token.refreshToken().isBlank()) { + throw new IOException("Gmail connection has no refresh token; reconnect required"); + } + GmailToken refreshed = refreshToken(token.refreshToken()); + saveConnection(username, refreshed, connection.profile()); + return refreshed; + } + + private GmailToken refreshToken(String refreshToken) throws IOException, InterruptedException { + requireConfigured(); + Map form = new LinkedHashMap<>(); + form.put("client_id", clientId); + form.put("client_secret", clientSecret); + form.put("refresh_token", refreshToken); + form.put("grant_type", "refresh_token"); + HttpRequest request = + HttpRequest.newBuilder(URI.create(TOKEN_URI)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(formEncode(form))) + .build(); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IOException( + "Gmail OAuth token refresh failed: HTTP " + response.statusCode()); + } + JsonNode body = objectMapper.readTree(response.body()); + String accessToken = body.path("access_token").asText(""); + if (accessToken.isBlank()) { + throw new IOException("Gmail OAuth refresh response did not contain an access token"); + } + long expiresIn = body.path("expires_in").asLong(3600); + return new GmailToken( + accessToken, refreshToken, System.currentTimeMillis() + expiresIn * 1000L); } public GmailMessagePage listMessages( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 389ffa535e..50e301d932 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -40,7 +40,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.repository", "stirling.software.proprietary.integration.repository", - "stirling.software.proprietary.failure" + "stirling.software.proprietary.failure", + "stirling.software.proprietary.mail" }) @EntityScan({ "stirling.software.proprietary.security.model", @@ -55,7 +56,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.model", "stirling.software.proprietary.integration.model", - "stirling.software.proprietary.failure" + "stirling.software.proprietary.failure", + "stirling.software.proprietary.mail" }) public class DatabaseConfig { diff --git a/frontend/editor/src/core/pages/EmailInboxPage.tsx b/frontend/editor/src/core/pages/EmailInboxPage.tsx index 1d1b08eef4..f541eaea2c 100644 --- a/frontend/editor/src/core/pages/EmailInboxPage.tsx +++ b/frontend/editor/src/core/pages/EmailInboxPage.tsx @@ -357,6 +357,27 @@ export default function EmailInboxPage() { window.location.assign(data.authorizationUrl); }; + const disconnectAccount = async () => { + if ( + !window.confirm( + "Gmail trennen? Die lokale Verbindung wird gelöscht und der Google-Zugriff widerrufen.", + ) + ) { + return; + } + try { + await apiClient.delete("/api/v1/email/gmail/connection"); + setAccountConnected(false); + setMailboxConfirmed(false); + setMessages([]); + setNextPageToken(null); + setSelectedAttachmentTypes([]); + setSettingsOpen(false); + } catch { + // Keep the connected state visible when the server could not complete the request. + } + }; + const importAttachment = async ( messageId: string, attachment: MailAttachment, @@ -810,9 +831,20 @@ export default function EmailInboxPage() { )} value={draftDisplayName} onChange={(event) => setDraftDisplayName(event.currentTarget.value)} - placeholder={t("email.displayNamePlaceholder", "z. B. Enrico Ludwig")} + placeholder={t("email.displayNamePlaceholder", "z. B. Peter Lustig")} autoFocus /> + {accountConnected && ( + + )}