UNPKG

f2e-server3

Version:

f2e-server 3.0

261 lines (246 loc) 10.9 kB
import { Route, RouteFilter } from "../../routes"; import { MiddlewareCreater } from "../interface"; import { createCookie, getCookie } from "../../utils/cookie"; import { createResponseHelper } from "../../utils"; import * as _ from '../../utils/misc' import { page_layout, page_login } from "../../utils/templates"; import { AuthConfig, LoginInfo } from "./interface"; import { getIpAddress } from "../../utils/resp"; import { APIContext } from "../../interface"; export * from "./interface" export * from "./user_store" import * as path from "node:path" import * as fs from "node:fs" import { UserStore } from "./user_store"; import { DBFile } from "../../utils/dbfile"; let db_login_user_map: DBFile<Map<string, LoginInfo[]>> let db_token_map: DBFile<Map<string, LoginInfo>> const ip_error_count_map = new Map<string, number>() const append_error = (ip: string) => { const times = ip_error_count_map.get(ip) || 0 ip_error_count_map.set(ip, times + 1) } const defaultConfig: Required<Omit<AuthConfig, 'store'>> = { max_login_count: 1, max_error_count: 5, redirect: '/', login_path: 'login', logout_path: 'logout', login_page: page_layout.replace('{{body}}', page_login), white_list: [], record_ignores: ["[.](js|css|svg|ico|png|gif|jpe?g)$", "^(api|login|logout|server-sent-bit)"], cookie: { name: 'f2e_auth', options: { maxAge: 60 * 60 * 24 * 7, httpOnly: true, secure: false, sameSite: 'strict', path: '/' } }, messages: { crsf_token_invalid: 'token不合法', crsf_token_not_found: 'token失效', account_not_found: '用户名密码错误', ip_error_count_exceed: '错误次数过的,请稍后再试', }, cache_root: path.resolve(process.cwd(), '.f2e_cache'), decrypt_account: (account: string, token: string) => { if (!account || !token) return null var bits = new Set(token.substring(4, 12).split('').map(n => parseInt(n, 16))); const chars = [] for (let i = 0; i < account.length; i++) { if (!bits.has(i)) { chars.push(account[i]) } } try { const { username, password = '' } = JSON.parse(atob(chars.join(''))) return { username, password } } catch (e) { return null } }, } const middleware_auth: MiddlewareCreater = { mode: ['dev', 'prod'], name: 'auth', execute: (conf) => { if (!conf.auth || !conf.auth.store) { return } /** 每隔5分钟清除错误次数 */ setInterval(() => { ip_error_count_map.clear() }, 1000 * 60 * 5) const { max_login_count = defaultConfig.max_login_count, max_error_count = defaultConfig.max_error_count, redirect = defaultConfig.redirect, login_path = defaultConfig.login_path, logout_path = defaultConfig.logout_path, login_page = defaultConfig.login_page, white_list = defaultConfig.white_list, record_ignores = defaultConfig.record_ignores, cookie = defaultConfig.cookie, messages = defaultConfig.messages, decrypt_account = defaultConfig.decrypt_account, cache_root = defaultConfig.cache_root, } = conf.auth if (!fs.existsSync(cache_root)) { fs.mkdirSync(cache_root, {recursive: true}) } const store = conf.auth.store || new UserStore(path.resolve(cache_root, 'auth.db')) const { crsf_token_invalid, crsf_token_not_found, account_not_found, ip_error_count_exceed, } = {...defaultConfig.messages, ...messages} const dataToFile = (data: Map<string, any>) => JSON.stringify([...data], null, 2) const fileToData = (filePath: string) => new Map<string, any>(JSON.parse(fs.readFileSync(filePath, 'utf8'))) db_login_user_map = new DBFile<Map<string, LoginInfo[]>>({ filePath: path.resolve(cache_root, 'login_user_map.json'), initData: new Map(), dataToFile, fileToData, autoSave: true, }) db_token_map = new DBFile<Map<string, LoginInfo>>({ filePath: path.resolve(cache_root, 'token_map.json'), initData: new Map(), dataToFile, fileToData, autoSave: true, }) const { handleSuccess, handleRedirect } = createResponseHelper(conf) const filter: RouteFilter = async (pathname, ctx) => { const { resp, headers = {}, method = 'GET', responseHeaders = {}, location } = ctx const crsf_token = getCookie(cookie.name, headers.cookie as string) || _.createSessionId() let loginInfo: LoginInfo = db_token_map.data?.get(crsf_token) || { token: crsf_token, last_url: '', ip: getIpAddress(resp), ua: headers['user-agent'] as string, expire: Date.now() + 1000 * 60 * 60 * 24, user: await store.getLoginUser?.(crsf_token) } db_token_map.data?.set(crsf_token, loginInfo) /** 登出页面操作完成跳转登录页 */ if (pathname === logout_path) { const loginInfo = db_token_map.data?.get(crsf_token) if (loginInfo) { db_token_map.data?.delete(crsf_token) if (loginInfo.user) { const login_clients = db_login_user_map.data?.get(loginInfo.user.username) if (login_clients) { db_login_user_map.data?.set(loginInfo.user.username, login_clients.filter(c => c.token !== crsf_token)) } } loginInfo.user = undefined } handleRedirect(resp, '/' + login_path) return false } /** 登录页面直接跳过 */ if (pathname === login_path && method.toUpperCase() === 'GET') { responseHeaders['Set-Cookie'] = createCookie({ ...cookie, value: crsf_token, }) handleSuccess(ctx, '.html', _.template(login_page, { title: '登录', redirect: redirect === true ? (loginInfo.last_url || '/') : redirect, crsf_token, })) return false } else { // 实际运行模式下,多请求公用判断,会影响其他响应的设置,需要清除 */ delete responseHeaders['Set-Cookie'] } /** 白名单页面直接跳过 */ if (white_list.length > 0 && white_list.some(p => new RegExp(p).test(pathname))) { return pathname } if (pathname != login_path) { if (!record_ignores.some(p => new RegExp(p).test(pathname))) { loginInfo.last_url = location.pathname + (location.search ? '?' + location.search : '') } /** 未登录,跳转登录页,并记录跳转前访问地址 */ if (!loginInfo.user) { handleRedirect(ctx.resp, '/' + login_path) return false } /** 验证登录成功, 续签 */ loginInfo.expire = Date.now() + 1000 * 60 * 60 * 24 if (pathname === login_path + '/info') { handleSuccess(ctx, '.json', JSON.stringify(loginInfo)) return false } } } const route = new Route(conf, filter) route.on(login_path, async (body, ctx) => { const { headers = {}, responseHeaders = {} } = ctx const crsf_token = getCookie(cookie.name, headers.cookie as string) if (!crsf_token) { return { error: crsf_token_not_found } } let loginInfo = db_token_map.data?.get(crsf_token) if (!loginInfo) { return { error: crsf_token_invalid } } const errors = ip_error_count_map.get(loginInfo.ip) || 0 if (errors > max_error_count) { return { error: ip_error_count_exceed } } const decrypt_result = decrypt_account(body.account, crsf_token) if (!decrypt_result) { append_error(loginInfo.ip) return { error: crsf_token_invalid } } const { username, password } = decrypt_result const user = await store.getUser(username, password) if (!user) { append_error(loginInfo.ip) return { error: account_not_found } } loginInfo.user = user db_token_map?.data.delete(crsf_token) loginInfo.token = _.createSessionId() db_token_map.data?.set(loginInfo.token, loginInfo) responseHeaders['Set-Cookie'] = createCookie({ ...cookie, value: loginInfo.token, }) const login_clients = db_login_user_map.data?.get(user.username) || [] login_clients.push(loginInfo) db_login_user_map.data?.set(user.username, login_clients) while (login_clients.length > max_login_count) { const deleted = login_clients.shift() if (deleted) { db_token_map.data?.delete(deleted.token) } } return { success: true, } }) /** 存储引擎删除用户,需要清除登录信息 */ store.onDeleteUser?.(username => { db_login_user_map.data.get(username)?.forEach(c => { db_token_map.data?.delete(c.token) }) db_login_user_map.data?.delete(username) }) return { onRoute: route.execute } } } export const createAuthHelper = (config: AuthConfig = defaultConfig) => { const { cookie } = {...defaultConfig, ...config} const getLoginUser = (ctx: APIContext) => { const { headers = {} } = ctx const crsf_token = getCookie(cookie.name, headers.cookie as string) if (crsf_token) { return db_token_map?.data?.get(crsf_token) } } return { getLoginUser, } } export default middleware_auth