mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-16 15:08:46 +00:00
백엔드에서 JWT가 아닌 세션 ID 기반으로 인증하도록 변경
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./client";
|
||||
export * from "./file";
|
||||
export * from "./mek";
|
||||
export * from "./token";
|
||||
export * from "./session";
|
||||
export * from "./user";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
124
src/lib/server/db/session.ts
Normal file
124
src/lib/server/db/session.ts
Normal 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()));
|
||||
};
|
||||
@@ -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()));
|
||||
};
|
||||
Reference in New Issue
Block a user