refactor: user authentication service

This commit is contained in:
monoid 2025-08-31 18:11:50 +09:00
parent 482892ffc1
commit 7ad7a00500
3 changed files with 172 additions and 73 deletions

View file

@ -0,0 +1,92 @@
import { makeApiUrl } from "../hook/fetcher.ts";
import { LoginRequest } from "dbtype/mod.ts";
export type LoginResponse = {
username: string;
permission: string[];
accessExpired: number;
};
export type LogoutResponse = {
ok: boolean;
username: string;
permission: string[];
};
export type RefreshResponse = LoginResponse & {
refresh: boolean;
};
export type ErrorFormat = {
code: number;
message: string;
detail?: string;
};
export class ApiError extends Error {
public readonly code: number;
public readonly detail?: string;
constructor(error: ErrorFormat) {
super(error.message);
this.name = "ApiError";
this.code = error.code;
this.detail = error.detail;
}
}
export async function refreshService(): Promise<RefreshResponse> {
const u = makeApiUrl("/api/user/refresh");
const res = await fetch(u, {
method: "POST",
credentials: "include",
});
const b = await res.json();
if (!res.ok) {
throw new ApiError(b as ErrorFormat);
}
return b as RefreshResponse;
}
export async function logoutService(): Promise<LogoutResponse> {
const u = makeApiUrl("/api/user/logout");
const req = await fetch(u, {
method: "POST",
credentials: "include",
});
const b = await req.json();
if (!req.ok) {
throw new ApiError(b as ErrorFormat);
}
return b as LogoutResponse;
}
export async function loginService(userLoginInfo: LoginRequest): Promise<LoginResponse> {
const u = makeApiUrl("/api/user/login");
const res = await fetch(u, {
method: "POST",
body: JSON.stringify(userLoginInfo),
headers: { "content-type": "application/json" },
credentials: "include",
});
const b = await res.json();
if (!res.ok) {
throw new ApiError(b as ErrorFormat);
}
return b as LoginResponse;
}
export async function resetPasswordService(username: string, oldpassword: string, newpassword: string): Promise<{ ok: boolean }> {
const u = makeApiUrl("/api/user/reset");
const res = await fetch(u, {
method: "POST",
body: JSON.stringify({ username, oldpassword, newpassword }),
headers: { "content-type": "application/json" },
credentials: "include",
});
const b = await res.json();
if (!res.ok) {
throw new ApiError(b as ErrorFormat);
}
return b;
}

View file

@ -1,18 +1,19 @@
import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts"; import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts";
import { makeApiUrl } from "../hook/fetcher.ts";
import { LoginRequest } from "dbtype/mod.ts"; import { LoginRequest } from "dbtype/mod.ts";
import {
ApiError,
loginService,
LoginResponse,
logoutService,
refreshService,
resetPasswordService,
} from "./api.ts";
type LoginLocalStorage = { let localObj: LoginResponse | null = null;
username: string;
permission: string[];
accessExpired: number;
};
let localObj: LoginLocalStorage | null = null;
function getUserSessions() { function getUserSessions() {
if (localObj === null) { if (localObj === null) {
const storagestr = localStorage.getItem("UserLoginContext") as string | null; const storagestr = localStorage.getItem("UserLoginContext") as string | null;
const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginLocalStorage | null) : null; const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginResponse | null) : null;
localObj = storage; localObj = storage;
} }
if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) { if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) {
@ -25,76 +26,76 @@ function getUserSessions() {
} }
export async function refresh() { export async function refresh() {
const u = makeApiUrl("/api/user/refresh"); try {
const res = await fetch(u, { const r = await refreshService();
method: "POST", if (r.refresh) {
credentials: "include", localObj = {
}); ...r,
if (res.status !== 200) throw new Error("Maybe Network Error"); };
const r = (await res.json()) as LoginLocalStorage & { refresh: boolean }; } else {
if (r.refresh) { localObj = {
localObj = { accessExpired: 0,
...r username: "",
}; permission: r.permission,
} else { };
localObj = { }
accessExpired: 0, localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
username: "", return {
permission: r.permission, username: r.username,
}; permission: r.permission,
} };
localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); } catch (e) {
return { if (e instanceof ApiError) {
username: r.username, console.error(`Refresh failed: ${e.detail}`);
permission: r.permission, }
}; localObj = { accessExpired: 0, username: "", permission: [] };
localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return { username: "", permission: [] };
}
} }
export const doLogout = async () => { export const doLogout = async () => {
const u = makeApiUrl("/api/user/logout"); const setVal = setAtomValue(userLoginStateAtom);
const req = await fetch(u, {
method: "POST",
credentials: "include",
});
const setVal = setAtomValue(userLoginStateAtom);
try { try {
const res = await req.json(); const res = await logoutService();
localObj = { localObj = {
accessExpired: 0, accessExpired: 0,
username: "", username: "",
permission: res.permission, permission: res.permission,
}; };
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
setVal(localObj); setVal(localObj);
return { return {
username: localObj.username, username: localObj.username,
permission: localObj.permission, permission: localObj.permission,
}; };
} catch (error) { } catch (error) {
console.error(`Server Error ${error}`); console.error(`Server Error ${error}`);
// Even if logout fails, clear client-side session
localObj = { accessExpired: 0, username: "", permission: [] };
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
setVal(localObj);
return { return {
username: "", username: "",
permission: [], permission: [],
}; };
} }
}; };
export const doLogin = async (userLoginInfo: LoginRequest): Promise<string | LoginLocalStorage> => {
const u = makeApiUrl("/api/user/login"); export const doLogin = async (userLoginInfo: LoginRequest): Promise<string | LoginResponse> => {
const res = await fetch(u, { try {
method: "POST", const b = await loginService(userLoginInfo);
body: JSON.stringify(userLoginInfo), const setVal = setAtomValue(userLoginStateAtom);
headers: { "content-type": "application/json" }, localObj = b;
credentials: "include", setVal(b);
}); window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
const b = await res.json(); return b;
if (res.status !== 200) { } catch (e) {
return b.detail as string; if (e instanceof ApiError) {
return e.detail ?? e.message;
}
return "An unknown error occurred.";
} }
const setVal = setAtomValue(userLoginStateAtom);
localObj = b;
setVal(b);
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return b;
}; };
Object.assign(window, { Object.assign(window, {
@ -104,19 +105,15 @@ Object.assign(window, {
}); });
export const doResetPassword = async (username: string, oldpassword: string, newpassword: string) => { export const doResetPassword = async (username: string, oldpassword: string, newpassword: string) => {
const u = makeApiUrl("/api/user/reset"); try {
const res = await fetch(u, { return await resetPasswordService(username, oldpassword, newpassword);
method: "POST", } catch (e) {
body: JSON.stringify({ username, oldpassword, newpassword }), if (e instanceof ApiError) {
headers: { "content-type": "application/json" }, return e.detail ?? e.message;
credentials: "include", }
}); return "An unknown error occurred.";
const b = await res.json();
if (res.status !== 200) {
return b.detail as string;
} }
return b; };
}
export async function getInitialValue() { export async function getInitialValue() {
const user = getUserSessions(); const user = getUserSessions();

View file

@ -6,6 +6,15 @@ import { sendError } from "./route/error_handler.ts";
import { get_setting } from "./SettingConfig.ts"; import { get_setting } from "./SettingConfig.ts";
import { LoginRequestSchema, LoginResetRequestSchema } from "dbtype"; import { LoginRequestSchema, LoginResetRequestSchema } from "dbtype";
type LoginResponse = {
accessExpired: number;
} & PayloadInfo;
type RefreshResponse = {
accessExpired: number;
refresh: boolean;
} & PayloadInfo;
type PayloadInfo = { type PayloadInfo = {
username: string; username: string;
permission: string[]; permission: string[];
@ -110,7 +119,7 @@ export const createLoginHandler = (userController: UserAccessor) => async (ctx:
username: user.username, username: user.username,
permission: userPermission, permission: userPermission,
accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
}; } satisfies LoginResponse;
console.log(`${username} logined`); console.log(`${username} logined`);
return; return;
}; };
@ -206,17 +215,18 @@ export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx:
const user = ctx.state.user as PayloadInfo; const user = ctx.state.user as PayloadInfo;
ctx.body = { ctx.body = {
refresh: false, refresh: false,
accessExpired: 0,
...user, ...user,
}; } satisfies RefreshResponse;
ctx.type = "json"; ctx.type = "json";
} };
async function success() { async function success() {
const user = ctx.state.user as PayloadInfo; const user = ctx.state.user as PayloadInfo;
ctx.body = { ctx.body = {
...user, ...user,
refresh: true, refresh: true,
refreshExpired: Math.floor(Date.now() / 1000 + accessExpiredTime), accessExpired: Math.floor(Date.now() / 1000 + accessExpiredTime),
}; } satisfies RefreshResponse;
ctx.type = "json"; ctx.type = "json";
} }
}; };