331 lines
9.2 KiB
TypeScript
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";
|
|
};
|