import nodeCrypto from 'node:crypto'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import { config } from './config.ts'; import { all, get, newId, now, run, tx } from './db.ts'; import { randomToken, sameString, sha256 } from './crypto.ts'; import { addTeamMember, canUseGateway, ensurePersonalVault, type TeamRole, type User } from './access.ts'; import { audit, body, HttpError, str } from './http.ts'; import { endLive } from './live.ts'; import { openSecret, recoveryHash, verifyTotp } from './totp.ts'; import { checkNewPassword, hashPassword, hashSlot, normalizeInviteCode, passwordField, Throttle, tooMany, verifyPassword } from './password.ts'; // 앱(화면이 앱 안에 들어 있다)과 서버가 서로 맞는지: 서버 API 수준과, 이 서버가 받아 주는 가장 낮은 앱 수준. // 앱이 쓰는 API 를 새로 넣으면 API_LEVEL 을, 옛 앱을 더는 받지 않을 때 MIN_APP_LEVEL 을 올린다 (web/src/compat.ts 와 짝) // 2: 항목 지우기 충돌 확인(baseUpdatedAt)·개인 볼트 서버 사본 비우기·오프라인 기록 시각 (팀 오프라인 기간은 7일 고정이라 없음) // 3: 볼트 키 다시 봉하기(PUT /api/vaults/:id/key/mine)·봉한 사람(wrappedBy)·팀 초대 수락/거절(/api/me/invites) export const API_LEVEL = 3; // SHELL_MIN_APP_API: 시험·긴급용 — 문제가 있는 옛 앱을 당장 막아야 할 때 올린다 (코드 값보다 낮출 수는 없다) export const MIN_APP_LEVEL = Math.max(1, Math.floor(Number(process.env.SHELL_MIN_APP_API) || 0)); // 아이디·비밀번호: 아이디마다 15분에 10번, 주소(IP)마다 15분에 50번 틀리면 막는다 const loginThrottle = new Throttle(10, 15 * 60 * 1000); const ipThrottle = new Throttle(50, 15 * 60 * 1000); // Google 로그인 시작: 주소마다 10분에 30번 (시작할 때마다 10분짜리 행이 생긴다) const startThrottle = new Throttle(30, 10 * 60 * 1000); const MAX_OAUTH_STATES = 5000; declare module 'fastify' { interface FastifyRequest { user: User | null; // Google 로그인은 끝났지만 2단계 인증 코드를 아직 넣지 않은 세션 mfaPending: boolean; } } // https 에서는 __Host- 쿠키: 같은 도메인의 다른 하위 도메인이 세션 쿠키를 심지 못한다(Secure·Path=/·Domain 없음이 강제됨). // 이름이 바뀌어 예전 쿠키(ss_sid)로 로그인해 있던 웹은 한 번 다시 로그인한다 — 예전 이름은 읽지 않는다 (보안 점검 09-29) const LEGACY_SESSION_COOKIE = 'ss_sid'; const SESSION_COOKIE = config.secureCookies ? '__Host-ss_sid' : 'ss_sid'; const OAUTH_COOKIE = config.secureCookies ? '__Secure-ss_oauth' : 'ss_oauth'; const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; const googleKeys = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs')); class LoginRejected extends Error { code: string; constructor(code: string) { super(code); this.code = code; } } // 초대받은 계정만 들어온다. 첫 로그인 때 받아 둔 초대를 모두 수락한다. function admitUser(identity: { sub: string; email: string; name: string; picture: string }): User { const email = identity.email.toLowerCase(); return tx(() => { let user = get('SELECT * FROM users WHERE google_sub = ?', identity.sub) ?? get('SELECT * FROM users WHERE email = ?', email); const invites = all<{ id: string; team_id: string; role: TeamRole }>('SELECT id, team_id, role FROM invites WHERE email = ?', email); const bootstrap = config.bootstrapAdmins.includes(email); if (user?.google_sub && user.google_sub !== identity.sub) throw new LoginRejected('account_mismatch'); // 아이디·비밀번호로 만든 계정(초대 코드 가입·env 관리자)에는 이메일이 같다는 것만으로 Google 을 붙이지 않는다. // 그 이메일은 서버가 확인한 적이 없다 — 남이 먼저 만들어 둔 계정에 진짜 주인이 들어가 버린다 (보안 점검 H-1) if (user && !user.google_sub && user.password_hash) throw new LoginRejected('password_account'); const isNew = !user; if (!user) { if (!invites.length && !bootstrap && !config.openSignup) throw new LoginRejected('not_invited'); const id = newId(); run('INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)', id, email, now()); user = get('SELECT * FROM users WHERE id = ?', id)!; } if (user.disabled) throw new LoginRejected('disabled'); run( 'UPDATE users SET google_sub = ?, email = ?, name = ?, avatar_url = ?, is_admin = ?, last_login_at = ? WHERE id = ?', identity.sub, email, identity.name || email.split('@')[0], identity.picture, user.is_admin || bootstrap, now(), user.id, ); // 초대는 받은 사람이 로그인해서 수락해야 들어간다 (보안 점검 M-3 — 이미 계정이 있는 사람을 묻지 않고 팀에 넣지 않는다). // 초대받아야만 들어오는 서버의 새 계정만은 그 초대 덕분에 들어온 것이라 바로 수락한다 if (isNew && !config.openSignup) { for (const invite of invites) { addTeamMember(invite.team_id, user.id, invite.role); run('DELETE FROM invites WHERE id = ?', invite.id); audit({ userId: user.id, teamId: invite.team_id, action: 'invite_accept', target: email }); } } const fresh = get('SELECT * FROM users WHERE id = ?', user.id)!; // 팀에서 모두 빠진 사람은 게이트웨이를 못 쓴다 (관리자·누구나 가입하는 서버 제외) if (!canUseGateway(fresh)) throw new LoginRejected('no_team'); ensurePersonalVault(fresh.id); return fresh; }); } function clientIp(req: FastifyRequest) { return req.ip ?? ''; } function createSession(req: FastifyRequest, user: User, via: string) { const token = randomToken(32); run( 'INSERT INTO sessions (id_hash, user_id, created_at, expires_at, ip, user_agent, mfa_ok) VALUES (?, ?, ?, ?, ?, ?, ?)', sha256(token), user.id, now(), now() + config.sessionTtlMs, clientIp(req), String(req.headers['user-agent'] ?? '').slice(0, 300), // OTP 를 켠 사람은 코드를 넣을 때까지 반쯤 로그인한 상태 (POST /api/auth/mfa/verify) user.totp_secret ? 0 : 1, ); audit({ userId: user.id, action: 'login', detail: { via }, ip: clientIp(req) }); return token; } // 비밀번호·초대 코드 로그인의 끝: 웹은 쿠키, 앱은 토큰 function finishLogin(req: FastifyRequest, reply: FastifyReply, user: User, via: string, desktop: boolean) { const mfa = Boolean(user.totp_secret); if (desktop) return { token: createSession(req, user, `${via}-desktop`), maxAgeSeconds: Math.floor(config.sessionTtlMs / 1000), mfa }; startSession(req, reply, user, via); return { ok: true, mfa }; } function startSession(req: FastifyRequest, reply: FastifyReply, user: User, via: string) { const token = createSession(req, user, via); if (LEGACY_SESSION_COOKIE !== SESSION_COOKIE && req.cookies?.[LEGACY_SESSION_COOKIE]) reply.clearCookie(LEGACY_SESSION_COOKIE, { path: '/' }); reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: config.secureCookies, maxAge: Math.floor(config.sessionTtlMs / 1000), }); } // 웹은 쿠키, 데스크톱 앱은 Authorization: Bearer (앱 화면은 앱 안에 들어 있고 API 는 앱 본체가 대신 부른다) export function sessionToken(req: FastifyRequest): string | undefined { const auth = req.headers.authorization; // Bearer 로 부르면 쿠키는 보지 않는다 (값이 비어도) — CSRF 확인과 같은 기준 (index.ts) if (typeof auth === 'string' && auth.startsWith('Bearer')) return auth.slice(6).trim() || undefined; return req.cookies?.[SESSION_COOKIE]; } export function sessionHash(req: FastifyRequest): string | null { const token = sessionToken(req); return token ? sha256(token) : null; } // 세션이 살아 있고, 그 사람이 아직 게이트웨이를 쓸 수 있으면 사용자를 돌려준다 (오래 가는 연결의 재확인에도 쓴다) // 2단계 인증까지 끝난 세션만 사용자로 본다. 코드를 기다리는 세션은 sessionRow 로 따로 본다 export function userBySessionHash(hash: string): User | null { const row = sessionRow(hash); return row && row.mfa_ok ? row : null; } type SessionUser = User & { expires_at: number; mfa_ok: number; mfa_fails: number }; function sessionRow(hash: string): SessionUser | null { const row = get('SELECT u.*, s.expires_at, s.mfa_ok, s.mfa_fails FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id_hash = ?', hash); if (!row || row.expires_at < now() || row.disabled) return null; if (!canUseGateway(row)) return null; return row; } // 로그인한 사람의 OTP 코드(또는 복구 코드) 확인 — 맞으면 쓴 칸·복구 코드를 표시한다 export function checkSecondFactor(user: User, code: string, allowRecovery: boolean): 'totp' | 'recovery' | null { if (!user.totp_secret) return null; const clean = code.replace(/[\s-]/g, ''); const step = verifyTotp(openSecret(user.totp_secret, user.id), clean, user.totp_last_step); if (step !== null) { // 쓴 칸을 조건부로 적는다 — 같은 코드를 동시에 두 번 보내도(요청마다 읽어 둔 사용자 정보가 옛것이어도) 한 번만 통과한다 const used = run('UPDATE users SET totp_last_step = ? WHERE id = ? AND totp_last_step < ?', step, user.id, step); return used.changes ? 'totp' : null; } if (!allowRecovery || !/^[a-z2-7]{10}$/i.test(clean)) return null; const used = run('UPDATE totp_recovery SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL', now(), user.id, recoveryHash(clean)); return used.changes ? 'recovery' : null; } // 2단계 인증 코드 대입 막기: 사람마다 다섯 번 틀리면 잠그고, 거듭 잠길수록 길게(15분 → 30분 → … 하루). 맞히면 풀린다. // 세션마다 세던 것은 다시 로그인하면 새로 셌다 (보안 점검 M-1). 로그인 확인·끄기·복구 코드 새로 받기가 함께 쓴다 const MFA_MAX_FAILS = 5; export function mfaGuard(userId: string) { const r = get<{ mfa_locked_until: number }>('SELECT mfa_locked_until FROM users WHERE id = ?', userId); if (r && r.mfa_locked_until > now()) { const minutes = Math.max(1, Math.ceil((r.mfa_locked_until - now()) / 60_000)); throw new HttpError(429, 'mfa_throttled', `코드를 여러 번 틀렸습니다. ${minutes}분 뒤에 다시 시도해 주세요.`); } } export function mfaFailed(userId: string) { const r = get<{ mfa_fails: number; mfa_lock_level: number }>('SELECT mfa_fails, mfa_lock_level FROM users WHERE id = ?', userId); if (!r) return; if (r.mfa_fails + 1 >= MFA_MAX_FAILS) { const minutes = Math.min(15 * 2 ** r.mfa_lock_level, 24 * 60); run('UPDATE users SET mfa_fails = 0, mfa_lock_level = mfa_lock_level + 1, mfa_locked_until = ? WHERE id = ?', now() + minutes * 60_000, userId); } else { run('UPDATE users SET mfa_fails = mfa_fails + 1 WHERE id = ?', userId); } } export function mfaPassed(userId: string) { run('UPDATE users SET mfa_fails = 0, mfa_lock_level = 0, mfa_locked_until = 0 WHERE id = ?', userId); } // 로그인한 지 얼마나 됐는지 (지우기처럼 되돌릴 수 없는 일 앞에서 확인한다) export function sessionAgeMs(req: FastifyRequest): number | null { const hash = sessionHash(req); const row = hash ? get<{ created_at: number }>('SELECT created_at FROM sessions WHERE id_hash = ?', hash) : undefined; return row ? now() - row.created_at : null; } export function userFromRequest(req: FastifyRequest): User | null { const hash = sessionHash(req); return hash ? userBySessionHash(hash) : null; } export function requireUser(req: FastifyRequest): User { if (!req.user) { if (req.mfaPending) throw new HttpError(401, 'mfa_required', '2단계 인증 코드를 입력해 주세요'); throw new HttpError(401, 'unauthorized', '로그인이 필요합니다'); } return req.user; } export function registerAuth(app: FastifyInstance) { app.decorateRequest('user', null); app.decorateRequest('mfaPending', false); app.addHook('onRequest', async (req) => { const hash = sessionHash(req); const row = hash ? sessionRow(hash) : null; req.user = row && row.mfa_ok ? row : null; req.mfaPending = Boolean(row && !row.mfa_ok); }); // 2단계 인증: Google 로그인 뒤 인증 앱의 코드(또는 복구 코드)를 넣는다. 다섯 번 틀리면 그 세션을 지운다 app.post('/api/auth/mfa/verify', async (req) => { const hash = sessionHash(req); const row = hash ? sessionRow(hash) : null; if (!row) throw new HttpError(401, 'unauthorized', '로그인이 필요합니다'); if (row.mfa_ok) return { ok: true }; // 그사이 OTP 를 껐으면 그대로 통과 if (!row.totp_secret) { run('UPDATE sessions SET mfa_ok = 1 WHERE id_hash = ?', hash); return { ok: true }; } const code = str(body(req.body), 'code', { min: 6, max: 24 })!; mfaGuard(row.id); const via = checkSecondFactor(row, code, true); if (!via) { mfaFailed(row.id); const fails = row.mfa_fails + 1; audit({ userId: row.id, action: 'mfa_fail', detail: { fails }, ip: clientIp(req) }); if (fails >= 5) { run('DELETE FROM sessions WHERE id_hash = ?', hash); throw new HttpError(401, 'mfa_locked', '코드를 여러 번 틀려 로그아웃했습니다. 다시 로그인해 주세요.'); } run('UPDATE sessions SET mfa_fails = ? WHERE id_hash = ?', fails, hash); throw new HttpError(400, 'mfa_bad', '코드가 맞지 않습니다. 인증 앱의 지금 코드를 입력해 주세요.'); } run('UPDATE sessions SET mfa_ok = 1, mfa_fails = 0 WHERE id_hash = ?', hash); mfaPassed(row.id); audit({ userId: row.id, action: 'mfa_verify', detail: { via }, ip: clientIp(req) }); return { ok: true, via }; }); app.get('/api/auth/config', async () => ({ google: Boolean(config.googleClientId && config.googleClientSecret), devLogin: config.devLogin, password: config.passwordLogin, openSignup: config.openSignup, links: { terms: config.termsUrl, privacy: config.privacyUrl, source: config.sourceUrl }, api: API_LEVEL, minAppApi: MIN_APP_LEVEL, })); // 아이디·비밀번호 로그인. 웹은 쿠키, 앱(desktop: true)은 토큰으로 받는다. 2단계 인증을 켠 사람은 이어서 코드를 넣는다 app.post('/api/auth/password', async (req, reply) => { if (!config.passwordLogin) throw new HttpError(404, 'not_found'); const b = body(req.body); const id = str(b, 'id', { min: 1, max: 200 })!.toLowerCase(); const password = passwordField(b, 'password'); const ip = clientIp(req); if (ipThrottle.blocked(ip) || loginThrottle.blocked(id)) throw tooMany(); // 느린 해시를 기다리기 전에 먼저 센다 — 한꺼번에 보내도 제한을 넘지 못하게 (보안 점검 M-2). 맞으면 되돌린다 ipThrottle.fail(ip); loginThrottle.fail(id); const release = hashSlot(`login:${id}`); const user = get('SELECT * FROM users WHERE email = ?', id); let ok: boolean; try { ok = await verifyPassword(password, user?.password_hash); } finally { release(); } // 해시를 기다리는 동안 비밀번호가 바뀌었거나 2단계 인증이 켜졌을 수 있다 — 지금 계정을 다시 읽어, 확인한 해시가 그대로일 때만 // 그 상태(지금의 2단계 인증)로 세션을 만든다 (공개 전 점검 OS-02). 다시 읽기부터 세션 만들기까지는 await 가 없어 끼어들 수 없다 const current = user && ok ? get('SELECT * FROM users WHERE id = ?', user.id) : undefined; if (!user || !ok || !current || current.password_hash !== user.password_hash) { audit({ action: 'login_rejected', target: id, detail: { reason: 'bad_password' }, ip }); throw new HttpError(401, 'bad_password', '아이디 또는 비밀번호가 맞지 않습니다'); } loginThrottle.reset(id); ipThrottle.undo(ip); const reason = current.disabled ? 'disabled' : !canUseGateway(current) ? 'no_team' : ''; if (reason) { audit({ userId: current.id, action: 'login_rejected', target: id, detail: { reason }, ip }); throw new HttpError(403, reason, reason); } run('UPDATE users SET last_login_at = ? WHERE id = ?', now(), current.id); ensurePersonalVault(current.id); return finishLogin(req, reply, current, 'password', b.desktop === true); }); // 초대 코드로 처음 비밀번호를 정하고 들어온다 (비밀번호 로그인 서버). 코드가 맞은 그 초대 하나만 수락한다 app.post('/api/auth/invite-signup', async (req, reply) => { if (!config.passwordLogin) throw new HttpError(404, 'not_found'); const b = body(req.body); const email = str(b, 'email', { min: 3, max: 200 })!.toLowerCase(); const code = normalizeInviteCode(str(b, 'code', { min: 6, max: 64 })!); const name = str(b, 'name', { optional: true, max: 80 }) ?? ''; const password = passwordField(b, 'password'); checkNewPassword(password); const ip = clientIp(req); if (ipThrottle.blocked(ip) || loginThrottle.blocked(`invite:${email}`)) throw tooMany(); // 서버 관리자가 될 이메일(부트스트랩·env 관리자)은 초대 코드로 만들 수 없다 — 코드를 받은 사람이 관리자 계정을 먼저 차지하지 못하게 if (config.bootstrapAdmins.includes(email) || email === config.adminId) throw new HttpError(403, 'forbidden', '이 이메일은 초대 코드로 가입할 수 없습니다'); const invite = all<{ id: string; team_id: string; role: TeamRole; code_hash: string | null; code_expires_at: number | null }>( 'SELECT id, team_id, role, code_hash, code_expires_at FROM invites WHERE email = ?', email, ).find((i) => i.code_hash && (i.code_expires_at ?? 0) > now() && sameString(i.code_hash, sha256(code))); if (!invite) { ipThrottle.fail(ip); loginThrottle.fail(`invite:${email}`); audit({ action: 'login_rejected', target: email, detail: { reason: 'invite_bad' }, ip }); throw new HttpError(400, 'invite_bad', '초대 코드가 맞지 않거나 기한이 지났습니다'); } if (get('SELECT 1 AS x FROM users WHERE email = ?', email)) throw new HttpError(409, 'account_exists', '이미 계정이 있습니다. 로그인해 주세요.'); const release = hashSlot(`invite:${email}`); let hash: string; try { hash = await hashPassword(password); } finally { release(); } const user = tx(() => { const id = newId(); run( 'INSERT INTO users (id, email, name, password_hash, password_changed_at, created_at, last_login_at) VALUES (?, ?, ?, ?, ?, ?, ?)', id, email, name || email.split('@')[0], hash, now(), now(), now(), ); addTeamMember(invite.team_id, id, invite.role); run('DELETE FROM invites WHERE id = ?', invite.id); audit({ userId: id, teamId: invite.team_id, action: 'invite_accept', target: email, detail: { via: 'code' }, ip }); ensurePersonalVault(id); return get('SELECT * FROM users WHERE id = ?', id)!; }); return finishLogin(req, reply, user, 'invite', b.desktop === true); }); app.get('/api/auth/google/start', async (req, reply) => { if (!config.googleClientId || !config.googleClientSecret) { throw new HttpError(503, 'google_not_configured', 'Google 로그인이 아직 설정되지 않았습니다'); } // 시작을 끝없이 되풀이해 표를 채우지 못하게: 주소마다 10분에 30번, 서버 전체로 기다리는 로그인 5000개까지 const ip = clientIp(req); run('DELETE FROM oauth_states WHERE created_at < ?', now() - 10 * 60 * 1000); if (startThrottle.blocked(ip) || get<{ n: number }>('SELECT COUNT(*) AS n FROM oauth_states')!.n >= MAX_OAUTH_STATES) return reply.redirect('/?login_error=too_many'); startThrottle.fail(ip); const state = randomToken(24); const nonce = randomToken(24); const verifier = randomToken(48); const q = req.query as { desktop_port?: string; desktop_challenge?: string }; let desktopPort: number | null = null; let desktopChallenge: string | null = null; if (q.desktop_port || q.desktop_challenge) { desktopPort = Number(q.desktop_port); desktopChallenge = String(q.desktop_challenge ?? ''); if (!Number.isInteger(desktopPort) || desktopPort < 1024 || desktopPort > 65535 || !/^[A-Za-z0-9_-]{43}$/.test(desktopChallenge)) { throw new HttpError(400, 'bad_request', '앱 로그인 요청이 올바르지 않습니다'); } } run( 'INSERT INTO oauth_states (state, nonce, verifier, created_at, desktop_port, desktop_challenge) VALUES (?, ?, ?, ?, ?, ?)', state, nonce, verifier, now(), desktopPort, desktopChallenge, ); const url = new URL(GOOGLE_AUTH_URL); url.search = new URLSearchParams({ client_id: config.googleClientId, redirect_uri: config.googleRedirectUri, response_type: 'code', scope: 'openid email profile', state, nonce, code_challenge: nodeCrypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', prompt: 'select_account', }).toString(); // state 를 이 브라우저에 묶어 둔다 (남의 로그인 결과를 끼워 넣는 공격 방지) reply.setCookie(OAUTH_COOKIE, state, { path: '/api/auth/google', httpOnly: true, sameSite: 'lax', secure: config.secureCookies, maxAge: 600 }); return reply.redirect(url.toString()); }); app.get('/api/auth/google/callback', async (req, reply) => { const q = req.query as Record; const row = q.state ? get<{ nonce: string; verifier: string; created_at: number; desktop_port: number | null; desktop_challenge: string | null }>('SELECT * FROM oauth_states WHERE state = ?', q.state) : undefined; const loopback = row?.desktop_port ? `http://127.0.0.1:${row.desktop_port}/callback` : null; const fail = (code: string) => { reply.clearCookie(OAUTH_COOKIE, { path: '/api/auth/google' }); return reply.redirect(loopback ? `${loopback}?error=${encodeURIComponent(code)}` : `/?login_error=${encodeURIComponent(code)}`); }; if (q.error || !q.code || !q.state) return fail('cancelled'); run('DELETE FROM oauth_states WHERE state = ?', q.state); if (!row || row.created_at < now() - 10 * 60 * 1000 || req.cookies[OAUTH_COOKIE] !== q.state) return fail('expired'); let claims: { sub: string; email: string; name: string; picture: string }; try { const res = await fetch(GOOGLE_TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code: q.code, client_id: config.googleClientId, client_secret: config.googleClientSecret, redirect_uri: config.googleRedirectUri, grant_type: 'authorization_code', code_verifier: row.verifier, }), }); if (!res.ok) throw new Error(`token endpoint ${res.status}: ${(await res.text()).slice(0, 200)}`); const tokens = (await res.json()) as { id_token?: string }; if (!tokens.id_token) throw new Error('no id_token'); const { payload } = await jwtVerify(tokens.id_token, googleKeys, { issuer: ['https://accounts.google.com', 'accounts.google.com'], audience: config.googleClientId, }); if (payload.nonce !== row.nonce) throw new Error('nonce mismatch'); if (payload.email_verified !== true || typeof payload.email !== 'string' || !payload.sub) return fail('unverified_email'); claims = { sub: `google:${payload.sub}`, email: payload.email, name: typeof payload.name === 'string' ? payload.name : '', picture: typeof payload.picture === 'string' ? payload.picture : '', }; } catch (err) { req.log.warn({ err }, 'google login failed'); return fail('google_error'); } try { const user = admitUser(claims); reply.clearCookie(OAUTH_COOKIE, { path: '/api/auth/google' }); if (loopback && row?.desktop_challenge) { const code = randomToken(32); run('DELETE FROM desktop_codes WHERE created_at < ?', now() - 2 * 60 * 1000); run('INSERT INTO desktop_codes (code_hash, user_id, challenge, created_at) VALUES (?, ?, ?, ?)', sha256(code), user.id, row.desktop_challenge, now()); return reply.redirect(`${loopback}?code=${encodeURIComponent(code)}`); } startSession(req, reply, user, 'google'); return reply.redirect('/'); } catch (err) { if (err instanceof LoginRejected) { audit({ action: 'login_rejected', target: claims.email.toLowerCase(), detail: { reason: err.code }, ip: clientIp(req) }); return fail(err.code); } throw err; } }); app.post('/api/auth/desktop/exchange', async (req) => { const b = body(req.body); const code = str(b, 'code', { min: 10, max: 200 })!; const verifier = str(b, 'verifier', { min: 43, max: 128 })!; const row = get<{ user_id: string; challenge: string; created_at: number }>('SELECT * FROM desktop_codes WHERE code_hash = ?', sha256(code)); run('DELETE FROM desktop_codes WHERE code_hash = ?', sha256(code)); if (!row || row.created_at < now() - 2 * 60 * 1000) throw new HttpError(400, 'expired', '로그인 코드가 만료되었습니다. 다시 로그인해 주세요.'); if (nodeCrypto.createHash('sha256').update(verifier).digest('base64url') !== row.challenge) throw new HttpError(400, 'bad_verifier', '로그인 확인에 실패했습니다.'); const user = get('SELECT * FROM users WHERE id = ?', row.user_id); if (!user || !canUseGateway(user)) throw new HttpError(403, 'forbidden', '로그인할 수 없는 계정입니다.'); return { token: createSession(req, user, 'desktop'), maxAgeSeconds: Math.floor(config.sessionTtlMs / 1000) }; }); app.post('/api/auth/dev-login', async (req, reply) => { if (!config.devLogin) throw new HttpError(404, 'not_found'); const b = body(req.body); const email = str(b, 'email', { min: 3, max: 200 })!.toLowerCase(); try { const user = admitUser({ sub: `dev:${email}`, email, name: str(b, 'name', { optional: true, max: 80 }) ?? '', picture: '' }); startSession(req, reply, user, 'dev'); return { ok: true }; } catch (err) { if (err instanceof LoginRejected) { audit({ action: 'login_rejected', target: email, detail: { reason: err.code, dev: true }, ip: clientIp(req) }); throw new HttpError(403, err.code, err.code); } throw err; } }); // 개발용: 데스크톱 앱이 쓰는 토큰으로 로그인 (SHELL_DEV_LOGIN=1 이고 localhost 일 때만) app.post('/api/auth/dev-login-token', async (req) => { if (!config.devLogin) throw new HttpError(404, 'not_found'); const email = str(body(req.body), 'email', { min: 3, max: 200 })!.toLowerCase(); try { const user = admitUser({ sub: `dev:${email}`, email, name: '', picture: '' }); return { token: createSession(req, user, 'dev-desktop'), maxAgeSeconds: Math.floor(config.sessionTtlMs / 1000) }; } catch (err) { if (err instanceof LoginRejected) throw new HttpError(403, err.code, err.code); throw err; } }); app.post('/api/auth/logout', async (req, reply) => { const token = sessionToken(req); if (token) { const hash = sha256(token); run('DELETE FROM sessions WHERE id_hash = ?', hash); // 이 세션으로 열어 둔 웹 SSH 중계도 닫는다 (보안 검토 F-05) endLive((l) => l.sessionHash === hash, 'logged out'); } if (req.user) audit({ userId: req.user.id, action: 'logout', ip: clientIp(req) }); reply.clearCookie(SESSION_COOKIE, { path: '/' }); if (LEGACY_SESSION_COOKIE !== SESSION_COOKIE) reply.clearCookie(LEGACY_SESSION_COOKIE, { path: '/' }); return { ok: true }; }); } export function purgeExpiredSessions() { run('DELETE FROM sessions WHERE expires_at < ?', now()); run('DELETE FROM oauth_states WHERE created_at < ?', now() - 10 * 60 * 1000); run('DELETE FROM desktop_codes WHERE created_at < ?', now() - 2 * 60 * 1000); } // 처음 켤 때: SHELL_ADMIN_ID·SHELL_ADMIN_PASSWORD 로 서버 관리자 계정을 만든다 (그 계정에 비밀번호가 아직 없을 때만). // 비밀번호를 잊으면: npm run user:password -w server -- 아이디 export async function ensureEnvAdmin(log: { info: (msg: string) => void; warn: (msg: string) => void }) { if (!config.passwordLogin || !config.adminId) return; const existing = get('SELECT * FROM users WHERE email = ?', config.adminId); if (existing?.password_hash) return; if (!config.adminPassword) { log.warn(`SHELL_ADMIN_ID=${config.adminId} 계정에 비밀번호가 없습니다. SHELL_ADMIN_PASSWORD 를 넣거나 npm run user:password 로 정해 주세요.`); return; } const hash = await hashPassword(config.adminPassword); const id = tx(() => { const userId = existing?.id ?? newId(); if (!existing) run('INSERT INTO users (id, email, name, is_admin, created_at) VALUES (?, ?, ?, 1, ?)', userId, config.adminId, config.adminId.split('@')[0], now()); run('UPDATE users SET password_hash = ?, password_changed_at = ?, is_admin = 1 WHERE id = ?', hash, now(), userId); ensurePersonalVault(userId); return userId; }); audit({ userId: id, action: 'password_set', target: config.adminId, detail: { by: 'env' } }); log.info(`서버 관리자 계정 ${config.adminId} 에 SHELL_ADMIN_PASSWORD 로 비밀번호를 정했습니다. 이제 env 에서 SHELL_ADMIN_PASSWORD 를 지워도 됩니다.`); }