152 lines
3.4 KiB
JavaScript
Executable File
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,
|
|
};
|