Add admin role with an admin-only hand range study page
- Optional ADMIN_KEY (6 digits, distinct from ACCESS_KEY): entering it on the key screen signs in as admin (role-specific HMAC cookie; member cookies stay valid). Logged-in members can open /key?admin to enter it. - /range (range chart + color/category quiz, ported from poker-hand and restyled to the site design) and its files under admin/ are served only to admins; everyone else gets 404. Card images cached privately for a day. - Lobby shows a link to the page for admins only (GET /api/me). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e1ac4e91d
commit
e89d867f85
64 files changed
+477
-19
No files matched your search
+44
-15
@@ -25,11 +25,13 @@ function loadSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
// 鍵を変えると既存の Cookie はすべて無効になる
|
||||
const AUTH_TOKEN = crypto
|
||||
.createHmac('sha256', loadSecret())
|
||||
.update('access:' + config.ACCESS_KEY)
|
||||
.digest('base64url');
|
||||
// Cookie の値はキーと役割から作る。キーを変えると既存の Cookie はすべて無効になる
|
||||
const SECRET = loadSecret();
|
||||
const tokenFor = (role, key) => crypto.createHmac('sha256', SECRET).update(role + ':' + key).digest('base64url');
|
||||
const TOKENS = {
|
||||
member: tokenFor('access', config.ACCESS_KEY), // 以前と同じ値(既存の Cookie はそのまま有効)
|
||||
admin: config.ADMIN_KEY ? tokenFor('admin', config.ADMIN_KEY) : null,
|
||||
};
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
@@ -51,18 +53,31 @@ function safeEqual(a, b) {
|
||||
return crypto.timingSafeEqual(ha, hb);
|
||||
}
|
||||
|
||||
function isAuthedCookieHeader(cookieHeader) {
|
||||
// 'admin' / 'member' / null
|
||||
function roleOf(cookieHeader) {
|
||||
const value = parseCookies(cookieHeader)[COOKIE_NAME];
|
||||
return Boolean(value) && safeEqual(value, AUTH_TOKEN);
|
||||
if (!value) return null;
|
||||
// どちらも必ず比較する(どちらに一致したかを処理時間から推測させない)
|
||||
const admin = TOKENS.admin ? safeEqual(value, TOKENS.admin) : false;
|
||||
const member = safeEqual(value, TOKENS.member);
|
||||
return admin ? 'admin' : member ? 'member' : null;
|
||||
}
|
||||
|
||||
function isAuthedCookieHeader(cookieHeader) {
|
||||
return roleOf(cookieHeader) !== null;
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
return isAuthedCookieHeader(req.headers.cookie);
|
||||
}
|
||||
|
||||
function setAuthCookie(req, res) {
|
||||
function isAdmin(req) {
|
||||
return roleOf(req.headers.cookie) === 'admin';
|
||||
}
|
||||
|
||||
function setAuthCookie(req, res, role) {
|
||||
const parts = [
|
||||
`${COOKIE_NAME}=${AUTH_TOKEN}`,
|
||||
`${COOKIE_NAME}=${TOKENS[role]}`,
|
||||
'Path=/',
|
||||
`Max-Age=${COOKIE_MAX_AGE_SEC}`,
|
||||
'HttpOnly',
|
||||
@@ -163,7 +178,10 @@ function handleLogin(req, res) {
|
||||
if (!/^\d{6}$/.test(input)) {
|
||||
return res.status(400).json({ error: '6桁の数字を入力してください。' });
|
||||
}
|
||||
if (!safeEqual(input, config.ACCESS_KEY)) {
|
||||
// どちらも必ず比較する
|
||||
const adminMatch = config.ADMIN_KEY ? safeEqual(input, config.ADMIN_KEY) : false;
|
||||
const memberMatch = safeEqual(input, config.ACCESS_KEY);
|
||||
if (!adminMatch && !memberMatch) {
|
||||
w.count++;
|
||||
global.count++;
|
||||
console.warn(`[auth] 鍵の入力ミス from ${key} (${w.count}/${config.AUTH_MAX_FAILURES}, 全体 ${global.count}/${config.AUTH_GLOBAL_MAX_FAILURES})`);
|
||||
@@ -174,15 +192,18 @@ function handleLogin(req, res) {
|
||||
return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' });
|
||||
}
|
||||
perClient.delete(key);
|
||||
console.log(`[auth] 入室 from ${key}`);
|
||||
setAuthCookie(req, res);
|
||||
res.json({ ok: true });
|
||||
// 管理者がメンバー用のキーを入れ直しても管理者のまま
|
||||
const role = adminMatch || isAdmin(req) ? 'admin' : 'member';
|
||||
console.log(`[auth] 入室 from ${key}${role === 'admin' ? '(管理者)' : ''}`);
|
||||
setAuthCookie(req, res, role);
|
||||
res.json({ ok: true, admin: role === 'admin' });
|
||||
}
|
||||
|
||||
// /key 以外のページ・API を保護する
|
||||
function requireAuth(req, res, next) {
|
||||
if (isAuthed(req)) {
|
||||
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res);
|
||||
const role = roleOf(req.headers.cookie);
|
||||
if (role) {
|
||||
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res, role);
|
||||
return next();
|
||||
}
|
||||
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
|
||||
@@ -190,6 +211,12 @@ function requireAuth(req, res, next) {
|
||||
res.status(401).end();
|
||||
}
|
||||
|
||||
// 管理者専用。管理者以外にはページがあること自体を知らせない(404)
|
||||
function requireAdmin(req, res, next) {
|
||||
if (isAdmin(req)) return next();
|
||||
res.status(404).type('text/plain').send('Not Found');
|
||||
}
|
||||
|
||||
// 状態を変える API は同じサイトからのみ受け付ける
|
||||
function requireSameOrigin(req, res, next) {
|
||||
if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next();
|
||||
@@ -203,12 +230,14 @@ function safeNext(next) {
|
||||
|
||||
module.exports = {
|
||||
isAuthed,
|
||||
isAdmin,
|
||||
isAuthedCookieHeader,
|
||||
isSameOrigin,
|
||||
isAllowedHost,
|
||||
requireAllowedHost,
|
||||
handleLogin,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
requireSameOrigin,
|
||||
safeNext,
|
||||
};
|
||||
Reference in new issue
Block a user