Add password-protected access flow

This commit is contained in:
zeer
2026-04-25 20:57:24 +08:00
parent 37907dd2f5
commit 7b8f4aae06
7 changed files with 275 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
require('dotenv').config();
const crypto = require('crypto');
const path = require('path');
const express = require('express');
const cors = require('cors');
@@ -9,11 +10,56 @@ const { sendMail, fetchLatestEmails, fetchMessageDetail } = require('./mailServi
const app = express();
const port = Number(process.env.PORT || 3000);
const appPassword = String(process.env.APP_PASSWORD || '').trim();
const authCookieName = 'mail_sr_auth';
const authCookieValue = appPassword
? crypto.createHash('sha256').update(appPassword).digest('hex')
: '';
app.use(cors());
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/auth/status', (req, res) => {
res.json({ ok: true, authenticated: isAuthenticated(req) });
});
app.post('/api/auth/login', (req, res) => {
if (!appPassword) {
return res.json({ ok: true, authenticated: true });
}
const password = String(req.body.password || '');
if (password !== appPassword) {
return res.status(401).json({ error: '密码错误' });
}
res.setHeader('Set-Cookie', serializeAuthCookie(authCookieValue));
res.json({ ok: true, authenticated: true });
});
app.post('/api/auth/logout', (_req, res) => {
res.setHeader('Set-Cookie', serializeAuthCookie('', { maxAge: 0 }));
res.json({ ok: true });
});
app.use('/api', (req, res, next) => {
if (!appPassword) {
return next();
}
if (req.path === '/auth/status' || req.path === '/auth/login' || req.path === '/health') {
return next();
}
if (!isAuthenticated(req)) {
return res.status(401).json({ error: '请先输入访问密码' });
}
return next();
});
const getChannelStmt = db.prepare('SELECT * FROM channels WHERE id = ?');
const getAccountStmt = db.prepare('SELECT * FROM accounts WHERE id = ?');
const listChannelsStmt = db.prepare(`
@@ -367,6 +413,52 @@ function serializeAccount(account) {
};
}
function isAuthenticated(req) {
if (!appPassword) {
return true;
}
const cookies = parseCookies(req.headers.cookie || '');
return cookies[authCookieName] === authCookieValue;
}
function parseCookies(cookieHeader) {
return String(cookieHeader || '')
.split(';')
.map((item) => item.trim())
.filter(Boolean)
.reduce((result, item) => {
const separatorIndex = item.indexOf('=');
if (separatorIndex === -1) {
return result;
}
const key = item.slice(0, separatorIndex).trim();
const value = decodeURIComponent(item.slice(separatorIndex + 1).trim());
result[key] = value;
return result;
}, {});
}
function serializeAuthCookie(value, options = {}) {
const parts = [
`${authCookieName}=${encodeURIComponent(value)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
];
if (process.env.NODE_ENV === 'production') {
parts.push('Secure');
}
if (typeof options.maxAge === 'number') {
parts.push(`Max-Age=${options.maxAge}`);
}
return parts.join('; ');
}
function sanitizeChannelPayload(body) {
const data = {
name: String(body.name || '').trim(),