KI-AGENT: Persistente App-Sessions mit Refresh-Tokens ergänzen
This commit is contained in:
@@ -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)
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user