557 lines
14 KiB
Markdown
557 lines
14 KiB
Markdown
---
|
|
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]]
|