import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync } 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, AuthProviderConfig } from '../config.js'; import type { Repository, User } from '../db/repository.js'; import { logger } from '../logger.js'; import { randomBytes } from 'crypto'; import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js'; // Stash the URL a user was trying to reach before being bounced to login, so we // can send them back after authenticating. Lives on the passport session and // survives the OAuth round trip. declare module 'express-session' { interface SessionData { returnTo?: string; } } /** * WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。 * Express の上では express-session + passport が自動でこれを担うが、 * server.on('upgrade', ...) は middleware を素通しするので個別に呼ぶ必要がある。 */ export type UpgradeAuthChecker = (req: IncomingMessage) => Promise; // ── 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, '''); } /** * A provider is usable only when ALL fields needed to complete an OAuth round * trip are present (Gitea additionally needs base_url). Used to gate auth * activation and login-button visibility so a partial config saved from the * Settings UI can't enable auth in a state where nobody can log in. */ export function isProviderConfigured( p: AuthProviderConfig | undefined, kind: 'google' | 'gitea', ): p is AuthProviderConfig { if (!p || !p.clientId || !p.clientSecret || !p.callbackUrl) return false; if (kind === 'gitea' && !p.baseUrl) return false; return true; } /** * Whether a provider should actually be LIVE (strategy + /auth/ route). * A valid `primaryProvider` restricts auth to that single provider, so the * restriction is enforced at the route layer too — not just hidden on the login * page (otherwise the "disabled" provider's route stayed open to direct URLs). * An invalid primary (pointing at an unconfigured provider) is ignored. */ export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea'): boolean { if (!isProviderConfigured(authConfig.providers?.[kind], kind)) return false; const primary = authConfig.primaryProvider; if (primary === 'google' && isProviderConfigured(authConfig.providers?.google, 'google')) return kind === 'google'; if (primary === 'gitea' && isProviderConfigured(authConfig.providers?.gitea, 'gitea')) return kind === 'gitea'; // primary=local restricts login to local accounts → OAuth providers off. if (primary === 'local' && isLocalEnabled(authConfig)) return false; return true; } /** Local email+password login is turned on. */ export function isLocalEnabled(authConfig: AuthConfig): boolean { return authConfig.local?.enabled === true; } /** * The org ids a user belongs to, for session.orgIds. Union of Gitea orgs * (from OAuth login) and local orgs (membership). buildVisibilityWhere is * provider-agnostic, so 'org' visibility works for either once these are set. */ export function resolveOrgIds(repo: Repository, userId: string): string[] { const gitea = repo.listUserGiteaOrgs(userId).map(o => o.orgId); const local = repo.listUserLocalOrgs(userId).map(o => o.orgId); return [...new Set([...gitea, ...local])]; } /** * Self-service password change for the authenticated local user. Mount behind * requireAuth + a JSON body parser. Requires the CURRENT password (so a * hijacked session can't silently re-key the account) and only applies to * accounts that already have a local credential — OAuth-only users have none * (adding one is a separate, deferred action). 204 on success. */ export function buildChangePasswordHandler(repo: Repository): RequestHandler { return function changePassword(req: Request, res: Response): void { const user = req.user as Express.User | undefined; if (!user) { res.status(401).json({ error: 'unauthenticated' }); return; } const { currentPassword, newPassword } = (req.body ?? {}) as { currentPassword?: unknown; newPassword?: unknown; }; if (typeof newPassword !== 'string' || newPassword.length < 8) { res.status(400).json({ error: 'new password must be at least 8 characters' }); return; } if (!repo.hasLocalCredential(user.id)) { res.status(400).json({ error: 'this account has no local password to change' }); return; } if (typeof currentPassword !== 'string' || !repo.verifyLocalPassword(user.id, currentPassword)) { res.status(403).json({ error: 'current password is incorrect' }); return; } repo.setLocalPassword(user.id, newPassword); res.status(204).end(); }; } /** * 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 googleConfigured = isProviderConfigured(authConfig.providers?.google, 'google'); const giteaConfigured = isProviderConfigured(authConfig.providers?.gitea, 'gitea'); const localEnabled = isLocalEnabled(authConfig); const allowSignup = authConfig.local?.allowSignup === true; // Ignore a primaryProvider that points to an unconfigured/disabled provider — // otherwise it would hide the only working login and lock the operator out. const primary = (authConfig.primaryProvider === 'google' && googleConfigured) || (authConfig.primaryProvider === 'gitea' && giteaConfigured) || (authConfig.primaryProvider === 'local' && localEnabled) ? authConfig.primaryProvider : undefined; // Decide which login options to show let showGoogle: boolean; let showGitea: boolean; let showLocal: boolean; if (primary === 'google') { showGoogle = googleConfigured; showGitea = false; showLocal = false; } else if (primary === 'gitea') { showGoogle = false; showGitea = giteaConfigured; showLocal = false; } else if (primary === 'local') { showGoogle = false; showGitea = false; showLocal = localEnabled; } else { // No primary specified: show every configured/enabled option showGoogle = googleConfigured; showGitea = giteaConfigured; showLocal = localEnabled; } const stripBlock = (html: string, startMarker: string, endMarker: string): string => { const re = new RegExp(`[\\s\\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'); // OAuth-only divider: only when BOTH oauth buttons are visible if (!(showGoogle && showGitea)) out = stripBlock(out, 'DIVIDER_START', 'DIVIDER_END'); // Local form + signup + its divider if (!showLocal) out = stripBlock(out, 'LOCAL_FORM_START', 'LOCAL_FORM_END'); else { if (!allowSignup) out = stripBlock(out, 'LOCAL_SIGNUP_START', 'LOCAL_SIGNUP_END'); // Divider above the local form only when an OAuth button sits above it if (!(showGoogle || showGitea)) out = stripBlock(out, 'LOCAL_DIVIDER_START', 'LOCAL_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; /** * Whether this account has a local (email+password) credential set, as * opposed to being OAuth-only (Google/Gitea). Drives whether the * password-change UI (Settings → Preferences) is shown — never carries * the credential itself. See repo.hasLocalCredential. */ hasLocalCredential: boolean; } } } // ── Middleware ──────────────────────────────────────────────────────────────── /** * safeReturnTo: オープンリダイレクト対策。同一オリジンの絶対パス(先頭が単一の * `/`)だけを許可し、正規化済みの文字列を返す。それ以外は null。 * - プロトコル相対 (`//host`)・バックスラッシュ経由 (`/\host`) の外部遷移を拒否 * - スキーム付き (`http:` / `javascript:` 等) を拒否(先頭が `/` でない時点で弾かれる) * - 制御文字・空文字・非文字列・極端に長い値を拒否 */ export function safeReturnTo(value: unknown): string | null { if (typeof value !== 'string') return null; if (value.length === 0 || value.length > 2048) return null; if (value[0] !== '/') return null; // must be a rooted, same-origin path if (value[1] === '/') return null; // protocol-relative //host if (value.includes('\\')) return null; // backslash smuggling (/\host) if (/[\u0000-\u001f]/.test(value)) return null; // control chars (CR/LF, tab, etc.) return value; } /** * isTopLevelNavigation: トップレベルのブラウザ遷移(アドレスバー入力・リンク * クリック・iframe 直開け)かを判定する。SPA の fetch/XHR は除外する。 * - GET 以外は false * - `Sec-Fetch-Mode: navigate` を主判定(モダンブラウザ) * - 同ヘッダが無い環境では `Accept: text/html` を代替条件にする */ export function isTopLevelNavigation(req: Request): boolean { if (req.method !== 'GET') return false; const headers = (req.headers ?? {}) as Record; const secFetchMode = headers['sec-fetch-mode']; if (typeof secFetchMode === 'string') return secFetchMode === 'navigate'; const accept = headers['accept']; return typeof accept === 'string' && accept.includes('text/html'); } /** * consumeReturnTo: session に退避された returnTo を取り出して削除し、再検証して * 返す(使い切り)。無効値・未設定なら null。 */ export function consumeReturnTo(req: Request): string | null { const session = req.session as (typeof req.session & { returnTo?: string }) | undefined; if (!session) return null; const raw = session.returnTo; if (raw !== undefined) delete session.returnTo; return safeReturnTo(raw); } /** * requireAuth: 認証済みかつ status=active のユーザーのみ通過させる。 * 未認証のトップレベルのブラウザ遷移は、戻り先 (returnTo) を付けてログイン画面へ * リダイレクトする(共有された信頼済みHTMLの直リンク等を救済する)。 * それ以外の未認証リクエストは従来通り、/api/ には 401 JSON、それ以外は * /auth/login(returnTo 無し)にリダイレクトする。 */ 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 (isTopLevelNavigation(req)) { const returnTo = safeReturnTo(req.originalUrl); const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''; res.redirect(`/auth/login${query}`); 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 ────────────────────────────────────────────────────── /** * Default session lifetime (ms) when auth.session_max_age is unset. Applied to * BOTH the cookie maxAge and the store TTL so an unconfigured deployment gets a * concrete, sliding expiry window instead of a browser-session cookie paired * with a store row that expires after a day (the old `?? 86400` fallback). * With rolling this is an inactivity window — an active session never expires; * we intentionally don't impose an absolute timeout for this self-hosted tool. */ export const DEFAULT_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days /** Default on-disk location for the auto-generated session secret. */ export const DEFAULT_SESSION_SECRET_PATH = './data/secrets/session-secret.key'; /** * Resolve the session secret when auth.session_secret is unset. Reads a stable * secret persisted at `secretPath`, generating + saving one (0600, in a 0700 * dir) on first run. This keeps sessions valid across restarts instead of the * old per-process random secret that logged everyone out on every restart. * Mirrors initMasterKey (src/crypto/sessions.ts). Falls back to an in-memory * random secret only if the file can't be read or written. */ export function loadOrCreateSessionSecret(secretPath: string): string { try { if (existsSync(secretPath)) { const existing = readFileSync(secretPath, 'utf8').trim(); if (existing.length > 0) { if (existing.length < 16) { logger.warn( `[auth] session secret at ${secretPath} is suspiciously short (${existing.length} chars) — it may be truncated. Delete it to regenerate, or set auth.session_secret.`, ); } return existing; } } const secret = randomBytes(32).toString('hex'); mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 }); if (existsSync(secretPath)) { // File exists but was empty/blank (we'd have returned above otherwise) — // overwrite it; no creation race to worry about since it already exists. writeFileSync(secretPath, secret, { mode: 0o600 }); } else { // Atomic create: 'wx' fails if another process won the race to create the // file first; re-read theirs so all processes converge on one secret. try { writeFileSync(secretPath, secret, { mode: 0o600, flag: 'wx' }); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') { const won = readFileSync(secretPath, 'utf8').trim(); if (won.length > 0) return won; writeFileSync(secretPath, secret, { mode: 0o600 }); // racer wrote blank — overwrite } else { throw e; } } } chmodSync(secretPath, 0o600); logger.info( `[auth] generated a persistent session secret at ${secretPath} (sessions now survive restarts). Override with auth.session_secret in Settings → Authentication.`, ); return secret; } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.warn( `[auth] could not persist a session secret at ${secretPath} (${msg}) — using a random per-process secret; sessions reset on restart. Set auth.session_secret in Settings → Authentication.`, ); return randomBytes(32).toString('hex'); } } /** * Repository の SQLite DB を使ったカスタムセッションストア。 * sessions テーブル (sid, sess, expired) を直接操作する。 */ export 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 { // cookie.maxAge is already in milliseconds; don't re-scale it. The // fallback is the same ms default the middleware applies, so the store // row and the cookie expire together. const ttl = sessionData.cookie?.maxAge ?? DEFAULT_SESSION_MAX_AGE_MS; 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 ?? DEFAULT_SESSION_MAX_AGE_MS; const expired = new Date(Date.now() + ttl).toISOString(); db.prepare("UPDATE sessions SET expired = ? WHERE sid = ?").run(expired, sid); callback?.(); } catch (err) { callback?.(err); } } } // Eagerly sweep already-expired rows at startup. get() prunes lazily, but a // session whose owner never returns would otherwise linger forever; the // idx_sessions_expired index keeps this cheap. try { db.prepare("DELETE FROM sessions WHERE expired < ?").run(new Date().toISOString()); } catch { // Non-fatal: a missing table on a brand-new DB is fine; the store still works. } 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 { 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, hasLocalCredential: repo.hasLocalCredential(user.id), }; 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 { 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 (!isProviderConfigured(googleConfig, 'google')) return; if (!isProviderActive(authConfig, 'google')) 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 (!isProviderConfigured(giteaConfig, 'gitea')) return; if (!isProviderActive(authConfig, 'gitea')) 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 = resolveOrgIds(repo, user.id); const sessionUser: Express.User = { ...user, orgIds, defaultVisibility: user.defaultVisibility ?? 'private', defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null, hasLocalCredential: repo.hasLocalCredential(user.id), }; done(null, sessionUser); } catch (err) { done(err); } } ) ); } // ── Auth Router ─────────────────────────────────────────────────────────────── function createAuthRouter( repo: Repository, authConfig: AuthConfig, getBranding?: () => LoginBranding, ): Router { const router = express.Router(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ログインページ router.get('/login', (req: Request, res: Response) => { // 戻り先 (returnTo) が付いていれば session に退避する。OAuth は外部往復で // session が生き残るため、ここで保存すれば全プロバイダで復帰できる。 const returnTo = safeReturnTo(req.query.returnTo); if (returnTo && req.session) req.session.returnTo = returnTo; 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 (isProviderActive(authConfig, 'google')) { router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); router.get( '/google/callback', passport.authenticate('google', { failureRedirect: '/auth/login', keepSessionInfo: true }), (req, res) => { const user = req.user as Express.User | undefined; if (user?.status === 'active') { res.redirect(consumeReturnTo(req) ?? '/'); } else { res.redirect('/auth/pending'); } } ); } // Gitea OAuth if (isProviderActive(authConfig, 'gitea')) { router.get('/gitea', passport.authenticate('gitea')); router.get( '/gitea/callback', passport.authenticate('gitea', { failureRedirect: '/auth/login', keepSessionInfo: true }), (req, res) => { const user = req.user as Express.User | undefined; if (user?.status === 'active') { res.redirect(consumeReturnTo(req) ?? '/'); } else { res.redirect('/auth/pending'); } } ); } // Local accounts (email + password) if (isLocalEnabled(authConfig)) { const parseBody = [express.urlencoded({ extended: false }), express.json()]; // Throttle online password guessing per client IP: lock for 15 min after // 10 failures within 15 min. scrypt already makes each guess costly; this // caps the rate. Keyed by IP only — NOT by account — on purpose: account // lockout would let anyone lock a victim out by failing on their email // (targeted DoS), and would let unauthenticated callers grow the key map // with arbitrary email strings (memory DoS). Process-local; a multi-process // / HA deployment behind a load balancer would need a shared store. const loginLimiter = new LoginRateLimiter({ maxAttempts: 10, windowMs: 15 * 60 * 1000, lockoutMs: 15 * 60 * 1000, maxKeys: 10_000, }); // `req.ip` honors Express `trust proxy` (set when secureCookie is on, i.e. // behind a TLS proxy). Falls back to the raw socket address otherwise. const clientIp = (req: Request): string => req.ip ?? req.socket?.remoteAddress ?? 'unknown'; // Keep raw user input out of log lines (strip control chars, cap length). const logSafe = (s: string): string => s.replace(/[^\x20-\x7e]/g, '').slice(0, 120); const toExpressUser = (u: User): Express.User => ({ ...u, orgIds: resolveOrgIds(repo, u.id), defaultVisibility: u.defaultVisibility ?? 'private', defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null, hasLocalCredential: repo.hasLocalCredential(u.id), }); const readCreds = (req: Request): { email: string; password: string } | null => { const b = (req.body ?? {}) as { email?: unknown; password?: unknown }; if (typeof b.email !== 'string' || typeof b.password !== 'string') return null; const email = b.email.trim(); if (!email || !b.password) return null; return { email, password: b.password }; }; // Login: verify password, then establish the passport session via req.login. router.post('/local', ...parseBody, (req: Request, res: Response, next: NextFunction) => { const creds = readCreds(req); if (!creds) { res.redirect('/auth/login?error=invalid'); return; } const ip = clientIp(req); // Key on the /64 for IPv6 so a client holding a whole /64 can't rotate // source addresses to evade the throttle (raw `ip` is kept for logs). const ipKey = `ip:${throttleScopeForIp(ip)}`; if (loginLimiter.isLocked(ipKey)) { logger.warn(`[auth] local login throttled ip=${logSafe(ip)}`); res.redirect('/auth/login?error=locked'); return; } const user = repo.getUserByEmail(creds.email); // Verify even when the user is missing is not needed; getUserByEmail is the // gate. Wrong password and unknown email both surface as the same error. if (!user || !repo.verifyLocalPassword(user.id, creds.password)) { loginLimiter.recordFailure(ipKey); logger.warn(`[auth] local login failed ip=${logSafe(ip)} email=${logSafe(creds.email)}`); res.redirect('/auth/login?error=credentials'); return; } if (user.status === 'disabled') { res.redirect('/auth/login?error=disabled'); return; } // Clear the throttle on success. req.login (passport SessionManager.logIn) // regenerates the session id itself, so no explicit regenerate is needed // here for session-fixation protection. loginLimiter.reset(ipKey); // keepSessionInfo: req.login regenerates the session id (fixation // protection) and would otherwise drop the returnTo we stashed on // GET /auth/login — keep it so the post-login redirect can use it. req.login(toExpressUser(user), { session: true, keepSessionInfo: true }, (err) => { if (err) { next(err); return; } res.redirect(user.status === 'active' ? (consumeReturnTo(req) ?? '/') : '/auth/pending'); }); }); // Self-signup (opt-in): creates a pending user an admin must approve. if (authConfig.local?.allowSignup) { router.post('/local/signup', ...parseBody, (req: Request, res: Response, next: NextFunction) => { const creds = readCreds(req); if (!creds) { res.redirect('/auth/login?error=invalid'); return; } if (creds.password.length < 8) { res.redirect('/auth/login?error=weak'); return; } let user; try { user = repo.createLocalUser({ email: creds.email, password: creds.password, role: 'user', status: 'pending' }); } catch { // Most likely the email is already registered. Don't disclose which. res.redirect('/auth/login?error=signup'); return; } // req.login regenerates the session id (passport SessionManager.logIn), // which is the session-fixation protection for this manual flow. req.login(toExpressUser(user), (err) => { if (err) { next(err); return; } 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, sessionSecretPath: string = DEFAULT_SESSION_SECRET_PATH, ): AuthMiddlewares { const db = repo.getDb(); // express-session throws "secret option required" (→ 500 on every request) if // the secret is empty. Auth can be enabled from the Settings UI before a // session_secret is set, so when it's unset we use a secret persisted on disk // (generated once) rather than a per-process random one — the latter logged // everyone out on every restart. A configured auth.session_secret still wins. // Trim so a whitespace-only YAML value (" ") isn't accepted as a (very weak) // secret — treat it as unset and fall back to the persisted secret. let sessionSecret = authConfig.sessionSecret?.trim() ?? ''; if (sessionSecret.length === 0) { sessionSecret = loadOrCreateSessionSecret(sessionSecretPath); } // セッションミドルウェア const sessionMiddleware = session({ secret: sessionSecret, resave: false, saveUninitialized: false, // rolling: re-issue the cookie (and bump the store TTL via touch) on every // response so an actively-used session never expires mid-use. Without it the // cookie's Max-Age is frozen at login and the user is logged out at a fixed // wall-clock time regardless of activity. rolling: true, store: createSqliteSessionStore(db), cookie: { // httpOnly: keep the session cookie out of document.cookie (defense in // depth against XSS token theft). sameSite 'lax': don't send the cookie on // cross-site POST/DELETE, which blocks the basic CSRF vector without a // token for this same-origin app. httpOnly: true, sameSite: 'lax', secure: authConfig.secureCookie, // Always give the cookie a concrete lifetime. When session_max_age is // unset, fall back to the default instead of leaving maxAge undefined — // an undefined maxAge yields a browser-session cookie (no Max-Age) that the // browser keeps only until it closes, paired with the store's 1-day TTL. // A concrete maxAge + rolling gives a proper sliding inactivity window. maxAge: authConfig.sessionMaxAge ?? DEFAULT_SESSION_MAX_AGE_MS, }, }); // 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 enriched: Express.User = { ...baseUser, orgIds: resolveOrgIds(repo, id), defaultVisibility: baseUser.defaultVisibility ?? 'private', defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null, hasLocalCredential: repo.hasLocalCredential(id), }; done(null, enriched); } catch (err) { done(err); } }); // OAuth ストラテジー登録 registerGoogleStrategy(repo, authConfig); registerGiteaStrategy(repo, authConfig); // 認証ルーター const authRouter = createAuthRouter(repo, 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, }; }