f2e-server3
Version:
f2e-server 3.0
261 lines (246 loc) • 10.9 kB
text/typescript
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