feat(auth): add 'remember me' functionality and token refresh logic
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof EnvSchema>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<void>;
|
||||
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
|
||||
register: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
|
||||
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -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<boolean> {
|
||||
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<Response> => {
|
||||
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 (
|
||||
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile }}>
|
||||
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, authFetch }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
@@ -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?: () =>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<span>{t("auth.rememberMe", "Remember me")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
|
||||
{loading ? t("common.loading", "Loading...") : t("auth.login", "Login")}
|
||||
</button>
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user