Files
accounting/server/auth.js
2026-03-26 01:23:19 +08:00

152 lines
3.4 KiB
JavaScript
Executable File

const crypto = require('crypto');
const AUTH_COOKIE_NAME = 'accounting_session';
const AUTH_USERNAME = process.env.AUTH_USERNAME || 'admin';
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || '1234';
const AUTH_SESSION_SECRET =
process.env.AUTH_SESSION_SECRET || 'change-this-session-secret-before-public-deploy';
const AUTH_SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
function safeCompare(left, right) {
const leftBuffer = Buffer.from(String(left));
const rightBuffer = Buffer.from(String(right));
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function serializeCookie(name, value, options = {}) {
const parts = [`${name}=${value}`];
if (options.maxAge !== undefined) {
parts.push(`Max-Age=${options.maxAge}`);
}
parts.push(`Path=${options.path || '/'}`);
parts.push(`SameSite=${options.sameSite || 'Lax'}`);
if (options.httpOnly !== false) {
parts.push('HttpOnly');
}
if (options.secure) {
parts.push('Secure');
}
return parts.join('; ');
}
function parseCookies(req) {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) {
return {};
}
return cookieHeader.split(';').reduce((cookies, item) => {
const [rawName, ...rawValue] = item.trim().split('=');
cookies[rawName] = rawValue.join('=');
return cookies;
}, {});
}
function signPayload(payload) {
return crypto.createHmac('sha256', AUTH_SESSION_SECRET).update(payload).digest('base64url');
}
function createSessionToken(username) {
const payload = Buffer.from(
JSON.stringify({
username,
expiresAt: Date.now() + AUTH_SESSION_MAX_AGE_SECONDS * 1000,
})
).toString('base64url');
return `${payload}.${signPayload(payload)}`;
}
function readSession(req) {
const token = parseCookies(req)[AUTH_COOKIE_NAME];
if (!token) {
return null;
}
const [payload, signature] = token.split('.');
if (!payload || !signature || !safeCompare(signature, signPayload(payload))) {
return null;
}
try {
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (!decoded?.username || !Number.isFinite(decoded?.expiresAt)) {
return null;
}
if (decoded.expiresAt < Date.now()) {
return null;
}
if (!safeCompare(decoded.username, AUTH_USERNAME)) {
return null;
}
return { username: AUTH_USERNAME };
} catch (error) {
return null;
}
}
function getCookieOptions(maxAge) {
return {
maxAge,
httpOnly: true,
path: '/',
sameSite: 'Lax',
secure: process.env.AUTH_COOKIE_SECURE === 'true',
};
}
function setAuthCookie(res, username) {
res.setHeader(
'Set-Cookie',
serializeCookie(
AUTH_COOKIE_NAME,
createSessionToken(username),
getCookieOptions(AUTH_SESSION_MAX_AGE_SECONDS)
)
);
}
function clearAuthCookie(res) {
res.setHeader('Set-Cookie', serializeCookie(AUTH_COOKIE_NAME, '', getCookieOptions(0)));
}
function isValidCredentials(username, password) {
return (
safeCompare(String(username || '').trim(), AUTH_USERNAME) &&
safeCompare(String(password || ''), AUTH_PASSWORD)
);
}
function requireAuth(req, res, next) {
const user = readSession(req);
if (!user) {
return res.status(401).json({ error: '请先登录后再继续访问' });
}
req.authUser = user;
next();
}
module.exports = {
AUTH_USERNAME,
clearAuthCookie,
isValidCredentials,
readSession,
requireAuth,
setAuthCookie,
};