Skip to content

feat(auth): OTP "이 기기에서 다시 인증하지 않기"(신뢰 기기) + OIDC prompt=login 결함 수정 - #87

Merged
mack-erel merged 3 commits into
mainfrom
feat/trusted-device
Jul 20, 2026
Merged

feat(auth): OTP "이 기기에서 다시 인증하지 않기"(신뢰 기기) + OIDC prompt=login 결함 수정#87
mack-erel merged 3 commits into
mainfrom
feat/trusted-device

Conversation

@mack-erel

Copy link
Copy Markdown
Owner

요약

OTP 인증 페이지에서 OTP가 강제로 요구된 경우가 아니라면 "이 기기에서 14일간 다시 인증하지 않기"를 제안하는 신뢰 기기 기능을 추가합니다.

구현 중 이 기능의 전제(강제 재인증 경로에서는 반드시 OTP를 받는다)를 무너뜨리는 OIDC prompt=login 결함을 발견해 함께 고쳤고, 그 과정에서 막혀 있던 ESLint 툴체인도 복구했습니다.

커밋

커밋 내용
6c382b5 chore(deps) typescript-eslint 8.64 / svelte-check 4.7 — TS 6 지원 복구
1cde9d1 feat(auth) 신뢰 기기
ec0f3d5 fix(oidc) prompt=login 무시 + 재인증 무한 루프

1. 신뢰 기기

  • trusted_devices 테이블 신설(4방언). 랜덤 32바이트 토큰을 쿠키로 내리고 DB엔 SHA-256 해시만 저장 — session.ts와 동일 모델입니다. mfa.ts의 HMAC 방식을 쓰지 않은 이유는 개별 강제 폐기가 필수 요구사항이기 때문입니다.
  • IP 바인딩은 기기별 opt-in. 체크하면 등록 IP와 다를 때 신뢰를 적용하지 않고, 체크하지 않으면 IP가 바뀌어도 유지됩니다.
  • 강제 재인증 경로에서는 신뢰 기기를 무시합니다: admin 로그인 / SAML ForceAuthn / OIDC prompt=login / max_age / ACR step-up. MFA pending 토큰에 forced 플래그를 추가했고, 필드가 없는 구버전 토큰은 강제로 간주합니다(fail-safe).
  • 체크박스 값은 서버에서 claims.forced로 다시 무시 처리합니다(클라이언트 우회 방지).
  • 비밀번호 변경·TOTP 해제 시 전체 폐기. 세션 관리 화면에서 개별/일괄 폐기.

리뷰 포인트 — amr/acr 부여

신뢰 기기로 MFA를 건너뛴 세션에 amr: [pwd, totp] / ACR_MFA를 부여했습니다. 14일 내 실제로 MFA를 통과한 기기라는 근거이고, 추적을 위해 감사 로그에 via: "trusted_device"를 남깁니다.

대안은 amr: [pwd]만 주는 것인데, 그러면 ACR_MFA를 요구하는 모든 RP가 매번 step-up을 걸어 기능이 사실상 무력화됩니다. 명시적 재인증 요구(ForceAuthn/prompt=login/max_age)는 전자에서도 그대로 뚫고 들어옵니다. 보수적으로 가야 한다면 이 지점을 뒤집으면 됩니다.

2. OIDC prompt=login (기존 버그)

결함 1reauthRequired 계산이 if (loggedIn) 안에만 있어, 세션 없는 사용자의 prompt=loginforceAuthn이 붙지 않았습니다. RP가 명시적으로 재인증을 요구했는데도 신뢰 기기가 OTP를 건너뛸 수 있는 경로입니다.

결함 2 — 재인증 후 동일 authorize URL로 복귀하면 파라미터가 그대로라 reauthRequired가 다시 참이 되어 /login ↔ /oidc/authorize 무한 왕복. SAML엔 saml_reauth_<id> 가드가 있으나 OIDC엔 대응물이 없었습니다. 신뢰 기기와 무관하게 오늘도 재현되는 버그입니다.

oidc_reauth_<hash> 마커 쿠키 추가. authorize 요청엔 고유 ID가 없어 재진입 시에도 동일한 pathname + search를 SHA-256 해시해 키로 씁니다. 해시 출력이 [A-Za-z0-9_-]뿐이라 SAML이 겪은 쿠키명 injection(#82의 651a569)은 구조적으로 발생하지 않습니다.

리뷰 포인트 — SAML 가드보다 조인 부분

마커에 **존재 여부가 아니라 발급 시각(ms)**을 담고, 복귀 시 session.createdAt >= marker일 때만 재인증을 인정합니다. 존재 여부만 보면 사용자가 /login에서 아무것도 하지 않고 같은 URL로 되돌아오기만 해도 prompt=login이 소진되어 재인증 요구를 우회할 수 있습니다.

같은 약점이 SAML의 saml_reauth_<id> 가드에도 그대로 있습니다. 이번 범위 밖이라 손대지 않았으니 별도 이슈로 다루면 좋겠습니다.

마이그레이션 ⚠️

drizzle/0030_perpetual_hellion.sql              (d1)
drizzle/pg/0013_mushy_justin_hammer.sql
drizzle/mysql/0013_awesome_malcolm_colcord.sql
drizzle/sqlite/0013_tiny_tomorrow_man.sql

4개 트랙 모두 trusted_devices 생성만 포함합니다. pg에는 이미 적용 완료, 나머지는 배포 환경에 맞춰 적용이 필요합니다.

검증

  • bun run lint — 통과 (prettier + eslint). 이 PR 전까지는 eslint가 실행조차 되지 않던 상태
  • bun run check — 1436 파일 0 errors 0 warnings
  • bun run typecheck — 통과
  • bun run test31 파일 245 테스트 통과 (기존 241 + 신규 4)

신규 테스트 4건: 미로그인 prompt=loginforceAuthn 포함 / 재인증 후 복귀는 통과 / 재인증 없이 복귀하면 마커가 있어도 재차 요구(우회 회귀 방지) / prompt=none + max_age 초과는 여전히 login_required. schema-parity.test.ts가 3방언의 trusted_devices 컬럼·인덱스를 대조 검증합니다.

남긴 것

  • account/sessions/+page.server.ts:35,40이 존재하지 않는 최상위 sessions.* 키를 참조해 원시 키 문자열이 렌더되는 기존 i18n 버그(실제 경로는 account.sessions.*). 무관한 버그라 손대지 않았습니다.

🤖 Generated with Claude Code

mack-erel and others added 3 commits July 20, 2026 15:21
typescript ^6.0.3 에 대해 typescript-eslint 8.59 는 peer 가 TS 5 까지라
typescript-estree 모듈 로드 단계에서 크래시했다.

  TypeError: Cannot read properties of undefined (reading 'Cjs')
  at create-program/shared.js:59

그 결과 `bun run lint` 의 eslint 와 `bun run check` 의 svelte-check 이 모두
동작하지 않았고, lefthook pre-commit 훅도 함께 막혀 있었다.

typescript-eslint 8.64.0 부터 peer 가 `>=4.8.4 <6.1.0` 으로 넓어져 TS 6 를
지원한다. 메이저 변경 없이 마이너 범위 안에서 해소된다.
(typescript 는 그대로 6.x 유지 — dependabot 이 7.x major 를 차단 중이다.)

검증: eslint 클린 통과, svelte-check 1436 파일 0 errors 0 warnings.

Co-Authored-By: Claude <[email protected]>
/mfa 페이지에서 OTP 가 강제로 요구된 경우가 아니면 신뢰 기기 등록을 제안한다.
등록된 기기는 14일간 로그인 시 TOTP 단계를 건너뛴다.

- trusted_devices 테이블 신설(4방언). 랜덤 32바이트 토큰을 쿠키로 내리고
  DB 에는 SHA-256 해시만 저장한다(session.ts 와 동일 모델).
  mfa.ts 의 HMAC 방식을 쓰지 않은 이유는 개별 강제 폐기가 필요해서다.
- IP 바인딩은 기기별 opt-in. 체크 시 등록 IP 와 다르면 신뢰를 적용하지 않는다.
- 강제 재인증 경로(admin 로그인 / SAML ForceAuthn / OIDC prompt=login /
  max_age / ACR step-up)에서는 신뢰 기기를 무시한다. MFA pending 토큰에
  forced 플래그를 추가하고, 필드가 없는 구버전 토큰은 강제로 간주한다(fail-safe).
- 신뢰 기기로 MFA 를 건너뛴 세션은 amr [pwd, totp] / ACR_MFA 를 갖는다.
  14일 내 실제로 MFA 를 통과한 기기이기 때문이며, 추적을 위해 감사 로그에
  via: "trusted_device" 를 남긴다.
- 비밀번호 변경·TOTP 해제 시 전체 폐기. 세션 관리 화면에서 개별/일괄 폐기.

Co-Authored-By: Claude <[email protected]>
두 결함이 얽혀 있어 함께 고친다.

1) reauthRequired 계산이 `if (loggedIn)` 블록 안에만 있어, 세션 없는 사용자가
   prompt=login 으로 오면 needsInteraction 은 !loggedIn 으로 참이 되지만
   forceAuthn 파라미터가 /login 에 전달되지 않았다. RP 가 명시적으로 재인증을
   요구했는데도 신뢰 기기가 OTP 를 건너뛸 수 있었다.
   → prompt=login 판정을 로그인 여부와 무관하게 승격. max_age/id_token_hint 는
     locals.session 을 읽으므로 기존 위치를 유지한다(미로그인은 자동 충족).

2) 재인증 후 동일 authorize URL 로 복귀하면 요청 파라미터가 그대로라
   reauthRequired 가 다시 참이 되어 /login ↔ /oidc/authorize 를 무한 왕복했다.
   SAML 에는 saml_reauth_<id> 가드가 있으나 OIDC 에는 대응물이 없었다.
   → oidc_reauth_<hash> 마커 쿠키 추가. authorize 요청에는 고유 ID 가 없으므로
     재진입 시에도 동일한 pathname+search 를 SHA-256 해시해 키로 쓴다.
     해시의 base64url 출력은 [A-Za-z0-9_-] 뿐이라 SAML 이 겪은 쿠키명 injection
     문제는 구조적으로 발생하지 않는다.

마커는 존재 여부가 아니라 발급 시각(ms)을 담고, 복귀 시 session.createdAt 이
그 이후일 때만 재인증을 인정한다. 존재 여부만 보면(SAML 가드의 약점) 사용자가
/login 에서 실제로 인증하지 않고 같은 URL 로 되돌아오기만 해도 prompt=login 이
소진돼 재인증 요구를 우회할 수 있다. 숫자 형식은 원시 문자열에서 검사한다 —
Number("") 는 0 이라 빈 쿠키 값이 "아주 오래된 마커" 로 둔갑한다.

가드는 prompt=none 오류 분기 이후에 두어 login_required 판정을 건드리지 않는다.

Co-Authored-By: Claude <[email protected]>
@mack-erel
mack-erel merged commit 9e7f13d into main Jul 20, 2026
6 checks passed
@mack-erel
mack-erel deleted the feat/trusted-device branch July 20, 2026 06:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant