diff --git a/.env.example b/.env.example index 41008db..676812f 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ AUTH_ENABLED=false # REFRESH_SECRET= # COOKIE_SECRET= +# Token TTL (optional - defaults shown) +# ACCESS_TOKEN_TTL_MINUTES=15 +# REFRESH_TOKEN_TTL_DAYS=7 + # SMTP (optional - for email notifications and password reset) SMTP_HOST= SMTP_PORT=587 diff --git a/backend/src/index.ts b/backend/src/index.ts index cda3f54..a71ecbb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -40,8 +40,8 @@ const app = Fastify({ const origins = env.CORS_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean); // Auth token TTLs (hardcoded - no need for user configuration) -const accessTtlMinutes = 15; // Access token: 15 minutes -const refreshTtlDays = 14; // Refresh token: 14 days +const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL +const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL const baseCookieOptions: CookieSerializeOptions = { httpOnly: true, diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts index 47afbcd..b056c9f 100644 --- a/backend/src/plugins/env.ts +++ b/backend/src/plugins/env.ts @@ -23,6 +23,10 @@ const EnvSchema = z.object({ JWT_SECRET: z.string().min(10).optional(), REFRESH_SECRET: z.string().min(10).optional(), COOKIE_SECRET: z.string().min(10).optional(), + + // Token TTL settings + ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"), + REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"), }); export type Env = z.infer; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2bacdfe..148ff55 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -36,6 +36,7 @@ const registerSchema = z.object({ const loginSchema = z.object({ username: z.string().min(1, "Username is required"), password: z.string().min(1, "Password is required"), + rememberMe: z.boolean().optional().default(false), }); const updateProfileSchema = z.object({ @@ -141,7 +142,7 @@ export async function authRoutes(app: FastifyInstance) { }); } - const { username, password } = parsed.data; + const { username, password, rememberMe } = parsed.data; // Find user by username const [user] = await db.select().from(users).where(eq(users.username, username)); @@ -196,11 +197,19 @@ export async function authRoutes(app: FastifyInstance) { { expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret } ); - app.log.info(`User logged in: ${username}`); + app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`); + + // Cookie options: with maxAge for "remember me", without for session cookie + const accessCookieOptions = rememberMe + ? app.config.cookieOptions + : { ...app.config.cookieOptions, maxAge: undefined }; + const refreshCookieOptions = rememberMe + ? app.config.refreshCookieOptions + : { ...app.config.refreshCookieOptions, maxAge: undefined }; return reply - .setCookie("access_token", accessToken, app.config.cookieOptions) - .setCookie("refresh_token", refreshToken, app.config.refreshCookieOptions) + .setCookie("access_token", accessToken, accessCookieOptions) + .setCookie("refresh_token", refreshToken, refreshCookieOptions) .send({ ok: true, user: { diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index 98ac9f5..c715717 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, createContext, useContext, ReactNode } from "react"; +import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from "react"; import { useTranslation } from "react-i18next"; // ============================================================================= @@ -22,11 +22,12 @@ interface AuthContextType { authState: AuthState | null; loading: boolean; authError: string | null; - login: (username: string, password: string) => Promise; + login: (username: string, password: string, rememberMe?: boolean) => Promise; register: (username: string, password: string) => Promise; logout: () => Promise; refreshUser: () => Promise; updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise; + authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; } // ============================================================================= @@ -56,6 +57,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { fetchAuthState(); }, []); + // Proactively refresh token every 10 minutes to prevent expiration + useEffect(() => { + if (!user || !authState?.authEnabled) return; + + const refreshInterval = setInterval(async () => { + const success = await tryRefreshToken(); + if (!success) { + // Refresh failed - check if user is still valid + await refreshUser(); + } + }, 10 * 60 * 1000); // 10 minutes (before 15 min access token expires) + + return () => clearInterval(refreshInterval); + }, [user, authState?.authEnabled]); + async function fetchAuthState() { try { setAuthError(null); @@ -84,6 +100,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (res.ok) { const userData = await res.json(); setUser(userData); + } else if (res.status === 401) { + // Access token expired - try to refresh it + const refreshed = await tryRefreshToken(); + if (refreshed) { + // Retry /auth/me with new token + const retryRes = await fetch("/api/auth/me", { credentials: "include" }); + if (retryRes.ok) { + const userData = await retryRes.json(); + setUser(userData); + return; + } + } + setUser(null); } else { setUser(null); } @@ -92,12 +121,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } - async function login(username: string, password: string) { + // Try to refresh the access token using the refresh token + async function tryRefreshToken(): Promise { + try { + const res = await fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + }); + return res.ok; + } catch { + return false; + } + } + + async function login(username: string, password: string, rememberMe: boolean = false) { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username, password, rememberMe }), }); if (!res.ok) { @@ -153,8 +195,32 @@ export function AuthProvider({ children }: { children: ReactNode }) { await refreshUser(); } + // Fetch wrapper that automatically refreshes token on 401 + const authFetch = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const options: RequestInit = { + ...init, + credentials: "include", + }; + + let res = await fetch(input, options); + + // If 401 and not already a refresh/login request, try to refresh token + if (res.status === 401 && !String(input).includes("/auth/")) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + // Retry the original request with new token + res = await fetch(input, options); + } else { + // Refresh failed - user needs to login again + setUser(null); + } + } + + return res; + }, []); + return ( - + {children} ); @@ -168,6 +234,7 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () => const { login, authState } = useAuth(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -177,7 +244,7 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () => setLoading(true); try { - await login(username, password); + await login(username, password, rememberMe); onSuccess?.(); } catch (err) { setError(err instanceof Error ? err.message : "Login failed"); @@ -220,6 +287,17 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () => /> +
+ +
+ diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 5fceb4b..e967b5c 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -254,7 +254,8 @@ "resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.", "passwordReset": "Passwort zurückgesetzt", "passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...", - "profileUpdated": "Profil erfolgreich aktualisiert" + "profileUpdated": "Profil erfolgreich aktualisiert", + "rememberMe": "Angemeldet bleiben" }, "common": { "loading": "Wird geladen...", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 52a0937..3f46478 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -256,7 +256,8 @@ "resetEmailSent": "If an account with this email exists, we've sent a password reset link.", "passwordReset": "Password Reset", "passwordResetSuccess": "Your password has been reset. Redirecting to login...", - "profileUpdated": "Profile updated successfully" + "profileUpdated": "Profile updated successfully", + "rememberMe": "Remember me" }, "common": { "loading": "Loading...", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e1e810e..c89bc9e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2460,6 +2460,26 @@ h3 .reminder-icon.info-tooltip { font-weight: 600; } +.auth-form .checkbox-group { + margin-bottom: 0.5rem; +} + +.auth-form .checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.auth-form .checkbox-label input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--accent); + cursor: pointer; +} + .auth-links { display: flex; flex-direction: column;