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,
|
"when": 1784021669805,
|
||||||
"tag": "0055_tenant_export_maintenance_lock",
|
"tag": "0055_tenant_export_maintenance_lock",
|
||||||
"breakpoints": true
|
"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_tenant_users"
|
||||||
export * from "./auth_user_roles"
|
export * from "./auth_user_roles"
|
||||||
export * from "./auth_users"
|
export * from "./auth_users"
|
||||||
|
export * from "./auth_refresh_tokens"
|
||||||
export * from "./bankaccounts"
|
export * from "./bankaccounts"
|
||||||
export * from "./bankrequisitions"
|
export * from "./bankrequisitions"
|
||||||
export * from "./bankstatements"
|
export * from "./bankstatements"
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
||||||
import { sendMail } from "../../utils/mailer";
|
import { sendMail } from "../../utils/mailer";
|
||||||
import { secrets } from "../../utils/secrets";
|
|
||||||
|
|
||||||
import { authUsers } from "../../../db/schema";
|
import { authUsers } from "../../../db/schema";
|
||||||
import { authTenantUsers } from "../../../db/schema";
|
import { authTenantUsers } from "../../../db/schema";
|
||||||
import { tenants } 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) {
|
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" });
|
return reply.code(401).send({ error: "Invalid credentials" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = jwt.sign(
|
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
|
||||||
{
|
const token = createAccessToken(user, tenantId);
|
||||||
user_id: user.id,
|
const refreshToken = await issueRefreshToken(server, user.id, tenantId);
|
||||||
email: user.email,
|
|
||||||
tenant_id: req.tenant?.id ?? null,
|
|
||||||
},
|
|
||||||
secrets.JWT_SECRET!,
|
|
||||||
{ expiresIn: "6h" }
|
|
||||||
);
|
|
||||||
|
|
||||||
reply.setCookie("token", token, {
|
reply.setCookie("token", token, {
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -140,9 +138,31 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
maxAge: 60 * 60 * 6,
|
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
|
// LOGOUT
|
||||||
@@ -153,6 +173,10 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
summary: "Logout User"
|
summary: "Logout User"
|
||||||
}
|
}
|
||||||
}, async (req, reply) => {
|
}, async (req, reply) => {
|
||||||
|
const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken;
|
||||||
|
if (refreshToken) {
|
||||||
|
await revokeRefreshToken(server, refreshToken);
|
||||||
|
}
|
||||||
reply.clearCookie("token", {
|
reply.clearCookie("token", {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||||
|
import { rotateRefreshToken } from "../utils/authTokens"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
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" })
|
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" })
|
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
||||||
|
|
||||||
// prüfen ob der User zu diesem Tenant gehört
|
// 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" })
|
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(
|
const token = jwt.sign(
|
||||||
{
|
{
|
||||||
user_id: req.user.user_id,
|
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 [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
|
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
|
||||||
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
|
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
|
||||||
const [showServerModal, setShowServerModal] = useState(false);
|
const [showServerModal, setShowServerModal] = useState(false);
|
||||||
@@ -141,14 +142,23 @@ export default function LoginScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Passwort</Text>
|
<Text style={styles.label}>Passwort</Text>
|
||||||
<TextInput
|
<View style={styles.passwordRow}>
|
||||||
secureTextEntry
|
<TextInput
|
||||||
placeholder="••••••••"
|
secureTextEntry={!isPasswordVisible}
|
||||||
placeholderTextColor="#9ca3af"
|
placeholder="••••••••"
|
||||||
style={styles.input}
|
placeholderTextColor="#9ca3af"
|
||||||
value={password}
|
style={styles.passwordInput}
|
||||||
onChangeText={setPassword}
|
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}
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||||
|
|
||||||
@@ -251,6 +261,30 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#111827',
|
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: {
|
button: {
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
backgroundColor: PRIMARY,
|
backgroundColor: PRIMARY,
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ export type MeResponse = {
|
|||||||
permissions: string[];
|
permissions: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type EncodedLabelRow = {
|
export type EncodedLabelRow = {
|
||||||
dataType: 'pixels' | 'void' | 'check';
|
dataType: 'pixels' | 'void' | 'check';
|
||||||
rowNumber: number;
|
rowNumber: number;
|
||||||
@@ -199,6 +204,20 @@ type RequestOptions = {
|
|||||||
timeoutMs?: number;
|
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 = {
|
export type MatrixStatus = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
ready?: 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)?.message ||
|
||||||
(payload as { message?: string; error?: string } | null)?.error ||
|
(payload as { message?: string; error?: string } | null)?.error ||
|
||||||
`Request failed (${response.status}) for ${path}`;
|
`Request failed (${response.status}) for ${path}`;
|
||||||
throw new Error(message);
|
throw new ApiError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as T;
|
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)?.message ||
|
||||||
(payload as { message?: string; error?: string } | null)?.error ||
|
(payload as { message?: string; error?: string } | null)?.error ||
|
||||||
`Request failed (${response.status}) for ${path}`;
|
`Request failed (${response.status}) for ${path}`;
|
||||||
throw new Error(message);
|
throw new ApiError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload as T;
|
return payload as T;
|
||||||
@@ -629,17 +648,37 @@ export async function renderPrintLabel(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
|
function requireAuthSession(payload: Partial<AuthSession>, action: string): AuthSession {
|
||||||
const payload = await apiRequest<{ token?: string }>('/auth/login', {
|
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',
|
method: 'POST',
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload?.token) {
|
return requireAuthSession(payload, 'Login');
|
||||||
throw new Error('Login did not return a token.');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
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> {
|
export async function switchTenantRequest(
|
||||||
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
|
tenantId: number,
|
||||||
|
token: string,
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<AuthSession> {
|
||||||
|
const payload = await apiRequest<Partial<AuthSession>>('/api/tenant/switch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token,
|
token,
|
||||||
body: { tenant_id: String(tenantId) },
|
body: { tenant_id: String(tenantId), refreshToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload?.token) {
|
return requireAuthSession(payload, 'Tenant switch');
|
||||||
throw new Error('Tenant switch did not return a token.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload.token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTasks(token: string): Promise<Task[]> {
|
export async function fetchTasks(token: string): Promise<Task[]> {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
const TOKEN_KEY = 'fedeo.mobile.auth.token';
|
const TOKEN_KEY = 'fedeo.mobile.auth.token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token';
|
||||||
|
|
||||||
let memoryToken: string | null = null;
|
let memoryToken: string | null = null;
|
||||||
|
let memoryRefreshToken: string | null = null;
|
||||||
|
|
||||||
|
export type StoredSession = {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function hasSecureStore(): Promise<boolean> {
|
async function hasSecureStore(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -30,11 +37,41 @@ export async function setStoredToken(token: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearStoredToken(): Promise<void> {
|
export async function getStoredSession(): Promise<StoredSession | null> {
|
||||||
memoryToken = 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()) {
|
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 { 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 { 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'];
|
export type AuthUser = MeResponse['user'];
|
||||||
|
|
||||||
@@ -27,6 +41,7 @@ type AuthContextValue = {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
const BOOTSTRAP_TIMEOUT_MS = 20000;
|
const BOOTSTRAP_TIMEOUT_MS = 20000;
|
||||||
|
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
function normalizeTenantId(value: number | string | null | undefined): number | null {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
@@ -52,6 +67,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||||
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
||||||
const [token, setToken] = 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 [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
|
||||||
@@ -80,11 +96,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
|
if (refreshToken) {
|
||||||
|
void logoutSession(refreshToken).catch(() => undefined);
|
||||||
|
}
|
||||||
await clearStoredToken();
|
await clearStoredToken();
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setRefreshToken(null);
|
||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
}, [resetSession]);
|
}, [refreshToken, resetSession]);
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -101,6 +121,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const resetLocalSession = useCallback(async () => {
|
const resetLocalSession = useCallback(async () => {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setRefreshToken(null);
|
||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
setIsBootstrapping(false);
|
setIsBootstrapping(false);
|
||||||
resetSession();
|
resetSession();
|
||||||
@@ -117,19 +138,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await withTimeout(
|
await withTimeout(
|
||||||
(async () => {
|
(async () => {
|
||||||
await hydrateApiBaseUrl();
|
await hydrateApiBaseUrl();
|
||||||
const storedToken = await getStoredToken();
|
const storedSession = await getStoredSession();
|
||||||
|
|
||||||
if (!storedToken) {
|
if (!storedSession) {
|
||||||
if (bootstrapRunRef.current !== runId) return;
|
if (bootstrapRunRef.current !== runId) return;
|
||||||
setToken(null);
|
setToken(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
return;
|
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;
|
if (bootstrapRunRef.current !== runId) return;
|
||||||
setToken(storedToken);
|
setToken(activeSession.token);
|
||||||
|
setRefreshToken(activeSession.refreshToken);
|
||||||
})(),
|
})(),
|
||||||
BOOTSTRAP_TIMEOUT_MS,
|
BOOTSTRAP_TIMEOUT_MS,
|
||||||
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
|
'Die mobile Session konnte nicht rechtzeitig initialisiert werden.'
|
||||||
@@ -137,9 +166,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (bootstrapRunRef.current === runId) {
|
if (bootstrapRunRef.current === runId) {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setRefreshToken(null);
|
||||||
resetSession();
|
resetSession();
|
||||||
setBootstrapError(err instanceof Error ? err.message : 'Die mobile Session konnte nicht initialisiert werden.');
|
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 {
|
} finally {
|
||||||
if (bootstrapRunRef.current === runId) {
|
if (bootstrapRunRef.current === runId) {
|
||||||
@@ -154,28 +186,49 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
const nextToken = await loginWithEmailPassword(email, password);
|
const session = await loginWithEmailPassword(email, password);
|
||||||
await setStoredToken(nextToken);
|
await setStoredSession(session);
|
||||||
await hydrateSession(nextToken);
|
await hydrateSession(session.token);
|
||||||
setToken(nextToken);
|
setToken(session.token);
|
||||||
|
setRefreshToken(session.refreshToken);
|
||||||
},
|
},
|
||||||
[hydrateSession]
|
[hydrateSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const switchTenant = useCallback(
|
const switchTenant = useCallback(
|
||||||
async (tenantId: number) => {
|
async (tenantId: number) => {
|
||||||
if (!token) {
|
if (!token || !refreshToken) {
|
||||||
throw new Error('No active session found.');
|
throw new Error('No active session found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextToken = await switchTenantRequest(tenantId, token);
|
const session = await switchTenantRequest(tenantId, token, refreshToken);
|
||||||
await setStoredToken(nextToken);
|
await setStoredSession(session);
|
||||||
await hydrateSession(nextToken);
|
await hydrateSession(session.token);
|
||||||
setToken(nextToken);
|
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(
|
const activeTenant = useMemo(
|
||||||
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
|
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
|
||||||
[activeTenantId, tenants]
|
[activeTenantId, tenants]
|
||||||
|
|||||||
Reference in New Issue
Block a user