백엔드에서 JWT가 아닌 세션 ID 기반으로 인증하도록 변경

This commit is contained in:
static
2025-01-12 07:28:38 +09:00
parent 0bdf990dae
commit 1a86c8d9e0
42 changed files with 487 additions and 624 deletions

View File

@@ -1,4 +1,6 @@
type IntegrityErrorMessages =
// Challenge
| "Challenge already registered"
// Client
| "Public key(s) already registered"
| "User client already exists"
@@ -9,9 +11,9 @@ type IntegrityErrorMessages =
// MEK
| "MEK already registered"
| "Inactive MEK version"
// Token
| "Refresh token not found"
| "Refresh token already registered";
// Session
| "Session not found"
| "Session already exists";
export class IntegrityError extends Error {
constructor(public message: IntegrityErrorMessages) {

View File

@@ -39,7 +39,7 @@ export const userClientChallenge = sqliteTable("user_client_challenge", {
clientId: integer("client_id")
.notNull()
.references(() => client.id),
answer: text("challenge").notNull().unique(), // Base64
answer: text("answer").notNull().unique(), // Base64
allowedIp: text("allowed_ip").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),

View File

@@ -1,5 +1,5 @@
export * from "./client";
export * from "./file";
export * from "./mek";
export * from "./token";
export * from "./session";
export * from "./user";

View File

@@ -2,31 +2,34 @@ import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core";
import { client } from "./client";
import { user } from "./user";
export const refreshToken = sqliteTable(
"refresh_token",
export const session = sqliteTable(
"session",
{
id: text("id").primaryKey(),
id: text("id").notNull().primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => user.id),
clientId: integer("client_id").references(() => client.id),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), // Only used for cleanup
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }).notNull(),
lastUsedByIp: text("last_used_by_ip"),
lastUsedByUserAgent: text("last_used_by_user_agent"),
},
(t) => ({
unq: unique().on(t.userId, t.clientId),
}),
);
export const tokenUpgradeChallenge = sqliteTable("token_upgrade_challenge", {
export const sessionUpgradeChallenge = sqliteTable("session_upgrade_challenge", {
id: integer("id").primaryKey(),
refreshTokenId: text("refresh_token_id")
sessionId: text("session_id")
.notNull()
.references(() => refreshToken.id),
.references(() => session.id)
.unique(),
clientId: integer("client_id")
.notNull()
.references(() => client.id),
answer: text("challenge").notNull().unique(), // Base64
answer: text("answer").notNull().unique(), // Base64
allowedIp: text("allowed_ip").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
isUsed: integer("is_used", { mode: "boolean" }).notNull().default(false),
});

View File

@@ -0,0 +1,124 @@
import { SqliteError } from "better-sqlite3";
import { and, eq, gt, lte, isNull } from "drizzle-orm";
import env from "$lib/server/loadenv";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { session, sessionUpgradeChallenge } from "./schema";
export const createSession = async (
userId: number,
clientId: number | null,
sessionId: string,
ip: string | null,
userAgent: string | null,
) => {
try {
const now = new Date();
await db.insert(session).values({
id: sessionId,
userId,
clientId,
createdAt: now,
lastUsedAt: now,
lastUsedByIp: ip,
lastUsedByUserAgent: userAgent,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new IntegrityError("Session already exists");
}
throw e;
}
};
export const refreshSession = async (
sessionId: string,
ip: string | null,
userAgent: string | null,
) => {
const now = new Date();
const sessions = await db
.update(session)
.set({
lastUsedAt: now,
lastUsedByIp: ip,
lastUsedByUserAgent: userAgent,
})
.where(
and(
eq(session.id, sessionId),
gt(session.lastUsedAt, new Date(now.getTime() - env.session.exp)),
),
)
.returning({ userId: session.userId, clientId: session.clientId });
if (!sessions[0]) {
throw new IntegrityError("Session not found");
}
return sessions[0];
};
export const upgradeSession = async (sessionId: string, clientId: number) => {
const res = await db
.update(session)
.set({ clientId })
.where(and(eq(session.id, sessionId), isNull(session.clientId)));
if (res.changes === 0) {
throw new IntegrityError("Session not found");
}
};
export const deleteSession = async (sessionId: string) => {
await db.delete(session).where(eq(session.id, sessionId));
};
export const cleanupExpiredSessions = async () => {
await db.delete(session).where(lte(session.lastUsedAt, new Date(Date.now() - env.session.exp)));
};
export const registerSessionUpgradeChallenge = async (
sessionId: string,
clientId: number,
answer: string,
allowedIp: string,
expiresAt: Date,
) => {
try {
await db.insert(sessionUpgradeChallenge).values({
sessionId,
clientId,
answer,
allowedIp,
expiresAt,
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new IntegrityError("Challenge already registered");
}
throw e;
}
};
export const consumeSessionUpgradeChallenge = async (
sessionId: string,
answer: string,
ip: string,
) => {
const challenges = await db
.delete(sessionUpgradeChallenge)
.where(
and(
eq(sessionUpgradeChallenge.sessionId, sessionId),
eq(sessionUpgradeChallenge.answer, answer),
eq(sessionUpgradeChallenge.allowedIp, ip),
gt(sessionUpgradeChallenge.expiresAt, new Date()),
),
)
.returning({ clientId: sessionUpgradeChallenge.clientId });
return challenges[0] ?? null;
};
export const cleanupExpiredSessionUpgradeChallenges = async () => {
await db
.delete(sessionUpgradeChallenge)
.where(lte(sessionUpgradeChallenge.expiresAt, new Date()));
};

View File

@@ -1,133 +0,0 @@
import { SqliteError } from "better-sqlite3";
import { and, eq, gt, lte } from "drizzle-orm";
import env from "$lib/server/loadenv";
import db from "./drizzle";
import { IntegrityError } from "./error";
import { refreshToken, tokenUpgradeChallenge } from "./schema";
const expiresAt = () => new Date(Date.now() + env.jwt.refreshExp);
export const registerRefreshToken = async (
userId: number,
clientId: number | null,
tokenId: string,
) => {
try {
await db.insert(refreshToken).values({
id: tokenId,
userId,
clientId,
expiresAt: expiresAt(),
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new IntegrityError("Refresh token already registered");
}
throw e;
}
};
export const getRefreshToken = async (tokenId: string) => {
const tokens = await db.select().from(refreshToken).where(eq(refreshToken.id, tokenId)).limit(1);
return tokens[0] ?? null;
};
export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => {
await db.transaction(
async (tx) => {
await tx
.delete(tokenUpgradeChallenge)
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
const res = await tx
.update(refreshToken)
.set({
id: newTokenId,
expiresAt: expiresAt(),
})
.where(eq(refreshToken.id, oldTokenId));
if (res.changes === 0) {
throw new IntegrityError("Refresh token not found");
}
},
{ behavior: "exclusive" },
);
};
export const upgradeRefreshToken = async (
oldTokenId: string,
newTokenId: string,
clientId: number,
) => {
await db.transaction(
async (tx) => {
await tx
.delete(tokenUpgradeChallenge)
.where(eq(tokenUpgradeChallenge.refreshTokenId, oldTokenId));
const res = await tx
.update(refreshToken)
.set({
id: newTokenId,
clientId,
expiresAt: expiresAt(),
})
.where(eq(refreshToken.id, oldTokenId));
if (res.changes === 0) {
throw new IntegrityError("Refresh token not found");
}
},
{ behavior: "exclusive" },
);
};
export const revokeRefreshToken = async (tokenId: string) => {
await db.delete(refreshToken).where(eq(refreshToken.id, tokenId));
};
export const cleanupExpiredRefreshTokens = async () => {
await db.delete(refreshToken).where(lte(refreshToken.expiresAt, new Date()));
};
export const registerTokenUpgradeChallenge = async (
tokenId: string,
clientId: number,
answer: string,
allowedIp: string,
expiresAt: Date,
) => {
await db.insert(tokenUpgradeChallenge).values({
refreshTokenId: tokenId,
clientId,
answer,
allowedIp,
expiresAt,
});
};
export const getTokenUpgradeChallenge = async (answer: string, ip: string) => {
const challenges = await db
.select()
.from(tokenUpgradeChallenge)
.where(
and(
eq(tokenUpgradeChallenge.answer, answer),
eq(tokenUpgradeChallenge.allowedIp, ip),
gt(tokenUpgradeChallenge.expiresAt, new Date()),
eq(tokenUpgradeChallenge.isUsed, false),
),
)
.limit(1);
return challenges[0] ?? null;
};
export const markTokenUpgradeChallengeAsUsed = async (id: number) => {
await db
.update(tokenUpgradeChallenge)
.set({ isUsed: true })
.where(eq(tokenUpgradeChallenge.id, id));
};
export const cleanupExpiredTokenUpgradeChallenges = async () => {
await db.delete(tokenUpgradeChallenge).where(lte(tokenUpgradeChallenge.expiresAt, new Date()));
};