mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
+48
@@ -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;
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+19
-25
@@ -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()) {
|
||||
|
||||
+116
-5
@@ -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(
|
||||
|
||||
+4
-2
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user