diff --git a/backend/db/migrations/0056_auth_refresh_tokens.sql b/backend/db/migrations/0056_auth_refresh_tokens.sql new file mode 100644 index 0000000..e3786d8 --- /dev/null +++ b/backend/db/migrations/0056_auth_refresh_tokens.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "auth_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "tenant_id" bigint, + "token_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + CONSTRAINT "auth_refresh_tokens_token_hash_unique" UNIQUE("token_hash"), + CONSTRAINT "auth_refresh_tokens_user_id_auth_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") + ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "auth_refresh_tokens_tenant_id_tenants_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") + ON DELETE set null ON UPDATE cascade +); + +CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_user_id_idx" + ON "auth_refresh_tokens" ("user_id"); +CREATE INDEX IF NOT EXISTS "auth_refresh_tokens_expires_at_idx" + ON "auth_refresh_tokens" ("expires_at"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 2b5a13b..6e6b436 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1784021669805, "tag": "0055_tenant_export_maintenance_lock", "breakpoints": true + }, + { + "idx": 53, + "version": "7", + "when": 1784548800000, + "tag": "0056_auth_refresh_tokens", + "breakpoints": true } ] } diff --git a/backend/db/schema/auth_refresh_tokens.ts b/backend/db/schema/auth_refresh_tokens.ts new file mode 100644 index 0000000..dd2c84d --- /dev/null +++ b/backend/db/schema/auth_refresh_tokens.ts @@ -0,0 +1,20 @@ +import { pgTable, text, timestamp, uuid, bigint } from "drizzle-orm/pg-core" + +import { authUsers } from "./auth_users" +import { tenants } from "./tenants" + +export const authRefreshTokens = pgTable("auth_refresh_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }), + tenantId: bigint("tenant_id", { mode: "number" }) + .references(() => tenants.id, { onDelete: "set null", onUpdate: "cascade" }), + tokenHash: text("token_hash").notNull().unique(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), +}) + +export type AuthRefreshToken = typeof authRefreshTokens.$inferSelect +export type NewAuthRefreshToken = typeof authRefreshTokens.$inferInsert diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 90a53dd..8f766d6 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -7,6 +7,7 @@ export * from "./auth_roles" export * from "./auth_tenant_users" export * from "./auth_user_roles" export * from "./auth_users" +export * from "./auth_refresh_tokens" export * from "./bankaccounts" export * from "./bankrequisitions" export * from "./bankstatements" diff --git a/backend/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index 7d7881e..74a324c 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -1,14 +1,18 @@ import { FastifyInstance } from "fastify"; import bcrypt from "bcrypt"; -import jwt from "jsonwebtoken"; import { generateRandomPassword, hashPassword } from "../../utils/password"; import { sendMail } from "../../utils/mailer"; -import { secrets } from "../../utils/secrets"; import { authUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema"; import { tenants } from "../../../db/schema"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; +import { + createAccessToken, + issueRefreshToken, + revokeRefreshToken, + rotateRefreshToken, +} from "../../utils/authTokens"; export default async function authRoutes(server: FastifyInstance) { @@ -122,15 +126,9 @@ export default async function authRoutes(server: FastifyInstance) { return reply.code(401).send({ error: "Invalid credentials" }); } - const token = jwt.sign( - { - user_id: user.id, - email: user.email, - tenant_id: req.tenant?.id ?? null, - }, - secrets.JWT_SECRET!, - { expiresIn: "6h" } - ); + const tenantId = req.tenant?.id ? Number(req.tenant.id) : null; + const token = createAccessToken(user, tenantId); + const refreshToken = await issueRefreshToken(server, user.id, tenantId); reply.setCookie("token", token, { path: "/", @@ -140,9 +138,31 @@ export default async function authRoutes(server: FastifyInstance) { maxAge: 60 * 60 * 6, }); - return { token }; + return { token, refreshToken }; }); + server.post("/auth/refresh", { + schema: { + tags: ["Auth"], + summary: "Refresh a persistent session", + body: { + type: "object", + required: ["refreshToken"], + properties: { + refreshToken: { type: "string" }, + }, + }, + }, + }, async (req, reply) => { + const { refreshToken } = req.body as { refreshToken: string }; + const refreshed = await rotateRefreshToken(server, refreshToken); + + if (!refreshed) { + return reply.code(401).send({ error: "Invalid or expired refresh token" }); + } + + return refreshed; + }); // ----------------------------------------------------- // LOGOUT @@ -153,6 +173,10 @@ export default async function authRoutes(server: FastifyInstance) { summary: "Logout User" } }, async (req, reply) => { + const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken; + if (refreshToken) { + await revokeRefreshToken(server, refreshToken); + } reply.clearCookie("token", { path: "/", httpOnly: true, diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 0c85424..b67fb3a 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -14,6 +14,7 @@ import { import {and, desc, eq, inArray} from "drizzle-orm" import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithTeams } from "../utils/profileTeams" +import { rotateRefreshToken } from "../utils/authTokens" export default async function tenantRoutes(server: FastifyInstance) { @@ -65,7 +66,7 @@ export default async function tenantRoutes(server: FastifyInstance) { return reply.code(401).send({ error: "Unauthorized" }) } - const { tenant_id } = req.body as { tenant_id: string } + const { tenant_id, refreshToken } = req.body as { tenant_id: string; refreshToken?: string } if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" }) // prüfen ob der User zu diesem Tenant gehört @@ -81,7 +82,22 @@ export default async function tenantRoutes(server: FastifyInstance) { return reply.code(403).send({ error: "Not a member of this tenant" }) } - // JWT neu erzeugen + if (refreshToken) { + const session = await rotateRefreshToken( + server, + refreshToken, + Number(tenant_id), + req.user.user_id + ) + + if (!session) { + return reply.code(401).send({ error: "Invalid or expired refresh token" }) + } + + return session + } + + // Abwärtskompatibilität für Clients ohne persistente Session const token = jwt.sign( { user_id: req.user.user_id, diff --git a/backend/src/utils/authTokens.ts b/backend/src/utils/authTokens.ts new file mode 100644 index 0000000..16ad5eb --- /dev/null +++ b/backend/src/utils/authTokens.ts @@ -0,0 +1,93 @@ +import { createHash, randomBytes } from "node:crypto" +import jwt from "jsonwebtoken" +import { FastifyInstance } from "fastify" +import { and, eq, gt, isNull } from "drizzle-orm" + +import { authRefreshTokens, authUsers } from "../../db/schema" +import { secrets } from "./secrets" + +const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 6 +const REFRESH_TOKEN_TTL_DAYS = 90 + +export function createAccessToken(user: { id: string; email: string }, tenantId: number | null) { + return jwt.sign( + { + user_id: user.id, + email: user.email, + tenant_id: tenantId, + }, + secrets.JWT_SECRET!, + { expiresIn: ACCESS_TOKEN_TTL_SECONDS } + ) +} + +export function hashRefreshToken(token: string) { + return createHash("sha256").update(token, "utf8").digest("hex") +} + +export async function issueRefreshToken( + server: FastifyInstance, + userId: string, + tenantId: number | null +) { + const token = randomBytes(48).toString("base64url") + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000) + + await server.db.insert(authRefreshTokens).values({ + userId, + tenantId, + tokenHash: hashRefreshToken(token), + expiresAt, + }) + + return token +} + +export async function rotateRefreshToken( + server: FastifyInstance, + token: string, + tenantOverride?: number | null, + expectedUserId?: string +) { + const tokenHash = hashRefreshToken(token) + const [session] = await server.db + .update(authRefreshTokens) + .set({ revokedAt: new Date() }) + .where(and( + eq(authRefreshTokens.tokenHash, tokenHash), + isNull(authRefreshTokens.revokedAt), + gt(authRefreshTokens.expiresAt, new Date()) + )) + .returning({ + id: authRefreshTokens.id, + userId: authRefreshTokens.userId, + tenantId: authRefreshTokens.tenantId, + }) + + if (!session) return null + if (expectedUserId && session.userId !== expectedUserId) return null + + const [user] = await server.db + .select({ id: authUsers.id, email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, session.userId)) + .limit(1) + + if (!user) return null + + const tenantId = tenantOverride === undefined ? session.tenantId : tenantOverride + const refreshToken = await issueRefreshToken(server, session.userId, tenantId) + const accessToken = createAccessToken(user, tenantId) + + return { token: accessToken, refreshToken } +} + +export async function revokeRefreshToken(server: FastifyInstance, token: string) { + await server.db + .update(authRefreshTokens) + .set({ revokedAt: new Date() }) + .where(and( + eq(authRefreshTokens.tokenHash, hashRefreshToken(token)), + isNull(authRefreshTokens.revokedAt) + )) +} diff --git a/mobile/app/login.tsx b/mobile/app/login.tsx index 87519ad..6b2c8c1 100644 --- a/mobile/app/login.tsx +++ b/mobile/app/login.tsx @@ -33,6 +33,7 @@ export default function LoginScreen() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync()); const [serverInput, setServerInput] = useState(getApiBaseUrlSync()); const [showServerModal, setShowServerModal] = useState(false); @@ -141,14 +142,23 @@ export default function LoginScreen() { /> Passwort - + + + setIsPasswordVisible((visible) => !visible)}> + {isPasswordVisible ? 'Ausblenden' : 'Anzeigen'} + + {error ? {error} : null} @@ -251,6 +261,30 @@ const styles = StyleSheet.create({ fontSize: 16, color: '#111827', }, + passwordRow: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + }, + passwordInput: { + flex: 1, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + color: '#111827', + }, + passwordToggle: { + alignSelf: 'stretch', + justifyContent: 'center', + paddingHorizontal: 12, + }, + passwordToggleText: { + color: PRIMARY, + fontSize: 13, + fontWeight: '600', + }, button: { marginTop: 6, backgroundColor: PRIMARY, diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 2ce138f..ecd20a0 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -131,6 +131,11 @@ export type MeResponse = { permissions: string[]; }; +export type AuthSession = { + token: string; + refreshToken: string; +}; + export type EncodedLabelRow = { dataType: 'pixels' | 'void' | 'check'; rowNumber: number; @@ -199,6 +204,20 @@ type RequestOptions = { timeoutMs?: number; }; +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = 'ApiError'; + } +} + +export function isAuthenticationError(error: unknown): boolean { + return error instanceof ApiError && error.status === 401; +} + export type MatrixStatus = { enabled?: boolean; ready?: boolean; @@ -363,7 +382,7 @@ export async function apiRequest(path: string, options: RequestOptions = {}): (payload as { message?: string; error?: string } | null)?.message || (payload as { message?: string; error?: string } | null)?.error || `Request failed (${response.status}) for ${path}`; - throw new Error(message); + throw new ApiError(message, response.status); } return payload as T; @@ -399,7 +418,7 @@ async function apiFormRequest(path: string, token: string, formData: FormData (payload as { message?: string; error?: string } | null)?.message || (payload as { message?: string; error?: string } | null)?.error || `Request failed (${response.status}) for ${path}`; - throw new Error(message); + throw new ApiError(message, response.status); } return payload as T; @@ -629,17 +648,37 @@ export async function renderPrintLabel( }); } -export async function loginWithEmailPassword(email: string, password: string): Promise { - const payload = await apiRequest<{ token?: string }>('/auth/login', { +function requireAuthSession(payload: Partial, action: string): AuthSession { + if (!payload?.token || !payload?.refreshToken) { + throw new Error(`${action} did not return a complete session.`); + } + + return { token: payload.token, refreshToken: payload.refreshToken }; +} + +export async function loginWithEmailPassword(email: string, password: string): Promise { + const payload = await apiRequest>('/auth/login', { method: 'POST', body: { email, password }, }); - if (!payload?.token) { - throw new Error('Login did not return a token.'); - } + return requireAuthSession(payload, 'Login'); +} - return payload.token; +export async function refreshAuthSession(refreshToken: string): Promise { + const payload = await apiRequest>('/auth/refresh', { + method: 'POST', + body: { refreshToken }, + }); + + return requireAuthSession(payload, 'Session refresh'); +} + +export async function logoutSession(refreshToken: string): Promise { + await apiRequest('/auth/logout', { + method: 'POST', + body: { refreshToken }, + }); } export async function fetchMe(token: string): Promise { @@ -666,18 +705,18 @@ export async function sendMobileTestPush(token: string): Promise<{ accepted: num }); } -export async function switchTenantRequest(tenantId: number, token: string): Promise { - const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', { +export async function switchTenantRequest( + tenantId: number, + token: string, + refreshToken: string +): Promise { + const payload = await apiRequest>('/api/tenant/switch', { method: 'POST', token, - body: { tenant_id: String(tenantId) }, + body: { tenant_id: String(tenantId), refreshToken }, }); - if (!payload?.token) { - throw new Error('Tenant switch did not return a token.'); - } - - return payload.token; + return requireAuthSession(payload, 'Tenant switch'); } export async function fetchTasks(token: string): Promise { diff --git a/mobile/src/lib/token-storage.ts b/mobile/src/lib/token-storage.ts index d86209f..766142a 100644 --- a/mobile/src/lib/token-storage.ts +++ b/mobile/src/lib/token-storage.ts @@ -1,8 +1,15 @@ import * as SecureStore from 'expo-secure-store'; const TOKEN_KEY = 'fedeo.mobile.auth.token'; +const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token'; let memoryToken: string | null = null; +let memoryRefreshToken: string | null = null; + +export type StoredSession = { + token: string; + refreshToken: string; +}; async function hasSecureStore(): Promise { try { @@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise { } } -export async function clearStoredToken(): Promise { - memoryToken = null; +export async function getStoredSession(): Promise { + if (await hasSecureStore()) { + const [token, refreshToken] = await Promise.all([ + SecureStore.getItemAsync(TOKEN_KEY), + SecureStore.getItemAsync(REFRESH_TOKEN_KEY), + ]); + memoryToken = token; + memoryRefreshToken = refreshToken; + } + + if (!memoryToken || !memoryRefreshToken) return null; + return { token: memoryToken, refreshToken: memoryRefreshToken }; +} + +export async function setStoredSession(session: StoredSession): Promise { + memoryToken = session.token; + memoryRefreshToken = session.refreshToken; if (await hasSecureStore()) { - await SecureStore.deleteItemAsync(TOKEN_KEY); + await Promise.all([ + SecureStore.setItemAsync(TOKEN_KEY, session.token), + SecureStore.setItemAsync(REFRESH_TOKEN_KEY, session.refreshToken), + ]); + } +} + +export async function clearStoredToken(): Promise { + memoryToken = null; + memoryRefreshToken = null; + + if (await hasSecureStore()) { + await Promise.all([ + SecureStore.deleteItemAsync(TOKEN_KEY), + SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY), + ]); } } diff --git a/mobile/src/providers/auth-provider.tsx b/mobile/src/providers/auth-provider.tsx index 3e59fd7..9811a4c 100644 --- a/mobile/src/providers/auth-provider.tsx +++ b/mobile/src/providers/auth-provider.tsx @@ -1,8 +1,22 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api'; +import { + fetchMe, + loginWithEmailPassword, + logoutSession, + MeResponse, + isAuthenticationError, + refreshAuthSession, + switchTenantRequest, + Tenant, +} from '@/src/lib/api'; import { hydrateApiBaseUrl } from '@/src/lib/server-config'; -import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage'; +import { + clearStoredToken, + getStoredSession, + setStoredSession, + tokenStorageInfo, +} from '@/src/lib/token-storage'; export type AuthUser = MeResponse['user']; @@ -27,6 +41,7 @@ type AuthContextValue = { const AuthContext = createContext(undefined); const BOOTSTRAP_TIMEOUT_MS = 20000; +const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 60 * 1000; function normalizeTenantId(value: number | string | null | undefined): number | null { if (value === null || value === undefined || value === '') { @@ -52,6 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isBootstrapping, setIsBootstrapping] = useState(true); const [bootstrapError, setBootstrapError] = useState(null); const [token, setToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); const [user, setUser] = useState(null); const [tenants, setTenants] = useState([]); const [activeTenantId, setActiveTenantId] = useState(null); @@ -80,11 +96,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); const logout = useCallback(async () => { + if (refreshToken) { + void logoutSession(refreshToken).catch(() => undefined); + } await clearStoredToken(); setToken(null); + setRefreshToken(null); setBootstrapError(null); resetSession(); - }, [resetSession]); + }, [refreshToken, resetSession]); const refreshUser = useCallback(async () => { if (!token) { @@ -101,6 +121,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const resetLocalSession = useCallback(async () => { setToken(null); + setRefreshToken(null); setBootstrapError(null); setIsBootstrapping(false); resetSession(); @@ -117,19 +138,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await withTimeout( (async () => { await hydrateApiBaseUrl(); - const storedToken = await getStoredToken(); + const storedSession = await getStoredSession(); - if (!storedToken) { + if (!storedSession) { if (bootstrapRunRef.current !== runId) return; setToken(null); resetSession(); return; } - await hydrateSession(storedToken); + let activeSession = storedSession; + try { + await hydrateSession(activeSession.token); + } catch { + activeSession = await refreshAuthSession(activeSession.refreshToken); + await setStoredSession(activeSession); + await hydrateSession(activeSession.token); + } if (bootstrapRunRef.current !== runId) return; - setToken(storedToken); + setToken(activeSession.token); + setRefreshToken(activeSession.refreshToken); })(), BOOTSTRAP_TIMEOUT_MS, 'Die mobile Session konnte nicht rechtzeitig initialisiert werden.' @@ -137,9 +166,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } catch (err) { if (bootstrapRunRef.current === runId) { setToken(null); + setRefreshToken(null); resetSession(); setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.'); - void clearStoredToken().catch(() => undefined); + if (isAuthenticationError(err)) { + void clearStoredToken().catch(() => undefined); + } } } finally { if (bootstrapRunRef.current === runId) { @@ -154,28 +186,49 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const login = useCallback( async (email: string, password: string) => { - const nextToken = await loginWithEmailPassword(email, password); - await setStoredToken(nextToken); - await hydrateSession(nextToken); - setToken(nextToken); + const session = await loginWithEmailPassword(email, password); + await setStoredSession(session); + await hydrateSession(session.token); + setToken(session.token); + setRefreshToken(session.refreshToken); }, [hydrateSession] ); const switchTenant = useCallback( async (tenantId: number) => { - if (!token) { + if (!token || !refreshToken) { throw new Error('No active session found.'); } - const nextToken = await switchTenantRequest(tenantId, token); - await setStoredToken(nextToken); - await hydrateSession(nextToken); - setToken(nextToken); + const session = await switchTenantRequest(tenantId, token, refreshToken); + await setStoredSession(session); + await hydrateSession(session.token); + setToken(session.token); + setRefreshToken(session.refreshToken); }, - [token, hydrateSession] + [token, refreshToken, hydrateSession] ); + useEffect(() => { + if (!refreshToken) return; + + const interval = setInterval(() => { + void (async () => { + try { + const session = await refreshAuthSession(refreshToken); + await setStoredSession(session); + setToken(session.token); + setRefreshToken(session.refreshToken); + } catch { + // Bei einem temporären Netzfehler bleibt die gespeicherte Session erhalten. + } + })(); + }, SESSION_REFRESH_INTERVAL_MS); + + return () => clearInterval(interval); + }, [refreshToken]); + const activeTenant = useMemo( () => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null, [activeTenantId, tenants]