Files
2nd/10_Wiki/Topics/Coding/Security_Login_Flows.md
T
2026-05-09 22:47:42 +09:00

14 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
security-login-flows Login Flows — Magic Link / Passkey / OAuth Coding draft B conceptual 2026-05-09 2026-05-09
security
auth
login
vibe-coding
language applicable_to
TS
Backend
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: 전통 + 강.

💻 코드 패턴

// 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)

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,
  });
}
// 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)

// 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)

<input type="text" name="username" autocomplete="username webauthn" />
const att = await startAuthentication({ ...options, useBrowserAutofill: true });

→ 사용자 가 username field 클릭 → passkey suggest.

OAuth (Google / Apple / GitHub)

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)

// 사용자 의 Apple ID hide email
// "abc123@privaterelay.appleid.com"
// → 정확 email X (privacy)

→ App Store guideline = OAuth 사용 시 Apple Sign-In 도 제공.

Password + 2FA (전통)

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
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 차단

// ❌ "Email not found" — attacker 가 valid email 알아냄
// ✅ 항상 "If account exists, you'll receive an email"

Brute force 방어

// 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

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,
  });
}
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

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)

// 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

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" 통일

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

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"

// 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

// 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 가 빠른 시작.

🔗 관련 문서