diff --git a/src/client/app.tsx b/src/client/app.tsx index e6b1ed3..fc4a6cd 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -1,8 +1,8 @@ -import React, { createContext, useRef, useState } from 'react'; +import React, { createContext, useEffect, useRef, useState } from 'react'; import ReactDom from 'react-dom'; import {BrowserRouter, Redirect, Route, Switch as RouterSwitch} from 'react-router-dom'; import { Gallery, ContentAbout, LoginPage, NotFoundPage} from './page/mod'; -import {UserContext} from './state'; +import {getInitialValue, UserContext} from './state'; import './css/style.css'; @@ -10,6 +10,14 @@ const FooProfile = ()=>
test profile
; const App = () => { const [user,setUser] = useState(""); const [userPermission,setUserPermission] = useState([]); + (async ()=>{ + const {username,permission} = await getInitialValue(); + if(username !== user){ + setUser(username); + setUserPermission(permission); + } + })(); + //useEffect(()=>{}); return ( {
setUserLoginInfo({...userLoginInfo,username:e.target.value ?? "",})}> - {if(e.key === 'Enter') doLogin();}} onChange={(e)=>setUserLoginInfo({...userLoginInfo,password:e.target.value ?? "",})}/>
diff --git a/src/client/state.tsx b/src/client/state.tsx index 9cab4b4..799c824 100644 --- a/src/client/state.tsx +++ b/src/client/state.tsx @@ -1,8 +1,47 @@ import React, { createContext, useRef, useState } from 'react'; export const UserContext = createContext({ - username:"", - permission:["openContent"], - setUsername:(s:string)=>{}, - setPermission:(permission:string[])=>{} -}); \ No newline at end of file + username: "", + permission: [] as string[], + setUsername: (s: string) => { }, + setPermission: (permission: string[]) => { } +}); + +type LoginLocalStorage = { + username: string, + permission: string[], + refreshExpired: number +}; + +let localObj: LoginLocalStorage|null = null; + +export const getInitialValue = async () => { + if(localObj === null){ + const storagestr = window.localStorage.getItem("UserLoginContext") as string | null; + const storage = storagestr !== null ? JSON.parse(storagestr) as LoginLocalStorage | null : null; + localObj = storage; + } + if (localObj !== null && localObj.refreshExpired > Math.floor(Date.now() / 1000)) { + return { + username: localObj.username, + permission: localObj.permission, + } + } + const res = await fetch('/user/refresh', { + method: 'POST', + }); + if (res.status !== 200) throw new Error("Maybe Network Error") + const r = await res.json() as LoginLocalStorage & { refresh: boolean }; + if (r.refresh) { + localObj = { + username: r.username, + permission: r.permission, + refreshExpired: r.refreshExpired + } + window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); + } + return { + username: r.username, + permission: r.permission + } +} \ No newline at end of file diff --git a/src/login.ts b/src/login.ts index 776c3fe..6089495 100644 --- a/src/login.ts +++ b/src/login.ts @@ -1,106 +1,253 @@ -import {sign, decode, verify} from 'jsonwebtoken'; -import Koa from 'koa'; -import Router from 'koa-router'; -import { sendError } from './route/error_handler'; -import Knex from 'knex' -import { createKnexUserController } from './db/mod'; -import { request } from 'http'; -import { get_setting } from './setting'; -import { IUser } from './model/mod'; +import { decode, sign, TokenExpiredError, verify } from "jsonwebtoken"; +import Koa from "koa"; +import Router from "koa-router"; +import { sendError } from "./route/error_handler"; +import Knex from "knex"; +import { createKnexUserController } from "./db/mod"; +import { request } from "http"; +import { get_setting } from "./setting"; +import { IUser, UserAccessor } from "./model/mod"; type PayloadInfo = { - username:string, - permission:string[] -} + username: string; + permission: string[]; +}; export type UserState = { - user:PayloadInfo + user: PayloadInfo; }; -const isUserState = (obj:object|string):obj is PayloadInfo =>{ - if(typeof obj ==="string") return false; - return 'username' in obj && 'permission' in obj && (obj as {permission:unknown}).permission instanceof Array; -} +const isUserState = (obj: object | string): obj is PayloadInfo => { + if (typeof obj === "string") return false; + return "username" in obj && "permission" in obj && + (obj as { permission: unknown }).permission instanceof Array; +}; +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 loginTokenName = 'access_token' +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 getAdminCookieValue = ()=>{ +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; +}; +const 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.localmode, + sameSite: "strict", + expires: new Date(Date.now() + expiredtime), + }); +}; +export const createLoginMiddleware = (knex: Knex) => { + const userController = createKnexUserController(knex); + return async (ctx: Koa.Context, next: Koa.Next) => { const setting = get_setting(); const secretKey = setting.jwt_secretkey; - return sign({ - username: "admin", - permission: [], - },secretKey,{expiresIn:'3d'}); -} - -export const createLoginMiddleware = (knex: Knex)=>{ - const userController = createKnexUserController(knex); - return async (ctx: Koa.Context,next: Koa.Next)=>{ - const setting = get_setting(); - const secretKey = setting.jwt_secretkey; - const body = ctx.request.body; - if(!('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']; - if(typeof username !== "string" || typeof password !== "string"){ - return sendError(400,"invalid form : username or password is not string") - } - if(setting.forbid_remote_admin_login && username === "admin"){ - return sendError(403,"forbid remote admin login"); - } - const user = await userController.findUser(username); - if(user === undefined){ - return sendError(401,"not authorized"); - } - if(!user.password.check_password(password)){ - return sendError(401,"not authorized"); - } - const userPermission = await user.get_permissions(); - const payload = sign({ - username: user.username, - permission: userPermission - },secretKey,{expiresIn:'3h'}); - ctx.cookies.set(loginTokenName,payload,{httpOnly:true, secure: !setting.localmode,sameSite:'strict'}); - ctx.body = {ok:true, username: user.username, permission: userPermission} - console.log(`${username} logined`); - return; - }; -}; -export const LogoutMiddleware = (ctx:Koa.Context,next:Koa.Next)=>{ - ctx.cookies.set(loginTokenName,undefined); - ctx.body = {ok:true}; - return; -} -export const UserMiddleWare = async (ctx:Koa.ParameterizedContext,next:Koa.Next)=>{ - const secretKey = get_setting().jwt_secretkey; - const payload = ctx.cookies.get(loginTokenName); - const setting = get_setting(); - if(payload == undefined){ - ctx.state['user'] = {username:"", - permission:setting.guest}; - return await next(); + const body = ctx.request.body; + //check format + if (!("username" in body) || !("password" in body)) { + return sendError( + 400, + "invalid form : username or password is not found in query.", + ); } - const o = verify(payload,secretKey); - if(isUserState(o)){ + 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, "forbiden 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, + refreshExpired: (Math.floor(Date.now() / 1000) + refreshExpiredTime), + }; + console.log(`${username} logined`); + return; + }; +}; +export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => { + ctx.cookies.set(accessTokenName, null); + ctx.cookies.set(refreshTokenName,null); + ctx.body = { ok: true }; + return; +}; +export const createUserMiddleWare = (knex: Knex) => +async (ctx: Koa.ParameterizedContext, next: Koa.Next) => { + const userController = createKnexUserController(knex); + 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 payload = ctx.cookies.get(accessTokenName); + const setting = get_setting(); + const secretKey = setting.jwt_secretkey; + const checkRefreshAndUpdate = async () => { + const payload2 = ctx.cookies.get(refreshTokenName); + if (payload2 === undefined) return await fail(); // refresh token doesn't exist + else { + try { + const o = verify(payload2, 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"); + return; + } + } catch (e) { + if (e instanceof TokenExpiredError) { // refresh token is expired. + return await fail(); + } else throw e; + } + } + return await next(); + }; + if (payload == undefined) { + return await checkRefreshAndUpdate(); + } + try { + const o = verify(payload, secretKey); + if (isUserState(o)) { ctx.state.user = o; return await next(); - } - else{ + } else { console.error("invalid token detected"); + throw new Error("token form invalid"); + } + } catch (e) { + if (e instanceof TokenExpiredError) { + return await checkRefreshAndUpdate(); + } else throw e; } } - -export const getAdmin = async(knex : Knex)=>{ - const cntr = createKnexUserController(knex); - const admin = await cntr.findUser("admin"); - if(admin === undefined){ - throw new Error("initial process failed!");//??? +export const createRefreshTokenMiddleware = (knex:Knex)=> async (ctx:Koa.Context,next:Koa.Next)=>{ + const cntr= createKnexUserController(knex); + const handler = refreshTokenHandler(cntr); + const fail = async ()=>{ + const user = ctx.state.user as PayloadInfo; + ctx.body = { + refresh:false, + ...user + } + ctx.type = 'json'; + }; + const success = async ()=>{ + const user = ctx.state.user as PayloadInfo; + ctx.body = { + ...user, + refresh:true, + refreshExpired: Math.floor(Date.now()/1000 + accessExpiredTime), + } + ctx.type = 'json'; } - return admin; + await handler(ctx,fail,success); } +export const getAdmin = async (knex: Knex) => { + const cntr = createKnexUserController(knex); + 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"; -} \ No newline at end of file +export const isAdminFirst = (admin: IUser) => { + return admin.password.hash === "unchecked" && + admin.password.salt === "unchecked"; +}; diff --git a/src/server.ts b/src/server.ts index 0ba387f..fc62ab4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,7 @@ import { createKnexContentsAccessor } from './db/contents'; import bodyparser from 'koa-bodyparser'; import {error_handler} from './route/error_handler'; -import {UserMiddleWare,createLoginMiddleware, isAdminFirst, getAdmin, LogoutMiddleware} from './login'; +import {createUserMiddleWare,createLoginMiddleware, isAdminFirst, getAdmin, LogoutMiddleware, createRefreshTokenMiddleware} from './login'; import {createInterface as createReadlineInterface} from 'readline'; @@ -35,7 +35,7 @@ export async function create_server(){ let app = new Koa(); app.use(bodyparser()); app.use(error_handler); - app.use(UserMiddleWare); + app.use(createUserMiddleWare(db)); //app.use(ctx=>{ctx.state['setting'] = settings}); const index_html = readFileSync("index.html"); @@ -67,12 +67,13 @@ export async function create_server(){ router.post('/user/login',createLoginMiddleware(db)); router.post('/user/logout',LogoutMiddleware); + router.post('/user/refresh',createRefreshTokenMiddleware(db)); if(setting.mode == "development"){ let mm_count = 0; app.use(async (ctx,next)=>{ console.log(`==========================${mm_count++}`); - const fromClient = ctx.state['user'] === undefined ? ctx.ip : ctx.state['user'].username; + const fromClient = ctx.state['user'].username === "" ? ctx.ip : ctx.state['user'].username; console.log(`${fromClient} : ${ctx.method} ${ctx.url}`); await next(); //console.log(`404`);