import { Elysia, t, type Context } from "elysia"; import { SignJWT, jwtVerify, errors } from "jose"; import type { IUser, UserAccessor } from "./model/mod.ts"; import { ClientRequestError } from "./route/error_handler.ts"; import { get_setting } from "./SettingConfig.ts"; type PayloadInfo = { username: string; permission: string[]; }; export type UserState = { user: PayloadInfo; }; type AuthStore = { user: PayloadInfo; refreshed: boolean; authenticated: boolean; }; type LoginResponse = { accessExpired: number; } & PayloadInfo; type RefreshResponse = { accessExpired: number; refresh: boolean; } & PayloadInfo; type RefreshPayloadInfo = { username: string }; type CookieJar = Context["cookie"]; const LoginBodySchema = t.Object({ username: t.String(), password: t.String(), }); const ResetBodySchema = t.Object({ username: t.String(), oldpassword: t.String(), newpassword: t.String(), }); const SettingsBodySchema = t.Record(t.String(), t.Unknown()); const accessExpiredTime = 60 * 60 * 2; // 2 hours const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 days async function createAccessToken(payload: PayloadInfo, secret: string) { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("2h") .sign(new TextEncoder().encode(secret)); } async function createRefreshToken(payload: RefreshPayloadInfo, secret: string) { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("14d") .sign(new TextEncoder().encode(secret)); } class TokenExpiredError extends Error { constructor() { super("Token expired"); } } async function verifyToken(token: string, secret: string): Promise { try { const { payload } = await jwtVerify(token, new TextEncoder().encode(secret)); return payload as T; } catch (error) { if (error instanceof errors.JWTExpired) { throw new TokenExpiredError(); } throw new Error("Invalid token"); } } export const accessTokenName = "access_token"; export const refreshTokenName = "refresh_token"; function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredSeconds: number) { if (token_payload === null) { cookie[token_name]?.remove(); return; } const setting = get_setting(); cookie[token_name].set({ value: token_payload, httpOnly: true, secure: setting.secure, sameSite: "strict", expires: new Date(Date.now() + expiredSeconds * 1000), }); } const isUserState = (obj: unknown): obj is PayloadInfo => { if (typeof obj !== "object" || obj === null) { return false; } return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission); }; const isRefreshToken = (obj: unknown): obj is RefreshPayloadInfo => { if (typeof obj !== "object" || obj === null) { return false; } return "username" in obj && typeof (obj as { username: unknown }).username === "string"; }; type AuthResult = { user: PayloadInfo; refreshed: boolean; success: boolean; }; async function authenticate( cookie: CookieJar, userController: UserAccessor, options: { forceRefresh?: boolean } = {}, ): Promise { const setting = get_setting(); const secretKey = setting.jwt_secretkey; const accessCookie = cookie[accessTokenName]; const refreshCookie = cookie[refreshTokenName]; const accessValue = accessCookie?.value; const refreshValue = refreshCookie?.value; const guestUser: PayloadInfo = { username: "", permission: setting.guest, }; const setGuest = (): AuthResult => { accessCookie?.remove(); refreshCookie?.remove(); return { user: guestUser, refreshed: false, success: false }; }; const issueAccessForUser = async (username: string): Promise => { const account = await userController.findUser(username); if (!account) { return setGuest(); } const permissions = await account.get_permissions(); const payload: PayloadInfo = { username: account.username, permission: permissions, }; const accessToken = await createAccessToken(payload, secretKey); setToken(cookie, accessTokenName, accessToken, accessExpiredTime); return { user: payload, refreshed: true, success: true }; }; const tryRefresh = async (): Promise => { if (!refreshValue) { return setGuest(); } try { const payload = await verifyToken(refreshValue, secretKey); if (!isRefreshToken(payload)) { return setGuest(); } return await issueAccessForUser(payload.username); } catch { return setGuest(); } }; if (options.forceRefresh) { if (accessValue) { try { const payload = await verifyToken(accessValue, secretKey); if (isUserState(payload)) { const accessToken = await createAccessToken(payload, secretKey); setToken(cookie, accessTokenName, accessToken, accessExpiredTime); return { user: payload, refreshed: true, success: true }; } return setGuest(); } catch (error) { if (!(error instanceof TokenExpiredError)) { return setGuest(); } } } return await tryRefresh(); } if (accessValue) { try { const payload = await verifyToken(accessValue, secretKey); if (isUserState(payload)) { return { user: payload, refreshed: false, success: true }; } return setGuest(); } catch (error) { if (!(error instanceof TokenExpiredError)) { return setGuest(); } } } return await tryRefresh(); } export const createLoginRouter = (userController: UserAccessor) => { return new Elysia({ name: "login-router" }) .group("/user", (app) => app .post("/login", async ({ body, cookie, set }) => { const setting = get_setting(); const secretKey = setting.jwt_secretkey; const { username, password } = body; if (username === "admin" && setting.forbid_remote_admin_login) { throw new ClientRequestError(403, "forbidden remote admin login"); } const user = await userController.findUser(username); if (!user || !user.password.check_password(password)) { throw new ClientRequestError(401, "not authorized"); } const permission = await user.get_permissions(); const accessToken = await createAccessToken({ username: user.username, permission }, secretKey); const refreshToken = await createRefreshToken({ username: user.username }, secretKey); setToken(cookie, accessTokenName, accessToken, accessExpiredTime); setToken(cookie, refreshTokenName, refreshToken, refreshExpiredTime); set.status = 200; return { username: user.username, permission, accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, } satisfies LoginResponse; }, { body: LoginBodySchema, }) .post("/logout", ({ cookie, set }) => { const setting = get_setting(); setToken(cookie, accessTokenName, null, 0); setToken(cookie, refreshTokenName, null, 0); set.status = 200; return { ok: true, username: "", permission: setting.guest, }; }) .post("/refresh", async ({ cookie }) => { const auth = await authenticate(cookie, userController, { forceRefresh: true }); if (!auth.success) { throw new ClientRequestError(401, "not authorized"); } return { ...auth.user, refresh: true, accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, } satisfies RefreshResponse; }) .post("/reset", async ({ body }) => { const { username, oldpassword, newpassword } = body; const account = await userController.findUser(username); if (!account || !account.password.check_password(oldpassword)) { throw new ClientRequestError(403, "not authorized"); } await account.reset_password(newpassword); return { ok: true }; }, { body: ResetBodySchema, }) .get("/settings", async ({ store }) => { const { user } = store as AuthStore; if (!user.username) { throw new ClientRequestError(403, "not authorized"); } const account = await userController.findUser(user.username); if (!account) { throw new ClientRequestError(403, "not authorized"); } return (await account.get_settings()) ?? {}; }) .post("/settings", async ({ body, store }) => { const { user } = store as AuthStore; if (!user.username) { throw new ClientRequestError(403, "not authorized"); } const account = await userController.findUser(user.username); if (!account) { throw new ClientRequestError(403, "not authorized"); } await account.set_settings(body as Record); return { ok: true }; }, { body: SettingsBodySchema, }), ); }; export const createUserHandler = (userController: UserAccessor) => { return new Elysia({ name: "user-handler", seed: "UserAccess", }) .derive({ as: "scoped" }, async ({ cookie }) => { const auth = await authenticate(cookie, userController); return { user: auth.user, refreshed: auth.refreshed, authenticated: auth.success, }; }); }; export const getAdmin = async (cntr: UserAccessor) => { const admin = await cntr.findUser("admin"); if (admin === undefined) { throw new Error("initial process failed!"); } return admin; }; export const isAdminFirst = (admin: IUser) => { return admin.password.hash === "unchecked" && admin.password.salt === "unchecked"; };