ionian/packages/server/src/login.ts

331 lines
9.2 KiB
TypeScript

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<T>(token: string, secret: string): Promise<T> {
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<AuthResult> {
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<AuthResult> => {
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<AuthResult> => {
if (!refreshValue) {
return setGuest();
}
try {
const payload = await verifyToken<RefreshPayloadInfo>(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<PayloadInfo>(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<PayloadInfo>(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<string, unknown>);
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";
};