1025 lines
41 KiB
TypeScript
1025 lines
41 KiB
TypeScript
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<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, ''');
|
||
}
|
||
|
||
/**
|
||
* 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/<kind> 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*${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');
|
||
// 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<string, string | string[] | undefined>;
|
||
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<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,
|
||
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<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 (!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,
|
||
};
|
||
}
|