diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 0bc7fed..d1f4248 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -210,6 +210,27 @@ func (h *AuthHandler) GetDownloadToken(c *fiber.Ctx) error { }) } +// GetEditToken issues a 24-hour token for file editing via OnlyOffice +// GET /api/auth/edit-token +func (h *AuthHandler) GetEditToken(c *fiber.Ctx) error { + userID := c.Locals("userID") + username := c.Locals("username") + + token, err := middleware.GenerateEditToken( + int(userID.(float64)), + username.(string), + h.Config.JWTSecret, + "", + ) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate edit token"}) + } + + return c.JSON(fiber.Map{ + "token": token, + }) +} + // Enable2FA generates a TOTP secret and returns the provisioning URI. // POST /api/auth/2fa/enable func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error { diff --git a/backend/handlers/onlyoffice.go b/backend/handlers/onlyoffice.go index e06b171..87357e5 100644 --- a/backend/handlers/onlyoffice.go +++ b/backend/handlers/onlyoffice.go @@ -38,8 +38,13 @@ func (h *OnlyOfficeHandler) Callback(c *fiber.Ctx) error { return c.Status(400).SendString("path required") } - // Validate the token to ensure the callback is authorized - if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path); err != nil { + // Validate the token to ensure the callback is authorized. + // First check if it's a long-lived edit token, fallback to short-lived download token. + err := middleware.ValidateEditToken(token, h.Config.JWTSecret, "", path) + if err != nil { + err = middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path) + } + if err != nil { return c.Status(401).SendString("unauthorized") } @@ -151,8 +156,13 @@ func (h *OnlyOfficeHandler) DownloadForEditor(c *fiber.Ctx) error { return c.Status(400).JSON(fiber.Map{"error": "path required"}) } - // Validate the download token - if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path); err != nil { + // Validate the token. + // Allow both short-lived download tokens and long-lived edit tokens. + err := middleware.ValidateEditToken(token, h.Config.JWTSecret, "", path) + if err != nil { + err = middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path) + } + if err != nil { return c.Status(401).JSON(fiber.Map{"error": "unauthorized"}) } diff --git a/backend/main.go b/backend/main.go index 774249c..76b6e43 100644 --- a/backend/main.go +++ b/backend/main.go @@ -160,6 +160,8 @@ func main() { protected.Put("/auth/password", authHandler.ChangePassword) // 2FA management + protected.Get("/auth/download-token", authHandler.GetDownloadToken) + protected.Get("/auth/edit-token", authHandler.GetEditToken) protected.Post("/auth/2fa/enable", authHandler.Enable2FA) protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA) protected.Post("/auth/2fa/disable", authHandler.Disable2FA) diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 2b0db6b..4926c85 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -137,6 +137,26 @@ func GenerateDownloadToken(userID int, username, secret, filePath string) (strin return token.SignedString([]byte(secret)) } +} + +// GenerateEditToken creates a long-lived edit token for OnlyOffice sessions. +func GenerateEditToken(userID int, username, secret, filePath string) (string, error) { + claims := jwt.MapClaims{ + "sub": userID, + "username": username, + "exp": time.Now().Add(24 * time.Hour).Unix(), + "iat": time.Now().Unix(), + "type": "edit", + } + + if filePath != "" { + claims["path"] = filePath + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + // ValidateDownloadToken validates a download token func ValidateDownloadToken(tokenString, secret, expectedUsername, expectedPath string) error { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { @@ -165,3 +185,40 @@ func ValidateDownloadToken(tokenString, secret, expectedUsername, expectedPath s return nil } + +// ValidateEditToken validates an edit token +func ValidateEditToken(tokenString, secret, expectedUsername, expectedPath string) error { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + + if err != nil || !token.Valid { + return err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return jwt.ErrSignatureInvalid + } + + tokenType, _ := claims["type"].(string) + if tokenType != "edit" { + return jwt.ErrSignatureInvalid + } + + if expectedUsername != "" { + if username, ok := claims["username"].(string); !ok || username != expectedUsername { + return jwt.ErrSignatureInvalid + } + } + + if expectedPath != "" { + if path, ok := claims["path"].(string); ok && path != "" { + if path != expectedPath { + return jwt.ErrSignatureInvalid + } + } + } + + return nil +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index fabac41..f63d1d7 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -10,13 +10,13 @@ type AppState = 'loading' | 'setup' | 'login' | 'app'; export default function Home() { const [state, setState] = useState('loading'); + const [sessionExpired, setSessionExpired] = useState(false); useEffect(() => { checkState(); const handleAuthError = () => { - api.clearTokens(); - setState('login'); + setSessionExpired(true); }; window.addEventListener('auth_error', handleAuthError); return () => window.removeEventListener('auth_error', handleAuthError); @@ -92,5 +92,29 @@ export default function Home() { return ; } - return ; + return ( + <> + + {sessionExpired && ( +
+
+

Session Expired

+

+ Your session has expired or encountered an error. Please log in again to continue. +

+ +
+
+ )} + + ); } diff --git a/frontend/src/components/OnlyOfficeEditor.tsx b/frontend/src/components/OnlyOfficeEditor.tsx index de0daa0..bc6b21b 100644 --- a/frontend/src/components/OnlyOfficeEditor.tsx +++ b/frontend/src/components/OnlyOfficeEditor.tsx @@ -80,8 +80,8 @@ export default function OnlyOfficeEditor({ file, type, onClose, onRename, theme, // the auth middleware which fails when requests come through the Next.js proxy. const INTERNAL_URL = process.env.NEXT_PUBLIC_API_URL || 'http://192.168.50.81:5827/api'; - const fileUrl = `${INTERNAL_URL}/public/onlyoffice/download?path=${encodeURIComponent(file.path)}&token=${downloadToken}`; - const callbackUrl = `${INTERNAL_URL}/public/onlyoffice/callback?path=${encodeURIComponent(file.path)}&token=${downloadToken}`; + const fileUrl = `${INTERNAL_URL}/public/onlyoffice/download?path=${encodeURIComponent(file.path)}&token=${editToken}`; + const callbackUrl = `${INTERNAL_URL}/public/onlyoffice/callback?path=${encodeURIComponent(file.path)}&token=${editToken}`; const baseConfig: any = { document: { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3a94017..de77b62 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -289,13 +289,13 @@ class ApiClient { } async createDownloadToken(): Promise { - const res = await fetch(`${API_BASE}/api/auth/download-token`, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.accessToken}`, - }, - }); - if (!res.ok) throw new Error('Failed to fetch download token'); + const res = await this.request('/api/auth/download-token'); + const data = await res.json(); + return data.token; + } + + async createEditToken(): Promise { + const res = await this.request('/api/auth/edit-token'); const data = await res.json(); return data.token; }