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.
This commit is contained in:
Ludy87
2026-08-24 18:12:13 +02:00
parent 2953358dd1
commit c57e859aa9
6 changed files with 230 additions and 33 deletions
@@ -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;
}
@@ -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<GmailConnectionEntity, Long> {
Optional<GmailConnectionEntity> findByUsername(String username);
}
@@ -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<Map<String, Object>> 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<GmailOAuthService.GmailMessagePage> 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()) {
@@ -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<String, GmailConnection> 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<String> 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<String, String> 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<String> 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(
@@ -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 {
@@ -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 && (
<Button
variant="secondary"
accent="danger"
fullWidth
onClick={() => void disconnectAccount()}
className="email-disconnect-button"
>
Gmail-Verbindung trennen
</Button>
)}
<div className="email-settings-actions">
<Button variant="secondary" onClick={() => setSettingsOpen(false)}>
{t("email.cancel", "Abbrechen")}