feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,636 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { Request, Response, NextFunction, RequestHandler, Router } from 'express';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import passport from 'passport';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import type { AuthConfig } from '../config.js';
|
||||
import type { Repository } from '../db/repository.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。
|
||||
* Express の上では express-session + passport が自動でこれを担うが、
|
||||
* server.on('upgrade', ...) は middleware を素通しするので個別に呼ぶ必要がある。
|
||||
*/
|
||||
export type UpgradeAuthChecker = (req: IncomingMessage) => Promise<Express.User | null>;
|
||||
|
||||
// ── Login Page Renderer ──────────────────────────────────────────────────────
|
||||
|
||||
const __authDirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface LoginBranding {
|
||||
appName: string;
|
||||
loginPageTitle: string;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGIN_BRANDING: LoginBranding = {
|
||||
appName: 'MAESTRO',
|
||||
loginPageTitle: 'MAESTRO',
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* auth-login.html をレンダリングする。
|
||||
* primary_provider 設定と各プロバイダの configured 状態に応じて
|
||||
* Google/Gitea ボタンおよび divider を表示/非表示する。
|
||||
* branding が指定されていれば {{APP_NAME}} / {{LOGIN_PAGE_TITLE}} を差し替える。
|
||||
*/
|
||||
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
|
||||
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
|
||||
const primary = authConfig.primaryProvider;
|
||||
const googleConfigured = !!authConfig.providers.google?.clientId;
|
||||
const giteaConfigured = !!authConfig.providers.gitea?.clientId;
|
||||
|
||||
// Decide which buttons to show
|
||||
let showGoogle: boolean;
|
||||
let showGitea: boolean;
|
||||
if (primary === 'google') {
|
||||
showGoogle = googleConfigured;
|
||||
showGitea = false;
|
||||
} else if (primary === 'gitea') {
|
||||
showGoogle = false;
|
||||
showGitea = giteaConfigured;
|
||||
} else {
|
||||
// No primary specified: show every configured provider
|
||||
showGoogle = googleConfigured;
|
||||
showGitea = giteaConfigured;
|
||||
}
|
||||
|
||||
const stripBlock = (html: string, startMarker: string, endMarker: string): string => {
|
||||
const re = new RegExp(`<!--\\s*${startMarker}\\s*-->[\\s\\S]*?<!--\\s*${endMarker}\\s*-->`, 'g');
|
||||
return html.replace(re, '');
|
||||
};
|
||||
|
||||
let out = raw;
|
||||
if (!showGoogle) out = stripBlock(out, 'GOOGLE_BUTTON_START', 'GOOGLE_BUTTON_END');
|
||||
if (!showGitea) out = stripBlock(out, 'GITEA_BUTTON_START', 'GITEA_BUTTON_END');
|
||||
// Hide divider unless both buttons are visible
|
||||
if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END');
|
||||
|
||||
// Branding placeholders
|
||||
out = out
|
||||
.replace(/\{\{APP_NAME\}\}/g, escapeHtml(branding.appName))
|
||||
.replace(/\{\{LOGIN_PAGE_TITLE\}\}/g, escapeHtml(branding.loginPageTitle));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Global type augmentation ─────────────────────────────────────────────────
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
role: 'admin' | 'user';
|
||||
status: 'active' | 'pending' | 'disabled';
|
||||
orgIds: string[];
|
||||
defaultVisibility: 'private' | 'org' | 'public';
|
||||
defaultVisibilityOrgId: string | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* requireAuth: 認証済みかつ status=active のユーザーのみ通過させる。
|
||||
* API リクエスト(/api/ プレフィックス)には 401 JSON を返す。
|
||||
* それ以外のリクエストは /auth/login にリダイレクトする。
|
||||
*/
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.isAuthenticated() && req.user && (req.user as Express.User).status === 'active') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.originalUrl.startsWith('/api/')) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
} else {
|
||||
res.redirect('/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* requireAdmin: admin ロールのユーザーのみ通過させる。
|
||||
* 未認証の場合は requireAuth と同じ挙動(401 or redirect)。
|
||||
* 認証済みだが admin でない場合は 403 を返す。
|
||||
*/
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.isAuthenticated() || !req.user) {
|
||||
if (req.originalUrl.startsWith('/api/')) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
} else {
|
||||
res.redirect('/auth/login');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const user = req.user as Express.User;
|
||||
if (user.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// ── SQLite Session Store ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Repository の SQLite DB を使ったカスタムセッションストア。
|
||||
* sessions テーブル (sid, sess, expired) を直接操作する。
|
||||
*/
|
||||
function createSqliteSessionStore(db: Database): session.Store {
|
||||
// session.Store の基底クラスを継承
|
||||
const Store = session.Store as unknown as new () => session.Store;
|
||||
|
||||
class SqliteStore extends Store {
|
||||
get(sid: string, callback: (err: unknown, session?: session.SessionData | null) => void): void {
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT sess, expired FROM sessions WHERE sid = ?')
|
||||
.get(sid) as { sess: string; expired: string } | undefined;
|
||||
|
||||
if (!row) {
|
||||
callback(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 期限切れチェック
|
||||
if (new Date(row.expired) <= new Date()) {
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
callback(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = JSON.parse(row.sess) as session.SessionData;
|
||||
callback(null, sessionData);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
set(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
|
||||
try {
|
||||
const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000;
|
||||
const expired = new Date(Date.now() + ttl).toISOString();
|
||||
const sess = JSON.stringify(sessionData);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (sid, sess, expired)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expired = excluded.expired
|
||||
`).run(sid, sess, expired);
|
||||
|
||||
callback?.();
|
||||
} catch (err) {
|
||||
callback?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(sid: string, callback?: (err?: unknown) => void): void {
|
||||
try {
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
callback?.();
|
||||
} catch (err) {
|
||||
callback?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
touch(sid: string, sessionData: session.SessionData, callback?: (err?: unknown) => void): void {
|
||||
try {
|
||||
const ttl = (sessionData.cookie?.maxAge ?? 86400) * 1000;
|
||||
const expired = new Date(Date.now() + ttl).toISOString();
|
||||
|
||||
db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid);
|
||||
callback?.();
|
||||
} catch (err) {
|
||||
callback?.(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SqliteStore();
|
||||
}
|
||||
|
||||
// ── OAuth Callback ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OAuth コールバック共通処理。
|
||||
* email から findOrCreateUserByOAuth を呼び出し、
|
||||
* adminEmails に一致する pending ユーザーは自動で admin に昇格する。
|
||||
*/
|
||||
async function handleOAuthCallback(
|
||||
repo: Repository,
|
||||
adminEmails: string[],
|
||||
provider: string,
|
||||
providerId: string,
|
||||
email: string,
|
||||
name: string,
|
||||
avatarUrl: string | undefined,
|
||||
done: (err: unknown, user?: Express.User | false) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
let user = repo.findOrCreateUserByOAuth({
|
||||
provider,
|
||||
providerId,
|
||||
email,
|
||||
name,
|
||||
avatarUrl,
|
||||
});
|
||||
|
||||
// adminEmails に一致する pending ユーザーを自動昇格
|
||||
if (user.status === 'pending' && adminEmails.includes(email)) {
|
||||
repo.updateUser(user.id, { status: 'active', role: 'admin' });
|
||||
const updated = repo.getUserById(user.id);
|
||||
if (updated) user = updated;
|
||||
}
|
||||
|
||||
// deserializeUser will enrich with orgIds + defaults on subsequent requests.
|
||||
const sessionUser: Express.User = {
|
||||
...user,
|
||||
orgIds: [],
|
||||
defaultVisibility: user.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
|
||||
};
|
||||
done(null, sessionUser);
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gitea Orgs Fetch ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gitea の /api/v1/user/orgs を呼び出してユーザーの所属 org 一覧を取得し、
|
||||
* Repository に永続化する。返り値は org ID の文字列配列。
|
||||
* 失敗時は空配列を返し、警告ログを出力する(認証フロー自体は継続)。
|
||||
*/
|
||||
export async function fetchGiteaOrgsForUser(
|
||||
repo: Repository,
|
||||
userId: string,
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
): Promise<string[]> {
|
||||
let res: globalThis.Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/api/v1/user/orgs`, {
|
||||
headers: { Authorization: `token ${accessToken}`, Accept: 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[auth] gitea orgs fetch error: ${(err as Error).message}`);
|
||||
// Clear stale cache: if we can't confirm membership, don't keep old grants around.
|
||||
repo.replaceUserGiteaOrgs(userId, []);
|
||||
return [];
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.warn(`[auth] gitea orgs fetch failed: ${res.status}`);
|
||||
repo.replaceUserGiteaOrgs(userId, []);
|
||||
return [];
|
||||
}
|
||||
const orgs = (await res.json()) as Array<{ id: number; username: string }>;
|
||||
const items = orgs.map(o => ({ orgId: String(o.id), orgName: o.username }));
|
||||
repo.replaceUserGiteaOrgs(userId, items);
|
||||
return items.map(i => i.orgId);
|
||||
}
|
||||
|
||||
// ── Strategy Registration ─────────────────────────────────────────────────────
|
||||
|
||||
function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
const googleConfig = authConfig.providers.google;
|
||||
if (!googleConfig) return;
|
||||
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: googleConfig.clientId,
|
||||
clientSecret: googleConfig.clientSecret,
|
||||
callbackURL: googleConfig.callbackUrl,
|
||||
},
|
||||
async (_accessToken, _refreshToken, profile, done) => {
|
||||
const email = profile.emails?.[0]?.value ?? '';
|
||||
const name = profile.displayName ?? '';
|
||||
const avatarUrl = profile.photos?.[0]?.value;
|
||||
|
||||
await handleOAuthCallback(
|
||||
repo,
|
||||
authConfig.adminEmails,
|
||||
'google',
|
||||
profile.id,
|
||||
email,
|
||||
name,
|
||||
avatarUrl,
|
||||
done as (err: unknown, user?: Express.User | false) => void
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
const giteaConfig = authConfig.providers.gitea;
|
||||
if (!giteaConfig) return;
|
||||
|
||||
const baseUrl = giteaConfig.baseUrl ?? '';
|
||||
|
||||
passport.use(
|
||||
'gitea',
|
||||
new OAuth2Strategy(
|
||||
{
|
||||
authorizationURL: `${baseUrl}/login/oauth/authorize`,
|
||||
tokenURL: `${baseUrl}/login/oauth/access_token`,
|
||||
clientID: giteaConfig.clientId,
|
||||
clientSecret: giteaConfig.clientSecret,
|
||||
callbackURL: giteaConfig.callbackUrl,
|
||||
},
|
||||
async (accessToken: string, _refreshToken: string, _params: unknown, _profile: unknown, done: (err: unknown, user?: Express.User | false) => void) => {
|
||||
try {
|
||||
// Gitea 専用: アクセストークンでユーザー情報を取得
|
||||
const response = await fetch(`${baseUrl}/api/v1/user`, {
|
||||
headers: {
|
||||
Authorization: `token ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
done(new Error(`Gitea userinfo fetch failed: ${response.status}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await response.json() as {
|
||||
id: number;
|
||||
login: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
const email = profile.email && profile.email.length > 0
|
||||
? profile.email
|
||||
: `${profile.login}@gitea.local`;
|
||||
// Gitea returns full_name="" when the user hasn't set it; `??` would
|
||||
// keep that empty string, so use `||` to fall through to the login.
|
||||
const name = profile.full_name || profile.login || '';
|
||||
const avatarUrl = profile.avatar_url;
|
||||
|
||||
// Gitea verify は handleOAuthCallback をインライン化:
|
||||
// accessToken/baseUrl がこのスコープでしか得られないため、
|
||||
// user を確定させた後 fetchGiteaOrgsForUser を呼んで orgs を永続化する。
|
||||
let user = repo.findOrCreateUserByOAuth({
|
||||
provider: 'gitea',
|
||||
providerId: String(profile.id),
|
||||
email,
|
||||
name,
|
||||
avatarUrl,
|
||||
});
|
||||
if (user.status === 'pending' && authConfig.adminEmails.includes(email)) {
|
||||
repo.updateUser(user.id, { status: 'active', role: 'admin' });
|
||||
const updated = repo.getUserById(user.id);
|
||||
if (updated) user = updated;
|
||||
}
|
||||
await fetchGiteaOrgsForUser(repo, user.id, baseUrl, accessToken);
|
||||
const orgIds = repo.listUserGiteaOrgs(user.id).map(o => o.orgId);
|
||||
const sessionUser: Express.User = {
|
||||
...user,
|
||||
orgIds,
|
||||
defaultVisibility: user.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
|
||||
};
|
||||
done(null, sessionUser);
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Auth Router ───────────────────────────────────────────────────────────────
|
||||
|
||||
function createAuthRouter(
|
||||
authConfig: AuthConfig,
|
||||
getBranding?: () => LoginBranding,
|
||||
): Router {
|
||||
const router = express.Router();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ログインページ
|
||||
router.get('/login', (_req: Request, res: Response) => {
|
||||
const branding = getBranding ? getBranding() : DEFAULT_LOGIN_BRANDING;
|
||||
res.type('html').send(renderLoginPage(authConfig, branding));
|
||||
});
|
||||
|
||||
// 承認待ちページ(承認済みなら自動リダイレクト)
|
||||
router.get('/pending', (req, res) => {
|
||||
if (req.isAuthenticated() && (req.user as Express.User).status === 'active') {
|
||||
res.redirect('/');
|
||||
return;
|
||||
}
|
||||
res.sendFile(path.join(__dirname, 'auth-pending.html'));
|
||||
});
|
||||
|
||||
// ステータス確認エンドポイント(承認待ちページのポーリング用)
|
||||
router.get('/status', (req, res) => {
|
||||
if (!req.isAuthenticated() || !req.user) {
|
||||
res.json({ status: 'unauthenticated' });
|
||||
return;
|
||||
}
|
||||
res.json({ status: (req.user as Express.User).status });
|
||||
});
|
||||
|
||||
// Google OAuth
|
||||
if (authConfig.providers.google) {
|
||||
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
|
||||
|
||||
router.get(
|
||||
'/google/callback',
|
||||
passport.authenticate('google', { failureRedirect: '/auth/login' }),
|
||||
(req, res) => {
|
||||
const user = req.user as Express.User | undefined;
|
||||
if (user?.status === 'active') {
|
||||
res.redirect('/');
|
||||
} else {
|
||||
res.redirect('/auth/pending');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Gitea OAuth
|
||||
if (authConfig.providers.gitea) {
|
||||
router.get('/gitea', passport.authenticate('gitea'));
|
||||
|
||||
router.get(
|
||||
'/gitea/callback',
|
||||
passport.authenticate('gitea', { failureRedirect: '/auth/login' }),
|
||||
(req, res) => {
|
||||
const user = req.user as Express.User | undefined;
|
||||
if (user?.status === 'active') {
|
||||
res.redirect('/');
|
||||
} else {
|
||||
res.redirect('/auth/pending');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ログアウト
|
||||
router.get('/logout', (req, res, next) => {
|
||||
req.logout((err) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
res.redirect('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// ── setupAuth ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AuthMiddlewares {
|
||||
sessionMiddleware: RequestHandler;
|
||||
passportInit: RequestHandler;
|
||||
passportSession: RequestHandler;
|
||||
authRouter: Router;
|
||||
/**
|
||||
* Raw HTTP upgrade(WebSocket)リクエストから認証済みユーザーを解決する。
|
||||
* Cookie → セッション → Passport deserialize の順に通し、最終的な req.user を返す。
|
||||
* 認証されていなければ null を返す。
|
||||
*/
|
||||
authenticateUpgrade: UpgradeAuthChecker;
|
||||
}
|
||||
|
||||
/**
|
||||
* 認証モジュールのセットアップ。
|
||||
* セッション、Passport、OAuth ストラテジーを設定し、
|
||||
* ミドルウェアと認証ルーターを返す。
|
||||
*/
|
||||
export function setupAuth(
|
||||
repo: Repository,
|
||||
authConfig: AuthConfig,
|
||||
getBranding?: () => LoginBranding,
|
||||
): AuthMiddlewares {
|
||||
const db = repo.getDb();
|
||||
|
||||
// セッションミドルウェア
|
||||
const sessionMiddleware = session({
|
||||
secret: authConfig.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: createSqliteSessionStore(db),
|
||||
cookie: {
|
||||
secure: authConfig.secureCookie,
|
||||
maxAge: authConfig.sessionMaxAge,
|
||||
},
|
||||
});
|
||||
|
||||
// Passport シリアライズ/デシリアライズ
|
||||
passport.serializeUser((user: Express.User, done) => {
|
||||
done(null, user.id);
|
||||
});
|
||||
|
||||
passport.deserializeUser((id: string, done) => {
|
||||
try {
|
||||
const baseUser = repo.getUserById(id);
|
||||
if (!baseUser) { done(null, false); return; }
|
||||
const orgs = repo.listUserGiteaOrgs(id);
|
||||
const enriched: Express.User = {
|
||||
...baseUser,
|
||||
orgIds: orgs.map(o => o.orgId),
|
||||
defaultVisibility: baseUser.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
|
||||
};
|
||||
done(null, enriched);
|
||||
} catch (err) {
|
||||
done(err);
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth ストラテジー登録
|
||||
registerGoogleStrategy(repo, authConfig);
|
||||
registerGiteaStrategy(repo, authConfig);
|
||||
|
||||
// 認証ルーター
|
||||
const authRouter = createAuthRouter(authConfig, getBranding);
|
||||
|
||||
const passportInit = passport.initialize();
|
||||
const passportSession = passport.session();
|
||||
|
||||
// 生 upgrade リクエスト用の認証チェッカー。
|
||||
// sessionMiddleware → passportInit → passportSession を順に走らせ、req.user を populate する。
|
||||
// 失敗時は null を返し、呼び出し側で socket.destroy() する想定。
|
||||
// 各 middleware が next(err) を呼んだ場合(session store 障害・deserialize 失敗等)は
|
||||
// ログを出してから null を返す(fail-closed)。
|
||||
const authenticateUpgrade: UpgradeAuthChecker = (req) => {
|
||||
return new Promise((resolve) => {
|
||||
// express-session 等が res.setHeader / res.end を呼ぶことがあるため、
|
||||
// 必要最小限のメソッドを no-op で備えたスタブを渡す。
|
||||
const fakeRes = {
|
||||
setHeader: () => fakeRes,
|
||||
getHeader: () => undefined,
|
||||
removeHeader: () => fakeRes,
|
||||
end: () => fakeRes,
|
||||
writeHead: () => fakeRes,
|
||||
statusCode: 200,
|
||||
on: () => fakeRes,
|
||||
} as unknown as Response;
|
||||
|
||||
const reqAny = req as unknown as Request;
|
||||
const failClosed = (stage: string, err: unknown): void => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(`[auth] authenticateUpgrade ${stage} failed: ${msg}`);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
sessionMiddleware(reqAny, fakeRes, (sessionErr?: unknown) => {
|
||||
if (sessionErr) { failClosed('sessionMiddleware', sessionErr); return; }
|
||||
passportInit(reqAny, fakeRes, (initErr?: unknown) => {
|
||||
if (initErr) { failClosed('passportInit', initErr); return; }
|
||||
passportSession(reqAny, fakeRes, (sessErr?: unknown) => {
|
||||
if (sessErr) { failClosed('passportSession', sessErr); return; }
|
||||
const user = reqAny.user as Express.User | undefined;
|
||||
if (!user) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
// status=active のみ認める(disabled/pending を弾く)
|
||||
if (user.status !== 'active') {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(user);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
sessionMiddleware,
|
||||
passportInit,
|
||||
passportSession,
|
||||
authRouter,
|
||||
authenticateUpgrade,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user