KI-AGENT: Persistente App-Sessions mit Refresh-Tokens ergänzen
This commit is contained in:
21
backend/db/migrations/0056_auth_refresh_tokens.sql
Normal file
21
backend/db/migrations/0056_auth_refresh_tokens.sql
Normal file
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
20
backend/db/schema/auth_refresh_tokens.ts
Normal file
20
backend/db/schema/auth_refresh_tokens.ts
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
93
backend/src/utils/authTokens.ts
Normal file
93
backend/src/utils/authTokens.ts
Normal file
@@ -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)
|
||||
))
|
||||
}
|
||||
@@ -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() {
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Passwort</Text>
|
||||
<TextInput
|
||||
secureTextEntry
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<View style={styles.passwordRow}>
|
||||
<TextInput
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.passwordInput}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isPasswordVisible ? 'Passwort ausblenden' : 'Passwort einblenden'}
|
||||
style={styles.passwordToggle}
|
||||
onPress={() => setIsPasswordVisible((visible) => !visible)}>
|
||||
<Text style={styles.passwordToggleText}>{isPasswordVisible ? 'Ausblenden' : 'Anzeigen'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : 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,
|
||||
|
||||
@@ -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<T>(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<T>(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<string> {
|
||||
const payload = await apiRequest<{ token?: string }>('/auth/login', {
|
||||
function requireAuthSession(payload: Partial<AuthSession>, 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<AuthSession> {
|
||||
const payload = await apiRequest<Partial<AuthSession>>('/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<AuthSession> {
|
||||
const payload = await apiRequest<Partial<AuthSession>>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: { refreshToken },
|
||||
});
|
||||
|
||||
return requireAuthSession(payload, 'Session refresh');
|
||||
}
|
||||
|
||||
export async function logoutSession(refreshToken: string): Promise<void> {
|
||||
await apiRequest('/auth/logout', {
|
||||
method: 'POST',
|
||||
body: { refreshToken },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMe(token: string): Promise<MeResponse> {
|
||||
@@ -666,18 +705,18 @@ export async function sendMobileTestPush(token: string): Promise<{ accepted: num
|
||||
});
|
||||
}
|
||||
|
||||
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
|
||||
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
|
||||
export async function switchTenantRequest(
|
||||
tenantId: number,
|
||||
token: string,
|
||||
refreshToken: string
|
||||
): Promise<AuthSession> {
|
||||
const payload = await apiRequest<Partial<AuthSession>>('/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<Task[]> {
|
||||
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
@@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStoredToken(): Promise<void> {
|
||||
memoryToken = null;
|
||||
export async function getStoredSession(): Promise<StoredSession | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
memoryToken = null;
|
||||
memoryRefreshToken = null;
|
||||
|
||||
if (await hasSecureStore()) {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||
SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuthContextValue | undefined>(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<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [activeTenantId, setActiveTenantId] = useState<number | null>(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]
|
||||
|
||||
Reference in New Issue
Block a user