[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
---
|
||||
id: security-login-flows
|
||||
title: Login Flows — Magic Link / Passkey / OAuth
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [security, auth, login, vibe-coding]
|
||||
tech_stack: { language: "TS", applicable_to: ["Backend"] }
|
||||
applied_in: []
|
||||
aliases: [magic link, passkey, OAuth, social login, password, login flow, account recovery]
|
||||
---
|
||||
|
||||
# Login Flows
|
||||
|
||||
> Password = 옛 (취약). **Magic link / Passkey / OAuth**. 사용자 friction + security trade-off. Account recovery = 가장 어려움.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Magic link: email 만 — UX 최고, 약한 security.
|
||||
- Passkey: WebAuthn — phishing 차단.
|
||||
- OAuth: 외부 IdP (Google, Apple).
|
||||
- Password + 2FA: 전통 + 강.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Magic link
|
||||
```ts
|
||||
// Send
|
||||
async function sendMagicLink(email: string) {
|
||||
const token = generateSecureToken(); // 32 bytes random
|
||||
await db.magicLinks.create({
|
||||
token,
|
||||
email,
|
||||
expiresAt: new Date(Date.now() + 10 * 60_000), // 10 min
|
||||
});
|
||||
|
||||
await emailService.send({
|
||||
to: email,
|
||||
subject: 'Sign in to Acme',
|
||||
template: 'magic-link',
|
||||
data: { url: `https://app.acme.com/auth/verify?token=${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify
|
||||
async function verifyMagicLink(token: string) {
|
||||
const link = await db.magicLinks.findUnique({ where: { token } });
|
||||
if (!link || link.expiresAt < new Date() || link.used) {
|
||||
throw new Error('Invalid or expired link');
|
||||
}
|
||||
|
||||
await db.magicLinks.update({ where: { token }, data: { used: true } });
|
||||
|
||||
let user = await db.users.findUnique({ where: { email: link.email } });
|
||||
if (!user) {
|
||||
user = await db.users.create({ data: { email: link.email } });
|
||||
}
|
||||
|
||||
return createSession(user);
|
||||
}
|
||||
```
|
||||
|
||||
→ UX 매우 좋음. Email security 가 weakness.
|
||||
|
||||
### Passkey (WebAuthn, modern)
|
||||
```ts
|
||||
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
|
||||
|
||||
// Registration (first time)
|
||||
async function startRegistration(userId: string, email: string) {
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: 'Acme',
|
||||
rpID: 'acme.com',
|
||||
userID: Buffer.from(userId),
|
||||
userName: email,
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
await redis.set(`webauthn:reg:${userId}`, options.challenge, 'EX', 300);
|
||||
return options;
|
||||
}
|
||||
|
||||
async function completeRegistration(userId: string, response: any) {
|
||||
const expectedChallenge = await redis.get(`webauthn:reg:${userId}`);
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: expectedChallenge!,
|
||||
expectedOrigin: 'https://acme.com',
|
||||
expectedRPID: 'acme.com',
|
||||
});
|
||||
|
||||
if (!verification.verified) throw new Error('Failed');
|
||||
|
||||
await db.passkeys.create({
|
||||
userId,
|
||||
credentialId: verification.registrationInfo!.credentialID,
|
||||
publicKey: verification.registrationInfo!.credentialPublicKey,
|
||||
counter: verification.registrationInfo!.counter,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Client
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
const options = await fetch('/auth/passkey/start').then(r => r.json());
|
||||
const att = await startRegistration(options);
|
||||
await fetch('/auth/passkey/complete', { method: 'POST', body: JSON.stringify(att) });
|
||||
```
|
||||
|
||||
→ [[Security_2FA_TOTP_WebAuthn]].
|
||||
|
||||
### Login flow (Passkey)
|
||||
```ts
|
||||
// Start
|
||||
async function startLogin() {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: 'acme.com',
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
await redis.set(`webauthn:auth:${options.challenge}`, '1', 'EX', 300);
|
||||
return options;
|
||||
}
|
||||
|
||||
async function completeLogin(response: any) {
|
||||
const passkey = await db.passkeys.findByCredentialId(response.id);
|
||||
if (!passkey) throw new Error('Unknown passkey');
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: ...,
|
||||
expectedOrigin: 'https://acme.com',
|
||||
expectedRPID: 'acme.com',
|
||||
authenticator: { ...passkey },
|
||||
});
|
||||
|
||||
if (!verification.verified) throw new Error('Failed');
|
||||
|
||||
await db.passkeys.update({ where: { id: passkey.id }, data: { counter: verification.authenticationInfo.newCounter } });
|
||||
|
||||
return createSession(passkey.userId);
|
||||
}
|
||||
```
|
||||
|
||||
→ Phishing 차단 (origin verify).
|
||||
|
||||
### Conditional UI (auto-fill passkey)
|
||||
```html
|
||||
<input type="text" name="username" autocomplete="username webauthn" />
|
||||
```
|
||||
|
||||
```ts
|
||||
const att = await startAuthentication({ ...options, useBrowserAutofill: true });
|
||||
```
|
||||
|
||||
→ 사용자 가 username field 클릭 → passkey suggest.
|
||||
|
||||
### OAuth (Google / Apple / GitHub)
|
||||
```ts
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
|
||||
const client = new OAuth2Client(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, REDIRECT_URI);
|
||||
|
||||
// Start
|
||||
app.get('/auth/google', (req, res) => {
|
||||
const url = client.generateAuthUrl({
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
state: generateState(), // CSRF
|
||||
prompt: 'select_account',
|
||||
});
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
// Callback
|
||||
app.get('/auth/google/callback', async (req, res) => {
|
||||
const { code, state } = req.query;
|
||||
// Verify state
|
||||
|
||||
const { tokens } = await client.getToken(code as string);
|
||||
const ticket = await client.verifyIdToken({ idToken: tokens.id_token! });
|
||||
const payload = ticket.getPayload();
|
||||
|
||||
let user = await db.users.findUnique({ where: { email: payload!.email } });
|
||||
if (!user) {
|
||||
user = await db.users.create({
|
||||
data: {
|
||||
email: payload!.email!,
|
||||
name: payload!.name,
|
||||
avatar: payload!.picture,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await createSession(user, res);
|
||||
res.redirect('/dashboard');
|
||||
});
|
||||
```
|
||||
|
||||
→ [[Security_OAuth_Flows]].
|
||||
|
||||
### Sign in with Apple (iOS / Android required)
|
||||
```ts
|
||||
// 사용자 의 Apple ID hide email
|
||||
// "abc123@privaterelay.appleid.com"
|
||||
// → 정확 email X (privacy)
|
||||
```
|
||||
|
||||
→ App Store guideline = OAuth 사용 시 Apple Sign-In 도 제공.
|
||||
|
||||
### Password + 2FA (전통)
|
||||
```ts
|
||||
async function login(email: string, password: string) {
|
||||
const user = await db.users.findUnique({ where: { email } });
|
||||
if (!user) throw new Error('Invalid credentials');
|
||||
|
||||
const valid = await argon2.verify(user.passwordHash, password);
|
||||
if (!valid) {
|
||||
await recordFailedAttempt(email);
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// Lockout
|
||||
const attempts = await getRecentFailedAttempts(email);
|
||||
if (attempts >= 5) throw new Error('Account locked');
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const tempToken = signTempToken({ userId: user.id });
|
||||
return { needs2FA: true, tempToken };
|
||||
}
|
||||
|
||||
return createSession(user);
|
||||
}
|
||||
|
||||
async function verify2FA(tempToken: string, code: string) {
|
||||
const payload = verifyTempToken(tempToken);
|
||||
const user = await db.users.findUnique({ where: { id: payload.userId } });
|
||||
|
||||
if (!authenticator.verify({ token: code, secret: user!.totpSecret! })) {
|
||||
throw new Error('Invalid 2FA code');
|
||||
}
|
||||
|
||||
return createSession(user!);
|
||||
}
|
||||
```
|
||||
|
||||
### Account recovery (어려움)
|
||||
```
|
||||
가능 path:
|
||||
1. Email reset (account = email)
|
||||
2. Backup codes
|
||||
3. Recovery passkey (다른 device)
|
||||
4. SMS (옛, weak)
|
||||
5. Identity verify (sensitive)
|
||||
|
||||
가장 안전:
|
||||
- Multiple recovery (email + passkey + codes)
|
||||
- Account-bound
|
||||
- Audit log
|
||||
```
|
||||
|
||||
```ts
|
||||
async function startRecovery(email: string) {
|
||||
const user = await db.users.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
// Always success (account enumeration 차단)
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
const token = generateSecureToken();
|
||||
await db.passwordResets.create({
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt: new Date(Date.now() + 60 * 60_000), // 1 hour
|
||||
});
|
||||
|
||||
await emailService.send({
|
||||
to: email,
|
||||
template: 'password-reset',
|
||||
data: { url: `https://app.acme.com/reset?token=${token}` },
|
||||
});
|
||||
|
||||
return { sent: true };
|
||||
}
|
||||
```
|
||||
|
||||
### Account enumeration 차단
|
||||
```ts
|
||||
// ❌ "Email not found" — attacker 가 valid email 알아냄
|
||||
// ✅ 항상 "If account exists, you'll receive an email"
|
||||
```
|
||||
|
||||
### Brute force 방어
|
||||
```ts
|
||||
// Rate limit (IP + email)
|
||||
const ipLimit = await rateLimit.check(`login:ip:${ip}`, 10, 60);
|
||||
const emailLimit = await rateLimit.check(`login:email:${email}`, 5, 300);
|
||||
|
||||
if (!ipLimit || !emailLimit) {
|
||||
return res.status(429).end();
|
||||
}
|
||||
|
||||
// Captcha (after N failures)
|
||||
if (failedAttempts > 3) {
|
||||
if (!await verifyCaptcha(req.body.captchaToken)) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session creation
|
||||
```ts
|
||||
async function createSession(user: User, res: Response) {
|
||||
const sessionId = uuid();
|
||||
await db.sessions.create({
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 3600_000), // 7 days
|
||||
});
|
||||
|
||||
res.cookie('session', sessionId, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 3600_000,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### JWT vs session cookie
|
||||
```
|
||||
Session cookie:
|
||||
+ Server 가 invalidate 가능
|
||||
+ Sensitive
|
||||
+ Database lookup
|
||||
- Stateful (DB call)
|
||||
|
||||
JWT:
|
||||
+ Stateless
|
||||
+ Multiple service share
|
||||
- Invalidate 어려움 (until expire)
|
||||
- Sensitive (sign key + payload)
|
||||
|
||||
Refresh token + short-lived JWT:
|
||||
+ JWT 의 stateless + invalidate 가능
|
||||
+ Best practice
|
||||
```
|
||||
|
||||
### Refresh token rotation
|
||||
```ts
|
||||
async function refresh(refreshToken: string) {
|
||||
const session = await db.sessions.findUnique({ where: { refreshToken } });
|
||||
if (!session) {
|
||||
// Used / unknown — possible replay
|
||||
await db.sessions.deleteMany({ where: { userId: session?.userId } });
|
||||
throw new Error('Token reuse — all sessions revoked');
|
||||
}
|
||||
|
||||
const newRefresh = generateSecureToken();
|
||||
await db.sessions.update({
|
||||
where: { id: session.id },
|
||||
data: { refreshToken: newRefresh, refreshedAt: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: signJwt({ userId: session.userId, exp: 15 * 60 }),
|
||||
refreshToken: newRefresh,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
→ Old refresh 사용 시 = revoke all (compromise 의심).
|
||||
|
||||
### Mobile flow
|
||||
```
|
||||
1. Login → server return (access, refresh)
|
||||
2. Access in memory + refresh in Keychain (iOS) / Keystore (Android)
|
||||
3. Access expired → use refresh
|
||||
4. Refresh expired → re-login
|
||||
```
|
||||
|
||||
### Magic code (mobile, 6 digit)
|
||||
```ts
|
||||
// SMS / Email send 6 digit
|
||||
await sendSMS(phone, `Your code: ${generate6DigitCode()}`);
|
||||
|
||||
// User input
|
||||
const valid = await verify(phone, code);
|
||||
```
|
||||
|
||||
→ Magic link 의 mobile 친화 alternative.
|
||||
|
||||
### Social account linking
|
||||
```ts
|
||||
async function linkGoogle(userId: string, googleSub: string) {
|
||||
// 같은 google account 가 다른 user 에 linked 면?
|
||||
const existing = await db.identities.findUnique({
|
||||
where: { provider: 'google', sub: googleSub },
|
||||
});
|
||||
if (existing && existing.userId !== userId) {
|
||||
throw new Error('Already linked to another account');
|
||||
}
|
||||
|
||||
await db.identities.upsert({
|
||||
where: { userId_provider: { userId, provider: 'google' } },
|
||||
create: { userId, provider: 'google', sub: googleSub },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### "Sign in or sign up" 통일
|
||||
```ts
|
||||
async function googleSignIn(idToken: string) {
|
||||
const payload = await verifyGoogleToken(idToken);
|
||||
|
||||
let user = await db.users.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: payload.email },
|
||||
{ identities: { some: { provider: 'google', sub: payload.sub } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await db.users.create({
|
||||
data: {
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
identities: { create: { provider: 'google', sub: payload.sub } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createSession(user);
|
||||
}
|
||||
```
|
||||
|
||||
→ 같은 사용자 가 password + Google = 같은 account.
|
||||
|
||||
### Existing user — security questions / email confirm
|
||||
```
|
||||
사용자 가 이미 password — Google login 가 같은 email:
|
||||
- Risk: attacker 가 Google account 가짐
|
||||
- 첫 link 시 password verify
|
||||
|
||||
→ Account takeover 방어.
|
||||
```
|
||||
|
||||
### Logout
|
||||
```ts
|
||||
async function logout(req: Request, res: Response) {
|
||||
await db.sessions.delete({ where: { id: req.cookies.session } });
|
||||
res.clearCookie('session');
|
||||
}
|
||||
|
||||
// Logout from all devices
|
||||
await db.sessions.deleteMany({ where: { userId } });
|
||||
```
|
||||
|
||||
### "Trust this device"
|
||||
```ts
|
||||
// Successful login + sensitive action 가 (2FA bypass) 가능
|
||||
const trustedDevice = await db.trustedDevices.findFirst({
|
||||
where: { userId, deviceFingerprint: req.fingerprint, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
|
||||
if (trustedDevice) {
|
||||
// Skip 2FA
|
||||
}
|
||||
```
|
||||
|
||||
→ Convenience vs security.
|
||||
|
||||
### A/B test login methods
|
||||
```ts
|
||||
// 50% magic link, 50% password
|
||||
const variant = hashUserId(userId) % 2 === 0 ? 'magic-link' : 'password';
|
||||
|
||||
// Track conversion
|
||||
analytics.track('login_completed', { variant });
|
||||
```
|
||||
|
||||
### Migration (password → passkey)
|
||||
```
|
||||
Phase 1: Add passkey (optional)
|
||||
Phase 2: Prompt to add passkey on login
|
||||
Phase 3: Disable password (keep recovery)
|
||||
Phase 4: Passkey only
|
||||
```
|
||||
|
||||
→ 점진. 사용자 educate.
|
||||
|
||||
### Best practices
|
||||
```
|
||||
1. HTTPS only
|
||||
2. HttpOnly + Secure + SameSite cookie
|
||||
3. CSRF token (cookie + form pair)
|
||||
4. Rate limit
|
||||
5. Account enumeration 차단
|
||||
6. Audit log
|
||||
7. Phishing-resistant MFA
|
||||
8. Session fixation 차단
|
||||
9. Secure cookie domain
|
||||
10. Logout 명확
|
||||
```
|
||||
|
||||
### Common 함정
|
||||
```
|
||||
- Plain password storage (instead of argon2 / bcrypt)
|
||||
- JWT secret weak / leaked
|
||||
- Email reset token long-lived (1 hour 권장)
|
||||
- OAuth state 검증 X (CSRF)
|
||||
- Session ID predictable
|
||||
- Cookie SameSite none + cross-origin
|
||||
- Account enumeration timing attack
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| 모던 product | Passkey + Magic link + OAuth |
|
||||
| Quick prototype | Magic link |
|
||||
| Enterprise | OAuth (Okta / Google Workspace) |
|
||||
| Mobile | Apple / Google + biometric |
|
||||
| 옛 user base | Password + 2FA + Passkey opt-in |
|
||||
| B2C | Auth0 / Clerk / Supabase |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **Password + no MFA**: weak.
|
||||
- **Plain password storage**: 절대 — argon2.
|
||||
- **Magic link 가 long expire (24h)**: 짧게 (10 min).
|
||||
- **2FA SMS only**: SIM swap.
|
||||
- **OAuth state 무**: CSRF.
|
||||
- **Account enumeration**: timing attack 가능.
|
||||
- **Refresh token long + no rotation**: leak 시 영원.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Passkey + magic link + OAuth = modern stack.
|
||||
- Account enumeration 항상 차단.
|
||||
- Rate limit + lockout + captcha.
|
||||
- Auth0 / Clerk 가 빠른 시작.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Security_2FA_TOTP_WebAuthn]]
|
||||
- [[Security_OAuth_Flows]]
|
||||
- [[Security_Auth_Authz_Patterns]]
|
||||
Reference in New Issue
Block a user