feat(auth): OTP "이 기기에서 다시 인증하지 않기"(신뢰 기기) + OIDC prompt=login 결함 수정 - #87
Merged
Conversation
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
요약
OTP 인증 페이지에서 OTP가 강제로 요구된 경우가 아니라면 "이 기기에서 14일간 다시 인증하지 않기"를 제안하는 신뢰 기기 기능을 추가합니다.
구현 중 이 기능의 전제(강제 재인증 경로에서는 반드시 OTP를 받는다)를 무너뜨리는 OIDC
prompt=login결함을 발견해 함께 고쳤고, 그 과정에서 막혀 있던 ESLint 툴체인도 복구했습니다.커밋
6c382b5chore(deps)typescript-eslint 8.64 / svelte-check 4.7 — TS 6 지원 복구1cde9d1feat(auth)신뢰 기기ec0f3d5fix(oidc)prompt=login 무시 + 재인증 무한 루프1. 신뢰 기기
trusted_devices테이블 신설(4방언). 랜덤 32바이트 토큰을 쿠키로 내리고 DB엔 SHA-256 해시만 저장 —session.ts와 동일 모델입니다.mfa.ts의 HMAC 방식을 쓰지 않은 이유는 개별 강제 폐기가 필수 요구사항이기 때문입니다.ForceAuthn/ OIDCprompt=login/max_age/ ACR step-up. MFA pending 토큰에forced플래그를 추가했고, 필드가 없는 구버전 토큰은 강제로 간주합니다(fail-safe).claims.forced로 다시 무시 처리합니다(클라이언트 우회 방지).리뷰 포인트 —
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(기존 버그)결함 1 —
reauthRequired계산이if (loggedIn)안에만 있어, 세션 없는 사용자의prompt=login에forceAuthn이 붙지 않았습니다. 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>가드에도 그대로 있습니다. 이번 범위 밖이라 손대지 않았으니 별도 이슈로 다루면 좋겠습니다.마이그레이션⚠️
4개 트랙 모두
trusted_devices생성만 포함합니다. pg에는 이미 적용 완료, 나머지는 배포 환경에 맞춰 적용이 필요합니다.검증
bun run lint— 통과 (prettier + eslint). 이 PR 전까지는 eslint가 실행조차 되지 않던 상태bun run check— 1436 파일 0 errors 0 warningsbun run typecheck— 통과bun run test— 31 파일 245 테스트 통과 (기존 241 + 신규 4)신규 테스트 4건: 미로그인
prompt=login→forceAuthn포함 / 재인증 후 복귀는 통과 / 재인증 없이 복귀하면 마커가 있어도 재차 요구(우회 회귀 방지) /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