Refresh Token 구현 변경

This commit is contained in:
static
2024-12-28 15:44:30 +09:00
parent 796e4a7831
commit 1d0c309878
11 changed files with 233 additions and 79 deletions

9
src/hooks.server.ts Normal file
View 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();
});
};

View File

@@ -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,
});
});
};

View File

@@ -1,2 +1,3 @@
export * from "./client";
export * from "./token";
export * from "./user";

View 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),
}),
);

View File

@@ -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(),
});

View 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();
};

View File

@@ -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;
};

View File

@@ -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,
};
};

View File

@@ -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 }),
};
};