[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
---
|
||||
id: security-2fa-totp-webauthn
|
||||
title: 2FA — TOTP / WebAuthn / Passkey
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [security, 2fa, totp, webauthn, passkey, vibe-coding]
|
||||
tech_stack: { language: "TS / WebAuthn", applicable_to: ["Backend", "Frontend"] }
|
||||
applied_in: []
|
||||
aliases: [2FA, TOTP, Google Authenticator, WebAuthn, passkey, FIDO2]
|
||||
---
|
||||
|
||||
# 2FA / Passkeys
|
||||
|
||||
> SMS 2FA = SIM swap 위험. **TOTP (Authenticator) = 무료 + 안전**, **WebAuthn / Passkey = 진짜 phishing-resistant**. iOS/Android Passkey 가 미래.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- TOTP: 6자리 30초 코드 (RFC 6238). HMAC + 시간.
|
||||
- WebAuthn: 공개키 인증. 서버 = 비밀 X.
|
||||
- Passkey: WebAuthn 의 동기화 + UX 개선 — Apple/Google 계정에 sync.
|
||||
- Recovery codes: 백업 — 1회용.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### TOTP (otplib)
|
||||
```ts
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
// Setup
|
||||
const secret = authenticator.generateSecret(); // base32
|
||||
const otpauth = authenticator.keyuri(user.email, 'Acme', secret);
|
||||
// QR: otpauth → qrcode 라이브러리
|
||||
|
||||
await db.user2fa.upsert({ userId: user.id, secret, enabled: false });
|
||||
|
||||
// Verify (활성화)
|
||||
function verify(code: string, secret: string) {
|
||||
return authenticator.check(code, secret);
|
||||
}
|
||||
|
||||
// 정상 검증 후
|
||||
await db.user2fa.update(userId, { enabled: true });
|
||||
```
|
||||
|
||||
### Login flow with TOTP
|
||||
```ts
|
||||
// Step 1: password
|
||||
const u = await verifyPassword(email, password);
|
||||
if (!u) return 401;
|
||||
|
||||
if (u.twoFactorEnabled) {
|
||||
// 임시 token (TOTP 만 검증 가능, 30초 유효)
|
||||
return { needs2FA: true, partialToken: signPartial({ userId: u.id }) };
|
||||
}
|
||||
return loginSuccess(u);
|
||||
|
||||
// Step 2: TOTP
|
||||
app.post('/login/2fa', async (req, res) => {
|
||||
const partial = verifyPartial(req.body.partialToken);
|
||||
const { secret } = await db.user2fa.find(partial.userId);
|
||||
if (!authenticator.check(req.body.code, secret)) return res.status(401).end();
|
||||
return loginSuccess({ id: partial.userId });
|
||||
});
|
||||
```
|
||||
|
||||
### Recovery codes
|
||||
```ts
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
function generateRecoveryCodes(n = 10): string[] {
|
||||
return Array.from({ length: n }, () => randomBytes(8).toString('hex'));
|
||||
}
|
||||
|
||||
const codes = generateRecoveryCodes();
|
||||
const hashed = await Promise.all(codes.map(c => argon2.hash(c)));
|
||||
await db.recoveryCodes.insertMany(userId, hashed);
|
||||
// codes 한 번만 사용자에게 표시
|
||||
```
|
||||
|
||||
### WebAuthn / Passkey (server side)
|
||||
```ts
|
||||
import { generateRegistrationOptions, verifyRegistrationResponse,
|
||||
generateAuthenticationOptions, verifyAuthenticationResponse }
|
||||
from '@simplewebauthn/server';
|
||||
|
||||
// Registration
|
||||
app.post('/webauthn/register/options', authRequired, async (req, res) => {
|
||||
const opts = await generateRegistrationOptions({
|
||||
rpName: 'Acme',
|
||||
rpID: 'acme.com',
|
||||
userID: Buffer.from(req.user.id),
|
||||
userName: req.user.email,
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred', // passkey 만들기
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
await redis.set(`webauthn:reg:${req.user.id}`, opts.challenge, 'EX', 300);
|
||||
res.json(opts);
|
||||
});
|
||||
|
||||
app.post('/webauthn/register/verify', authRequired, async (req, res) => {
|
||||
const expectedChallenge = await redis.get(`webauthn:reg:${req.user.id}`);
|
||||
const v = await verifyRegistrationResponse({
|
||||
response: req.body, expectedChallenge: expectedChallenge!,
|
||||
expectedOrigin: 'https://acme.com', expectedRPID: 'acme.com',
|
||||
});
|
||||
if (!v.verified) return res.status(400).end();
|
||||
|
||||
await db.passkeys.insert({
|
||||
userId: req.user.id,
|
||||
credentialId: v.registrationInfo!.credentialID,
|
||||
publicKey: v.registrationInfo!.credentialPublicKey,
|
||||
counter: v.registrationInfo!.counter,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Authentication
|
||||
app.post('/webauthn/login/options', async (req, res) => {
|
||||
const opts = await generateAuthenticationOptions({
|
||||
rpID: 'acme.com',
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
await redis.set(`webauthn:auth:${opts.challenge}`, '1', 'EX', 300);
|
||||
res.json(opts);
|
||||
});
|
||||
|
||||
app.post('/webauthn/login/verify', async (req, res) => {
|
||||
// ... credentialId 로 passkey lookup
|
||||
const passkey = await db.passkeys.findByCredentialId(req.body.id);
|
||||
const v = await verifyAuthenticationResponse({
|
||||
response: req.body,
|
||||
expectedChallenge: ..., expectedOrigin, expectedRPID,
|
||||
authenticator: { credentialID: passkey.credentialId, credentialPublicKey: passkey.publicKey, counter: passkey.counter },
|
||||
});
|
||||
if (!v.verified) return res.status(401).end();
|
||||
await db.passkeys.update(passkey.id, { counter: v.authenticationInfo.newCounter });
|
||||
res.json(loginSuccess(passkey.userId));
|
||||
});
|
||||
```
|
||||
|
||||
### Client (browser)
|
||||
```ts
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
const opts = await fetch('/webauthn/register/options').then(r => r.json());
|
||||
const att = await startRegistration(opts); // 시스템 dialog
|
||||
await fetch('/webauthn/register/verify', { method: 'POST', body: JSON.stringify(att) });
|
||||
```
|
||||
|
||||
### Conditional UI (auto-fill passkey)
|
||||
```html
|
||||
<input type="text" name="username" autocomplete="username webauthn" />
|
||||
```
|
||||
|
||||
```ts
|
||||
const att = await startAuthentication({ ...opts, useBrowserAutofill: true });
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 사용자 / 환경 | 추천 |
|
||||
|---|---|
|
||||
| 일반 사용자 | TOTP + recovery codes |
|
||||
| 보안 강 | TOTP + WebAuthn |
|
||||
| 미래 / UX 최고 | Passkey (Apple/Google) |
|
||||
| Enterprise SSO | SAML / OIDC + MFA on IdP |
|
||||
| SMS | 비추 (SIM swap) — 다른 방법 없을 때만 |
|
||||
| Hardware token | YubiKey + WebAuthn |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **SMS only**: SIM swap.
|
||||
- **TOTP secret 평문 저장**: 암호화 저장 또는 KMS.
|
||||
- **Recovery codes 무제한 사용**: 1회용 필수.
|
||||
- **Passkey 만 — fallback X**: device 잃으면 lock-out. recovery 같이.
|
||||
- **Counter 검증 안 함 (WebAuthn)**: clone 방어 못 함.
|
||||
- **Challenge 재사용**: replay 가능.
|
||||
- **TOTP window 큼 (5+)**: 30초 코드 무의미.
|
||||
- **2FA 우회 endpoint**: 모두 enforce.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- TOTP = otplib + recovery codes.
|
||||
- Passkey = @simplewebauthn (server + browser).
|
||||
- 두 개 모두 지원 + recovery.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Security_OWASP_Top_10_Practical]]
|
||||
- [[Security_OAuth_Flows]]
|
||||
- [[Security_Auth_Authz_Patterns]]
|
||||
Reference in New Issue
Block a user