ionian/packages/server/src/login.ts
2024-03-29 00:19:36 +09:00

243 lines
8.3 KiB
TypeScript

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<UserState>, 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";
};