mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-15 06:18:48 +00:00
Refresh Token 구현 변경
This commit is contained in:
9
src/hooks.server.ts
Normal file
9
src/hooks.server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ServerInit } from "@sveltejs/kit";
|
||||
import schedule from "node-schedule";
|
||||
import { cleanupExpiredRefreshTokens } from "$lib/server/db/token";
|
||||
|
||||
export const init: ServerInit = () => {
|
||||
schedule.scheduleJob("0 * * * *", () => {
|
||||
cleanupExpiredRefreshTokens();
|
||||
});
|
||||
};
|
||||
@@ -5,8 +5,10 @@ import { client, userClient } from "./schema";
|
||||
export const createClient = async (pubKey: string, userId: number) => {
|
||||
await db.transaction(async (tx) => {
|
||||
const insertRes = await tx.insert(client).values({ pubKey }).returning({ id: client.id });
|
||||
const { id: clientId } = insertRes[0]!;
|
||||
await tx.insert(userClient).values({ userId, clientId });
|
||||
await tx.insert(userClient).values({
|
||||
userId,
|
||||
clientId: insertRes[0]!.id,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./client";
|
||||
export * from "./token";
|
||||
export * from "./user";
|
||||
|
||||
18
src/lib/server/db/schema/token.ts
Normal file
18
src/lib/server/db/schema/token.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core";
|
||||
import { client } from "./client";
|
||||
import { user } from "./user";
|
||||
|
||||
export const refreshToken = sqliteTable(
|
||||
"refresh_token",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
clientId: integer("client_id").references(() => client.id),
|
||||
expiresAt: integer("expires_at").notNull(), // Only used for cleanup
|
||||
},
|
||||
(t) => ({
|
||||
unq: unique().on(t.userId, t.clientId),
|
||||
}),
|
||||
);
|
||||
@@ -5,9 +5,3 @@ export const user = sqliteTable("user", {
|
||||
email: text("email").notNull().unique(),
|
||||
password: text("password").notNull(),
|
||||
});
|
||||
|
||||
export const revokedToken = sqliteTable("revoked_token", {
|
||||
id: integer("id").primaryKey(),
|
||||
token: text("token").notNull().unique(),
|
||||
revokedAt: integer("revoked_at").notNull(),
|
||||
});
|
||||
|
||||
75
src/lib/server/db/token.ts
Normal file
75
src/lib/server/db/token.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import { eq, lte } from "drizzle-orm";
|
||||
import ms from "ms";
|
||||
import env from "$lib/server/loadenv";
|
||||
import db from "./drizzle";
|
||||
import { refreshToken } from "./schema";
|
||||
|
||||
const expiresIn = ms(env.jwt.refreshExp);
|
||||
const expiresAt = () => Date.now() + expiresIn;
|
||||
|
||||
export const registerRefreshToken = async (
|
||||
userId: number,
|
||||
clientId: number | null,
|
||||
tokenId: string,
|
||||
) => {
|
||||
try {
|
||||
await db
|
||||
.insert(refreshToken)
|
||||
.values({
|
||||
id: tokenId,
|
||||
userId,
|
||||
clientId,
|
||||
expiresAt: expiresAt(),
|
||||
})
|
||||
.execute();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRefreshToken = async (tokenId: string) => {
|
||||
const tokens = await db.select().from(refreshToken).where(eq(refreshToken.id, tokenId)).execute();
|
||||
return tokens[0] ?? null;
|
||||
};
|
||||
|
||||
export const rotateRefreshToken = async (oldTokenId: string, newTokenId: string) => {
|
||||
const res = await db
|
||||
.update(refreshToken)
|
||||
.set({
|
||||
id: newTokenId,
|
||||
expiresAt: expiresAt(),
|
||||
})
|
||||
.where(eq(refreshToken.id, oldTokenId))
|
||||
.execute();
|
||||
return res.changes > 0;
|
||||
};
|
||||
|
||||
export const upgradeRefreshToken = async (
|
||||
oldTokenId: string,
|
||||
newTokenId: string,
|
||||
clientId: number,
|
||||
) => {
|
||||
const res = await db
|
||||
.update(refreshToken)
|
||||
.set({
|
||||
id: newTokenId,
|
||||
clientId,
|
||||
expiresAt: expiresAt(),
|
||||
})
|
||||
.where(eq(refreshToken.id, oldTokenId))
|
||||
.execute();
|
||||
return res.changes > 0;
|
||||
};
|
||||
|
||||
export const revokeRefreshToken = async (tokenId: string) => {
|
||||
await db.delete(refreshToken).where(eq(refreshToken.id, tokenId)).execute();
|
||||
};
|
||||
|
||||
export const cleanupExpiredRefreshTokens = async () => {
|
||||
await db.delete(refreshToken).where(lte(refreshToken.expiresAt, Date.now())).execute();
|
||||
};
|
||||
@@ -1,27 +1,8 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import db from "./drizzle";
|
||||
import { user, revokedToken } from "./schema";
|
||||
import { user } from "./schema";
|
||||
|
||||
export const getUserByEmail = async (email: string) => {
|
||||
const users = await db.select().from(user).where(eq(user.email, email)).execute();
|
||||
return users[0] ?? null;
|
||||
};
|
||||
|
||||
export const revokeToken = async (token: string) => {
|
||||
await db
|
||||
.insert(revokedToken)
|
||||
.values({
|
||||
token,
|
||||
revokedAt: Date.now(),
|
||||
})
|
||||
.execute();
|
||||
};
|
||||
|
||||
export const isTokenRevoked = async (token: string) => {
|
||||
const tokens = await db
|
||||
.select()
|
||||
.from(revokedToken)
|
||||
.where(eq(revokedToken.token, token))
|
||||
.execute();
|
||||
return tokens.length > 0;
|
||||
};
|
||||
|
||||
@@ -2,34 +2,31 @@ import { error } from "@sveltejs/kit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import env from "$lib/server/loadenv";
|
||||
|
||||
interface TokenData {
|
||||
type: "access" | "refresh";
|
||||
userId: number;
|
||||
clientId?: number;
|
||||
}
|
||||
type TokenPayload =
|
||||
| {
|
||||
type: "access";
|
||||
userId: number;
|
||||
clientId?: number;
|
||||
}
|
||||
| {
|
||||
type: "refresh";
|
||||
jti: string;
|
||||
};
|
||||
|
||||
export enum TokenError {
|
||||
EXPIRED,
|
||||
INVALID,
|
||||
}
|
||||
|
||||
export const issueToken = (type: "access" | "refresh", userId: number, clientId?: number) => {
|
||||
return jwt.sign(
|
||||
{
|
||||
type,
|
||||
userId,
|
||||
clientId,
|
||||
} satisfies TokenData,
|
||||
env.jwt.secret,
|
||||
{
|
||||
expiresIn: type === "access" ? env.jwt.accessExp : env.jwt.refreshExp,
|
||||
},
|
||||
);
|
||||
export const issueToken = (payload: TokenPayload) => {
|
||||
return jwt.sign(payload, env.jwt.secret, {
|
||||
expiresIn: payload.type === "access" ? env.jwt.accessExp : env.jwt.refreshExp,
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyToken = (token: string) => {
|
||||
try {
|
||||
return jwt.verify(token, env.jwt.secret) as TokenData;
|
||||
return jwt.verify(token, env.jwt.secret) as TokenPayload;
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
return TokenError.EXPIRED;
|
||||
@@ -41,18 +38,18 @@ export const verifyToken = (token: string) => {
|
||||
export const authenticate = (request: Request) => {
|
||||
const accessToken = request.headers.get("Authorization");
|
||||
if (!accessToken?.startsWith("Bearer ")) {
|
||||
error(401, "Token required");
|
||||
error(401, "Access token required");
|
||||
}
|
||||
|
||||
const tokenData = verifyToken(accessToken.slice(7));
|
||||
if (tokenData === TokenError.EXPIRED) {
|
||||
error(401, "Token expired");
|
||||
} else if (tokenData === TokenError.INVALID || tokenData.type !== "access") {
|
||||
error(401, "Invalid token");
|
||||
const tokenPayload = verifyToken(accessToken.slice(7));
|
||||
if (tokenPayload === TokenError.EXPIRED) {
|
||||
error(401, "Access token expired");
|
||||
} else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "access") {
|
||||
error(401, "Invalid access token");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: tokenData.userId,
|
||||
clientId: tokenData.clientId,
|
||||
userId: tokenPayload.userId,
|
||||
clientId: tokenPayload.clientId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import argon2 from "argon2";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getClientByPubKey } from "$lib/server/db/client";
|
||||
import { getUserByEmail, revokeToken, isTokenRevoked } from "$lib/server/db/user";
|
||||
import { getUserByEmail } from "$lib/server/db/user";
|
||||
import {
|
||||
getRefreshToken,
|
||||
registerRefreshToken,
|
||||
rotateRefreshToken,
|
||||
revokeRefreshToken,
|
||||
} from "$lib/server/db/token";
|
||||
import { issueToken, verifyToken, TokenError } from "$lib/server/modules/auth";
|
||||
|
||||
const verifyPassword = async (hash: string, password: string) => {
|
||||
return await argon2.verify(hash, password);
|
||||
};
|
||||
|
||||
const issueAccessToken = (userId: number, clientId?: number) => {
|
||||
return issueToken({ type: "access", userId, clientId });
|
||||
};
|
||||
|
||||
const issueRefreshToken = async (userId: number, clientId?: number) => {
|
||||
const jti = uuidv4();
|
||||
const token = issueToken({ type: "refresh", jti });
|
||||
|
||||
if (!(await registerRefreshToken(userId, clientId ?? null, jti))) {
|
||||
error(403, "Already logged in");
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export const login = async (email: string, password: string, pubKey?: string) => {
|
||||
const user = await getUserByEmail(email);
|
||||
if (!user) {
|
||||
error(401, "Invalid email or password");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.password, password);
|
||||
if (!isValid) {
|
||||
if (!user || !(await verifyPassword(user.password, password))) {
|
||||
error(401, "Invalid email or password");
|
||||
}
|
||||
|
||||
@@ -25,36 +41,45 @@ export const login = async (email: string, password: string, pubKey?: string) =>
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: issueToken("access", user.id, client?.id),
|
||||
refreshToken: issueToken("refresh", user.id, client?.id),
|
||||
accessToken: issueAccessToken(user.id, client?.id),
|
||||
refreshToken: await issueRefreshToken(user.id, client?.id),
|
||||
};
|
||||
};
|
||||
|
||||
const verifyRefreshToken = async (refreshToken: string) => {
|
||||
const tokenData = verifyToken(refreshToken);
|
||||
if (tokenData === TokenError.EXPIRED) {
|
||||
error(401, "Token expired");
|
||||
} else if (
|
||||
tokenData === TokenError.INVALID ||
|
||||
tokenData.type !== "refresh" ||
|
||||
(await isTokenRevoked(refreshToken))
|
||||
) {
|
||||
error(401, "Invalid token");
|
||||
const tokenPayload = verifyToken(refreshToken);
|
||||
if (tokenPayload === TokenError.EXPIRED) {
|
||||
error(401, "Refresh token expired");
|
||||
} else if (tokenPayload === TokenError.INVALID || tokenPayload.type !== "refresh") {
|
||||
error(401, "Invalid refresh token");
|
||||
}
|
||||
return tokenData;
|
||||
|
||||
const tokenData = await getRefreshToken(tokenPayload.jti);
|
||||
if (!tokenData) {
|
||||
error(500, "Refresh token not found");
|
||||
}
|
||||
|
||||
return {
|
||||
jti: tokenPayload.jti,
|
||||
userId: tokenData.userId,
|
||||
clientId: tokenData.clientId ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const logout = async (refreshToken: string) => {
|
||||
await verifyRefreshToken(refreshToken);
|
||||
await revokeToken(refreshToken);
|
||||
const { jti } = await verifyRefreshToken(refreshToken);
|
||||
await revokeRefreshToken(jti);
|
||||
};
|
||||
|
||||
export const refreshToken = async (refreshToken: string) => {
|
||||
const tokenData = await verifyRefreshToken(refreshToken);
|
||||
const { jti: oldJti, userId, clientId } = await verifyRefreshToken(refreshToken);
|
||||
const newJti = uuidv4();
|
||||
|
||||
await revokeToken(refreshToken);
|
||||
if (!(await rotateRefreshToken(oldJti, newJti))) {
|
||||
error(500, "Refresh token not found");
|
||||
}
|
||||
return {
|
||||
accessToken: issueToken("access", tokenData.userId, tokenData.clientId),
|
||||
refreshToken: issueToken("refresh", tokenData.userId, tokenData.clientId),
|
||||
accessToken: issueAccessToken(userId, clientId),
|
||||
refreshToken: issueToken({ type: "refresh", jti: newJti }),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user