import { request } from "node:http"; import { decode, sign, TokenExpiredError, verify } from "jsonwebtoken"; import Knex from "knex"; import type Koa from "koa"; import Router from "koa-router"; import { createSqliteUserController } from "./db/mod"; import type { IUser, UserAccessor } from "./model/mod"; import { sendError } from "./route/error_handler"; import { get_setting } from "./SettingConfig"; type PayloadInfo = { username: string; permission: string[]; }; export type UserState = { user: PayloadInfo; }; const isUserState = (obj: object | string): obj is PayloadInfo => { if (typeof obj === "string") return false; return "username" in obj && "permission" in obj && Array.isArray((obj as { permission: unknown }).permission); }; type RefreshPayloadInfo = { username: string }; const isRefreshToken = (obj: object | string): obj is RefreshPayloadInfo => { if (typeof obj === "string") return false; return "username" in obj && typeof (obj as { username: unknown }).username === "string"; }; export const accessTokenName = "access_token"; export const refreshTokenName = "refresh_token"; const accessExpiredTime = 60 * 60; // 1 hour const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 day; export const getAdminAccessTokenValue = () => { const { jwt_secretkey } = get_setting(); return publishAccessToken(jwt_secretkey, "admin", [], accessExpiredTime); }; export const getAdminRefreshTokenValue = () => { const { jwt_secretkey } = get_setting(); return publishRefreshToken(jwt_secretkey, "admin", refreshExpiredTime); }; const publishAccessToken = (secretKey: string, username: string, permission: string[], expiredtime: number) => { const payload = sign( { username: username, permission: permission, }, secretKey, { expiresIn: expiredtime }, ); return payload; }; const publishRefreshToken = (secretKey: string, username: string, expiredtime: number) => { const payload = sign({ username: username }, secretKey, { expiresIn: expiredtime }); return payload; }; function setToken(ctx: Koa.Context, token_name: string, token_payload: string | null, expiredtime: number) { const setting = get_setting(); if (token_payload === null && !ctx.cookies.get(token_name)) { return; } ctx.cookies.set(token_name, token_payload, { httpOnly: true, secure: setting.secure, sameSite: "strict", expires: new Date(Date.now() + expiredtime * 1000), }); } export const createLoginMiddleware = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => { const setting = get_setting(); const secretKey = setting.jwt_secretkey; const body = ctx.request.body; // check format if (typeof body === "string" || !("username" in body) || !("password" in body)) { return sendError(400, "invalid form : username or password is not found in query."); } const username = body.username; const password = body.password; // check type if (typeof username !== "string" || typeof password !== "string") { return sendError(400, "invalid form : username or password is not string"); } // if admin login is forbidden? if (username === "admin" && setting.forbid_remote_admin_login) { return sendError(403, "forbidden remote admin login"); } const user = await userController.findUser(username); // username not exist if (user === undefined) return sendError(401, "not authorized"); // password not matched if (!user.password.check_password(password)) { return sendError(401, "not authorized"); } // create token const userPermission = await user.get_permissions(); const payload = publishAccessToken(secretKey, user.username, userPermission, accessExpiredTime); const payload2 = publishRefreshToken(secretKey, user.username, refreshExpiredTime); setToken(ctx, accessTokenName, payload, accessExpiredTime); setToken(ctx, refreshTokenName, payload2, refreshExpiredTime); ctx.body = { username: user.username, permission: userPermission, accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, }; console.log(`${username} logined`); return; }; export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => { const setting = get_setting(); ctx.cookies.set(accessTokenName, null); ctx.cookies.set(refreshTokenName, null); ctx.body = { ok: true, username: "", permission: setting.guest, }; return; }; export const createUserMiddleWare = (userController: UserAccessor) => async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { const refreshToken = refreshTokenHandler(userController); const setting = get_setting(); const setGuest = async () => { setToken(ctx, accessTokenName, null, 0); setToken(ctx, refreshTokenName, null, 0); ctx.state.user = { username: "", permission: setting.guest }; return await next(); }; return await refreshToken(ctx, setGuest, next); }; const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => { const accessPayload = ctx.cookies.get(accessTokenName); const setting = get_setting(); const secretKey = setting.jwt_secretkey; if (accessPayload === undefined) { return await checkRefreshAndUpdate(); } try { const o = verify(accessPayload, secretKey); if (isUserState(o)) { ctx.state.user = o; return await next(); } console.error("invalid token detected"); throw new Error("token form invalid"); } catch (e) { if (e instanceof TokenExpiredError) { return await checkRefreshAndUpdate(); }throw e; } async function checkRefreshAndUpdate() { const refreshPayload = ctx.cookies.get(refreshTokenName); if (refreshPayload === undefined) { return await fail(); // refresh token doesn't exist } try { const o = verify(refreshPayload, secretKey); if (isRefreshToken(o)) { const user = await cntr.findUser(o.username); if (user === undefined) return await fail(); // already non-existence user const perm = await user.get_permissions(); const payload = publishAccessToken(secretKey, user.username, perm, accessExpiredTime); setToken(ctx, accessTokenName, payload, accessExpiredTime); ctx.state.user = { username: o.username, permission: perm }; } else { console.error("invalid token detected"); throw new Error("token form invalid"); } } catch (e) { if (e instanceof TokenExpiredError) { // refresh token is expired. return await fail(); }throw e; } return await next(); } }; export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => { const handler = refreshTokenHandler(cntr); await handler(ctx, fail, success); async function fail() { const user = ctx.state.user as PayloadInfo; ctx.body = { refresh: false, ...user, }; ctx.type = "json"; } async function success() { const user = ctx.state.user as PayloadInfo; ctx.body = { ...user, refresh: true, refreshExpired: Math.floor(Date.now() / 1000 + accessExpiredTime), }; ctx.type = "json"; } }; export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => { const body = ctx.request.body; if (typeof body !== "object" || !("username" in body) || !("oldpassword" in body) || !("newpassword" in body)) { return sendError(400, "request body is invalid format"); } const username = body.username; const oldpw = body.oldpassword; const newpw = body.newpassword; if (typeof username !== "string" || typeof oldpw !== "string" || typeof newpw !== "string") { return sendError(400, "request body is invalid format"); } const user = await cntr.findUser(username); if (user === undefined) { return sendError(403, "not authorized"); } if (!user.password.check_password(oldpw)) { return sendError(403, "not authorized"); } user.reset_password(newpw); ctx.body = { ok: true }; ctx.type = "json"; }; export function createLoginRouter(userController: UserAccessor) { const router = new Router(); router.post("/login", createLoginMiddleware(userController)); router.post("/logout", LogoutMiddleware); router.post("/refresh", createRefreshTokenMiddleware(userController)); router.post("/reset", resetPasswordMiddleware(userController)); return router; } 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"; };