fix: OnlyOffice edit session token expiration and add session expired modal
Some checks failed
Automated Container Build / build-and-push (push) Failing after 15s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 15s
This commit is contained in:
parent
1f83d57942
commit
218fb40743
7 changed files with 130 additions and 16 deletions
|
|
@ -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.
|
// Enable2FA generates a TOTP secret and returns the provisioning URI.
|
||||||
// POST /api/auth/2fa/enable
|
// POST /api/auth/2fa/enable
|
||||||
func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error {
|
func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error {
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,13 @@ func (h *OnlyOfficeHandler) Callback(c *fiber.Ctx) error {
|
||||||
return c.Status(400).SendString("path required")
|
return c.Status(400).SendString("path required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the token to ensure the callback is authorized
|
// Validate the token to ensure the callback is authorized.
|
||||||
if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path); err != nil {
|
// 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")
|
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"})
|
return c.Status(400).JSON(fiber.Map{"error": "path required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the download token
|
// Validate the token.
|
||||||
if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, "", path); err != nil {
|
// 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"})
|
return c.Status(401).JSON(fiber.Map{"error": "unauthorized"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,8 @@ func main() {
|
||||||
protected.Put("/auth/password", authHandler.ChangePassword)
|
protected.Put("/auth/password", authHandler.ChangePassword)
|
||||||
|
|
||||||
// 2FA management
|
// 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/enable", authHandler.Enable2FA)
|
||||||
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
|
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
|
||||||
protected.Post("/auth/2fa/disable", authHandler.Disable2FA)
|
protected.Post("/auth/2fa/disable", authHandler.Disable2FA)
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,26 @@ func GenerateDownloadToken(userID int, username, secret, filePath string) (strin
|
||||||
return token.SignedString([]byte(secret))
|
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
|
// ValidateDownloadToken validates a download token
|
||||||
func ValidateDownloadToken(tokenString, secret, expectedUsername, expectedPath string) error {
|
func ValidateDownloadToken(tokenString, secret, expectedUsername, expectedPath string) error {
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, 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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ type AppState = 'loading' | 'setup' | 'login' | 'app';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [state, setState] = useState<AppState>('loading');
|
const [state, setState] = useState<AppState>('loading');
|
||||||
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkState();
|
checkState();
|
||||||
|
|
||||||
const handleAuthError = () => {
|
const handleAuthError = () => {
|
||||||
api.clearTokens();
|
setSessionExpired(true);
|
||||||
setState('login');
|
|
||||||
};
|
};
|
||||||
window.addEventListener('auth_error', handleAuthError);
|
window.addEventListener('auth_error', handleAuthError);
|
||||||
return () => window.removeEventListener('auth_error', handleAuthError);
|
return () => window.removeEventListener('auth_error', handleAuthError);
|
||||||
|
|
@ -92,5 +92,29 @@ export default function Home() {
|
||||||
return <LoginPage onSuccess={handleLoginSuccess} />;
|
return <LoginPage onSuccess={handleLoginSuccess} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <FileExplorer onLogout={handleLogout} />;
|
return (
|
||||||
|
<>
|
||||||
|
<FileExplorer onLogout={handleLogout} />
|
||||||
|
{sessionExpired && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 z-[9999] flex items-center justify-center p-4">
|
||||||
|
<div className="bg-[var(--color-bg-primary)] p-6 rounded-2xl max-w-sm w-full shadow-2xl border border-[var(--color-border)]">
|
||||||
|
<h3 className="text-xl font-bold mb-2 text-[var(--color-text-primary)]">Session Expired</h3>
|
||||||
|
<p className="text-[var(--color-text-secondary)] mb-6 text-sm">
|
||||||
|
Your session has expired or encountered an error. Please log in again to continue.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSessionExpired(false);
|
||||||
|
api.clearTokens();
|
||||||
|
setState('login');
|
||||||
|
}}
|
||||||
|
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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 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 fileUrl = `${INTERNAL_URL}/public/onlyoffice/download?path=${encodeURIComponent(file.path)}&token=${editToken}`;
|
||||||
const callbackUrl = `${INTERNAL_URL}/public/onlyoffice/callback?path=${encodeURIComponent(file.path)}&token=${downloadToken}`;
|
const callbackUrl = `${INTERNAL_URL}/public/onlyoffice/callback?path=${encodeURIComponent(file.path)}&token=${editToken}`;
|
||||||
|
|
||||||
const baseConfig: any = {
|
const baseConfig: any = {
|
||||||
document: {
|
document: {
|
||||||
|
|
|
||||||
|
|
@ -289,13 +289,13 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDownloadToken(): Promise<string> {
|
async createDownloadToken(): Promise<string> {
|
||||||
const res = await fetch(`${API_BASE}/api/auth/download-token`, {
|
const res = await this.request('/api/auth/download-token');
|
||||||
method: 'GET',
|
const data = await res.json();
|
||||||
headers: {
|
return data.token;
|
||||||
Authorization: `Bearer ${this.accessToken}`,
|
}
|
||||||
},
|
|
||||||
});
|
async createEditToken(): Promise<string> {
|
||||||
if (!res.ok) throw new Error('Failed to fetch download token');
|
const res = await this.request('/api/auth/edit-token');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.token;
|
return data.token;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in a new issue