From adb992e6d87bab8f494438ed44bdbe6b2d6a3242 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 10:57:14 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=2012=EB=8B=A8=EA=B3=84=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EB=B3=B4=EC=95=88=20=E2=80=94=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=205=EC=A0=88=20=C2=B7=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EB=84=B7=20=C2=B7=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록을 엔진(Stalwart 0.16.27) 실험 결과에 맞춰 정했다. 엔진이 코드 확인 · 메일 프로그램 막기 · 앱 비밀번호를 맡고, 서버는 첫 코드 확인 · 다시 쓰기 막기 · 실패 한도 · 기록을 맡는다. 사용자 결정(2026-10-31): 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 바로 막는다. Co-Authored-By: Claude Opus 5.5 --- docs/design/webmail/LoginTwoFactor.dc.html | 229 ++++++++ docs/design/webmail/README.md | 97 ++++ .../webmail/SettingsAppPasswords.dc.html | 464 ++++++++++++++++ .../webmail/SettingsLoginHistory.dc.html | 501 ++++++++++++++++++ docs/design/webmail/SettingsTwoFactor.dc.html | 493 +++++++++++++++++ docs/design/webmail/canvas.json | 11 +- docs/mail/api-contract.md | 310 ++++++++++- 7 files changed, 2101 insertions(+), 4 deletions(-) create mode 100644 docs/design/webmail/LoginTwoFactor.dc.html create mode 100644 docs/design/webmail/SettingsAppPasswords.dc.html create mode 100644 docs/design/webmail/SettingsLoginHistory.dc.html create mode 100644 docs/design/webmail/SettingsTwoFactor.dc.html diff --git a/docs/design/webmail/LoginTwoFactor.dc.html b/docs/design/webmail/LoginTwoFactor.dc.html new file mode 100644 index 0000000..1624a8c --- /dev/null +++ b/docs/design/webmail/LoginTwoFactor.dc.html @@ -0,0 +1,229 @@ + + + + +로그인 · 2단계 인증 + + + + + + + +
+
+ +
+ ARA SYSTEM +
+

사내 업무를
한 계정으로

+

메일부터 시작해 캘린더, 파일 저장소, ERP까지 같은 계정으로 이어집니다.

+
+
+ +
+
+
+

2단계 인증

+

비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +
+
+ + +
+ +
+ +

인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.

+

Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다.

+
+
+
+ +
+ +
+

로그인 · 2단계 인증

+

2단계 인증을 켠 직원은 비밀번호가 맞으면 위 화면으로 옵니다. 켜지 않은 직원은 지금처럼 바로 메일함으로 갑니다. 화면 틀 · 칸 · 단추는 로그인 화면과 같고, 오류 문구는 에러 코드 사전에서 가져옵니다. 2026-10-31 확정.

+
+ +
+ +
+
+

코드 틀림

+

칸 아래 빨간 글 · 칸을 비우고 초점은 그 칸에 — 알림 없음

+
+
+
+
+

2단계 인증

+

비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +
+
+ + +

+ + 코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요. +

+
+ +
+ +

인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.

+
+
+

인증 앱의 코드는 30초마다 바뀌고, 휴대폰 시계가 어긋나면 맞게 넣어도 틀립니다. 틀린 코드는 비밀번호 틀림과 따로 세고(같은 주소 15분 5회 · 하루 20회), 로그인 기록에 「실패 — 코드 틀림」으로 남습니다.

+
+ +
+
+

시도가 너무 많음

+

위쪽 빨간 상자 — 로그인 오류와 같은 자리 · 같은 모양

+
+
+
+
+

2단계 인증

+

비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +

+ + 요청이 너무 많습니다. 잠시 후 다시 시도해 주세요. +

+ +
+
+ + +
+ +
+ +

인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.

+
+
+

코드 실패 한도(같은 주소 15분 5회 · 하루 20회)를 넘었을 때입니다 — 문구는 사전의 「요청이 너무 많습니다」입니다. 막힌 동안 [확인]은 같은 안내를 다시 띄우고, 칸 · 단추는 그대로 둡니다 — 화면은 언제 풀리는지 모릅니다.

+
+ +
+
+

확인 중

+

단추 바쁨 · 칸은 읽기만

+
+
+
+
+

2단계 인증

+

비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +
+
+ + +
+ +
+ +

인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.

+
+
+

로그인 단추와 같은 모양입니다(「로그인하는 중…」처럼 흐리게 · aria-busy). 맞으면 받은편지함으로 — 로그인 전에 가려던 주소가 있으면 그곳으로 갑니다.

+
+
+ +
+ +
+
+

흐름 · 규칙

+
+
    +
  • 비밀번호가 틀리면 지금처럼 「이메일 또는 비밀번호가 올바르지 않습니다.」 — 이 화면으로 오지 않습니다. 2단계 인증을 켰는지는 비밀번호가 맞은 뒤에야 드러납니다.
  • +
  • [다른 계정으로 로그인] → 처음 로그인 화면(주소 칸은 비움). 확인한 비밀번호는 버립니다.
  • +
  • 칸은 숫자 6자리만 받습니다 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지웁니다. 휴대폰 · 암호 관리자의 코드 자동 채우기(one-time-code)를 켭니다.
  • +
  • 이 화면에 10분 넘게 머물면 [확인] 때 「다시 로그인해 주세요」와 함께 처음 화면으로 갑니다. 새로고침도 처음 화면으로 갑니다.
  • +
  • 이 브라우저 기억 · 복구 코드 · 문자 · 메일 코드는 두지 않습니다 — 로그인할 때마다 코드를 묻습니다.
  • +
+
+ +
+
+

메일 프로그램 · 휴대폰을 잃었을 때

+
+
    +
  • Outlook · 썬더버드 · 휴대폰 메일 앱(IMAP · SMTP)은 이 단계를 거치지 않습니다. 2단계 인증을 켜면 웹메일 비밀번호로는 새로 연결할 수 없고 앱 비밀번호로만 연결합니다(설정 › 앱 비밀번호).
  • +
  • 휴대폰을 잃었거나 인증 앱을 지웠으면 관리자에게 문의합니다 — 관리자가 메일 서버 관리 화면에서 그 직원의 2단계 인증을 끄면, 비밀번호만으로 로그인한 뒤 새 휴대폰으로 다시 켭니다.
  • +
  • 로그인 기록: 코드까지 맞으면 「성공 · 2단계 인증」, 코드가 틀리면 「실패 — 코드 틀림」.
  • +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index c3b1e90..24cb0eb 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -662,6 +662,103 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". - 확정본이 미룬 것: 기간 고르기 · 연장, 다시 보내기, 받는 사람 확인 코드 · 비밀번호, 사내만, 받는 사람별 링크 · 기록, 처음 받음 · 만료 전 알림, 미리 보기, zip 모두 받기, 폴더 올리기, 관리자 화면 · 퇴사자 처리, 보낸 메일을 지울 때 파일도, 모바일 앱, 파일 저장소 연동. +## 12단계 — 계정 보안 (2026-10-31 확정 — `LoginTwoFactor` · `SettingsTwoFactor` · `SettingsAppPasswords` · `SettingsLoginHistory`) + +2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록. 직원이 스스로 켠다 — 관리자가 전원에게 강제하는 것은 13단계다. +데이터 · 규칙은 계약 5절(`docs/mail/api-contract.md` "계정 보안 — 12단계")이 정하고, 새 에러 코드(`AUTH_006`~`AUTH_009` — 계약 5절 "에러 코드")의 사전 문구 초안도 거기 있다. +설정 메뉴의 계정 묶음: 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록(새 항목 아이콘: 방패(체크) · 열쇠 · 시계(되감기)). +새 토큰은 없다 — 키 [복사] · 표의 [지우기] · [로그아웃] · 안내 상자 안 단추는 차단한 주소의 [차단 풀기]와 같은 밀도 예외 36(`--size-notice-action`)이다. +각 보드 위쪽은 실제 크기 화면(설정 셸은 `SettingsAccount` 와 같다 — 메뉴 208 · 콘텐츠 열 800), 아래는 상태 카드다. + +### 로그인 — 코드 단계 (`LoginTwoFactor`) + +- **누가 오나**: 비밀번호가 맞고 2단계 인증을 켠 직원만. 켜지 않은 직원은 지금처럼 바로 메일함으로 간다. + 비밀번호가 틀리면 지금처럼 「이메일 또는 비밀번호가 올바르지 않습니다.」 — 2단계를 켰는지는 비밀번호가 맞은 뒤에야 드러난다. +- **틀**은 로그인 화면과 같다. 위에서부터: + - 제목 "2단계 인증" / "비밀번호를 확인했습니다. 휴대폰 인증 앱에 표시된 코드를 넣어 주세요." + - 주소 + [다른 계정으로 로그인] — 처음 화면으로(주소 칸은 비움), 확인한 비밀번호는 버린다. + - 칸 "인증 앱에 표시된 6자리 코드" — 숫자 6자리만, 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다. `inputmode="numeric"` · `autocomplete="one-time-code"`. + - [확인] → "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." → "Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다." +- **상태** + - 코드 틀림 — 칸 아래 빨간 글(`AUTH_006` 사전 문구 "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요."). 칸을 비우고 초점은 그 칸, 알림은 없다. + - 시도 과다 — 위쪽 빨간 상자(로그인 오류와 같은 자리 · 같은 모양)에 `REQ_001` 사전 문구. 코드 실패는 비밀번호 틀림과 따로 센다(같은 주소 15분 5회 · 하루 20회). 칸 · 단추는 그대로 둔다. + - 확인 중 — 「확인하는 중…」(로그인 단추와 같은 모양 · `aria-busy`), 칸은 읽기만. 맞으면 받은편지함으로 — 로그인 전에 가려던 주소가 있으면 그곳으로. +- 이 화면에 10분 넘게 머물면 [확인] 때 「다시 로그인해 주세요」와 함께 처음 화면으로 간다. 새로고침도 처음 화면이다(비밀번호는 화면 메모리에만 있다). +- 로그인 기록에는 코드까지 맞으면 「성공 · 2단계 인증」, 틀리면 「실패 — 코드 틀림」으로 남는다. + +### 설정 › 계정 › 2단계 인증 (`SettingsTwoFactor`) + +- 머리 "2단계 인증" + "로그인할 때 비밀번호와 함께 휴대폰 인증 앱의 코드를 확인합니다. 비밀번호가 새어 나가도 휴대폰이 없으면 로그인할 수 없습니다." +- **상태는 글자로만**(뱃지 · 스위치 없음) — 테두리 상자 하나: + - 「꺼짐」(회색 굵게) / "지금은 로그인할 때 비밀번호만 확인합니다." + [2단계 인증 켜기] + - 「켜짐 — 2026년 10월 31일부터」(초록 #067647 굵게 — 켠 때를 모르면 「켜짐」) / "로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다." + [2단계 인증 끄기] +- **꺼져 있을 때** 상자 아래: + - 갈색 경고 "켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다" / "Outlook · 썬더버드 · 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다."(앱 비밀번호 링크) + - 「켜는 차례」 1 · 2 · 3(청록 굵은 숫자 — 동그라미 없음): 인증 앱 설치(Google · Microsoft Authenticator 처럼 6자리 코드를 만드는 앱) → [2단계 인증 켜기]를 눌러 QR 찍기 → 코드와 지금 비밀번호를 넣으면 켜진다. +- **켜져 있을 때** 상자 아래: "휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요 — 끌 때 지금 휴대폰의 코드가 필요합니다." · "휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요." · "메일 프로그램은 앱 비밀번호로 연결합니다." +- **켜기 창**(모달 620 — `wide`, 필터 고치기 창과 같은 폭 · 처음 초점은 코드 칸) + - ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → 「QR 코드 스캔」을 고르고 이 그림을 찍습니다." + QR 그림 + - "찍을 수 없으면 이 키를 입력하세요" + 32자 키(4자씩 띄움, 고정폭) + [복사](36 — 「키를 복사했습니다.」) + "앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다." + - ② "앱에 표시된 6자리 코드" ③ "지금 비밀번호" → [켜기] [취소] + - 칸 아래 오류(알림 없이, 첫 잘못된 칸에 초점): 코드 `AUTH_006` · 지금 비밀번호 `AUTH_005`("지금 비밀번호가 올바르지 않습니다."). 둘 다 틀렸으면 코드부터 — 서버가 코드를 먼저 본다. + 틀린 코드는 지우고 그 칸에 초점을 둔다. QR · 키는 그대로라 다시 찍지 않아도 된다. 비밀번호 틀림은 로그인 실패와 함께, 코드 틀림은 코드 실패로 따로 센다. + - 켜는 동안 「켜는 중…」 · [취소]. 맞으면 창을 닫고 화면이 「켜짐」이 되며, 위에 안내 상자 "2단계 인증을 켰습니다" / "메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다." [앱 비밀번호 만들기](36) — 이 화면을 떠나면 사라진다. + - 키는 창을 열 때마다 새로 만든다(10분 안에 켠다 — 지나면 `AUTH_008` 로 처음부터). 켜지 않고 닫으면 그 키는 버린다 — 앱에 먼저 저장한 항목은 지워 달라고 안내한다. QR · 키는 이 창에만 보이고 로그에 남기지 않는다. + - 켜도 다른 곳의 웹메일 로그인은 끊지 않는다 — 끊으려면 로그인 기록에서. +- **끄기 창**(alertdialog 400 · 처음 초점 「취소」, Esc 도 같다): "2단계 인증을 끌까요?" / "끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다." + 지금 비밀번호 · 인증 앱 코드 → [끄기](빨강) [취소]. + 끄면 「2단계 인증을 껐습니다.」 — 화면은 「꺼짐」. 틀리면 켜기 창과 같은 칸 아래 글이다. +- **비밀번호 화면**(설정 › 비밀번호) + - 2단계 인증을 켠 직원: 지금 양식 맨 아래에 「인증 앱 코드」 칸 + "코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요." + 머리 설명은 "웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다." 다른 탭에서 켜서 `AUTH_007` 이 오면 2단계 상태를 다시 받아 코드 칸을 보인다. + - 모든 직원: 단추 아래 "바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다." "앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요." + +### 설정 › 계정 › 앱 비밀번호 (`SettingsAppPasswords`) + +- 머리 "앱 비밀번호" + "Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다." + 오른쪽 "3 / 5개" + [앱 비밀번호 만들기]. +- **표**(구현은 실제 table): 이름 · 만든 날 · 마지막 사용 · [지우기](36, 빨간 글). 최근에 만든 것이 위, 긴 이름은 말줄임. + 마지막 사용은 "오늘 오전 9:12 · IMAP · 211.186.95.252" — 쓰지 않았으면 "아직 쓰지 않음"(회색, 오래 쓰지 않은 줄은 지워도 된다는 신호). +- **메일 프로그램 설정값 상자**: 받는 메일(IMAP) `mx1.arasystem.it.kr` · 포트 993 · SSL/TLS / 보내는 메일(SMTP) `mx1.arasystem.it.kr` · 포트 465 · SSL/TLS / + 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다. 호스트 · 포트는 화면 상수 하나에 둔다(전 직원 전환 때 다시 본다). +- **아래 안내**: "앱 비밀번호는 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰입니다. 웹메일 로그인에는 쓸 수 없습니다." · "2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다." · + "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요." · "휴대폰이나 PC 를 잃어버렸으면 그 기기의 앱 비밀번호를 바로 지워 주세요." +- **5개를 모두 만들었을 때**: [앱 비밀번호 만들기]는 `aria-disabled` + 머리 아래 한 줄 "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 누르면 창을 열지 않고 까닭을 읽어 준다(role=status). +- **빈 상태**: "0 / 5개" + "만든 앱 비밀번호가 없습니다" / "Outlook · 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결합니다." + 설정값 상자 · 아래 안내는 그대로. 목록을 받지 못하면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도]) — 그동안 [만들기]는 막는다(개수를 모르므로). +- **만들기 창**(400 · 처음 초점 이름 칸): 「이름」 + "어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지." → [만들기] [취소]. + 칸 아래: "이름을 넣어 주세요." · "같은 이름의 앱 비밀번호가 있습니다."(앞뒤 공백을 지운 값으로 화면이 먼저 본다) · 그사이 다른 창에서 5개를 채웠으면 `AUTH_009` 사전 문구. +- **만든 결과 창**(620 · 처음 초점 [복사]): "앱 비밀번호를 만들었습니다" / "‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요." + - 값은 고정폭으로 4자씩 떨어뜨려 보이지만 띄어쓰기는 없다 — [복사](36)는 40자 그대로. "40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다." + - 경고 "이 창을 닫으면 다시 볼 수 없습니다. 잊어버리면 지우고 새로 만들어 주세요." + 설정값 요약 + [닫기]. + - 복사 「앱 비밀번호를 복사했습니다.」 / 못 하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」 + - [닫기] · Esc 로 닫으면 목록 맨 위에 새 줄이 생기고 값은 화면 메모리에서도 지운다. 복사하지 않고 닫아도 다시 묻지 않는다. +- **지우기 확인**(alertdialog 400 · 처음 초점 「취소」): "‘휴대폰 메일 앱’ 앱 비밀번호를 지울까요?" / "이 비밀번호로 연결한 메일 프로그램은 다음 연결부터 로그인하지 못합니다. 지운 비밀번호는 되돌릴 수 없습니다." + [지우기](빨강) [취소] → 「‘휴대폰 메일 앱’ 앱 비밀번호를 지웠습니다.」 — 줄이 빠지고 개수가 준다(되돌리기 없음). + +### 설정 › 계정 › 로그인 기록 (`SettingsLoginHistory`) + +- 머리 "로그인 기록" + "최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요."(두 링크). +- **로그인된 브라우저**: 「로그인된 브라우저」 + "웹메일 3곳" · 오른쪽 [다른 곳 모두 로그아웃](44). + - 줄마다 PC · 휴대폰 아이콘(16) · 브라우저 · 운영체제 / IP / 마지막 활동 / [로그아웃](36). 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만. 모르면 "알 수 없는 브라우저". + - 아래 "웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다."(링크) + - 줄의 [로그아웃]은 묻지 않고 바로 끊는다 → 「‘Edge · Windows’의 로그인을 끊었습니다.」(되돌리기 없음). 끊은 줄은 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않는다. + - [다른 곳 모두 로그아웃] → 확인(alertdialog 400 · 처음 초점 「취소」) "다른 곳의 로그인을 모두 끊을까요?" / + "이 브라우저를 뺀 2곳(Edge · Windows, Safari · iPhone)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다." "메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요." + [모두 로그아웃](청록 주 단추 — 데이터를 지우는 일이 아니다) [취소] → 「다른 곳의 로그인 2곳을 끊었습니다.」 + - 끊긴 브라우저는 다음 요청에서 바로 로그인 화면으로 간다(열린 실시간 알림도 30초 안에) — 계약 5절 "로그인된 브라우저". +- **최근 로그인**: 「최근 로그인」 + "최근 90일 · 웹메일과 메일 프로그램" · 오른쪽 전환 [전체 · 실패만](`.segmented`). 표: 시각 · 방식 · IP · 기기 · 결과. + - 방식: "웹메일" / "메일 프로그램 · IMAP" · "메일 프로그램 · SMTP" + 앱 비밀번호로 들어왔으면 아래 줄 "앱 비밀번호 ‘회사 PC 아웃룩’"(지운 앱 비밀번호도 그 이름 그대로). + - 기기: 웹메일은 "Chrome · Windows", 메일 프로그램은 "—". 나라 · 지역은 보이지 않는다. + - 결과: 「성공」 · 「성공 · 2단계 인증」 · 「실패 — 비밀번호 틀림」 · 「실패 — 코드 틀림」 · 「실패 — 앱 비밀번호 필요」(2단계를 켠 계정에 메일 프로그램이 웹메일 비밀번호로 연결하려 함). 실패는 빨간 굵은 글자만 — 뱃지 없음. + - 최근 순서 · 20개씩 [더 보기](44). 「실패만」이면 "최근 90일 · 실패 4번", [더 보기]도 실패만 이어서. + - 빈 상태(실패만): "최근 90일 동안 실패한 로그인이 없습니다" / "[전체]를 누르면 성공한 로그인도 봅니다." 기록은 이 기능을 배포한 날부터 쌓인다. +- 목록을 받지 못하면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도]). + +### 처음 판에서 미룬 것 + +복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키 · 패스키, 관리자가 2단계 인증을 강제하기(13단계), 켜진 채 휴대폰 바꾸기, 앱 비밀번호 이름 바꾸기 · 만료, +새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 로그인 기록의 나라 · 지역. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). diff --git a/docs/design/webmail/SettingsAppPasswords.dc.html b/docs/design/webmail/SettingsAppPasswords.dc.html new file mode 100644 index 0000000..2e5ffad --- /dev/null +++ b/docs/design/webmail/SettingsAppPasswords.dc.html @@ -0,0 +1,464 @@ + + + + +설정 · 앱 비밀번호 + + + + + + + +
+
+ + + +
+ +
+ 설정 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+ +
+
+

앱 비밀번호

+

Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다.

+
+
+ 3 / 5개 + +
+
+ +
+ +
    +
  • + 노트북 썬더버드 + 10월 31일 + 아직 쓰지 않음 + +
  • +
  • + 회사 PC 아웃룩 + 10월 27일 + 오늘 오전 9:12 · IMAP · 211.186.95.252 + +
  • +
  • + 휴대폰 메일 앱 + 10월 20일 + 어제 오후 6:40 · SMTP · 223.38.120.77 + +
  • +
+
+ +
+

메일 프로그램 설정값

+
+
받는 메일 (IMAP)
+
mx1.arasystem.it.kr · 포트 993 · SSL/TLS
+
보내는 메일 (SMTP)
+
mx1.arasystem.it.kr · 포트 465 · SSL/TLS
+
사용자 이름
+
ara.kim@arasystem.it.kr — 메일 주소 전체
+
비밀번호
+
여기서 만든 앱 비밀번호 — 웹메일 비밀번호가 아닙니다
+
+
+ +
+

앱 비밀번호는 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰입니다. 웹메일 로그인에는 쓸 수 없습니다.

+

2단계 인증을 켜면 메일 프로그램은 앱 비밀번호로만 연결합니다. 켜지 않아도 만들어 쓸 수 있습니다.

+

웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 메일 프로그램마다 새로 만들어 주세요.

+

휴대폰이나 PC 를 잃어버렸으면 그 기기의 앱 비밀번호를 바로 지워 주세요.

+
+ +
+
+
+
+
+ +
+ +
+

설정 · 앱 비밀번호

+

위 화면은 3개를 만든 상태입니다. 값은 메일 서버가 만든 40자(app_ 로 시작)이고 만들 때 한 번만 보입니다. 이름은 꼭 붙이고 5개까지 둡니다. 메일 읽기 · 보내기(IMAP · SMTP)에만 쓰이고, 2단계 인증을 켜지 않은 직원도 만들 수 있습니다. 메뉴는 계정 묶음의 「앱 비밀번호」(2단계 인증 아래). 2026-10-31 확정.

+
+ +
+ +
+
+

5개를 모두 만들었을 때

+ [앱 비밀번호 만들기]는 aria-disabled — 까닭은 머리 아래 한 줄 · 콘텐츠 열 800 +
+
+
+
+

앱 비밀번호

+

Outlook · 썬더버드 · 휴대폰 메일 앱 같은 메일 프로그램에 넣는 비밀번호입니다. 프로그램마다 따로 만들어 두면, 기기를 잃었을 때 그것만 지울 수 있습니다.

+
+
+ 5 / 5개 + +
+
+ +

5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.

+ +
+ +
    +
  • + 태블릿 메일 앱 + 10월 31일 + 아직 쓰지 않음 + +
  • +
  • + 노트북 썬더버드 + 10월 31일 + 오늘 오전 9:40 · IMAP · 211.186.95.252 + +
  • +
  • + 회사 PC 아웃룩 + 10월 27일 + 오늘 오전 9:12 · IMAP · 211.186.95.252 + +
  • +
  • + 휴대폰 메일 앱 + 10월 20일 + 어제 오후 6:40 · SMTP · 223.38.120.77 + +
  • +
  • + 집 PC 아웃룩 + 9월 2일 + 10월 3일 오후 8:15 · IMAP · 121.134.7.88 + +
  • +
+
+
+
+ +
+
+

빈 상태

+

아직 하나도 만들지 않았을 때 — 설정값 상자 · 아래 안내는 그대로

+
+
+ 앱 비밀번호 +
+ 0 / 5개 + +
+
+
+ +

만든 앱 비밀번호가 없습니다

+

Outlook · 휴대폰 메일 앱에서 메일을 보려면 앱 비밀번호를 만들어 주세요. 2단계 인증을 켰다면 메일 프로그램은 앱 비밀번호로만 연결합니다.

+
+

목록을 받지 못하면 지금의 목록 오류와 같은 모양 — 사전 문구 + [다시 시도]. 그동안 [만들기]는 막습니다(개수를 모르므로).

+
+
+ +
+ +
+
+

만든 결과 창

+

모달 620 · 값은 이 창에서 한 번만 · 처음 초점 [복사]

+
+
+
+

앱 비밀번호를 만들었습니다

+

‘회사 PC 아웃룩’ — 메일 프로그램의 비밀번호 칸에 아래 값을 넣어 주세요.

+
+ app_7qm2xk9vtn3wpl1srb3hcy7djf2gaz1euk0n + +
+

40자입니다. 보기 좋게 4자씩 띄웠지만 띄어쓰기는 없습니다.

+
+ + 이 창을 닫으면 다시 볼 수 없습니다. 잊어버리면 지우고 새로 만들어 주세요. +
+
+
받는 메일 (IMAP)
+
mx1.arasystem.it.kr · 993 · SSL/TLS
+
보내는 메일 (SMTP)
+
mx1.arasystem.it.kr · 465 · SSL/TLS
+
사용자 이름
+
ara.kim@arasystem.it.kr
+
+
+ +
+
+
+
+ + 앱 비밀번호를 복사했습니다. +
+

[닫기] · Esc 로 닫으면 목록 맨 위에 새 줄이 생기고 값은 다시 볼 수 없습니다 — 복사하지 않고 닫아도 다시 묻지 않습니다. 복사하지 못하면 「앱 비밀번호를 복사하지 못했습니다. 값을 직접 골라 복사해 주세요.」

+
+ +
+
+
+

만들기 창

+

모달 400 · 처음 초점은 이름 칸 · 이름은 꼭

+
+
+
+

앱 비밀번호 만들기

+
+ + +

어느 프로그램 · 기기에서 쓰는지 알아볼 이름입니다. 40자까지.

+
+
+ + +
+
+
+
+
이름이 비었을 때 (칸 아래)
이름을 넣어 주세요.
+
같은 이름이 있을 때 (칸 아래)
같은 이름의 앱 비밀번호가 있습니다.
+
그사이 다른 창에서 5개를 채웠을 때
5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.
+
+
+
+
+

지우기 확인 창

+

모달 400 · 되돌릴 수 없는 일 — 처음 초점 「취소」

+
+
+
+

‘휴대폰 메일 앱’ 앱 비밀번호를 지울까요?

+

이 비밀번호로 연결한 메일 프로그램은 다음 연결부터 로그인하지 못합니다. 지운 비밀번호는 되돌릴 수 없습니다.

+
+ + +
+
+
+
+ + ‘휴대폰 메일 앱’ 앱 비밀번호를 지웠습니다. +
+

줄이 사라지고 개수가 줄어듭니다(되돌리기 없음). 이미 연결된 프로그램은 다음에 메일을 받거나 보낼 때 비밀번호를 다시 묻습니다.

+
+
+
+ +
+ +
+
+

줄 · 값

+
+
    +
  • 줄마다 이름 · 만든 날 · 마지막 사용 · [지우기]. 최근에 만든 것이 위입니다. 구현은 실제 표(table) — 캔버스만 grid 로 그렸습니다.
  • +
  • 마지막 사용 = 그 앱 비밀번호로 메일 서버에 로그인한 마지막 때(시각 · IMAP 또는 SMTP · IP). 쓰지 않았으면 「아직 쓰지 않음」(회색) — 오래 쓰지 않은 줄은 지워도 된다는 신호입니다.
  • +
  • 값은 메일 서버(엔진)가 만든 40자(app_ 로 시작). 만들 때 한 번만 보이고, 서버도 다시 보여 주지 못합니다.
  • +
  • 결과 창의 값은 4자씩 떨어뜨려 보이지만 띄어쓰기는 없습니다 — [복사]는 40자 그대로 복사합니다.
  • +
  • [지우기] 36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외(새 토큰 없음). 긴 이름은 줄 안에서 말줄임.
  • +
+
+ +
+
+

누르면

+
+
    +
  • [앱 비밀번호 만들기] → 이름 창 → [만들기] → 결과 창(값 · [복사] · 설정값) → [닫기] → 목록 맨 위에 새 줄, 「4 / 5개」.
  • +
  • 5개면 [만들기]는 창을 열지 않고 까닭을 읽어 줍니다(aria-disabled · role=status).
  • +
  • [지우기] → 확인 창 → 줄이 사라지고 개수가 줄어듭니다.
  • +
  • 로그인 기록의 메일 프로그램 줄에 앱 비밀번호 이름이 함께 남습니다. 지운 앱 비밀번호의 기록도 그 이름 그대로 남습니다.
  • +
  • 2단계 인증 화면의 「켰습니다」 안내 · 경고의 링크가 이 화면으로 옵니다.
  • +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/SettingsLoginHistory.dc.html b/docs/design/webmail/SettingsLoginHistory.dc.html new file mode 100644 index 0000000..3a95610 --- /dev/null +++ b/docs/design/webmail/SettingsLoginHistory.dc.html @@ -0,0 +1,501 @@ + + + + +설정 · 로그인 기록 + + + + + + + +
+
+ + + +
+ +
+ 설정 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+ +
+

로그인 기록

+

최근 90일 동안의 로그인입니다. 모르는 기록이 있으면 바로 비밀번호를 바꾸고, 2단계 인증이 꺼져 있으면 켜 주세요.

+
+ +
+
+
+

로그인된 브라우저

+ 웹메일 3곳 +
+ +
+
+ +
    +
  • + + Chrome · Windows + 211.186.95.252 + 지금 이 브라우저 + +
  • +
  • + + Edge · Windows + 211.186.95.252 + 오늘 오전 9:05 + +
  • +
  • + + Safari · iPhone + 223.38.120.77 + 어제 오후 10:31 + +
  • +
+
+

웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.

+
+ +
+
+
+

최근 로그인

+ 최근 90일 · 웹메일과 메일 프로그램 +
+
+ + +
+
+
+ +
    +
  • + 오늘 오전 9:30 + 웹메일 + 211.186.95.252 + Chrome · Windows + 성공 · 2단계 인증 +
  • +
  • + 오늘 오전 9:29 + 웹메일 + 211.186.95.252 + Chrome · Windows + 실패 — 코드 틀림 +
  • +
  • + 오늘 오전 9:12 + + 메일 프로그램 · IMAP + 앱 비밀번호 ‘회사 PC 아웃룩’ + + 211.186.95.252 + — + 성공 +
  • +
  • + 오늘 오전 8:47 + 웹메일 + 211.186.95.252 + Edge · Windows + 성공 +
  • +
  • + 어제 오후 11:58 + 메일 프로그램 · SMTP + 45.133.10.21 + — + 실패 — 비밀번호 틀림 +
  • +
  • + 어제 오후 10:05 + 웹메일 + 223.38.120.77 + Safari · iPhone + 성공 +
  • +
  • + 어제 오후 6:40 + + 메일 프로그램 · SMTP + 앱 비밀번호 ‘휴대폰 메일 앱’ + + 223.38.120.77 + — + 성공 +
  • +
  • + 10월 29일 오후 3:21 + 웹메일 + 185.220.101.4 + Chrome · Linux + 실패 — 비밀번호 틀림 +
  • +
+
+
+ +
+ + 최근 순서 · 20개씩. 90일이 지난 기록은 지웁니다. +
+ +
+
+
+
+
+ +
+ +
+

설정 · 로그인 기록

+

위 화면은 2단계 인증을 켠 직원의 기록입니다(2단계 인증 · 앱 비밀번호 보드와 같은 날). 웹메일과 메일 프로그램(IMAP · SMTP)의 로그인을 성공 · 실패 모두 보이고, 지금 로그인된 브라우저를 하나씩 또는 한꺼번에 끊습니다. 메뉴는 계정 묶음의 「로그인 기록」(맨 아래). 2026-10-31 확정.

+
+ +
+ +
+
+

최근 로그인 — 실패만

+ [실패만]을 고르면 실패한 줄만 · [더 보기]도 실패만 이어서 · 콘텐츠 열 800 +
+
+
+
+
+

최근 로그인

+ 최근 90일 · 실패 4번 +
+
+ + +
+
+
+ +
    +
  • + 오늘 오전 9:29 + 웹메일 + 211.186.95.252 + Chrome · Windows + 실패 — 코드 틀림 +
  • +
  • + 어제 오후 11:58 + 메일 프로그램 · SMTP + 45.133.10.21 + — + 실패 — 비밀번호 틀림 +
  • +
  • + 10월 29일 오후 3:21 + 웹메일 + 185.220.101.4 + Chrome · Linux + 실패 — 비밀번호 틀림 +
  • +
  • + 10월 12일 오전 4:02 + 메일 프로그램 · IMAP + 91.240.118.30 + — + 실패 — 비밀번호 틀림 +
  • +
+
+
+
+
+ +
+
+

다른 곳 모두 로그아웃 — 확인 창

+

모달 400 · 되돌릴 수 없는 일 — 처음 초점 「취소」

+
+
+
+

다른 곳의 로그인을 모두 끊을까요?

+

이 브라우저를 뺀 2곳(Edge · Windows, Safari · iPhone)의 웹메일 로그인을 끊습니다. 끊긴 곳은 다음 동작에서 바로 로그인 화면으로 돌아갑니다.

+

메일 프로그램은 끊기지 않습니다 — 앱 비밀번호를 지워 끊어 주세요.

+
+ + +
+
+
+
+ + 다른 곳의 로그인 2곳을 끊었습니다. +
+
+ + ‘Edge · Windows’의 로그인을 끊었습니다. +
+

위 알림은 확인 창 뒤, 아래 알림은 줄의 [로그아웃] 뒤입니다(묻지 않고 바로 끊음 · 되돌리기 없음). 끊은 줄은 목록에서 빠지고, 다른 곳이 없으면 [다른 곳 모두 로그아웃]을 그리지 않습니다.

+
+
+ +
+ +
+
+

빈 상태

+ 다른 곳의 로그인이 없고, 실패한 로그인도 없을 때 · 콘텐츠 열 800 +
+
+
+
+
+

로그인된 브라우저

+ 웹메일 1곳 +
+
+
+ +
    +
  • + + Chrome · Windows + 211.186.95.252 + 지금 이 브라우저 + +
  • +
+
+

다른 곳에 로그인된 브라우저가 없습니다. 웹메일 로그인만 보입니다. 메일 프로그램은 앱 비밀번호를 지워 끊습니다.

+
+ +
+
+
+

최근 로그인

+ 최근 90일 · 웹메일과 메일 프로그램 +
+
+ + +
+
+
+ +

최근 90일 동안 실패한 로그인이 없습니다

+

[전체]를 누르면 성공한 로그인도 봅니다.

+
+
+
+
+ +
+
+
+

줄 · 값

+
+
    +
  • 로그인된 브라우저 — 웹메일 로그인(세션)마다 한 줄: PC · 휴대폰 아이콘 · 브라우저 · 운영체제 · IP · 마지막 활동 · [로그아웃]. 맨 위는 지금 이 브라우저 — 단추 대신 「지금 이 브라우저」 글자만.
  • +
  • 브라우저 · 운영체제는 로그인할 때 브라우저가 알려 준 값으로 짐작합니다(틀릴 수 있음). 마지막 활동은 그 브라우저가 로그인을 마지막으로 이어 간 때입니다.
  • +
  • 최근 로그인 — 최근 순서. 메일 프로그램 줄은 기기를 알 수 없어 「—」, 대신 방식 아래에 앱 비밀번호 이름(맞는 앱 비밀번호가 없던 실패는 이름 없이).
  • +
  • 결과 글: 성공 · 성공 · 2단계 인증 · 실패 — 비밀번호 틀림 · 실패 — 코드 틀림 · 실패 — 앱 비밀번호 필요(2단계 인증을 켠 계정에 메일 프로그램이 웹메일 비밀번호로 연결하려 함). 실패는 빨간 굵은 글자만 — 뱃지 없음.
  • +
  • IP 는 앞단 프록시를 지난 실제 주소입니다. 나라 · 지역은 보이지 않습니다. 구현은 실제 표(table) — 캔버스만 grid 로 그렸습니다.
  • +
+
+
+
+

알아 둘 것

+
+
    +
  • 줄의 [로그아웃]은 36 — 차단한 주소의 [차단 풀기]와 같은 밀도 예외(새 토큰 없음). [다른 곳 모두 로그아웃] · [더 보기]는 44.
  • +
  • 끊은 브라우저는 바로 막힙니다 — 다음 요청에서 로그인 화면으로 가고, 열린 실시간 알림 연결도 30초 안에 끊깁니다.
  • +
  • 비밀번호를 바꾸면 지금처럼 다른 곳의 로그인을 모두 끊습니다 — 이 목록에도 이 브라우저만 남습니다.
  • +
  • 목록을 받지 못하면 지금의 목록 오류와 같은 모양(사전 문구 + [다시 시도]).
  • +
  • 기록은 이 기능을 배포한 날부터 쌓입니다 — 그 전 로그인은 보이지 않습니다.
  • +
+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/SettingsTwoFactor.dc.html b/docs/design/webmail/SettingsTwoFactor.dc.html new file mode 100644 index 0000000..5254c1a --- /dev/null +++ b/docs/design/webmail/SettingsTwoFactor.dc.html @@ -0,0 +1,493 @@ + + + + +설정 · 2단계 인증 + + + + + + + +
+
+ + + +
+ +
+ 설정 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+ +
+

2단계 인증

+

로그인할 때 비밀번호와 함께 휴대폰 인증 앱의 코드를 확인합니다. 비밀번호가 새어 나가도 휴대폰이 없으면 로그인할 수 없습니다.

+
+ +
+
+ 꺼짐 + 지금은 로그인할 때 비밀번호만 확인합니다. +
+ +
+ +
+ +
+ 켜면 메일 프로그램은 앱 비밀번호로 다시 연결해야 합니다 + Outlook · 썬더버드 · 휴대폰 메일 앱은 웹메일 비밀번호로 새로 연결할 수 없고, 이미 연결해 둔 프로그램도 다음에 다시 연결할 때 막힙니다. 켜기 전에 앱 비밀번호를 만들어 두면 편합니다. +
+
+ +
+

켜는 차례

+
    +
  1. + + 휴대폰에 인증 앱을 설치합니다 — Google Authenticator, Microsoft Authenticator 처럼 6자리 코드를 만드는(TOTP) 앱이면 됩니다. +
  2. +
  3. + + [2단계 인증 켜기]를 누르고 화면의 QR 코드를 인증 앱으로 찍습니다. +
  4. +
  5. + + 앱에 표시된 6자리 코드와 지금 비밀번호를 넣으면 켜집니다. 그다음부터 로그인할 때마다 코드를 묻습니다. +
  6. +
+
+ +
+
+
+
+
+ +
+ +
+

설정 · 2단계 인증

+

위 화면은 꺼진 상태입니다. 인증 앱(TOTP) 하나로만 켭니다 — 이 브라우저 기억 · 복구 코드 · 문자 · 메일 코드는 없고, 휴대폰을 잃으면 관리자에게 문의합니다. 메뉴는 계정 묶음의 「2단계 인증」(비밀번호 아래). 2026-10-31 확정.

+
+ +
+ +
+
+

켜기 창

+

모달 620(필터 고치기 창과 같은 폭) · 처음 초점은 코드 칸

+
+
+
+

2단계 인증 켜기

+
    +
  1. + +
    + 인증 앱으로 QR 코드를 찍어 주세요 +
    +
    + +
    +
    +

    인증 앱에서 [+] → 「QR 코드 스캔」을 고르고 이 그림을 찍습니다.

    +
    + 찍을 수 없으면 이 키를 입력하세요 +
    + K7QF 2M4X HB6R TZ3N PW5C YL7D JQ2V MGAS + +
    + 앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다. +
    +
    +
    +
    +
  2. +
  3. + +
    + + +
    +
  4. +
  5. + +
    +
    + +
    + + +
    +
    +
    +
  6. +
+
+ + +
+
+
+
+ +
+
+
+

칸 아래 오류

+

[켜기]를 누른 뒤 — 알림 없이 칸 아래에만, 첫 잘못된 칸에 초점

+
+
+ + +

+ + 코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요. +

+
+
+
+ +
+ + +
+

+ + 지금 비밀번호가 올바르지 않습니다. +

+
+
+

틀린 코드는 지우고 그 칸에 초점을 둡니다. 둘 다 틀렸으면 코드부터 알립니다. 틀려도 QR · 키는 그대로라 다시 찍지 않아도 됩니다. 비밀번호 틀림은 로그인 실패와 함께, 코드 틀림은 코드 실패로 따로 셉니다(로그인과 같은 한도) — 너무 많으면 「요청이 너무 많습니다…」 알림.

+
+
+
+

켜는 동안 · 창을 닫으면

+
+
+ 켜기 창 아래 단추 줄 + + +
+
    +
  • [켜기]는 코드와 지금 비밀번호를 함께 확인합니다. 맞으면 창을 닫고 화면이 「켜짐」으로 바뀝니다(아래 왼쪽 — 위에 안내 상자).
  • +
  • 키는 창을 열 때마다 새로 만듭니다. 켜지 않고 닫으면 그 키는 버립니다 — 앱에 먼저 저장한 항목은 지워 주세요.
  • +
  • 켜도 다른 곳의 웹메일 로그인은 끊지 않습니다 — 끊으려면 로그인 기록에서.
  • +
  • QR · 키는 이 창에만 보이고 로그에 남기지 않습니다.
  • +
+
+
+
+ +
+ +
+
+

켜진 상태 — 켠 직후

+ 콘텐츠 열 800 · 위 안내 상자는 켠 직후에만 보이고 이 화면을 떠나면 사라집니다 +
+
+
+

2단계 인증

+

로그인할 때 비밀번호와 함께 휴대폰 인증 앱의 코드를 확인합니다. 비밀번호가 새어 나가도 휴대폰이 없으면 로그인할 수 없습니다.

+
+ +
+ +
+
+ 2단계 인증을 켰습니다 + 메일 프로그램(Outlook · 썬더버드 · 휴대폰 메일 앱)은 다시 연결할 때 앱 비밀번호가 필요합니다. +
+ 앱 비밀번호 만들기 +
+
+ +
+
+ 켜짐 — 2026년 10월 31일부터 + 로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다. +
+ +
+ +
+

휴대폰을 바꿀 때는 2단계 인증을 끈 뒤 새 휴대폰으로 다시 켜 주세요 — 끌 때 지금 휴대폰의 코드가 필요합니다.

+

휴대폰을 잃어버렸거나 인증 앱을 지웠으면 관리자에게 문의해 주세요.

+

메일 프로그램은 앱 비밀번호로 연결합니다.

+
+
+
+ +
+
+

끄기 창

+

모달 400 · 처음 초점 「취소」(Esc 도 같음)

+
+
+
+

2단계 인증을 끌까요?

+

끄면 비밀번호만으로 로그인합니다. 만들어 둔 앱 비밀번호는 그대로 남습니다.

+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + 2단계 인증을 껐습니다. +
+

틀리면 켜기 창과 같은 칸 아래 글입니다. 끄면 화면은 「꺼짐」(위 화면)으로 돌아가고, 메일 프로그램은 웹메일 비밀번호로도 다시 연결할 수 있습니다.

+
+
+ +
+ +
+
+

비밀번호 — 2단계 인증을 켠 직원

+ 지금 양식(설정 · 비밀번호)에 「인증 앱 코드」 칸을 맨 아래 더합니다 · 콘텐츠 열 800 +
+
+
+

비밀번호

+

웹메일에 로그인하는 비밀번호입니다. 2단계 인증이 켜져 있어 메일 프로그램은 앱 비밀번호로 연결합니다.

+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+

8자 이상 · 지금 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다.

+
+
+ +
+ +
+
+
+ + +

코드는 30초마다 바뀝니다 — 다른 칸을 다 쓴 뒤 넣어 주세요.

+
+
+ +
+
+ +
+ +
+ 바꾸면 이 브라우저를 뺀 다른 기기 · 브라우저의 웹메일 로그인이 끊깁니다. + 앱 비밀번호도 모두 지워집니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결해 주세요. +
+
+
+
+ +
+
+

정한 것 · 확인할 것

+
+
    +
  • 메뉴: 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 로그인 기록. 새 항목 셋의 아이콘은 방패(체크) · 열쇠 · 시계(되감기).
  • +
  • 상태는 글자로만 — 「꺼짐」(회색 굵게) · 「켜짐 — 2026년 10월 31일부터」(초록 굵게). 뱃지 · 스위치 없이 [켜기] · [끄기] 단추.
  • +
  • 켜기 창은 필터 고치기 창과 같은 폭(620), 끄기 창은 확인 창 폭(400). 키의 [복사]는 36 — 차단 풀기와 같은 밀도 예외(새 토큰 없음). 복사하면 알림 「키를 복사했습니다.」
  • +
  • 비밀번호 화면의 코드 칸은 2단계 인증을 켠 직원에게만, 맨 아래에 둡니다 — 코드가 30초마다 바뀌어 먼저 넣으면 다른 칸을 쓰는 동안 지날 수 있습니다. 틀리면 그 칸 아래 「코드가 올바르지 않습니다. …」.
  • +
  • 비밀번호를 바꾸면 앱 비밀번호도 모두 지웁니다 — 비밀번호가 샌 동안 누군가 만든 앱 비밀번호가 남지 않게.
  • +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index dc28efa..dff128e 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -38,9 +38,13 @@ "LargeFileBlock.dc.html": { "x": 1520, "y": 18920, "w": 1440, "h": 2020, "title": "본문 덩이 — 바깥 메일 프로그램에서" }, "MailReadLargeFiles.dc.html": { "x": 3040, "y": 18920, "w": 1440, "h": 1940, "title": "읽기 화면 · 대용량 첨부" }, "SharePage.dc.html": { "x": 4560, "y": 18920, "w": 1440, "h": 2130, "title": "내려받기 화면 (files.arasystem.it.kr)" }, - "SettingsLargeFiles.dc.html": { "x": 6080, "y": 18920, "w": 1440, "h": 2200, "title": "설정 · 대용량 첨부" } + "SettingsLargeFiles.dc.html": { "x": 6080, "y": 18920, "w": 1440, "h": 2200, "title": "설정 · 대용량 첨부" }, + "LoginTwoFactor.dc.html": { "x": 0, "y": 21880, "w": 1440, "h": 1990, "title": "로그인 · 2단계 인증" }, + "SettingsTwoFactor.dc.html": { "x": 1520, "y": 21880, "w": 1440, "h": 3130, "title": "설정 · 2단계 인증" }, + "SettingsAppPasswords.dc.html": { "x": 3040, "y": 21880, "w": 1440, "h": 2810, "title": "설정 · 앱 비밀번호" }, + "SettingsLoginHistory.dc.html": { "x": 4560, "y": 21880, "w": 1440, "h": 2300, "title": "설정 · 로그인 기록" } }, - "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html"], + "order": ["Main.dc.html", "Login.dc.html", "Compose.dc.html", "States.dc.html", "MainList.dc.html", "MainListRead.dc.html", "ComposeEditor.dc.html", "Settings.dc.html", "ComposeAssist.dc.html", "Search.dc.html", "BulkSelect.dc.html", "ListStates.dc.html", "SettingsAccount.dc.html", "SettingsVacation.dc.html", "MailboxManage.dc.html", "MailActions4.dc.html", "SettingsNotify.dc.html", "MailRealtime.dc.html", "SettingsFilter.dc.html", "MailFilterStates.dc.html", "MailFilterApply.dc.html", "MailFilterApplyStates.dc.html", "Contacts.dc.html", "ContactsEdit.dc.html", "ContactsMail.dc.html", "MailReadTools.dc.html", "MailPrint.dc.html", "MailSendLater.dc.html", "SettingsSendRead.dc.html", "ComposeLargeFiles.dc.html", "LargeFileBlock.dc.html", "MailReadLargeFiles.dc.html", "SharePage.dc.html", "SettingsLargeFiles.dc.html", "LoginTwoFactor.dc.html", "SettingsTwoFactor.dc.html", "SettingsAppPasswords.dc.html", "SettingsLoginHistory.dc.html"], "notes": { "flow": { "x": 0, "y": -300, "text": "arasystem 웹메일 — 1단계 화면 (로그인 → 메일함 → 쓰기)", "kind": "title1", "maxW": 4480 }, "phase2": { "x": 0, "y": 2180, "text": "2단계 화면 — 임시 저장 · 받는 사람 자동 완성 · 서명 (2026-10-27 확정)", "kind": "title1", "maxW": 2960 }, @@ -51,7 +55,8 @@ "phase7": { "x": 0, "y": 12400, "text": "7단계 화면 — 이미 받은 메일에 필터 적용 · 필터 창 나가기 확인 · 부재중 안내 — 2026-10-29 확정", "kind": "title1", "maxW": 2960 }, "phase8": { "x": 0, "y": 14280, "text": "8단계 화면 — 주소록 (내 연락처 · 그룹 · 사내 목록 · 메일에서 쓰기) — 2026-10-29 확정", "kind": "title1", "maxW": 4480 }, "phase9": { "x": 0, "y": 16520, "text": "9단계 화면 — 보내기 취소 · 예약 발송 · 외부 이미지 보기 · 인쇄 · 보낸 사람 차단 — 2026-10-29 확정", "kind": "title1", "maxW": 6000 }, - "phase11": { "x": 0, "y": 18620, "text": "11단계 화면 — 대용량 첨부 (쓰기 · 본문 덩이 · 읽기 · 내려받기 화면 · 설정) — 초안, 사용자 확인 전", "kind": "title1", "maxW": 7520 } + "phase11": { "x": 0, "y": 18620, "text": "11단계 화면 — 대용량 첨부 (쓰기 · 본문 덩이 · 읽기 · 내려받기 화면 · 설정) — 2026-10-30 확정", "kind": "title1", "maxW": 7520 }, + "phase12": { "x": 0, "y": 21580, "text": "12단계 화면 — 계정 보안(2단계 인증 · 앱 비밀번호 · 로그인 기록) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index a4c65a6..c881e2e 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -9,7 +9,7 @@ - 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }` - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` - 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001` - (11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다) + (11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를 더한다) - **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, @@ -35,6 +35,8 @@ ### `POST /api/auth/login` +> 12단계: 응답에 `status` 가 붙고, 2단계 인증을 켠 직원은 코드 단계를 거친다 — 5절 "로그인". + ```jsonc // 요청 { "email": "ara.kim@arasystem.it.kr", "password": "…" } @@ -74,6 +76,7 @@ 두 번 이상 지난 토큰이나 30초가 지난 이전 토큰은 위 규칙대로 세션을 끊는다. - 세션은 로그인 후 **최대 30일**(절대 만료)이다. 그 안에서도 리프레시 토큰을 14일 동안 쓰지 않으면 끝난다. - 액세스 토큰은 서버 조회 없이 검증한다. 그래서 세션을 끊어도 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하다. + → 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 브라우저"). ### `POST /api/auth/logout` @@ -81,6 +84,8 @@ ### `PUT /api/auth/password` — 4단계 +> 12단계: 2단계 인증을 켠 직원은 `otpCode` 를 함께 보낸다 — 5절 "비밀번호 바꾸기". + ```jsonc // 요청 (로그인 필요) { "currentPassword": "…", "newPassword": "…" } @@ -102,6 +107,7 @@ - 바뀌면 그 직원의 **다른 로그인 세션을 모두 끊는다** — 이 요청의 리프레시 쿠키(Path=`/api/auth` 라 이 요청에도 온다)가 가리키는 세션만 남긴다. 쿠키가 없거나 다른 직원 것이면 이 세션도 끊긴다(다음 갱신에서 로그인 화면으로). 끊긴 기기의 액세스 토큰은 만료(최대 1시간)까지 쓰인다(위 세션 규칙). 휴대폰 · PC 메일 프로그램(IMAP)은 새 비밀번호를 다시 넣어야 한다. + → 12단계부터: 끊긴 기기는 바로 막히고, 앱 비밀번호도 모두 지운다(5절 "비밀번호 바꾸기"). - 비밀번호는 바뀌었는데 세션 끊기가 실패하면 200 으로 답하고 오류 로그를 남긴다 — 실패로 답하면 사용자가 옛 비밀번호로 다시 시도하기 때문이다. - 비밀번호는 로그 · 응답에 남기지 않는다. 바꾼 일은 info 로그에 주소(가린 것)만 남긴다. - **클라이언트**: 같은 규칙(8자 이상 · 지금과 다름 · 확인 칸 일치)을 먼저 보고, `AUTH_005` 는 지금 비밀번호 칸 아래에 사전 문구로 보인다. 성공하면 세 칸을 비운다. @@ -2050,3 +2056,305 @@ interface FileUploadCreate { 9. **`FILE_006` 의 HTTP 상태** — 추천: 507(저장 공간을 정확히 말하고, 클라이언트의 자동 다시 시도 대상(502 · 503 · 504)과 상태로 갈린다). 대안 503. 10. **형식 검사의 MIME** — 추천: 확장자 허용 목록만(위 "올리기" 1). 보안 규칙(`.claude/rules/security.md` "확장자와 MIME 을 둘 다")의 예외라 규칙 문서에 까닭과 함께 적는다(rules 담당). 11. **인용 속 링크 상태를 묻는 길** — 추천: 주소 목록을 보내는 `POST /api/share-link-lookups`(이 계약 — 저장하지 않은 쓰기 화면의 인용에도 쓴다). 대안 — 원본 메일 id 로 서버가 본문에서 찾기(`GET /api/messages/{id}/…`) — 클라이언트가 주소를 고르지 않아도 되지만 임시 저장본 · 고친 인용은 따로 다뤄야 한다. + +--- + +## 5. 계정 보안 — 12단계 (2026-10-31 확정) + +> 2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록. 근거는 Stalwart 0.16.27 개발 엔진 실험(2026-10-31 — 엔진 TOTP · 앱 비밀번호 · 웹훅 · 자동 차단). +> - 엔진이 하는 것: 코드 확인(앞뒤 30초까지), 2단계를 켠 계정의 비밀번호만 하는 IMAP · SMTP 로그인 막기, 앱 비밀번호 만들기(값은 엔진이 정한다), 로그인 이벤트 보내기(웹훅). +> - 서버가 하는 것(엔진이 하지 않는 것): 등록 때 첫 코드 확인, 같은 코드 다시 쓰기 막기, 코드 실패 한도, 웹메일 로그인에서 앱 비밀번호 거절, 로그인 기록 저장 · 보존. +> - 웹(브라우저)만이다. 모바일 앱은 앱 착수 때 이 절에 더한다. + +### 공유 계약을 바꾸는 곳 (한데 모음) + +- `POST /api/auth/login`: 요청에 `otpCode`(선택), 응답에 `status`(`signedIn` · `twoFactorRequired`). `twoFactorRequired` 는 200 이고 쿠키가 없다 — `email` · `name` 은 `null`. +- `PUT /api/auth/password`: 요청에 `otpCode` — 2단계 인증을 켠 직원만 필수. +- 새 API: `/api/two-factor` · `/api/two-factor/enrollments` · `/api/two-factor/activation` · `/api/two-factor/deactivation` · `/api/app-passwords` · `/api/auth/sessions` · `/api/login-events`. +- 엔진만 부르는 길 `POST /api/internal/mail-engine-events` — 앞단 nginx 가 막는다(404 `RES_001`). +- 에러 코드 `AUTH_006` ~ `AUTH_009`. +- 루트 env `MAIL_WEBHOOK_SECRET`(setup · backend 공용 — 운영 필수). + +### 한도 · 규칙 + +| 항목 | 값 | +| --- | --- | +| 코드 | 숫자 6자리 · 30초 · SHA-1(인증 앱 기본값). 엔진은 앞뒤 한 단계(±30초)까지 받는다 | +| 같은 코드 다시 쓰기 | 같은 직원이 성공에 쓴 코드는 2분 동안 다시 받지 않는다(코드가 받아지는 90초보다 길게 — 로그인 · 설정 모두) → `AUTH_006` | +| 코드 실패 | 같은 주소 15분 5회 · 24시간 20회. 같은 IP 는 로그인 실패 한도(1분 20회)에 함께 센다 → 429 `REQ_001` | +| 등록 시간 | 등록을 시작하고 10분 안에 켠다. 직원마다 기다리는 등록은 하나다(새로 시작하면 앞의 것은 버린다) | +| 앱 비밀번호 | 엔진 상한(기본 5개 — `x:Authentication.maxAppPasswords`). 이름 1~40자(앞뒤 공백을 지운 값, JS 문자열 길이). 권한은 메일 프로그램만 | +| 로그인 기록 | 90일 보존(날마다 정리) | +| 로그인된 브라우저 | 끝나지 않은 웹메일 세션 — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일) | + +### 로그인 (`POST /api/auth/login`) — 1절에 더하는 것 + +```jsonc +// 1) 비밀번호 +{ "email": "ara.kim@arasystem.it.kr", "password": "…" } +// 2단계 인증을 켜지 않은 직원 → 200 + Set-Cookie (지금과 같다) +{ "status": "signedIn", "email": "ara.kim@arasystem.it.kr", "name": "김아라" } +// 켠 직원이고 비밀번호가 맞음 → 200, 쿠키 없음 +{ "status": "twoFactorRequired", "email": null, "name": null } + +// 2) 코드 — 비밀번호를 한 번 더 싣는다(서버는 비밀번호를 들고 있지 않는다) +{ "email": "ara.kim@arasystem.it.kr", "password": "…", "otpCode": "123456" } +// → 200 + Set-Cookie { "status": "signedIn", "email": "…", "name": "…" } +``` + +| 실패 | 코드 | +| --- | --- | +| 비밀번호 틀림(코드 단계에서도 — 그사이 바뀜) · **앱 비밀번호 모양**(`app` + 1자 + 36자 · 40자 — 엔진에 묻지 않고 실패로 센다) · 그 밖의 1절 규칙 | 401 `AUTH_004` | +| 코드 틀림 · 2분 안에 다시 쓴 코드 | 400 `AUTH_006` — 401 이 아닌 까닭은 `AUTH_005` 와 같다(같은 코드를 로그인한 채 쓰는 설정에서도 쓴다) | +| `otpCode` 가 숫자 6자리가 아님 | 400 `VAL_001` | +| 코드 실패 한도 · 1절의 한도 | 429 `REQ_001` | + +- 서버의 차례 + 1. 앱 비밀번호 모양이면 거절한다. + 2. 1절처럼 실패 한 번을 차지하고 엔진에 본인 Basic 으로 묻는다(지금의 `verifyCredentials`). + 3. 200 → 2단계 인증이 꺼진 직원이다. 로그인한다(코드가 와도 보지 않는다). + 4. **402**(비밀번호는 맞고 엔진이 2단계를 요구) → 차지한 비밀번호 실패를 돌려준다. `otpCode` 가 없으면 `twoFactorRequired` 로 답한다. + 있으면: 다시 쓴 코드인지 보고 → 코드 실패 한 번을 차지하고 → 엔진 `POST /api/auth`(`accountSecret` + `mfaToken`)에 묻는다. + `authenticated` 면 로그인한다(차지한 것을 돌려주고 코드를 2분 기억). `failure` 면 `AUTH_006`. + 5. 401 → `AUTH_004`. +- **지금은 402 를 엔진 장애(503 `MAIL_001`)로 다룬다 — 고친다.** 운영자가 엔진 관리 화면에서 OTP 를 켠 직원은 지금 웹메일에 들어오지 못한다. 4단계 비밀번호 바꾸기도 같다. +- 코드 단계에서도 비밀번호를 엔진 Basic 으로 먼저 보는 까닭: `/api/auth` 의 `failure` 는 비밀번호 틀림과 코드 틀림을 가르지 않는다. 비밀번호를 추측하는 요청이 코드 실패로 세지지 않게 한다. +- `twoFactorRequired` 는 비밀번호가 맞았다는 뜻이다(2단계 로그인의 성질 — 코드 없이는 들어오지 못한다). 비밀번호 추측은 1절의 실패 한도가 그대로 막는다. +- 엔진 `/api/auth` 가 성공하면 엔진 메모리에 1회용 OAuth 코드가 남는다(쓰지 않으면 10분 뒤 사라진다) — 쓰지 않는다. + `redirectUri` 는 https 주소여야 한다(`https:///`). `clientId` 는 고정 이름(`arasystem-webmail`)이다. +- **클라이언트** + - 코드 단계는 비밀번호를 화면 메모리에만 둔다. 성공 · [다른 계정으로 로그인] · 화면을 떠날 때 지운다. + 코드 단계에 10분 넘게 머물면 비밀번호를 지우고 첫 단계로 돌아간다(새로고침도 첫 단계). + - 코드 칸은 `inputmode="numeric"` · `autocomplete="one-time-code"` 이다. 붙여 넣은 글에서 숫자만 남긴다(공백 · `-`). + - `AUTH_006` 은 칸 아래에 보이고(알림 없음) 칸을 비운다. `AUTH_004` 는 첫 단계로 돌아가 비밀번호 칸 아래에 보인다. + +### 비밀번호 바꾸기 (`PUT /api/auth/password`) — 4단계에 더하는 것 + +```jsonc +{ "currentPassword": "…", "newPassword": "…", "otpCode": "123456" } // otpCode — 2단계 인증을 켠 직원만 +``` + +- 비밀번호를 먼저 본인 Basic 으로 확인한다 — 엔진은 지금 비밀번호 틀림과 코드 틀림을 같은 `forbidden` 으로 거절하기 때문이다. + - 200(2단계 꺼짐) → 지금처럼 본인 자격으로 바꾼다. + - 402(2단계 켜짐, 비밀번호 맞음) → 게이트웨이 대리 접근으로 `x:AccountPassword/set`(`currentSecret` + `secret` + `otpAuth.otpCode`)을 보낸다. 2단계 인증은 그대로 남는다. +- `otpCode` 가 없으면 400 `AUTH_007`(다른 탭에서 켠 경우 — 화면이 코드 칸을 보인다). 틀리거나 다시 쓴 코드면 400 `AUTH_006`(코드 실패로 센다). +- 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 `AUTH_005` 다(세지 않는다 — 4단계 규칙과 같다). +- **바뀌면 그 직원의 앱 비밀번호를 모두 지운다**(2단계 인증과 상관없이 — 대리 접근 `x:AppPassword/set` 의 destroy, Google 과 같다). + - 비밀번호가 샌 동안 누군가 웹메일에서 만든 앱 비밀번호가 남지 않게 한다. + - 지우기에 실패해도 200 이고 오류 로그를 남긴다. 결과를 모르는 실패(503)에도 지운다 — 다른 세션을 끊는 규칙과 같다. + - 화면이 미리 안내한다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). +- 구현 때 확인: 2단계를 켠 채 대리 접근으로 바꾸기의 성공 경로(조사 때는 실패 경로만 돌렸다), 바꾼 뒤 `otpUrl` 이 남는지. + +### 2단계 인증 (`/api/two-factor`) + +```jsonc +// GET /api/two-factor → 200 +{ "isEnabled": true, "enabledAt": "2026-10-31T00:12:00.000Z" } // enabledAt — 웹메일에서 켠 때. 엔진 관리 화면에서 켰으면 null + +// POST /api/two-factor/enrollments → 201 (꺼져 있을 때만, Cache-Control: no-store) +{ "enrollmentId": "…", "secret": "JBSWY3DPEHPK3PXP…(32자)", + "otpauthUrl": "otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30", + "expiresAt": "2026-10-31T00:22:00.000Z" } + +// POST /api/two-factor/activation → 200 (GET 과 같은 모양) +{ "enrollmentId": "…", "otpCode": "123456", "currentPassword": "…" } + +// POST /api/two-factor/deactivation → 200 (GET 과 같은 모양) +{ "currentPassword": "…", "otpCode": "123456" } +``` + +- **엔진이 기준이다.** 켜짐은 엔진 `x:AccountPassword/get` 에 `otpAuth.otpUrl` 이 있는지로 본다(대리 접근). 서버는 켠 때(`enabledAt`)만 둔다 — 엔진에서 꺼졌으면 지운다. +- 비밀(`secret`): 서버가 만든 160비트를 base32(RFC 4648, 채움 없음 — 32자)로 적는다. 등록을 기다리는 동안(10분)만 서버 메모리에 둔다. + 켠 뒤에는 엔진에만 있다 — 서버 · DB · 로그 어디에도 두지 않는다. +- QR 은 화면이 `otpauthUrl` 로 그린다. 새 프론트 의존성이 든다 — 작은 MIT QR 부호기 하나, `v-html` 없이 SVG 로 그린다(고르는 것은 구현 때). +- 켜기 + 1. 받은 코드를 등록의 비밀로 먼저 확인한다(±1 단계). 엔진은 등록 때 코드를 보지 않는다 — 잘못 등록하면 직원이 끄지도 못한다. + 2. 맞으면 대리 접근으로 `currentSecret` + `otpAuth.otpUrl` 을 보낸다. + 3. 지금 비밀번호가 틀리면 `AUTH_005` 다(로그인 실패 한도로 센다 — 비밀번호 바꾸기와 같다). 켠 코드는 2분 기억한다. +- 끄기: 비밀번호를 본인 Basic 으로 먼저 확인(402) → 다시 쓴 코드인지 확인 → 대리 접근으로 `currentSecret` + `otpAuth: { otpCode, otpUrl: null }`. 이미 꺼져 있으면 200 이다(같은 결과). +- 켜고 끄는 일은 웹메일 세션을 끊지 않는다 — 로그인 기록 화면에 [다른 곳 모두 로그아웃]이 따로 있다. 이미 열린 IMAP 연결은 다시 연결할 때까지 남는다(알려진 한계). +- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`. +- 휴대폰 바꾸기: 끈 뒤 다시 켠다(켜진 채 다시 등록하기는 처음 판에 없다). +- 휴대폰을 잃었으면 운영자가 엔진 관리 화면에서 그 직원의 OTP 를 지운다(직원 비밀번호 없이 된다 — 절차는 mail/README). + +| 실패 | 코드 | +| --- | --- | +| 코드 틀림 · 다시 쓴 코드 | 400 `AUTH_006` | +| 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` | +| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(등록 · 켜기) | 409 `AUTH_008` — 화면은 처음부터 다시 | +| 형식 오류 | 400 `VAL_001` | +| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` | +| 웹메일로 다루지 않는 계정 | 401 `AUTH_001` | +| 엔진 장애 · 결과를 모르는 실패 | 503 `MAIL_001` — 켜기 · 끄기의 결과를 모르면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로) | + +### 앱 비밀번호 (`/api/app-passwords`) + +```jsonc +// GET /api/app-passwords → 200 (한 쪽 — 엔진 상한이 작다) +{ "items": [ { "id": "b", "name": "회사 PC 아웃룩", "createdAt": "2026-10-31T00:20:00.000Z", + "lastUsed": { "at": "2026-10-31T00:41:00.000Z", "method": "imap", "ip": "211.186.95.252" } } ], + "pageInfo": { "hasNext": false, "nextCursor": null, "total": 1 } } + +// POST /api/app-passwords → 201 (Cache-Control: no-store) — password 는 이 응답에만 +{ "name": "회사 PC 아웃룩" } +→ { "id": "c", "name": "회사 PC 아웃룩", "createdAt": "…", "lastUsed": null, "password": "app_…(40자)" } + +// DELETE /api/app-passwords/{id} → 200 (data: null) — 이미 없으면 200 +``` + +- 만들기는 대리 접근 `x:AppPassword/set` 의 create 다: + `{ description: 이름, permissions: { "@type": "Replace", "permissions": { "authenticate": true, "imapAuthenticate": true, "emailSend": true } } }`. + 기본(`Inherit`)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만들 수 있다 — 그래서 늘 좁힌다. 만료 · 허용 IP 는 두지 않는다. +- 값(`password`)은 엔진이 만들어 만들 때 한 번만 준다. 서버는 응답에 실어 넘기기만 한다(로그 · DB · 캐시 없음). +- 상한을 넘으면 엔진이 `overQuota` 로 거절한다 → 409 `AUTH_009`. 이름이 비었거나 40자를 넘거나 이미 있는 이름(앞뒤 공백을 지운 값이 같음)이면 400 `VAL_001` — 화면이 먼저 보고 칸 아래에 알린다. +- 목록은 최근에 만든 것이 위다. + 화면의 "n / 5개"는 엔진 기본값 5 를 상수로 쓴다 — 엔진 상한을 바꾸면 화면 상수도 함께 바꾼다. +- 2단계 인증을 켜지 않은 직원도 만든다(기기마다 따로 끊기 편하다). + - 쓰는 곳: IMAP 993 · SMTP 465(사용자 이름 = 메일 주소). + - 쓰이지 않는 곳: 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 끄기. + - 웹메일 비밀번호를 바꾸면 모두 지워진다(위 "비밀번호 바꾸기"). +- 이름 바꾸기 · 만료 · 허용 IP 는 처음 판에 없다. +- **마지막 사용**: 엔진에 칸이 없어 서버가 웹훅의 `auth.success`(앱 비밀번호 — `id` 가 자격 번호)로 만든다. + 엔진은 지운 번호를 다시 쓴다 — 서버 기록은 같은 번호 · 같은 `createdAt` 일 때만 잇는다(만들 때 서버가 기록을 새로 둔다). +- 지운 앱 비밀번호로 이미 열린 IMAP 연결은 엔진이 끊지 않는다(알려진 한계 — 다음 연결부터 막힌다). + +### 로그인된 브라우저 (`/api/auth/sessions`) + +경로가 `/api/auth` 아래인 까닭: 리프레시 쿠키(Path=`/api/auth`)로 이 요청의 세션을 안다(비밀번호 바꾸기와 같다). + +```jsonc +// GET /api/auth/sessions → 200 (지금 세션이 맨 앞, 그다음 최근 활동 차례) +{ "items": [ { "id": "…", "createdAt": "…", "lastActiveAt": "…", "ip": "211.186.95.252", + "device": { "browser": "Chrome", "os": "Windows" }, "isCurrent": true } ], + "pageInfo": { "hasNext": false, "nextCursor": null, "total": 3 } } + +// DELETE /api/auth/sessions/{id} → 200 (data: null) +// 지금 세션 → 400 VAL_001 (로그아웃을 쓴다) · 남의 것 · 없는 것 · 끝난 것 → 404 RES_001 +// DELETE /api/auth/sessions → 200 { "ended": 2 } — 이 요청의 세션만 남기고 모두 끊는다 +``` + +- `lastActiveAt` 은 마지막 갱신(회전) 때다 — 없으면 로그인 때. +- `ip` · `device` 는 로그인 · 갱신 때 서버가 둔다. + - IP 는 `TRUST_PROXY` 기준 클라이언트 IP 다. + - 브라우저는 서버가 User-Agent 를 짧은 이름(`Edge` · `Chrome` · `Firefox` · `Safari` · `Whale` · `Samsung Internet` / `Windows` · `macOS` · `iOS` · `Android` · `Linux` · `ChromeOS`)으로 바꾼다 — 서버 순수 함수 하나, 모르면 `null`. + - 12단계 전에 연 세션은 둘 다 `null` 이다. +- **끊은 세션은 바로 막는다.** 액세스 토큰에 세션 id(`sid`)를 싣는다. 서버는 끊은 세션 id 를 1시간(액세스 토큰의 가장 긴 수명) 기억하고, 가드가 그 토큰을 401 `AUTH_001` 로 막는다 — DB 를 묻지 않는다. + - 끊긴 브라우저는 다음 요청에서 갱신이 실패해(세션이 끊김) 로그인 화면으로 간다. 열린 실시간 알림 연결은 다음 ping(25초) 때 끝낸다. + - 로그아웃 · 비밀번호 바꾸기 · 이미 쓴 리프레시 토큰 감지로 세션을 끊을 때도 같다 — 1절의 "끊어도 만료까지 유효"를 바꾼다. + - 서버가 다시 뜨면 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다. 12단계 전에 발급한 토큰(`sid` 없음)은 지금처럼 만료까지 쓰인다. +- 리프레시 쿠키가 없거나 남의 것이면 `DELETE /api/auth/sessions` 는 모두 끊는다(비밀번호 바꾸기와 같다). 그때 `isCurrent` 는 모두 false 다. +- 페이지: 기본 20 · 최대 100(`limit`), 위치 커서(`shared/utils/offset-cursor.ts`). + +### 로그인 기록 (`GET /api/login-events`) + +```jsonc +// GET /api/login-events?limit=20&cursor=…&isSuccess=false → 200 (최근 차례) +{ "items": [ + { "id": "…", "occurredAt": "…", "method": "webmail", "isSuccess": true, "isTwoFactor": true, "failureReason": null, + "ip": "211.186.95.252", "device": { "browser": "Edge", "os": "Windows" }, "appPassword": null }, + { "id": "…", "occurredAt": "…", "method": "imap", "isSuccess": true, "isTwoFactor": false, "failureReason": null, + "ip": "…", "device": null, "appPassword": { "id": "b", "name": "회사 PC 아웃룩" } }, + { "id": "…", "occurredAt": "…", "method": "smtp", "isSuccess": false, "isTwoFactor": false, "failureReason": "appPasswordRequired", + "ip": "…", "device": null, "appPassword": null } ], + "pageInfo": { "hasNext": true, "nextCursor": "…", "total": 143 } } +``` + +| 칸 | 값 | +| --- | --- | +| `method` | `webmail` · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기) | +| `isTwoFactor` | 웹메일 성공이 코드까지 거쳤는지 | +| `failureReason` | `wrongPassword`(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · `wrongCode`(코드 틀림 · 다시 쓴 코드) · `appPasswordRequired`(2단계를 켠 계정에 메일 프로그램이 비밀번호로 — 엔진 `auth.mfa-required`) | +| `appPassword` | 메일 프로그램이 앱 비밀번호로 들어온 경우. `name` 은 그때의 이름이다(지운 뒤에도 남는다) | + +- 남기는 것 + - 웹메일: 서버가 판정한 로그인 시도(성공 · `AUTH_004` · `AUTH_006`). 한도로 막은 요청(429) · 사내 도메인이 아닌 주소 · 막은 계정은 남기지 않는다. + - 메일 프로그램: 엔진 웹훅(아래)의 IMAP · SMTP 이벤트. +- 남기지 않는 것: 갱신(`/auth/refresh`), 게이트웨이 대리 접근, 엔진 HTTP 리스너의 이벤트(웹메일이 백엔드 IP 로 남기는 것 — 서버 기록과 겹친다). +- 90일 보존, 커서 페이지(기본 20 · 최대 100). `isSuccess=false` 면 실패만 준다. +- 비밀번호 · 코드 · 앱 비밀번호 값은 기록에 없다. User-Agent 원문은 512자까지 둔다(화면에는 짧은 이름만). + +### 엔진 웹훅 받기 (`POST /api/internal/mail-engine-events`) + +- 엔진만 부른다. 세 겹으로 막는다: + 1. 앞단 nginx 가 `/api/internal/` 을 404 `RES_001` 로 닫는다(두 server 모두). + 2. 서버는 접속 주소(`X-Forwarded-For` 가 아니라 소켓)가 앱 네트워크(`APP_NETWORK_SUBNET`)인지 본다. + 3. 본문 원문의 HMAC-SHA256(`X-Signature`, base64)이 `MAIL_WEBHOOK_SECRET` 으로 맞는지 본다(상수 시간 비교). + 하나라도 아니면 401 `AUTH_001` 이다 — 본문을 쓰지 않고, warn 로그에 접속 주소만 남긴다. +- 본문은 `{"events":[{ "id", "createdAt", "type", "data": {…} }]}` 이다. 서버가 쓰는 것: + - `type`: `auth.success` · `auth.failed` · `auth.mfa-required` + - `data.listenerId`: `imap…` → imap, `submission…` · `smtp` → smtp. 그 밖(`http` 등)은 버린다. + - `data.remoteIp` · `data.accountName`(주소, 앱 비밀번호면 로컬 이름) · `data.details` + - `data.id`: 앱 비밀번호 번호. `details` 가 앱 비밀번호 표시일 때만 쓴다 — IMAP 실패의 `id` 는 명령 태그다. +- 사내 도메인의 직원만 남긴다. 사내 목록에 없는 주소의 실패와 막은 계정은 버린다(엔진은 서버 전체의 이벤트를 보낸다). +- 엔진 이벤트 `id` 로 겹침을 거른다(엔진이 다시 보낸 묶음). 곧바로 200 으로 답한다(엔진이 5초 기다린다). +- 이 경로만 다르게 둔다 + - 요청 수 한도가 없다(`@SkipThrottle`). 출처 검사는 `Origin` 이 없어 통과한다. + - JSON 한도를 따로 둔다(본문 4MB · 배열 하나 10,000 · 값 200,000) — 공격이 오면 1초 묶음이 커진다. +- 엔진의 대기열은 메모리에 있다. 서버가 5분 넘게 받지 못하거나 엔진이 다시 뜨면 그동안의 메일 프로그램 기록은 빠진다(알려진 한계). + +### 엔진 설정 (setup — release-ops) + +- `x:WebHook` 하나를 둔다(setup 표시로 찾는다). + - url `http://backend:/api/internal/mail-engine-events`, `signatureKey` = `MAIL_WEBHOOK_SECRET` + - `eventsPolicy: include` + `auth.success` · `auth.failed` · `auth.mfa-required` — 기본(`exclude` + 빈 목록)은 모든 이벤트를 보낸다. + - `level: trace` — 이 판은 웹훅에 수준을 쓰지 않지만 새 판 문서는 쓴다(기본 info 면 실패 이벤트가 빠진다). + - throttle 1000ms · timeout 5000ms · discardAfter 300000ms · lossy false. 바꾼 뒤 리로드한다. +- `MAIL_WEBHOOK_SECRET` 이 비면(개발) 웹훅을 만들지 않고 `[주의]` 를 낸다(있던 것은 끈다). 운영은 비면 setup 이 멈춘다. +- 엔진 `/api/auth` 는 백엔드가 앱 네트워크로(`MAIL_JMAP_URL` 의 `/api/auth`) 묻는다. 공개 443 은 지금처럼 403 이다. +- 앱 비밀번호 상한(`maxAppPasswords`)은 엔진 기본 5 그대로 둔다. + +### 환경 변수 + +- `MAIL_WEBHOOK_SECRET` — 루트 env. 영문 · 숫자 32자 이상(Node `--env-file` 이 `#` 부터 자른다). +- setup 과 backend(compose `environment`)가 읽는다. 운영 필수이고 예시 값은 거부한다(`env.validation.ts`). +- 바꾸면 setup 을 다시 돌리고 backend 를 다시 띄운다. + +### 로그 + +- 코드 · 비밀(`secret` · `otpauthUrl`) · 앱 비밀번호 값 · 비밀번호는 어떤 수준에도 남기지 않는다. 앱 비밀번호 이름(사용자 글)도 남기지 않는다(id 만). +- info: 2단계 켜기 · 끄기, 앱 비밀번호 만들기 · 지우기, 다른 세션 끊기 — 가린 주소 + id. +- warn: 코드 틀림, 서명 · 주소가 맞지 않는 웹훅. +- 웹훅 수신은 남기지 않는다(반복되는 정상 흐름). 버린 수 · 남긴 수는 debug. +- 엔진 오류의 설명 문장은 남기지 않는다(지금 규칙). + +### 에러 코드 (12단계에 더함 — 제안, 문구는 레지스트리에서 정한다) + +| 코드 | HTTP | 상황 | 문구(제안) | +| --- | --- | --- | --- | +| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요." | +| `AUTH_007` | 400 | 2단계 인증을 켠 직원의 요청에 코드가 없음(다른 곳에서 켠 뒤의 화면) | "인증 앱의 코드를 입력해 주세요." | +| `AUTH_008` | 409 | 등록이 없음 · 시간이 지남 · 그사이 다른 곳에서 바뀜 | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." | +| `AUTH_009` | 409 | 앱 비밀번호 상한 | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 | + +웹이 분기하는 코드: 넷 모두. + +### 확장 때 옮길 것 (nestjs 스킬의 Redis 목록에 더한다) + +기다리는 등록(비밀 10분), 다시 쓴 코드 기억(2분), 코드 실패 카운터, 같은 직원의 2단계 바꾸기 잠금, 끊은 세션 id 기억(1시간) — 모두 프로세스 메모리다. 인스턴스를 늘리면 공유 저장소로 옮긴다. + +### 운영 확인 + +- setup 뒤 `[확인]` 줄에 웹훅이 켜졌는지 본다. 메일 프로그램으로 한 번 로그인해 로그인 기록에 IMAP 줄이 생기는지 본다. +- 휴대폰을 잃은 직원: 엔진 관리 화면 › 그 계정 › 자격 증명(Password)의 OTP 를 지운다(mail/README 에 절차 — 구현 때 실물로 확인). +- 직원에게 알릴 것: 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다. + +### 알려진 한계 + +- IMAP · SMTP 에서 2단계를 켠 계정에 맞는 비밀번호를 넣으면 "2단계 필요"가 나와 비밀번호가 맞았는지 알 수 있다. 엔진 자동 차단은 틀린 시도만 센다(들어오지는 못한다). +- 2단계를 켜거나 앱 비밀번호를 지워도 이미 열린 IMAP 연결은 엔진이 끊지 않는다(지운 앱 비밀번호의 연결은 다음 명령에서 멈춘다). +- 웹훅이 잃는 기록이 있다(엔진 재시작 · 서버가 5분 넘게 받지 못함). +- 엔진 관리 화면에서 켠 2단계 인증은 `enabledAt` 이 없다. +- 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다. + +### 이 단계에서 하지 않는 것 + +복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기, +앱 비밀번호 이름 바꾸기 · 만료 · 허용 IP, 새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 메일 프로그램의 OAuth 로그인, 모바일 앱. + +### 정한 것 (2026-10-31 추천대로) + +1. 인증 앱에 보일 이름은 "ARA SYSTEM" 이다(앱에는 「ARA SYSTEM (주소)」). 엔진이 받는지 구현 때 확인한다 — 안 되면 "ARASYSTEM". +2. 코드 실패 한도는 같은 주소 15분 5회 · 24시간 20회다(위 "한도 · 규칙"). 비밀번호를 아는 공격자가 1년 내내 시도해도 맞힐 확률이 약 2% 다. +3. 2단계를 켜거나 꺼도 다른 세션을 끊지 않는다 — 로그인 기록 화면에서 따로 끊는다. +4. 비밀번호를 바꾸면 앱 비밀번호도 모두 지운다(위 "비밀번호 바꾸기"). +5. 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 브라우저"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다. -- 2.52.0 From f9e3b1c57a90aba1a0a260485d354065bf46004f Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 17:17:27 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(backend):=2012=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=B3=B4=EC=95=88=20=E2=80=94=202?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=C2=B7=202FA?= =?UTF-8?q?=20=C2=B7=20=EC=95=B1=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=C2=B7=20=EC=84=B8=EC=85=98=20=EB=81=8A=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EC=9B=B9=ED=9B=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔진(Stalwart 0.16.27)의 TOTP 와 앱 비밀번호를 쓰되, 엔진이 하지 않는 것을 서버가 맡는다. - 등록 때 첫 코드 확인, 성공에 쓴 코드 2분 막기(확인 중인 같은 코드도), 코드 실패 한도(15분 5 · 24시간 20) - 웹메일 로그인 · 비밀번호 바꾸기에서 앱 비밀번호 모양 거절, 앱 비밀번호는 메일 프로그램 권한만 - 엔진 Basic 의 402 를 장애(MAIL_001)로 다루던 것을 고쳐 코드 단계로 — 402 면 정식 주소로 한도 · 기록 - 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 액세스 토큰의 sid 로 바로 막는다 - 메일 프로그램 로그인은 엔진 웹훅(소켓 주소 · HMAC · 내용 지문 · 1,000행씩)으로 받아 90일 남긴다 - 요청 대상에 날 \ · # 가 있으면 라우팅 전에 400 — Express 가 이를 다시 읽어 앞단이 닫은 경로가 열렸다 에러 코드 AUTH_006 ~ AUTH_009 와 웹 · 앱 사전, 마이그레이션 AddAccountSecurity 를 함께 넣었다. Co-Authored-By: Claude Opus 5.5 --- backend/src/app.module.ts | 3 + backend/src/app.setup.ts | 31 +- .../decorators/current-user.decorator.ts | 5 + backend/src/common/errors/error-codes.ts | 24 + .../request-target.middleware.spec.ts | 136 +++ .../middleware/request-target.middleware.ts | 37 + .../1793415011070-AddAccountSecurity.ts | 65 ++ .../account-security.constants.ts | 64 ++ .../account-security.module.ts | 102 ++ .../account-security.provider.ts | 81 ++ .../account-security.settings.spec.ts | 40 + .../account-security.settings.ts | 48 + .../app-password-usage.entity.ts | 65 ++ .../account-security/app-password.spec.ts | 53 ++ .../modules/account-security/app-password.ts | 42 + .../app-passwords.controller.ts | 127 +++ .../app-passwords.service.spec.ts | 316 ++++++ .../account-security/app-passwords.service.ts | 300 ++++++ .../account-security/dto/app-password.dto.ts | 107 +++ .../account-security/dto/login-event.dto.ts | 140 +++ .../account-security/dto/two-factor.dto.ts | 103 ++ .../login-event-cursor.spec.ts | 51 + .../account-security/login-event-cursor.ts | 84 ++ .../account-security/login-event.entity.ts | 110 +++ .../account-security/login-events.cleanup.ts | 34 + .../login-events.controller.ts | 70 ++ .../login-events.service.spec.ts | 186 ++++ .../account-security/login-events.service.ts | 215 +++++ .../mail-engine-event.spec.ts | 237 +++++ .../account-security/mail-engine-event.ts | 177 ++++ .../mail-engine-events.controller.ts | 75 ++ .../mail-engine-events.guard.ts | 27 + .../mail-engine-events.parser.spec.ts | 284 ++++++ .../mail-engine-events.parser.ts | 240 +++++ .../mail-engine-events.service.spec.ts | 219 +++++ .../mail-engine-events.service.ts | 128 +++ .../two-factor-code-guard.spec.ts | 288 ++++++ .../account-security/two-factor-code-guard.ts | 156 +++ .../two-factor-enrollments.ts | 71 ++ .../account-security/two-factor-locks.ts | 44 + .../two-factor-setting.entity.ts | 22 + .../account-security/two-factor.controller.ts | 170 ++++ .../two-factor.service.spec.ts | 780 +++++++++++++++ .../account-security/two-factor.service.ts | 418 ++++++++ .../src/modules/auth/auth-session.entity.ts | 27 +- .../src/modules/auth/auth-session.module.ts | 17 + .../modules/auth/auth-session.repository.ts | 103 +- .../modules/auth/auth-sessions.controller.ts | 119 +++ .../auth/auth-sessions.service.spec.ts | 169 ++++ .../src/modules/auth/auth-sessions.service.ts | 166 ++++ backend/src/modules/auth/auth-token.types.ts | 6 +- backend/src/modules/auth/auth.constants.ts | 17 + .../src/modules/auth/auth.controller.spec.ts | 87 +- backend/src/modules/auth/auth.controller.ts | 51 +- backend/src/modules/auth/auth.module.ts | 21 +- backend/src/modules/auth/auth.service.spec.ts | 603 +++++++++++- backend/src/modules/auth/auth.service.ts | 429 +++++++-- .../src/modules/auth/dto/auth-session.dto.ts | 72 ++ .../modules/auth/dto/change-password.dto.ts | 13 +- .../src/modules/auth/dto/login-result.dto.ts | 31 + backend/src/modules/auth/dto/login.dto.ts | 16 +- .../modules/auth/dto/security-fields.spec.ts | 86 ++ .../src/modules/auth/dto/security-fields.ts | 46 + backend/src/modules/auth/jwt.strategy.spec.ts | 82 +- backend/src/modules/auth/jwt.strategy.ts | 17 +- .../src/modules/auth/login-attempt.module.ts | 13 + .../auth/login-attempt.service.spec.ts | 109 +++ .../src/modules/auth/login-attempt.service.ts | 116 ++- .../src/modules/auth/revoked-sessions.spec.ts | 65 ++ backend/src/modules/auth/revoked-sessions.ts | 91 ++ .../jmap-account-security.provider.spec.ts | 389 ++++++++ .../jmap/jmap-account-security.provider.ts | 360 +++++++ .../modules/mail/jmap/jmap-mail.provider.ts | 28 +- .../src/modules/mail/jmap/jmap.client.spec.ts | 310 +++++- backend/src/modules/mail/jmap/jmap.client.ts | 316 +++++- backend/src/modules/mail/jmap/jmap.types.ts | 2 + .../modules/mail/mail-events.controller.ts | 1 + .../modules/mail/mail-events.service.spec.ts | 50 +- .../src/modules/mail/mail-events.service.ts | 48 +- backend/src/modules/mail/mail.module.ts | 3 + backend/src/modules/mail/mail.provider.ts | 30 +- backend/src/modules/mail/mail.service.spec.ts | 1 + backend/src/modules/mail/mail.types.ts | 27 +- .../src/shared/config/env.validation.spec.ts | 83 ++ backend/src/shared/config/env.validation.ts | 34 + backend/src/shared/utils/cidr.spec.ts | 57 ++ backend/src/shared/utils/cidr.ts | 63 ++ .../src/shared/utils/request-target.spec.ts | 35 + backend/src/shared/utils/request-target.ts | 31 + backend/src/shared/utils/totp.spec.ts | 135 +++ backend/src/shared/utils/totp.ts | 124 +++ backend/src/shared/utils/user-agent.spec.ts | 116 +++ backend/src/shared/utils/user-agent.ts | 101 ++ backend/test/app.e2e-spec.ts | 899 +++++++++++++++++- flutter/lib/core/api/error_lexicon.g.dart | 4 + .../shared/constants/error-codes.generated.ts | 4 + 96 files changed, 11588 insertions(+), 210 deletions(-) create mode 100644 backend/src/common/middleware/request-target.middleware.spec.ts create mode 100644 backend/src/common/middleware/request-target.middleware.ts create mode 100644 backend/src/migrations/1793415011070-AddAccountSecurity.ts create mode 100644 backend/src/modules/account-security/account-security.constants.ts create mode 100644 backend/src/modules/account-security/account-security.module.ts create mode 100644 backend/src/modules/account-security/account-security.provider.ts create mode 100644 backend/src/modules/account-security/account-security.settings.spec.ts create mode 100644 backend/src/modules/account-security/account-security.settings.ts create mode 100644 backend/src/modules/account-security/app-password-usage.entity.ts create mode 100644 backend/src/modules/account-security/app-password.spec.ts create mode 100644 backend/src/modules/account-security/app-password.ts create mode 100644 backend/src/modules/account-security/app-passwords.controller.ts create mode 100644 backend/src/modules/account-security/app-passwords.service.spec.ts create mode 100644 backend/src/modules/account-security/app-passwords.service.ts create mode 100644 backend/src/modules/account-security/dto/app-password.dto.ts create mode 100644 backend/src/modules/account-security/dto/login-event.dto.ts create mode 100644 backend/src/modules/account-security/dto/two-factor.dto.ts create mode 100644 backend/src/modules/account-security/login-event-cursor.spec.ts create mode 100644 backend/src/modules/account-security/login-event-cursor.ts create mode 100644 backend/src/modules/account-security/login-event.entity.ts create mode 100644 backend/src/modules/account-security/login-events.cleanup.ts create mode 100644 backend/src/modules/account-security/login-events.controller.ts create mode 100644 backend/src/modules/account-security/login-events.service.spec.ts create mode 100644 backend/src/modules/account-security/login-events.service.ts create mode 100644 backend/src/modules/account-security/mail-engine-event.spec.ts create mode 100644 backend/src/modules/account-security/mail-engine-event.ts create mode 100644 backend/src/modules/account-security/mail-engine-events.controller.ts create mode 100644 backend/src/modules/account-security/mail-engine-events.guard.ts create mode 100644 backend/src/modules/account-security/mail-engine-events.parser.spec.ts create mode 100644 backend/src/modules/account-security/mail-engine-events.parser.ts create mode 100644 backend/src/modules/account-security/mail-engine-events.service.spec.ts create mode 100644 backend/src/modules/account-security/mail-engine-events.service.ts create mode 100644 backend/src/modules/account-security/two-factor-code-guard.spec.ts create mode 100644 backend/src/modules/account-security/two-factor-code-guard.ts create mode 100644 backend/src/modules/account-security/two-factor-enrollments.ts create mode 100644 backend/src/modules/account-security/two-factor-locks.ts create mode 100644 backend/src/modules/account-security/two-factor-setting.entity.ts create mode 100644 backend/src/modules/account-security/two-factor.controller.ts create mode 100644 backend/src/modules/account-security/two-factor.service.spec.ts create mode 100644 backend/src/modules/account-security/two-factor.service.ts create mode 100644 backend/src/modules/auth/auth-session.module.ts create mode 100644 backend/src/modules/auth/auth-sessions.controller.ts create mode 100644 backend/src/modules/auth/auth-sessions.service.spec.ts create mode 100644 backend/src/modules/auth/auth-sessions.service.ts create mode 100644 backend/src/modules/auth/dto/auth-session.dto.ts create mode 100644 backend/src/modules/auth/dto/login-result.dto.ts create mode 100644 backend/src/modules/auth/dto/security-fields.spec.ts create mode 100644 backend/src/modules/auth/dto/security-fields.ts create mode 100644 backend/src/modules/auth/login-attempt.module.ts create mode 100644 backend/src/modules/auth/revoked-sessions.spec.ts create mode 100644 backend/src/modules/auth/revoked-sessions.ts create mode 100644 backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-account-security.provider.ts create mode 100644 backend/src/shared/utils/cidr.spec.ts create mode 100644 backend/src/shared/utils/cidr.ts create mode 100644 backend/src/shared/utils/request-target.spec.ts create mode 100644 backend/src/shared/utils/request-target.ts create mode 100644 backend/src/shared/utils/totp.spec.ts create mode 100644 backend/src/shared/utils/totp.ts create mode 100644 backend/src/shared/utils/user-agent.spec.ts create mode 100644 backend/src/shared/utils/user-agent.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 53ca020..f8d008e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { APP_GUARD } from '@nestjs/core'; import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard'; import { OriginGuard } from '@/common/guards/origin.guard'; +import { AccountSecurityModule } from '@/modules/account-security/account-security.module'; import { AuthModule } from '@/modules/auth/auth.module'; import { ContactsModule } from '@/modules/contacts/contacts.module'; import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; @@ -66,6 +67,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 HealthModule, // 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로 AuthModule, + // 계정 보안 (12단계) — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기 (인증 모듈도 가져온다) + AccountSecurityModule, // 웹메일 API (Stalwart JMAP 어댑터) MailModule, // 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다) diff --git a/backend/src/app.setup.ts b/backend/src/app.setup.ts index 25d8ec9..b25ed4f 100644 --- a/backend/src/app.setup.ts +++ b/backend/src/app.setup.ts @@ -19,6 +19,16 @@ import { BusinessException } from '@/common/errors/business.exception'; import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; import { requestIdMiddleware } from '@/common/middleware/request-id.middleware'; +import { requestTargetMiddleware } from '@/common/middleware/request-target.middleware'; +import { MAIL_ENGINE_EVENTS_SHAPE_LIMITS } from '@/modules/account-security/account-security.constants'; +import { + ACCOUNT_SECURITY_SETTINGS, + type AccountSecuritySettings, +} from '@/modules/account-security/account-security.settings'; +import { + createMailEngineEventsBodyParser, + isMailEngineEventsRequest, +} from '@/modules/account-security/mail-engine-events.parser'; import { FILE_SHARING_SETTINGS, type FileSharingSettings, @@ -182,7 +192,8 @@ export function verifyJsonBody( /** * 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001. * 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) — - * 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다 + * 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다. + * 엔진 웹훅(12단계 — `POST /api/internal/mail-engine-events`)만 그 경로의 한도(MAIL_ENGINE_EVENTS_SHAPE_LIMITS)로 본다 */ export function rejectOversizedJsonBody( req: Request, @@ -190,7 +201,10 @@ export function rejectOversizedJsonBody( next: NextFunction, ): void { const body: unknown = req.body; - const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS); + const limits = isMailEngineEventsRequest(req) + ? MAIL_ENGINE_EVENTS_SHAPE_LIMITS + : JSON_SHAPE_LIMITS; + const problem = findJsonShapeProblem(body, limits); if (problem === null) { next(); return; @@ -241,6 +255,10 @@ export function configureApp(app: NestExpressApplication): void { // 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다 app.use(requestIdMiddleware); + // 요청 대상 원문 검사 — 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 라우팅 전에 400 VAL_001 (common/middleware/request-target.middleware.ts). + // Express 의 parseurl 이 그런 대상을 옛 url.parse 로 다시 읽으며 `\` 를 `/` 로 바꿔, 앞단 nginx 가 정규식으로 닫은 경로가 여기서 열렸다 (2026-10-31) + app.use(requestTargetMiddleware); + // 공개 호스트(대용량 첨부의 내려받기 주소 — 11단계)를 포털과 따로 두면 그 호스트에서는 공개 링크 API 만, 포털에서는 공개 링크 API 를 닫는다. // 본문 파서 앞이라 막은 요청의 본문을 읽지 않는다. 같은 주소면(개발) 아무것도 막지 않는다 (modules/file-sharing/public-host.ts) const sharing = app.get(FILE_SHARING_SETTINGS, { @@ -272,6 +290,15 @@ export function configureApp(app: NestExpressApplication): void { // 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다. // 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다) // 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다) + // + // 엔진 웹훅(12단계 — 계약 5절 "엔진 웹훅 받기")은 맨 앞의 자기 파서가 읽는다: 본문을 받기 전에 소켓 주소(앱 네트워크) · 서명 키 유무를 보고 + // 아니면 본문을 받지 않고 401 AUTH_001(연결을 닫는다), 서명(HMAC)은 원문을 받은 뒤 JSON 으로 읽기 전에 본다. + // 한도는 4MB · 배열 10,000 · 값 200,000 (modules/account-security/mail-engine-events.parser.ts) + const accountSecurity = app.get( + ACCOUNT_SECURITY_SETTINGS, + { strict: false }, + ); + app.use(createMailEngineEventsBodyParser(accountSecurity)); app.use( json({ limit: SEND_MESSAGE_JSON_BODY_LIMIT, diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts index bd03fab..7f7fb6c 100644 --- a/backend/src/common/decorators/current-user.decorator.ts +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -14,6 +14,11 @@ export interface AuthenticatedUser { * 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다 */ readonly accessTokenExpiresAt?: number; + /** + * 이 액세스 토큰의 로그인 세션 id (auth_sessions.id) — 가드를 거친 요청에만, 12단계 뒤에 발급한 토큰에만 있다. + * 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저") + */ + readonly sessionId?: string; } /** diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index cf3489f..b7c5069 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -51,6 +51,30 @@ const COMMON_ERROR_CODES = { status: HttpStatus.BAD_REQUEST, message: '지금 비밀번호가 올바르지 않습니다.', }, + // 2단계 인증 코드가 틀렸거나 2분 안에 다시 쓴 코드 — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기 (계약 5절). + // 401 이 아닌 까닭은 AUTH_005 와 같다(같은 코드를 로그인한 채 쓰는 설정에서도 쓴다) + AUTH_006: { + status: HttpStatus.BAD_REQUEST, + message: + '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.', + }, + // 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절) + AUTH_007: { + status: HttpStatus.BAD_REQUEST, + message: '인증 앱의 코드를 입력해 주세요.', + }, + // 2단계 인증 등록이 없음 · 10분 지남 · 그사이 다른 곳에서 바뀜 — 화면은 처음부터 다시 (계약 5절) + AUTH_008: { + status: HttpStatus.CONFLICT, + message: + '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', + }, + // 앱 비밀번호 상한(엔진 기본 5개) — 엔진 상한을 바꾸면 이 문구와 화면 상수도 함께 (계약 5절) + AUTH_009: { + status: HttpStatus.CONFLICT, + message: + '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', + }, VAL_001: { status: HttpStatus.BAD_REQUEST, message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', diff --git a/backend/src/common/middleware/request-target.middleware.spec.ts b/backend/src/common/middleware/request-target.middleware.spec.ts new file mode 100644 index 0000000..87d401b --- /dev/null +++ b/backend/src/common/middleware/request-target.middleware.spec.ts @@ -0,0 +1,136 @@ +import express, { type Request, type Response } from 'express'; +import { createServer, type Server } from 'http'; +import { connect } from 'net'; + +import { requestIdMiddleware } from './request-id.middleware'; +import { requestTargetMiddleware } from './request-target.middleware'; + +/** 원문 바이트 그대로 요청을 보내고 응답(상태 · 머리글 · 본문)을 읽는다 — supertest 는 경로를 고쳐 보낸다(`\` → `/` 등) */ +function sendRaw( + server: Server, + head: Buffer, +): Promise<{ status: number; headers: string; body: string }> { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1'); + const chunks: Buffer[] = []; + socket.on('data', (chunk: Buffer) => chunks.push(chunk)); + socket.on('error', reject); + socket.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const [headers, ...rest] = text.split('\r\n\r\n'); + resolve({ + status: Number(headers.split(' ')[1]), + headers: headers.toLowerCase(), + body: rest.join('\r\n\r\n'), + }); + }); + socket.write(head); + }); +} + +const requestLine = (method: string, target: string) => + Buffer.from( + `${method} ${target} HTTP/1.1\r\nHost: portal.test\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + 'latin1', + ); + +describe('요청 대상 원문 검사 미들웨어 (라우팅 전에 400 VAL_001)', () => { + let server: Server; + const reached: string[] = []; + + beforeAll(async () => { + const app = express(); + app.set('case sensitive routing', true); + app.use(requestIdMiddleware); + app.use(requestTargetMiddleware); + app.get('/api/health', (_req, res) => { + reached.push('health'); + res.json({ ok: true }); + }); + app.post('/api/internal/mail-engine-events', (_req, res) => { + reached.push('webhook'); + res.json({ ok: true }); + }); + app.get('/api/search', (req, res) => { + reached.push('search'); + res.json({ q: req.query.q }); + }); + server = createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + reached.length = 0; + }); + + it.each([ + ['POST', '/api/internal\\mail-engine-events#x'], + ['GET', '/api/health\\#a'], + ['GET', '/api/health#a'], + ['GET', 'http://portal.test/api/internal\\mail-engine-events'], + ])( + '%s %s — 처리기에 닿지 않고 표준 실패 형식의 400 VAL_001 (traceId · 연결 닫기)', + async (method, target) => { + const res = await sendRaw(server, requestLine(method, target)); + + expect(res.status).toBe(400); + expect(res.headers).toContain('connection: close'); + const body = JSON.parse(res.body) as { + success: boolean; + error: { code: string; traceId?: string }; + }; + expect(body.success).toBe(false); + expect(body.error.code).toBe('VAL_001'); + expect(body.error.traceId).toMatch(/^[0-9a-f-]{36}$/); + expect(res.headers).toContain(`x-request-id: ${body.error.traceId}`); + expect(reached).toEqual([]); + }, + ); + + it('정상 경로 · 퍼센트 인코딩한 한글은 그대로 지나간다', async () => { + const health = await sendRaw(server, requestLine('GET', '/api/health')); + expect(health.status).toBe(200); + + const search = await sendRaw( + server, + requestLine('GET', '/api/search?q=%ED%95%9C%EA%B8%80'), + ); + expect(search.status).toBe(200); + expect(JSON.parse(search.body)).toEqual({ q: '한글' }); + expect(reached).toEqual(['health', 'search']); + }); + + it('0xA0 · 제어 문자가 든 대상도 막는다 — 지금 Node 는 HTTP 파서가 먼저 거절하지만 파서가 느슨해져도 여기서 막는다', () => { + for (const target of ['/api/a b', '/api/a\u0000b', '/api/a\u007fb']) { + const status = jest.fn().mockReturnThis(); + const json = jest.fn(); + const setHeader = jest.fn(); + const next = jest.fn(); + requestTargetMiddleware( + { + originalUrl: target, + url: target, + header: () => 'trace-1', + } as unknown as Request, + { status, json, setHeader } as unknown as Response, + next, + ); + expect(next).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith({ + success: false, + error: { + code: 'VAL_001', + message: expect.any(String) as unknown, + traceId: 'trace-1', + }, + }); + } + }); +}); diff --git a/backend/src/common/middleware/request-target.middleware.ts b/backend/src/common/middleware/request-target.middleware.ts new file mode 100644 index 0000000..7b73099 --- /dev/null +++ b/backend/src/common/middleware/request-target.middleware.ts @@ -0,0 +1,37 @@ +import { HttpStatus } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; + +import { getErrorDefinition } from '@/common/errors/error-codes'; +import { hasForbiddenRequestTargetChar } from '@/shared/utils/request-target'; +import { REQUEST_ID_HEADER } from './request-id.middleware'; + +/** + * 요청 대상 원문 검사 미들웨어 (express) — `configureApp()` 이 요청 추적 ID 바로 뒤, 라우팅 · 본문 파서 앞에 붙인다. + * 원문 대상(`req.originalUrl` · `req.url`)에 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 표준 실패 형식의 400 VAL_001 로 끝낸다 + * (규칙과 까닭은 shared/utils/request-target.ts). 앞단 nginx 도 server 단계에서 400 으로 막는다(release-ops — 두 겹). + * 본문을 받지 않고 끝내므로 연결을 닫는다. 로그를 남기지 않는다(두드려 보는 요청 — 대상 원문에 로그를 흐트러뜨리는 글자가 들 수 있다) + */ +export function requestTargetMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const original = req.originalUrl ?? req.url; + if ( + !hasForbiddenRequestTargetChar(original) && + (req.url === original || !hasForbiddenRequestTargetChar(req.url)) + ) { + next(); + return; + } + const traceId = req.header(REQUEST_ID_HEADER) ?? undefined; + res.setHeader('Connection', 'close'); + res.status(HttpStatus.BAD_REQUEST).json({ + success: false, + error: { + code: 'VAL_001', + message: getErrorDefinition('VAL_001').message, + ...(traceId ? { traceId } : {}), + }, + }); +} diff --git a/backend/src/migrations/1793415011070-AddAccountSecurity.ts b/backend/src/migrations/1793415011070-AddAccountSecurity.ts new file mode 100644 index 0000000..8c92352 --- /dev/null +++ b/backend/src/migrations/1793415011070-AddAccountSecurity.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 계정 보안 표 (docs/mail/api-contract.md 5절 "계정 보안" — 12단계). + * - login_events: 로그인 기록(웹메일 · 메일 프로그램) — 90일 보존. 직원 · 일어난 때 · 차례 번호 인덱스(커서 페이지), + * 일어난 때 인덱스(정리), 엔진 이벤트의 겹침 거르기 키 유일 인덱스(웹메일 기록은 null — 여럿이어도 된다) + * - app_password_usages: 앱 비밀번호의 서버 기록(이름 · 엔진의 만든 때 · 마지막 사용) — 직원 · 앱 비밀번호 id 마다 한 줄 + * - two_factor_settings: 웹메일에서 2단계 인증을 켠 때 — 직원 주소마다 한 줄 (비밀은 두지 않는다) + * - auth_sessions: 로그인 · 갱신한 클라이언트의 IP · User-Agent 칸(12단계 전의 세션은 null)과 직원 주소 인덱스(로그인된 브라우저 목록 · 다른 곳 모두 끊기) + * 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다. + * (자동 생성: npm run migration:generate — 엔티티는 modules/account-security/*.entity.ts · modules/auth/auth-session.entity.ts) + */ +export class AddAccountSecurity1793415011070 implements MigrationInterface { + name = 'AddAccountSecurity1793415011070'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "login_events" ("id" BIGSERIAL NOT NULL, "user_email" character varying(254) NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "method" character varying(32) NOT NULL, "is_success" boolean NOT NULL, "is_two_factor" boolean NOT NULL, "failure_reason" character varying(32), "ip" character varying(64), "user_agent" character varying(512), "app_password_id" character varying(16), "app_password_name" character varying(40), "dedupe_key" character(43), CONSTRAINT "PK_5cd6102474db62bc181590b9902" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_login_events_occurred_at" ON "login_events" ("occurred_at") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_login_events_dedupe_key" ON "login_events" ("dedupe_key") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_login_events_user_occurred" ON "login_events" ("user_email", "occurred_at", "id") `, + ); + await queryRunner.query( + `CREATE TABLE "app_password_usages" ("user_email" character varying(254) NOT NULL, "app_password_id" character varying(16) NOT NULL, "name" character varying(40) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_used_at" TIMESTAMP WITH TIME ZONE, "last_used_method" character varying(8), "last_used_ip" character varying(64), CONSTRAINT "PK_6db68c01bf629755c5eeaf95322" PRIMARY KEY ("user_email", "app_password_id"))`, + ); + await queryRunner.query( + `CREATE TABLE "two_factor_settings" ("user_email" character varying(254) NOT NULL, "enabled_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e1f907880f3d367cca2717934b6" PRIMARY KEY ("user_email"))`, + ); + await queryRunner.query( + `ALTER TABLE "auth_sessions" ADD "ip" character varying(64)`, + ); + await queryRunner.query( + `ALTER TABLE "auth_sessions" ADD "user_agent" character varying(512)`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_auth_sessions_user_email" ON "auth_sessions" ("user_email") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_auth_sessions_user_email"`, + ); + await queryRunner.query( + `ALTER TABLE "auth_sessions" DROP COLUMN "user_agent"`, + ); + await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "ip"`); + await queryRunner.query(`DROP TABLE "two_factor_settings"`); + await queryRunner.query(`DROP TABLE "app_password_usages"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_login_events_user_occurred"`, + ); + await queryRunner.query(`DROP INDEX "public"."UQ_login_events_dedupe_key"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_login_events_occurred_at"`, + ); + await queryRunner.query(`DROP TABLE "login_events"`); + } +} diff --git a/backend/src/modules/account-security/account-security.constants.ts b/backend/src/modules/account-security/account-security.constants.ts new file mode 100644 index 0000000..0469ef3 --- /dev/null +++ b/backend/src/modules/account-security/account-security.constants.ts @@ -0,0 +1,64 @@ +import type { JsonShapeLimits } from '@/shared/utils/json-shape'; + +/** + * 계정 보안(12단계 — 계약 5절 "계정 보안") 상수 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅. + * 로그인 · 코드 실패 한도는 같은 카운터를 쓰므로 modules/auth/auth.constants.ts 에 있다. + */ + +/** + * 인증 앱에 보일 발급자 이름 — 앱에는 「ARA SYSTEM (주소)」로 저장된다 (계약 5절 "정한 것" 1). + * 엔진(totp-rs from_url)이 이 이름이 든 otpauth 주소를 받고 그 주소로 코드를 확인하는 것을 개발 엔진에서 확인했다 (2026-10-31) + */ +export const TWO_FACTOR_ISSUER = 'ARA SYSTEM'; +/** 비밀 길이 — 160비트(base32 32자) */ +export const TWO_FACTOR_SECRET_BYTES = 20; +/** 등록을 시작하고 켜기까지 — 10분 (계약 5절 "한도 · 규칙") */ +export const TWO_FACTOR_ENROLLMENT_TTL_MS = 10 * 60_000; +/** + * 같은 직원이 성공에 쓴 코드를 다시 받지 않는 시간 — 2분 (코드가 받아지는 90초보다 길게 — 로그인 · 설정 모두). + * 엔진은 같은 코드를 다시 받는다(재사용을 막지 않는다 — 2026-10-31 확인) + */ +export const TWO_FACTOR_CODE_REUSE_MS = 2 * 60_000; + +/** 앱 비밀번호 이름 — 1~40자 (앞뒤 공백을 지운 값, JS 문자열 길이) */ +export const APP_PASSWORD_NAME_MAX_LENGTH = 40; +/** + * 앱 비밀번호의 권한 — 메일 프로그램만(로그인 · IMAP · 메일 보내기). 엔진 기본(Inherit)이면 앱 비밀번호로 JMAP 을 쓰고 + * 앱 비밀번호를 또 만들 수 있다 — 그래서 늘 좁힌다 (계약 5절 "앱 비밀번호", 2026-10-31 확인) + */ +export const APP_PASSWORD_PERMISSIONS = { + authenticate: true, + imapAuthenticate: true, + emailSend: true, +} as const; + +/** 로그인 기록 보존 — 90일 (날마다 정리) */ +export const LOGIN_EVENT_RETENTION_DAYS = 90; +/** + * 엔진 이벤트 기록을 한 문장에 넣는 행 수 — PostgreSQL 한 문장의 매개변수 상한(65,535)을 넘지 않게 나눈다. + * 행마다 11칸이라 5,958행부터 넘는다(2026-10-31 리뷰) — 엔진은 실패한 묶음에 새 이벤트를 합쳐 다시 보내 묶음이 줄지 않는다 + */ +export const LOGIN_EVENT_INSERT_CHUNK = 1_000; + +/** 엔진 웹훅을 받는 경로 (글로벌 prefix 뒤) — 앞단 nginx 가 /api/internal/ 을 닫는다 (계약 5절 "엔진 웹훅 받기") */ +export const MAIL_ENGINE_EVENTS_ROUTE = 'internal/mail-engine-events'; +/** 이 경로만의 JSON 본문 한도 — 공격이 오면 엔진의 1초 묶음이 커진다 (계약 5절) */ +export const MAIL_ENGINE_EVENTS_JSON_LIMIT = '4mb'; +export const MAIL_ENGINE_EVENTS_SHAPE_LIMITS: JsonShapeLimits = { + maxArrayLength: 10_000, + maxValues: 200_000, + maxDepth: 32, +}; +/** 엔진이 본문에 붙이는 서명 머리글 — 본문 원문의 HMAC-SHA256, base64 */ +export const MAIL_ENGINE_SIGNATURE_HEADER = 'x-signature'; +/** + * 웹훅 거절의 warn 을 같은 까닭마다 이 시간에 한 번만 남긴다 — 서명 키가 어긋나면 엔진이 1초마다 묶음을 보내고 다시 보내 + * 거절마다 남기면 하루 수만 줄이 된다 (2026-10-31 개발 — 서명 키 없이 뜬 백엔드에 1초마다). 그사이 거절한 수는 다음 줄에 붙인다 + */ +export const MAIL_ENGINE_REJECTION_LOG_WINDOW_MS = 60_000; + +/** 앱 네트워크 대역의 기본값 — compose 의 APP_NETWORK_SUBNET 기본값과 같다 (docker 스킬) */ +export const DEFAULT_APP_NETWORK_SUBNET = '172.16.71.0/24'; + +/** 웹훅 서명 키 — 영문 · 숫자 32자 이상 (Node 의 --env-file 이 `#` 부터 자른다 — 계약 5절 "환경 변수") */ +export const MAIL_WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9]{32,}$/; diff --git a/backend/src/modules/account-security/account-security.module.ts b/backend/src/modules/account-security/account-security.module.ts new file mode 100644 index 0000000..110b955 --- /dev/null +++ b/backend/src/modules/account-security/account-security.module.ts @@ -0,0 +1,102 @@ +import { Logger, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { LoginAttemptModule } from '@/modules/auth/login-attempt.module'; +import { ContactsModule } from '@/modules/contacts/contacts.module'; +import { JmapAccountSecurityProvider } from '@/modules/mail/jmap/jmap-account-security.provider'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { MailModule } from '@/modules/mail/mail.module'; +import { ACCOUNT_SECURITY_PROVIDER } from './account-security.provider'; +import { + ACCOUNT_SECURITY_SETTINGS, + type AccountSecuritySettings, + buildAccountSecuritySettings, +} from './account-security.settings'; +import { AppPasswordUsageEntity } from './app-password-usage.entity'; +import { AppPasswordsController } from './app-passwords.controller'; +import { AppPasswordsService } from './app-passwords.service'; +import { LoginEventEntity } from './login-event.entity'; +import { LoginEventsCleanup } from './login-events.cleanup'; +import { LoginEventsController } from './login-events.controller'; +import { LoginEventsService } from './login-events.service'; +import { MailEngineEventsController } from './mail-engine-events.controller'; +import { MailEngineEventsGuard } from './mail-engine-events.guard'; +import { MailEngineEventsService } from './mail-engine-events.service'; +import { TwoFactorCodeGuard } from './two-factor-code-guard'; +import { TwoFactorEnrollments } from './two-factor-enrollments'; +import { TwoFactorLocks } from './two-factor-locks'; +import { TwoFactorSettingEntity } from './two-factor-setting.entity'; +import { TwoFactorController } from './two-factor.controller'; +import { TwoFactorService } from './two-factor.service'; + +function createSettings(config: ConfigService): AccountSecuritySettings { + const settings = buildAccountSecuritySettings((key) => + config.get(key), + ); + if (settings.webhookSecret === null) { + // 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다) + new Logger('AccountSecurityModule').warn( + 'MAIL_WEBHOOK_SECRET 이 없어 메일 엔진 웹훅을 모두 401 로 거절합니다 — 메일 프로그램(IMAP · SMTP)의 로그인 기록이 남지 않습니다.', + ); + } + return settings; +} + +/** + * 계정 보안 모듈 (12단계 — 계약 5절 "계정 보안") — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기. + * 엔진을 바꿀 때는 ACCOUNT_SECURITY_PROVIDER 의 구현(useClass)만 바꾼다 — 서비스 · 컨트롤러는 포트만 안다. + * 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/` 에 있다. + * + * 인증 모듈이 로그인 · 비밀번호 바꾸기에 쓰도록 코드 한도 · 잠금 · 로그인 기록 · 앱 비밀번호를 내보낸다. + * 이 모듈은 인증 모듈을 가져오지 않는다(인증 → 계정 보안 방향만) — 함께 쓰는 실패 카운터는 LoginAttemptModule 하나가 내놓는다. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + TwoFactorSettingEntity, + AppPasswordUsageEntity, + LoginEventEntity, + ]), + MailEngineModule, + // 지금 비밀번호 확인(verifyCredentials — 본인 Basic) + MailModule, + // 로그인 기록의 실패를 사내 목록으로 거른다 + ContactsModule, + LoginAttemptModule, + ], + controllers: [ + TwoFactorController, + AppPasswordsController, + LoginEventsController, + MailEngineEventsController, + ], + providers: [ + { + provide: ACCOUNT_SECURITY_SETTINGS, + inject: [ConfigService], + useFactory: createSettings, + }, + { + provide: ACCOUNT_SECURITY_PROVIDER, + useClass: JmapAccountSecurityProvider, + }, + TwoFactorService, + TwoFactorEnrollments, + TwoFactorCodeGuard, + TwoFactorLocks, + AppPasswordsService, + LoginEventsService, + LoginEventsCleanup, + MailEngineEventsService, + MailEngineEventsGuard, + ], + exports: [ + ACCOUNT_SECURITY_SETTINGS, + TwoFactorCodeGuard, + TwoFactorLocks, + AppPasswordsService, + LoginEventsService, + ], +}) +export class AccountSecurityModule {} diff --git a/backend/src/modules/account-security/account-security.provider.ts b/backend/src/modules/account-security/account-security.provider.ts new file mode 100644 index 0000000..12fb0d2 --- /dev/null +++ b/backend/src/modules/account-security/account-security.provider.ts @@ -0,0 +1,81 @@ +/** + * 계정 보안 엔진 포트 (계약 5절 — 2단계 인증 · 앱 비밀번호) — 서비스 계층은 이 인터페이스만 안다 (메일 엔진 포트 mail.provider.ts 와 같은 규칙). + * + * 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-account-security.provider.ts`(게이트웨이 대리 접근으로 x:AccountPassword · x:AppPassword). + * 엔진을 바꿀 때는 이 인터페이스의 새 구현을 account-security.module.ts 에 연결한다. + * + * 규칙: + * - 우리 도메인 타입만 받고 돌려준다 (엔진 타입 · id 형식을 밖으로 흘리지 않는다 — 앱 비밀번호 id 는 불투명 문자열) + * - 실패는 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001 · 한도 REQ_001 · 계정을 쓸 수 없음 AUTH_001 + * - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 자격 증명에만 접근한다 + * - 비밀번호 · 코드 · 비밀(otpauth) · 앱 비밀번호 값 · 이름은 어떤 로그에도 남기지 않는다 + */ +export const ACCOUNT_SECURITY_PROVIDER = Symbol('ACCOUNT_SECURITY_PROVIDER'); + +/** 앱 비밀번호 하나 — 값은 만들 때만 (CreatedAppPassword) */ +export interface AppPasswordRecord { + /** 엔진의 앱 비밀번호 id — 지운 번호를 엔진이 다시 쓴다 (createdAt 과 함께 봐야 같은 것이다) */ + readonly id: string; + /** 이름 — 직원이 붙인 글 */ + readonly name: string; + /** 만든 때 (ISO 8601 UTC) */ + readonly createdAt: string; +} + +/** 방금 만든 앱 비밀번호 — 값(password)은 이 한 번만 엔진이 준다 */ +export interface CreatedAppPassword extends AppPasswordRecord { + readonly password: string; +} + +/** + * 2단계 인증 켜기의 엔진 판단: + * - enabled: 켰다 + * - refused: 엔진이 받지 않았다 — 지금 비밀번호가 맞지 않음 · 그사이 다른 곳에서 켜짐(지금 코드를 요구함) + * - unknown: 결과를 알 수 없게 끝났다(시간 초과 · 연결 끊김 · 5xx) — 엔진을 다시 읽어 맞춘다 + */ +export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown'; + +/** + * 2단계 인증 끄기의 엔진 판단 — 비밀번호는 부르는 쪽이 먼저 확인한다: + * - disabled: 껐다 + * - code-rejected: 엔진이 받지 않았다 — 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다) + * - unknown: 결과를 알 수 없게 끝났다 — 엔진을 다시 읽어 맞춘다 + */ +export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown'; + +export interface AccountSecurityProvider { + /** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */ + isTwoFactorEnabled(user: string): Promise; + + /** + * 2단계 인증을 켠다 — 지금 비밀번호와 otpauth 주소(서버가 만든 비밀). 엔진은 코드를 보지 않고 저장한다 — + * 첫 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다 + */ + enableTwoFactor( + user: string, + currentPassword: string, + otpauthUrl: string, + ): Promise; + + /** 2단계 인증을 끈다 — 지금 비밀번호와 지금 코드 */ + disableTwoFactor( + user: string, + currentPassword: string, + otpCode: string, + ): Promise; + + /** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */ + listAppPasswords(user: string): Promise; + + /** + * 앱 비밀번호를 만든다 — 권한은 메일 프로그램만(로그인 · IMAP · 보내기), 만료 · 허용 IP 없음. + * 엔진 상한을 넘으면 AUTH_009. 결과를 모르게 끝나면 MAIL_001 (값을 받지 못했으니 다시 만든다 — 목록에 남은 것은 직원이 지운다) + */ + createAppPassword(user: string, name: string): Promise; + + /** 앱 비밀번호 하나를 지운다 — 이미 없으면 그대로 끝난다 */ + deleteAppPassword(user: string, id: string): Promise; + + /** 그 직원의 앱 비밀번호를 모두 지운다 → 지운 수 (비밀번호를 바꿨을 때 — 계약 5절 "비밀번호 바꾸기") */ + deleteAllAppPasswords(user: string): Promise; +} diff --git a/backend/src/modules/account-security/account-security.settings.spec.ts b/backend/src/modules/account-security/account-security.settings.spec.ts new file mode 100644 index 0000000..b51121a --- /dev/null +++ b/backend/src/modules/account-security/account-security.settings.spec.ts @@ -0,0 +1,40 @@ +import { buildAccountSecuritySettings } from './account-security.settings'; + +const read = + (values: Record) => + (key: string): string | undefined => + values[key]; + +describe('buildAccountSecuritySettings (계약 5절 "환경 변수")', () => { + it('서명 키 · 앱 네트워크 대역을 읽는다', () => { + const settings = buildAccountSecuritySettings( + read({ + MAIL_WEBHOOK_SECRET: 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a', + APP_NETWORK_SUBNET: '10.20.0.0/16', + }), + ); + expect(settings.webhookSecret).toBe('Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a'); + expect(settings.appNetwork).toEqual({ + address: '10.20.0.0', + prefix: 16, + family: 4, + }); + expect(settings.isAppNetworkAddress('10.20.3.4')).toBe(true); + expect(settings.isAppNetworkAddress('::ffff:10.20.3.4')).toBe(true); + expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(false); + }); + + it('비거나 형식이 틀리면 서명 키 없음(웹훅을 모두 거절) · 대역은 compose 기본값 172.16.71.0/24', () => { + const cases: Record[] = [ + {}, + { MAIL_WEBHOOK_SECRET: '', APP_NETWORK_SUBNET: '' }, + { MAIL_WEBHOOK_SECRET: 'short', APP_NETWORK_SUBNET: 'not-a-cidr' }, + ]; + for (const values of cases) { + const settings = buildAccountSecuritySettings(read(values)); + expect(settings.webhookSecret).toBeNull(); + expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(true); + expect(settings.isAppNetworkAddress('172.16.72.2')).toBe(false); + } + }); +}); diff --git a/backend/src/modules/account-security/account-security.settings.ts b/backend/src/modules/account-security/account-security.settings.ts new file mode 100644 index 0000000..917059a --- /dev/null +++ b/backend/src/modules/account-security/account-security.settings.ts @@ -0,0 +1,48 @@ +import { createCidrMatcher, parseCidr, type Cidr } from '@/shared/utils/cidr'; +import { + DEFAULT_APP_NETWORK_SUBNET, + MAIL_WEBHOOK_SECRET_PATTERN, +} from './account-security.constants'; + +/** + * 계정 보안 설정 (계약 5절 "환경 변수" · "엔진 웹훅 받기") — 루트 env 에서 compose 가 넣는다. 검사는 env.validation.ts 가 기동 때 한다. + */ +export const ACCOUNT_SECURITY_SETTINGS = Symbol('ACCOUNT_SECURITY_SETTINGS'); + +export interface AccountSecuritySettings { + /** + * 엔진 웹훅 서명 키(`MAIL_WEBHOOK_SECRET` — setup 이 엔진의 x:WebHook 에 같은 값을 넣는다). 없거나 형식이 틀리면 null — + * 웹훅을 모두 401 로 거절한다 (운영은 env 검증이 필수로 막는다) + */ + readonly webhookSecret: string | null; + /** 엔진이 붙는 앱 네트워크 대역(`APP_NETWORK_SUBNET` — 없으면 compose 기본값 172.16.71.0/24) — 웹훅의 소켓 주소가 이 안이어야 한다 */ + readonly appNetwork: Cidr; + /** 소켓 주소가 앱 네트워크 안인가 */ + readonly isAppNetworkAddress: (address: string | null | undefined) => boolean; +} + +/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다). 대역이 틀리면 기본값 (형식 오류는 env 검증이 기동 때 막는다) */ +export function buildAccountSecuritySettings( + read: (key: string) => string | undefined, +): AccountSecuritySettings { + const secret = read('MAIL_WEBHOOK_SECRET')?.trim() ?? ''; + const configured = read('APP_NETWORK_SUBNET')?.trim(); + const appNetwork = + (configured ? parseCidr(configured) : null) ?? defaultAppNetwork(); + return { + webhookSecret: MAIL_WEBHOOK_SECRET_PATTERN.test(secret) ? secret : null, + appNetwork, + isAppNetworkAddress: createCidrMatcher(appNetwork), + }; +} + +/** compose 기본값의 대역 — 상수라 늘 읽힌다 */ +function defaultAppNetwork(): Cidr { + const cidr = parseCidr(DEFAULT_APP_NETWORK_SUBNET); + if (cidr === null) { + throw new Error( + `기본 앱 네트워크 대역(${DEFAULT_APP_NETWORK_SUBNET})을 읽지 못했습니다.`, + ); + } + return cidr; +} diff --git a/backend/src/modules/account-security/app-password-usage.entity.ts b/backend/src/modules/account-security/app-password-usage.entity.ts new file mode 100644 index 0000000..8e1dc31 --- /dev/null +++ b/backend/src/modules/account-security/app-password-usage.entity.ts @@ -0,0 +1,65 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ +const EMAIL_COLUMN_LENGTH = 254; +/** 엔진의 JMAP id — 64비트 수의 32진수라 13자까지 */ +const APP_PASSWORD_ID_COLUMN_LENGTH = 16; +/** 쓰인 곳 — imap · smtp */ +const METHOD_COLUMN_LENGTH = 8; +/** IP 문자열 — IPv6 최대 45자에 여유 */ +const IP_COLUMN_LENGTH = 64; + +/** 앱 비밀번호가 쓰인 곳 (엔진 웹훅의 리스너) */ +export type AppPasswordUseMethod = 'imap' | 'smtp'; + +/** + * 앱 비밀번호의 서버 기록 (계약 5절 "앱 비밀번호 — 마지막 사용") — 엔진에 마지막 사용 칸이 없어 웹훅의 auth.success(앱 비밀번호)로 만든다. + * 엔진은 지운 번호를 다시 쓴다 — 그래서 **같은 번호 · 같은 createdAt** 일 때만 잇는다(목록이 엔진의 createdAt 과 견준다). + * 만들 때 서버가 이 줄을 새로 둔다(웹메일 밖에서 만든 것은 목록을 볼 때). 이름은 그때의 이름이다 — 로그인 기록에 앱 비밀번호 이름을 남길 때 쓴다. + * 값(비밀)은 두지 않는다 + */ +@Entity({ name: 'app_password_usages' }) +export class AppPasswordUsageEntity { + @PrimaryColumn({ + name: 'user_email', + type: 'varchar', + length: EMAIL_COLUMN_LENGTH, + }) + userEmail!: string; + + @PrimaryColumn({ + name: 'app_password_id', + type: 'varchar', + length: APP_PASSWORD_ID_COLUMN_LENGTH, + }) + appPasswordId!: string; + + /** 그때의 이름 (40자까지 — 엔진에서 더 긴 이름이면 잘라 둔다) */ + @Column({ type: 'varchar', length: APP_PASSWORD_NAME_MAX_LENGTH }) + name!: string; + + /** 엔진이 알려 준 만든 때 — 같은 번호를 다시 쓴 앱 비밀번호와 가른다 */ + @Column({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt!: Date | null; + + @Column({ + name: 'last_used_method', + type: 'varchar', + length: METHOD_COLUMN_LENGTH, + nullable: true, + }) + lastUsedMethod!: AppPasswordUseMethod | null; + + @Column({ + name: 'last_used_ip', + type: 'varchar', + length: IP_COLUMN_LENGTH, + nullable: true, + }) + lastUsedIp!: string | null; +} diff --git a/backend/src/modules/account-security/app-password.spec.ts b/backend/src/modules/account-security/app-password.spec.ts new file mode 100644 index 0000000..792d328 --- /dev/null +++ b/backend/src/modules/account-security/app-password.spec.ts @@ -0,0 +1,53 @@ +import { + APP_PASSWORD_ID_PATTERN, + credentialNumberToId, + isAppPasswordShape, +} from './app-password'; + +describe('isAppPasswordShape — 엔진이 앱 비밀번호로 읽는 모양 (웹메일에서 거절한다)', () => { + it('엔진이 만드는 값(app_ + 엔진 base32 36자)은 앱 비밀번호다', () => { + expect(isAppPasswordShape('app_abcdefghijklmnopqrstuvwxyz0123791203')).toBe( + true, + ); + // 넷째 글자는 엔진이 건너뛴다 · 대문자 K-Z 도 엔진이 받는다(역표의 특이점) + expect(isAppPasswordShape(`appX${'kLMNOPQRSTUVWXYZ'.repeat(2)}abcd`)).toBe( + true, + ); + }); + + it.each([ + ['39자', `app_${'a'.repeat(35)}`], + ['41자', `app_${'a'.repeat(37)}`], + ['app 으로 시작하지 않음', `ap__${'a'.repeat(36)}`], + ['대문자 APP', `APP_${'a'.repeat(36)}`], + ['엔진이 받지 않는 글자(4 · 5 · 6 · 8)', `app_${'4568'.repeat(9)}`], + ['엔진이 받지 않는 대문자 A-J', `app_${'ABCDEFGHIJ'.repeat(3)}abcdef`], + ['기호', `app_${'a'.repeat(35)}!`], + ['보통 비밀번호', 'correct-horse-battery-staple'], + ])('%s 은 앱 비밀번호가 아니다', (_label, value) => { + expect(isAppPasswordShape(value)).toBe(false); + }); +}); + +describe('credentialNumberToId — 엔진의 자격 번호 → JMAP id (Stalwart Id::as_string)', () => { + it.each([ + [0, 'a'], + [1, 'b'], + [2, 'c'], + [25, 'z'], + [26, '7'], + [31, '3'], + [32, 'ba'], + [33, 'bb'], + [1024, 'baa'], + ])('%d → %s', (value, id) => { + expect(credentialNumberToId(value)).toBe(id); + expect(APP_PASSWORD_ID_PATTERN.test(id)).toBe(true); + }); + + it('음이 아닌 안전한 정수가 아니면 null', () => { + for (const value of [-1, 1.5, Number.NaN, '1', null, undefined, 2 ** 60]) { + expect(credentialNumberToId(value)).toBeNull(); + } + }); +}); diff --git a/backend/src/modules/account-security/app-password.ts b/backend/src/modules/account-security/app-password.ts new file mode 100644 index 0000000..b715649 --- /dev/null +++ b/backend/src/modules/account-security/app-password.ts @@ -0,0 +1,42 @@ +/** + * 앱 비밀번호 판정 (순수 함수) — 계약 5절 "앱 비밀번호". + */ + +/** + * 엔진이 앱 비밀번호로 읽는 모양 — `app` + 아무 글자 하나 + 엔진의 base32 글자 36자 (모두 40자). + * 엔진(Stalwart 0.16.27 — `auth/credential.rs`)은 `app` 으로 시작하고 넷째 글자를 건너뛴 뒤 사용자 정의 base32 + * (소문자 a-z · 숫자 0 1 2 3 7 9, 그리고 역표의 특이점으로 대문자 K-Z)로 22바이트(자격 번호 4 + 비밀 18)를 읽을 수 있으면 앱 비밀번호로 본다. + * 엔진이 만드는 값은 `app_` + 36자다. 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기에서 이 모양이면 엔진에 묻지 않고 거절한다 — + * 엔진에는 앱 비밀번호를 웹메일에서 빼는 설정이 없다. 우연히 이 모양인 진짜 비밀번호는 엔진도 앱 비밀번호로 읽어 어차피 실패한다. + * 정규식은 길이가 고정이라 입력 길이에 비례해 끝난다 + */ +const APP_PASSWORD_SHAPE = /^app[\s\S][a-zK-Z012379]{36}$/; + +export function isAppPasswordShape(password: string): boolean { + return password.length === 40 && APP_PASSWORD_SHAPE.test(password); +} + +/** 엔진의 JMAP id 글자 (Stalwart `utils::codec::base32_custom::BASE32_ALPHABET`) */ +const JMAP_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; +const JMAP_ID_RADIX = 32; + +/** + * 엔진의 자격 번호(웹훅 `data.id` — 앱 비밀번호로 들어온 성공 이벤트) → 앱 비밀번호의 JMAP id (`x:AppPassword` 의 id). + * 엔진은 자격 번호를 그대로 JMAP id 로 적는다 — 위 글자로 쓴 32진수, 큰 자리부터(0 은 `a`, 1 은 `b` — Stalwart `types::id::Id::as_string`). + * 음이 아닌 안전한 정수가 아니면 null + */ +export function credentialNumberToId(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + return null; + } + let rest = value; + let id = ''; + do { + id = JMAP_ID_ALPHABET[rest % JMAP_ID_RADIX] + id; + rest = Math.floor(rest / JMAP_ID_RADIX); + } while (rest > 0); + return id; +} + +/** 앱 비밀번호 id 의 모양 — 엔진 JMAP id 글자만, 짧게 (경로 매개변수 검사) */ +export const APP_PASSWORD_ID_PATTERN = /^[a-z0-9]{1,13}$/; diff --git a/backend/src/modules/account-security/app-passwords.controller.ts b/backend/src/modules/account-security/app-passwords.controller.ts new file mode 100644 index 0000000..e6043a9 --- /dev/null +++ b/backend/src/modules/account-security/app-passwords.controller.ts @@ -0,0 +1,127 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { NoStore } from '@/common/decorators/no-store.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + AppPasswordsService, + type AppPasswordView, +} from './app-passwords.service'; +import { + AppPasswordPageDto, + CreateAppPasswordDto, + CreatedAppPasswordDto, + type AppPasswordDto, +} from './dto/app-password.dto'; + +function toDto(view: AppPasswordView): AppPasswordDto { + return { + id: view.id, + name: view.name, + createdAt: view.createdAt.toISOString(), + lastUsed: view.lastUsed + ? { + at: view.lastUsed.at.toISOString(), + method: view.lastUsed.method, + ip: view.lastUsed.ip, + } + : null, + }; +} + +// 앱 비밀번호 (계약 5절 "앱 비밀번호") — 메일 프로그램(IMAP 993 · SMTP 465)에 넣는 비밀번호. 판단은 AppPasswordsService 가 한다 +@ApiTags('Account security') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') +@Controller('app-passwords') +export class AppPasswordsController { + constructor(private readonly appPasswords: AppPasswordsService) {} + + @Get() + @ApiOperation({ + summary: '앱 비밀번호 목록 — 최근에 만든 것이 위 (한 쪽)', + description: + '마지막 사용(lastUsed)은 서버가 엔진의 로그인 이벤트(웹훅)로 만든 기록이다 — 같은 번호 · 같은 만든 때인 것만 잇는다. 아직 쓰지 않았으면 null.', + }) + @ApiStandardResponse(AppPasswordPageDto, { description: '목록' }) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + async list(@CurrentUser() user: AuthenticatedUser) { + const page = await this.appPasswords.list(user.email); + return { items: page.items.map(toDto), pageInfo: page.pageInfo }; + } + + @Post() + @NoStore() + @ApiOperation({ + summary: '앱 비밀번호 만들기 — 값은 이 응답에만', + description: + '권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기) — 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 끄기에는 쓰이지 않는다. 만료 · 허용 IP 는 두지 않는다. ' + + '값은 엔진이 만들어 이 응답에만 싣는다(서버 · 로그에 남지 않는다 — Cache-Control: private, no-store). 2단계 인증을 켜지 않은 직원도 만든다.', + }) + @ApiStandardResponse(CreatedAppPasswordDto, { + status: 201, + description: '만든 앱 비밀번호 — password 는 다시 볼 수 없다', + }) + @ApiErrorResponse( + 'VAL_001', + '이름이 비었거나 40자를 넘음 · 이미 있는 이름(앞뒤 공백을 지운 값이 같음) · 형식 오류', + ) + @ApiErrorResponse( + 'AUTH_009', + '엔진 상한(기본 5개)을 넘음 — 쓰지 않는 것을 지운 뒤 만든다', + ) + @ApiErrorResponse('REQ_001', '같은 직원의 만들기가 처리 중(한 번에 하나)') + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 결과를 알 수 없음(값을 받지 못했다 — 목록에 남은 것은 지우고 다시 만든다)', + ) + async create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateAppPasswordDto, + ): Promise { + const created = await this.appPasswords.create(user.email, dto.name); + return { ...toDto(created), password: created.password }; + } + + @Delete(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '앱 비밀번호 지우기 — 이미 없으면 200', + description: + '이 비밀번호로 새로 연결하는 메일 프로그램은 바로 막힌다. 이미 열린 IMAP 연결은 엔진이 끊지 않는다(다음 연결부터 — 알려진 한계).', + }) + @ApiParam({ name: 'id', description: '앱 비밀번호 id', example: 'b' }) + @ApiStandardResponse(undefined, { description: '지웠다 (data: null)' }) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + async remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ): Promise { + await this.appPasswords.remove(user.email, id); + return null; + } +} diff --git a/backend/src/modules/account-security/app-passwords.service.spec.ts b/backend/src/modules/account-security/app-passwords.service.spec.ts new file mode 100644 index 0000000..71a1894 --- /dev/null +++ b/backend/src/modules/account-security/app-passwords.service.spec.ts @@ -0,0 +1,316 @@ +import { Logger } from '@nestjs/common'; +import type { Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { + AccountSecurityProvider, + AppPasswordRecord, +} from './account-security.provider'; +import type { AppPasswordUsageEntity } from './app-password-usage.entity'; +import { AppPasswordsService } from './app-passwords.service'; + +const ARA = 'ara.kim@arasystem.test'; +const VALUE = 'app_abcdefghijklmnopqrstuvwxyz0123791203'; + +async function failureOf( + promise: Promise, +): Promise { + try { + await promise; + } catch (error) { + if (error instanceof BusinessException) return error; + throw error; + } + return undefined; +} + +describe('AppPasswordsService (앱 비밀번호 — 계약 5절)', () => { + let records: AppPasswordRecord[]; + let usageRows: AppPasswordUsageEntity[]; + let provider: jest.Mocked< + Pick< + AccountSecurityProvider, + | 'listAppPasswords' + | 'createAppPassword' + | 'deleteAppPassword' + | 'deleteAllAppPasswords' + > + >; + let usages: { + findBy: jest.Mock; + findOneBy: jest.Mock; + upsert: jest.Mock; + delete: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let service: AppPasswordsService; + let logs: string[]; + + beforeEach(() => { + records = []; + usageRows = []; + provider = { + listAppPasswords: jest.fn, [string]>(() => + Promise.resolve([...records]), + ), + createAppPassword: jest.fn((_user: string, name: string) => { + const created = { + id: 'c', + name, + createdAt: '2026-10-31T00:20:00.000Z', + password: VALUE, + }; + records.push({ id: 'c', name, createdAt: created.createdAt }); + return Promise.resolve(created); + }), + deleteAppPassword: jest.fn, [string, string]>(() => + Promise.resolve(), + ), + deleteAllAppPasswords: jest.fn, [string]>(() => + Promise.resolve(records.length), + ), + }; + usages = { + findBy: jest.fn(() => Promise.resolve([...usageRows])), + findOneBy: jest.fn(({ appPasswordId }: { appPasswordId: string }) => + Promise.resolve( + usageRows.find((row) => row.appPasswordId === appPasswordId) ?? null, + ), + ), + upsert: jest.fn( + (rows: AppPasswordUsageEntity | AppPasswordUsageEntity[]) => { + for (const row of Array.isArray(rows) ? rows : [rows]) { + usageRows = usageRows.filter( + (existing) => existing.appPasswordId !== row.appPasswordId, + ); + usageRows.push({ ...row }); + } + return Promise.resolve(); + }, + ), + delete: jest.fn(() => Promise.resolve()), + createQueryBuilder: jest.fn(() => { + const builder = { + update: () => builder, + set: () => builder, + where: () => builder, + execute: () => Promise.resolve({ affected: 1 }), + }; + return builder; + }), + }; + service = new AppPasswordsService( + provider as unknown as AccountSecurityProvider, + usages as unknown as Repository, + ); + logs = []; + jest + .spyOn(Logger.prototype, 'log') + .mockImplementation((message: unknown) => { + logs.push(String(message)); + }); + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation((message: unknown) => { + logs.push(String(message)); + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('만들기 — 앞뒤 공백을 지운 이름으로 만들고 값은 응답에만 준다, 기록을 새로 둔다 (로그에는 가린 주소 + id 만)', async () => { + const created = await service.create(ARA, ' 회사 PC 아웃룩 '); + + expect(provider.createAppPassword).toHaveBeenCalledWith( + ARA, + '회사 PC 아웃룩', + ); + expect(created).toEqual({ + id: 'c', + name: '회사 PC 아웃룩', + createdAt: new Date('2026-10-31T00:20:00.000Z'), + lastUsed: null, + password: VALUE, + }); + expect(usageRows).toEqual([ + expect.objectContaining({ + appPasswordId: 'c', + name: '회사 PC 아웃룩', + createdAt: new Date('2026-10-31T00:20:00.000Z'), + lastUsedAt: null, + }), + ]); + const everything = logs.join('\n'); + expect(everything).toContain('a***@arasystem.test, id=c'); + expect(everything).not.toContain(VALUE); + expect(everything).not.toContain('회사 PC 아웃룩'); + }); + + it('같은 이름(앞뒤 공백을 지운 값)이 있으면 400 VAL_001 — 엔진에 만들기를 보내지 않는다', async () => { + records = [ + { + id: 'b', + name: '회사 PC 아웃룩 ', + createdAt: '2026-10-30T00:00:00.000Z', + }, + ]; + expect((await failureOf(service.create(ARA, '회사 PC 아웃룩')))?.code).toBe( + 'VAL_001', + ); + expect(provider.createAppPassword).not.toHaveBeenCalled(); + }); + + it('엔진 상한의 거절(AUTH_009)은 그대로 던진다', async () => { + provider.createAppPassword.mockRejectedValue( + new BusinessException('AUTH_009'), + ); + expect((await failureOf(service.create(ARA, '다섯째')))?.code).toBe( + 'AUTH_009', + ); + }); + + it('목록 — 최근에 만든 것이 위, 같은 번호 · 같은 만든 때의 기록만 마지막 사용으로 잇는다', async () => { + records = [ + { id: 'b', name: '집 PC', createdAt: '2026-10-30T00:00:00.000Z' }, + { id: 'c', name: '휴대폰', createdAt: '2026-10-31T00:00:00.000Z' }, + ]; + usageRows = [ + { + userEmail: ARA, + appPasswordId: 'b', + name: '집 PC', + createdAt: new Date('2026-10-30T00:00:00.000Z'), + lastUsedAt: new Date('2026-10-31T00:41:00.000Z'), + lastUsedMethod: 'imap', + lastUsedIp: '211.186.95.252', + }, + // 엔진이 번호 c 를 다시 썼다 — 옛 기록(만든 때가 다르다)은 잇지 않는다 + { + userEmail: ARA, + appPasswordId: 'c', + name: '지운 옛것', + createdAt: new Date('2026-10-01T00:00:00.000Z'), + lastUsedAt: new Date('2026-10-02T00:00:00.000Z'), + lastUsedMethod: 'smtp', + lastUsedIp: null, + }, + ]; + + const page = await service.list(ARA); + + expect(page.items.map(({ id }) => id)).toEqual(['c', 'b']); + expect(page.items[0].lastUsed).toBeNull(); + expect(page.items[1].lastUsed).toEqual({ + at: new Date('2026-10-31T00:41:00.000Z'), + method: 'imap', + ip: '211.186.95.252', + }); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 2, + }); + // 다른 앱 비밀번호의 기록은 새로 둔다(만든 때 · 이름 · 마지막 사용 없음) + expect(usageRows.find((row) => row.appPasswordId === 'c')).toMatchObject({ + name: '휴대폰', + createdAt: new Date('2026-10-31T00:00:00.000Z'), + lastUsedAt: null, + }); + }); + + it('지우기 — 모양이 아닌 id 는 엔진에 묻지 않고 그대로 끝난다', async () => { + await service.remove(ARA, 'b'); + expect(provider.deleteAppPassword).toHaveBeenCalledWith(ARA, 'b'); + + provider.deleteAppPassword.mockClear(); + await service.remove(ARA, '../etc/passwd'); + await service.remove(ARA, 'B'); + expect(provider.deleteAppPassword).not.toHaveBeenCalled(); + }); + + it('비밀번호를 바꾼 뒤 모두 지운다 — 실패해도 던지지 않고 error 로그(가린 주소)', async () => { + records = [ + { id: 'b', name: 'x', createdAt: '2026-10-30T00:00:00.000Z' }, + { id: 'c', name: 'y', createdAt: '2026-10-30T00:00:00.000Z' }, + ]; + await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(2); + expect(usages.delete).toHaveBeenCalledWith({ userEmail: ARA }); + + provider.deleteAllAppPasswords.mockRejectedValue( + new BusinessException('MAIL_001'), + ); + await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(0); + expect(logs.join('\n')).toMatch( + /\[SYS_001\].*앱 비밀번호를 지우지 못했습니다.*a\*\*\*@arasystem\.test/, + ); + }); + + it('웹훅의 사용 — 기록이 있으면 마지막 사용을 고치고 그때의 이름을 돌려준다, 없으면 null', async () => { + usageRows = [ + { + userEmail: ARA, + appPasswordId: 'b', + name: '집 PC', + createdAt: new Date('2026-10-30T00:00:00.000Z'), + lastUsedAt: null, + lastUsedMethod: null, + lastUsedIp: null, + }, + ]; + const use = { at: new Date(), method: 'imap' as const, ip: '1.2.3.4' }; + await expect(service.recordUse(ARA, 'b', use)).resolves.toBe('집 PC'); + expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1); + await expect(service.recordUse(ARA, 'z', use)).resolves.toBeNull(); + expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1); + }); +}); + +describe('AppPasswordsService.create — 같은 직원의 만들기는 한 번에 하나 (같은 이름 검사가 동시 요청에서 겹치지 않게)', () => { + it('앞 만들기가 끝나기 전에 온 만들기는 429 REQ_001 — 끝나면 다시 된다, 다른 직원은 따로', async () => { + let finish!: () => void; + const provider = { + listAppPasswords: jest.fn(() => Promise.resolve([])), + createAppPassword: jest.fn( + (_user: string, name: string) => + new Promise((resolve) => { + finish = () => + resolve({ + id: 'b', + name, + createdAt: '2026-10-31T00:00:00.000Z', + password: `app_${'a'.repeat(36)}`, + }); + }), + ), + }; + const usages = { upsert: jest.fn(() => Promise.resolve()) }; + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + const service = new AppPasswordsService( + provider as unknown as AccountSecurityProvider, + usages as unknown as Repository, + ); + + const first = service.create('ara.kim@arasystem.test', '회사 PC'); + await new Promise((resolve) => setImmediate(resolve)); + + let failure: unknown; + try { + await service.create('ara.kim@arasystem.test', '회사 PC'); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(BusinessException); + expect((failure as BusinessException).code).toBe('REQ_001'); + expect(provider.createAppPassword).toHaveBeenCalledTimes(1); + + finish(); + await expect(first).resolves.toMatchObject({ id: 'b' }); + + provider.listAppPasswords.mockResolvedValueOnce([]); + const again = service.create('ara.kim@arasystem.test', '휴대폰'); + await new Promise((resolve) => setImmediate(resolve)); + finish(); + await expect(again).resolves.toMatchObject({ name: '휴대폰' }); + jest.restoreAllMocks(); + }); +}); diff --git a/backend/src/modules/account-security/app-passwords.service.ts b/backend/src/modules/account-security/app-passwords.service.ts new file mode 100644 index 0000000..bc6f8d8 --- /dev/null +++ b/backend/src/modules/account-security/app-passwords.service.ts @@ -0,0 +1,300 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Not, type Repository } from 'typeorm'; + +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { normalizeEmail } from '@/modules/auth/employee-address'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants'; +import { + ACCOUNT_SECURITY_PROVIDER, + type AccountSecurityProvider, + type AppPasswordRecord, +} from './account-security.provider'; +import { APP_PASSWORD_ID_PATTERN } from './app-password'; +import { + AppPasswordUsageEntity, + type AppPasswordUseMethod, +} from './app-password-usage.entity'; + +/** 앱 비밀번호의 마지막 사용 — 서버 기록 */ +export interface AppPasswordLastUse { + readonly at: Date; + readonly method: AppPasswordUseMethod; + readonly ip: string | null; +} + +/** 목록의 앱 비밀번호 하나 */ +export interface AppPasswordView { + readonly id: string; + readonly name: string; + readonly createdAt: Date; + readonly lastUsed: AppPasswordLastUse | null; +} + +/** 방금 만든 앱 비밀번호 — 값은 이 응답에만 */ +export interface CreatedAppPasswordView extends AppPasswordView { + readonly password: string; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 화면에 둘 이름 — 앞뒤 공백을 지운 값 (견줄 때도 이것으로) */ +function displayName(name: string): string { + return name.trim(); +} + +/** + * 앱 비밀번호 (계약 5절 "앱 비밀번호") — 엔진의 x:AppPassword (대리 접근). + * + * - 값(password)은 엔진이 만들어 만들 때 한 번만 준다 — 서버는 응답에 실어 넘기기만 한다(로그 · DB · 캐시 없음) + * - 권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기 — 어댑터가 늘 좁힌다). 만료 · 허용 IP 는 두지 않는다 + * - 같은 이름(앞뒤 공백을 지운 값이 같음)은 400 VAL_001, 엔진 상한(기본 5개)을 넘으면 409 AUTH_009. + * 같은 직원의 만들기는 한 번에 하나다 — 처리 중이면 429 REQ_001 (동시 요청이 같은 이름 검사를 함께 지나가지 않게 — 2단계 잠금과 같은 결) + * - 목록은 최근에 만든 것이 위다. 마지막 사용은 서버 기록(AppPasswordUsageEntity)에서 — 같은 번호 · 같은 createdAt 일 때만 잇는다 + * - 웹메일 비밀번호를 바꾸면 모두 지운다(removeAllAfterPasswordChange — 실패해도 던지지 않고 error 로그) + * 이름(사용자 글) · 값은 로그에 남기지 않는다 — 만들기 · 지우기는 info 에 가린 주소 + id + * + * ⚠️ 만들기 잠금은 프로세스 메모리다 — 인스턴스를 늘리면 공유 잠금(SET NX PX)으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것"). + */ +@Injectable() +export class AppPasswordsService { + private readonly logger = new Logger(AppPasswordsService.name); + /** 만들기가 진행 중인 직원 주소 */ + private readonly creating = new Set(); + + constructor( + @Inject(ACCOUNT_SECURITY_PROVIDER) + private readonly provider: AccountSecurityProvider, + @InjectRepository(AppPasswordUsageEntity) + private readonly usages: Repository, + ) {} + + /** GET /api/app-passwords — 한 쪽 (엔진 상한이 작다), total 포함 */ + async list(user: string): Promise> { + const address = normalizeEmail(user); + const records = await this.provider.listAppPasswords(address); + const items = await this.withUsage(address, records); + return toPaginatedResult(items, { + hasNext: false, + nextCursor: null, + total: items.length, + }); + } + + /** POST /api/app-passwords — 같은 직원의 만들기가 처리 중이면 REQ_001 (확인과 차지 사이에 await 가 없다) */ + async create(user: string, name: string): Promise { + const address = normalizeEmail(user); + if (this.creating.has(address)) { + throw new BusinessException('REQ_001', { + context: { + reason: 'app-password-create-in-progress', + email: maskEmail(address), + }, + }); + } + this.creating.add(address); + try { + return await this.createOne(address, displayName(name)); + } finally { + this.creating.delete(address); + } + } + + private async createOne( + address: string, + trimmed: string, + ): Promise { + const existing = await this.provider.listAppPasswords(address); + if (existing.some((record) => displayName(record.name) === trimmed)) { + throw new BusinessException('VAL_001', { + context: { + reason: 'app-password-name-taken', + email: maskEmail(address), + }, + }); + } + + const created = await this.provider.createAppPassword(address, trimmed); + const createdAt = new Date(created.createdAt); + try { + // 만들 때 기록을 새로 둔다 — 엔진이 지운 번호를 다시 썼으면 옛 기록(그때의 마지막 사용)을 덮는다 + await this.usages.upsert( + { + userEmail: address, + appPasswordId: created.id, + name: trimmed.slice(0, APP_PASSWORD_NAME_MAX_LENGTH), + createdAt, + lastUsedAt: null, + lastUsedMethod: null, + lastUsedIp: null, + }, + ['userEmail', 'appPasswordId'], + ); + } catch (error) { + // 값은 이미 만들어졌다 — 실패로 답하면 직원이 값을 받지 못한다. 기록은 목록을 볼 때 다시 둔다 + this.logger.error( + `[SYS_001]${traceTag()} 앱 비밀번호 기록을 두지 못했습니다(앱 비밀번호는 만들었다): user=${maskEmail(address)}, id=${created.id}, reason=${errorMessage(error)}`, + ); + } + this.logger.log( + `앱 비밀번호를 만들었습니다: ${maskEmail(address)}, id=${created.id}`, + ); + return { + id: created.id, + name: trimmed, + createdAt, + lastUsed: null, + password: created.password, + }; + } + + /** + * DELETE /api/app-passwords/{id} — 이미 없으면 그대로 끝난다. + * 모양이 아닌 id 는 있을 수 없는 앱 비밀번호다 — 엔진에 묻지 않고 "이미 없음"과 같게 끝낸다 + */ + async remove(user: string, id: string): Promise { + if (!APP_PASSWORD_ID_PATTERN.test(id)) return; + const address = normalizeEmail(user); + await this.provider.deleteAppPassword(address, id); + await this.usages.delete({ userEmail: address, appPasswordId: id }); + this.logger.log( + `앱 비밀번호를 지웠습니다: ${maskEmail(address)}, id=${id}`, + ); + } + + /** + * 웹메일 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") → 지운 수. + * 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있다. 실패하면 error 로그(남은 앱 비밀번호가 있다 — 운영자가 지울 수 있게 가린 주소와 함께) + */ + async removeAllAfterPasswordChange(user: string): Promise { + const address = normalizeEmail(user); + try { + const removed = await this.provider.deleteAllAppPasswords(address); + await this.usages.delete({ userEmail: address }); + return removed; + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 앱 비밀번호를 지우지 못했습니다 — 남은 앱 비밀번호는 메일 프로그램에서 계속 쓰입니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + return 0; + } + } + + /** + * 엔진 웹훅의 앱 비밀번호 로그인 — 그 번호의 기록에 마지막 사용을 남긴다(지금 남은 것보다 늦을 때만 — 늦게 온 묶음이 되돌리지 않게) → + * 그 기록의 이름(로그인 기록에 남긴다). 기록이 없으면(웹메일 밖에서 만들고 목록도 보지 않음) null + */ + async recordUse( + user: string, + id: string, + use: AppPasswordLastUse, + ): Promise { + const address = normalizeEmail(user); + const row = await this.usages.findOneBy({ + userEmail: address, + appPasswordId: id, + }); + if (!row) return null; + await this.usages + .createQueryBuilder() + .update(AppPasswordUsageEntity) + .set({ + lastUsedAt: use.at, + lastUsedMethod: use.method, + lastUsedIp: use.ip, + }) + .where( + 'user_email = :address AND app_password_id = :id AND (last_used_at IS NULL OR last_used_at < :at)', + { address, id, at: use.at }, + ) + .execute(); + return row.name; + } + + /** + * 엔진 목록 + 서버 기록 → 최근에 만든 것이 위. 같은 번호 · 같은 createdAt 인 기록만 잇고, + * 기록이 없거나 다른 앱 비밀번호의 것이면(엔진이 번호를 다시 씀) 새로 둔다. 엔진에 없는 번호의 기록은 지운다 + */ + private async withUsage( + address: string, + records: readonly AppPasswordRecord[], + ): Promise { + const rows = await this.usages.findBy({ userEmail: address }); + const byId = new Map(rows.map((row) => [row.appPasswordId, row])); + const fresh: AppPasswordUsageEntity[] = []; + + const items = records.map((record): AppPasswordView => { + const createdAt = new Date(record.createdAt); + const row = byId.get(record.id); + const same = + row !== undefined && row.createdAt.getTime() === createdAt.getTime(); + if (!same) { + fresh.push( + Object.assign(new AppPasswordUsageEntity(), { + userEmail: address, + appPasswordId: record.id, + name: displayName(record.name).slice( + 0, + APP_PASSWORD_NAME_MAX_LENGTH, + ), + createdAt, + lastUsedAt: null, + lastUsedMethod: null, + lastUsedIp: null, + }), + ); + } + return { + id: record.id, + name: displayName(record.name), + createdAt, + lastUsed: + same && row.lastUsedAt && row.lastUsedMethod + ? { + at: row.lastUsedAt, + method: row.lastUsedMethod, + ip: row.lastUsedIp, + } + : null, + }; + }); + + await this.saveUsageRows(address, fresh, records); + return items.sort( + (a, b) => + b.createdAt.getTime() - a.createdAt.getTime() || + b.id.localeCompare(a.id), + ); + } + + /** 목록을 본 김에 기록을 엔진에 맞춘다 — 실패해도 목록은 그대로 준다(마지막 사용이 빠질 뿐) */ + private async saveUsageRows( + address: string, + fresh: readonly AppPasswordUsageEntity[], + records: readonly AppPasswordRecord[], + ): Promise { + try { + if (fresh.length > 0) { + await this.usages.upsert([...fresh], ['userEmail', 'appPasswordId']); + } + const ids = records.map((record) => record.id); + await this.usages.delete( + ids.length > 0 + ? { userEmail: address, appPasswordId: Not(In(ids)) } + : { userEmail: address }, + ); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 앱 비밀번호 기록을 엔진 목록에 맞추지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + } + } +} diff --git a/backend/src/modules/account-security/dto/app-password.dto.ts b/backend/src/modules/account-security/dto/app-password.dto.ts new file mode 100644 index 0000000..a5d8700 --- /dev/null +++ b/backend/src/modules/account-security/dto/app-password.dto.ts @@ -0,0 +1,107 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { TrimString } from '@/common/decorators/trim-string.decorator'; +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { APP_PASSWORD_NAME_MAX_LENGTH } from '@/modules/account-security/account-security.constants'; + +/** 앱 비밀번호의 마지막 사용 — 서버가 엔진 웹훅으로 만든 기록 */ +export class AppPasswordLastUseDto { + @ApiProperty({ + description: '마지막으로 쓴 때 (ISO 8601 UTC)', + type: String, + format: 'date-time', + example: '2026-10-31T00:41:00.000Z', + }) + at!: string; + + @ApiProperty({ + description: '쓴 곳 — imap(메일 받기) · smtp(메일 보내기)', + enum: ['imap', 'smtp'], + example: 'imap', + }) + method!: 'imap' | 'smtp'; + + @ApiProperty({ + description: '메일 프로그램의 IP — 모르면 null', + type: String, + nullable: true, + example: '211.186.95.252', + }) + ip!: string | null; +} + +export class AppPasswordDto { + @ApiProperty({ + description: + '앱 비밀번호 id — 지우기에 쓴다 (엔진이 지운 번호를 다시 쓴다)', + example: 'b', + }) + id!: string; + + @ApiProperty({ description: '이름', example: '회사 PC 아웃룩' }) + name!: string; + + @ApiProperty({ + description: '만든 때 (ISO 8601 UTC)', + type: String, + format: 'date-time', + example: '2026-10-31T00:20:00.000Z', + }) + createdAt!: string; + + @ApiProperty({ + description: '마지막 사용 — 아직 쓰지 않았으면 null', + type: AppPasswordLastUseDto, + nullable: true, + }) + lastUsed!: AppPasswordLastUseDto | null; +} + +/** POST /api/app-passwords → 201 — 값은 이 응답에만 */ +export class CreatedAppPasswordDto extends AppPasswordDto { + @ApiProperty({ + description: + '앱 비밀번호 값 — 엔진이 만든 40자(`app_` + 36자). 이 응답에만 있고 다시 볼 수 없다(서버는 두지 않는다)', + example: 'app_abcdefghijklmnopqrstuvwxyz0123791203', + }) + password!: string; +} + +export class AppPasswordPageDto { + @ApiProperty({ + type: [AppPasswordDto], + description: '최근에 만든 것이 위 — 한 쪽에 모두 (엔진 상한이 작다)', + }) + items!: AppPasswordDto[]; + + @ApiProperty({ + type: PageInfoDto, + description: 'hasNext false · nextCursor null · total 개수', + }) + pageInfo!: PageInfoDto; +} + +/** POST /api/app-passwords */ +export class CreateAppPasswordDto { + @ApiProperty({ + description: `이름 — 1~${APP_PASSWORD_NAME_MAX_LENGTH}자(앞뒤 공백을 지운 값, JS 문자열 길이). 이미 있는 이름이면 400 VAL_001`, + minLength: 1, + maxLength: APP_PASSWORD_NAME_MAX_LENGTH, + example: '회사 PC 아웃룩', + }) + @applyDecorators( + TrimString(), + IsString({ message: '이름을 넣어 주세요.' }), + MinCodeUnits(1, { message: '이름을 넣어 주세요.' }), + MaxCodeUnits(APP_PASSWORD_NAME_MAX_LENGTH, { + message: `이름은 ${APP_PASSWORD_NAME_MAX_LENGTH}자 이하로 넣어 주세요.`, + }), + ) + name!: string; +} diff --git a/backend/src/modules/account-security/dto/login-event.dto.ts b/backend/src/modules/account-security/dto/login-event.dto.ts new file mode 100644 index 0000000..e706541 --- /dev/null +++ b/backend/src/modules/account-security/dto/login-event.dto.ts @@ -0,0 +1,140 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, type TransformFnParams } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +import { + CursorPaginationQueryDto, + PageInfoDto, +} from '@/common/dto/pagination.dto'; + +/** 쿼리의 'true' · 'false' → 불리언 (그 밖의 값은 그대로 두어 IsBoolean 이 VAL_001 로 막는다) */ +function toBooleanQuery({ value }: TransformFnParams): unknown { + const raw: unknown = value; + if (raw === 'true') return true; + if (raw === 'false') return false; + return raw; +} + +/** GET /api/login-events 의 쿼리 — 커서 · 개수(기본 20 · 최대 100) · 성공만/실패만 */ +export class LoginEventQueryDto extends CursorPaginationQueryDto { + @ApiPropertyOptional({ + description: 'false 면 실패만, true 면 성공만 — 없으면 전체', + enum: ['true', 'false'], + example: 'false', + }) + @IsOptional() + @Transform(toBooleanQuery) + @IsBoolean({ message: 'isSuccess 는 true 또는 false 여야 합니다.' }) + isSuccess?: boolean; +} + +/** 브라우저 · 운영체제의 짧은 이름 (shared/utils/user-agent.ts) — 모르면 null */ +export class DeviceDto { + @ApiProperty({ + description: + 'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet — 모르면 null', + type: String, + nullable: true, + example: 'Chrome', + }) + browser!: string | null; + + @ApiProperty({ + description: + 'Windows · macOS · iOS · Android · Linux · ChromeOS — 모르면 null', + type: String, + nullable: true, + example: 'Windows', + }) + os!: string | null; +} + +export class LoginEventAppPasswordDto { + @ApiProperty({ description: '앱 비밀번호 id', example: 'b' }) + id!: string; + + @ApiProperty({ + description: '그때의 이름 (지운 뒤에도 남는다) — 서버 기록이 없으면 null', + type: String, + nullable: true, + example: '회사 PC 아웃룩', + }) + name!: string | null; +} + +export class LoginEventDto { + @ApiProperty({ description: '기록 id', example: '1042' }) + id!: string; + + @ApiProperty({ + description: '일어난 때 (ISO 8601 UTC)', + type: String, + format: 'date-time', + example: '2026-10-31T00:41:00.000Z', + }) + occurredAt!: string; + + @ApiProperty({ + description: + 'webmail · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)', + enum: ['webmail', 'imap', 'smtp'], + example: 'webmail', + }) + method!: 'webmail' | 'imap' | 'smtp'; + + @ApiProperty({ description: '성공했는가', example: true }) + isSuccess!: boolean; + + @ApiProperty({ + description: + '웹메일 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)', + example: true, + }) + isTwoFactor!: boolean; + + @ApiProperty({ + description: + '실패 까닭 — wrongPassword(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · wrongCode(코드 틀림 · 다시 쓴 코드) · ' + + 'appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로). 성공이면 null', + type: String, + enum: ['wrongPassword', 'wrongCode', 'appPasswordRequired'], + nullable: true, + example: null, + }) + failureReason!: 'wrongPassword' | 'wrongCode' | 'appPasswordRequired' | null; + + @ApiProperty({ + description: '클라이언트 IP — 모르면 null', + type: String, + nullable: true, + example: '211.186.95.252', + }) + ip!: string | null; + + @ApiProperty({ + description: + '웹메일의 브라우저 · 운영체제 — 메일 프로그램이거나 모르면 null', + type: DeviceDto, + nullable: true, + }) + device!: DeviceDto | null; + + @ApiProperty({ + description: + '메일 프로그램이 앱 비밀번호로 들어왔으면 그 앱 비밀번호 — 아니면 null', + type: LoginEventAppPasswordDto, + nullable: true, + }) + appPassword!: LoginEventAppPasswordDto | null; +} + +export class LoginEventPageDto { + @ApiProperty({ type: [LoginEventDto], description: '최근 차례' }) + items!: LoginEventDto[]; + + @ApiProperty({ + type: PageInfoDto, + description: 'total — 최근 90일 · 거른 개수', + }) + pageInfo!: PageInfoDto; +} diff --git a/backend/src/modules/account-security/dto/two-factor.dto.ts b/backend/src/modules/account-security/dto/two-factor.dto.ts new file mode 100644 index 0000000..a5ff824 --- /dev/null +++ b/backend/src/modules/account-security/dto/two-factor.dto.ts @@ -0,0 +1,103 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +import { + CurrentPasswordField, + OtpCodeField, +} from '@/modules/auth/dto/security-fields'; +import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants'; + +/** GET /api/two-factor — 켜기 · 끄기의 응답도 같다 (계약 5절 "2단계 인증") */ +export class TwoFactorStatusDto { + @ApiProperty({ + description: '엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다', + example: true, + }) + isEnabled!: boolean; + + @ApiProperty({ + description: + '웹메일에서 켠 때 (ISO 8601 UTC) — 꺼져 있거나 엔진 관리 화면에서 켰으면 null', + type: String, + format: 'date-time', + nullable: true, + example: '2026-10-31T00:12:00.000Z', + }) + enabledAt!: string | null; +} + +/** POST /api/two-factor/enrollments → 201 (Cache-Control: private, no-store) */ +export class TwoFactorEnrollmentDto { + @ApiProperty({ + description: '등록 id — 켜기(activation)에 그대로 보낸다', + example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44', + }) + enrollmentId!: string; + + @ApiProperty({ + description: + '비밀 — 160비트를 base32(RFC 4648, 채움 없음)로 적은 32자. QR 을 찍을 수 없을 때 직원이 직접 넣는다. 이 응답에만 있다(서버 · 로그에 남지 않는다)', + example: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', + }) + secret!: string; + + @ApiProperty({ + description: + '인증 앱에 넣을 otpauth 주소 — 화면이 이 주소로 QR 을 그린다. 발급자 「ARA SYSTEM」 · SHA1 · 6자리 · 30초', + example: + 'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30', + }) + otpauthUrl!: string; + + @ApiProperty({ + description: '이때까지 켠다 (등록을 시작하고 10분) — 지나면 409 AUTH_008', + type: String, + format: 'date-time', + example: '2026-10-31T00:22:00.000Z', + }) + expiresAt!: string; +} + +/** POST /api/two-factor/activation */ +export class TwoFactorActivationDto { + @ApiProperty({ + description: '등록 응답의 enrollmentId', + example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44', + }) + @IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' }) + enrollmentId!: string; + + @ApiProperty({ + description: + '인증 앱에 보이는 숫자 6자리 — 등록의 비밀로 서버가 먼저 확인한다(앞뒤 30초까지)', + example: '123456', + }) + @OtpCodeField() + otpCode!: string; + + @ApiProperty({ + description: `지금 비밀번호 — 엔진이 2단계 인증을 켤 때 요구한다 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`, + format: 'password', + example: 'current-password', + }) + @CurrentPasswordField() + currentPassword!: string; +} + +/** POST /api/two-factor/deactivation */ +export class TwoFactorDeactivationDto { + @ApiProperty({ + description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`, + format: 'password', + example: 'current-password', + }) + @CurrentPasswordField() + currentPassword!: string; + + @ApiProperty({ + description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다', + example: '123456', + }) + @OtpCodeField() + otpCode!: string; +} diff --git a/backend/src/modules/account-security/login-event-cursor.spec.ts b/backend/src/modules/account-security/login-event-cursor.spec.ts new file mode 100644 index 0000000..bc8e977 --- /dev/null +++ b/backend/src/modules/account-security/login-event-cursor.spec.ts @@ -0,0 +1,51 @@ +import { + decodeLoginEventCursor, + encodeLoginEventCursor, + loginEventScope, +} from './login-event-cursor'; + +describe('로그인 기록의 커서 (자리 — 일어난 때 · 차례 번호)', () => { + const position = { + occurredAt: new Date('2026-10-31T00:41:00.000Z'), + id: '1042', + }; + + it('같은 거르기로 읽으면 자리가 그대로 나온다', () => { + const scope = loginEventScope(false); + const cursor = encodeLoginEventCursor(position, scope); + expect(decodeLoginEventCursor(cursor, scope)).toEqual({ position }); + }); + + it('다른 거르기(전체 · 성공만 · 실패만)의 커서는 받지 않는다', () => { + const cursor = encodeLoginEventCursor(position, loginEventScope(undefined)); + expect(decodeLoginEventCursor(cursor, loginEventScope(false))).toEqual({ + problem: 'other-query', + }); + expect(decodeLoginEventCursor(cursor, loginEventScope(true))).toEqual({ + problem: 'other-query', + }); + }); + + it('모양이 틀리면 까닭과 함께 거절한다', () => { + const scope = loginEventScope(undefined); + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url'); + expect(decodeLoginEventCursor('a'.repeat(300), scope)).toEqual({ + problem: 'too-long', + }); + expect(decodeLoginEventCursor('@@@', scope)).toEqual({ + problem: 'not-json', + }); + for (const bad of [ + { v: 2, t: position.occurredAt.toISOString(), i: '1', s: 'x' }, + { v: 1, t: 'not-a-date', i: '1', s: 'x' }, + { v: 1, t: '2026-10-31T00:41:00Z', i: '1', s: 'x' }, + { v: 1, t: position.occurredAt.toISOString(), i: '0', s: 'x' }, + { v: 1, t: position.occurredAt.toISOString(), i: '1; DROP', s: 'x' }, + ]) { + expect(decodeLoginEventCursor(encode(bad), scope)).toEqual({ + problem: 'malformed', + }); + } + }); +}); diff --git a/backend/src/modules/account-security/login-event-cursor.ts b/backend/src/modules/account-security/login-event-cursor.ts new file mode 100644 index 0000000..c4a48fc --- /dev/null +++ b/backend/src/modules/account-security/login-event-cursor.ts @@ -0,0 +1,84 @@ +import { createHash } from 'crypto'; + +/** + * 로그인 기록의 커서 (순수 함수) — 최근 차례(일어난 때 · 차례 번호의 내림차순)에서 마지막으로 준 기록의 자리. + * 위치(offset)가 아니라 자리(keyset)를 적는다 — 새 기록은 맨 위에 쌓이므로 위치 커서면 다음 쪽이 밀려 겹친다. + * 다른 거르기(isSuccess)의 커서를 섞어 쓰지 못하게 범위의 해시를 함께 넣는다 (shared/utils/offset-cursor.ts 와 같은 결). + */ +const CURSOR_VERSION = 1; +const MAX_CURSOR_LENGTH = 256; +const SCOPE_HASH_LENGTH = 16; +/** 차례 번호 — bigint 의 10진 표기 */ +const ID_PATTERN = /^[1-9]\d{0,18}$/; + +/** 마지막으로 준 기록의 자리 */ +export interface LoginEventPosition { + readonly occurredAt: Date; + readonly id: string; +} + +export type LoginEventCursorReading = + | { readonly position: LoginEventPosition } + | { readonly problem: 'too-long' | 'not-json' | 'malformed' | 'other-query' }; + +interface EncodedCursor { + v: number; + t: string; + i: string; + s: string; +} + +/** 커서의 범위 — 거르기 값 (전체 · 성공만 · 실패만) */ +export function loginEventScope(isSuccess: boolean | undefined): string { + return isSuccess === undefined ? 'all' : String(isSuccess); +} + +function scopeHash(scope: string): string { + return createHash('sha256') + .update(`login-events:${scope}`) + .digest('base64url') + .slice(0, SCOPE_HASH_LENGTH); +} + +export function encodeLoginEventCursor( + position: LoginEventPosition, + scope: string, +): string { + const payload: EncodedCursor = { + v: CURSOR_VERSION, + t: position.occurredAt.toISOString(), + i: position.id, + s: scopeHash(scope), + }; + return Buffer.from(JSON.stringify(payload)).toString('base64url'); +} + +/** 커서를 읽는다 — 형식이 틀리거나 다른 범위의 커서면 그 까닭 (부르는 쪽이 VAL_001 로 바꾼다) */ +export function decodeLoginEventCursor( + cursor: string, + scope: string, +): LoginEventCursorReading { + if (cursor.length > MAX_CURSOR_LENGTH) return { problem: 'too-long' }; + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + return { problem: 'not-json' }; + } + const { v, t, i, s } = (parsed ?? {}) as Partial; + if ( + v !== CURSOR_VERSION || + typeof t !== 'string' || + typeof i !== 'string' || + typeof s !== 'string' || + !ID_PATTERN.test(i) + ) { + return { problem: 'malformed' }; + } + const occurredAt = new Date(t); + if (Number.isNaN(occurredAt.getTime()) || occurredAt.toISOString() !== t) { + return { problem: 'malformed' }; + } + if (s !== scopeHash(scope)) return { problem: 'other-query' }; + return { position: { occurredAt, id: i } }; +} diff --git a/backend/src/modules/account-security/login-event.entity.ts b/backend/src/modules/account-security/login-event.entity.ts new file mode 100644 index 0000000..e3d710f --- /dev/null +++ b/backend/src/modules/account-security/login-event.entity.ts @@ -0,0 +1,110 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ +const EMAIL_COLUMN_LENGTH = 254; +/** 방식 · 실패 까닭 같은 짧은 낱말 */ +const WORD_COLUMN_LENGTH = 32; +/** IP 문자열 — IPv6 최대 45자에 여유 */ +const IP_COLUMN_LENGTH = 64; +/** User-Agent 원문 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */ +const USER_AGENT_COLUMN_LENGTH = 512; +/** 엔진의 JMAP id — 13자까지 */ +const APP_PASSWORD_ID_COLUMN_LENGTH = 16; +/** 겹침 거르기 키 — SHA-256 의 base64url (43자) */ +const DEDUPE_KEY_LENGTH = 43; + +/** 로그인 방식 — 웹메일 · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */ +export type LoginEventMethod = 'webmail' | 'imap' | 'smtp'; + +/** + * 실패 까닭 (계약 5절 "로그인 기록"): + * - wrongPassword: 비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함 + * - wrongCode: 2단계 인증 코드 틀림 · 다시 쓴 코드 + * - appPasswordRequired: 2단계를 켠 계정에 메일 프로그램이 비밀번호로 들어오려 함 (엔진 auth.mfa-required) + */ +export type LoginFailureReason = + | 'wrongPassword' + | 'wrongCode' + | 'appPasswordRequired'; + +/** + * 로그인 기록 (계약 5절 "로그인 기록") — 90일 보존(날마다 정리 — login-events.cleanup.ts). + * - 웹메일: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다 + * - 메일 프로그램: 엔진 웹훅의 IMAP · SMTP 이벤트 (mail-engine-events.service.ts) — `dedupe_key` 로 엔진이 다시 보낸 묶음을 거른다 + * 비밀번호 · 코드 · 앱 비밀번호 값은 없다. 앱 비밀번호 이름은 그때의 이름이다(지운 뒤에도 남는다) + */ +@Entity({ name: 'login_events' }) +@Index('IDX_login_events_user_occurred', ['userEmail', 'occurredAt', 'id']) +export class LoginEventEntity { + /** 차례 번호 — 응답에서는 문자열 (pg 는 bigint 를 문자열로 준다) */ + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id!: string; + + @Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH }) + userEmail!: string; + + /** 일어난 때 — 웹메일은 서버가 판정한 때, 메일 프로그램은 엔진 이벤트의 createdAt(초 단위) */ + @Index('IDX_login_events_occurred_at') + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ type: 'varchar', length: WORD_COLUMN_LENGTH }) + method!: LoginEventMethod; + + @Column({ name: 'is_success', type: 'boolean' }) + isSuccess!: boolean; + + /** 웹메일 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */ + @Column({ name: 'is_two_factor', type: 'boolean' }) + isTwoFactor!: boolean; + + @Column({ + name: 'failure_reason', + type: 'varchar', + length: WORD_COLUMN_LENGTH, + nullable: true, + }) + failureReason!: LoginFailureReason | null; + + @Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true }) + ip!: string | null; + + /** 웹메일의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */ + @Column({ + name: 'user_agent', + type: 'varchar', + length: USER_AGENT_COLUMN_LENGTH, + nullable: true, + }) + userAgent!: string | null; + + /** 메일 프로그램이 앱 비밀번호로 들어왔으면 그 id */ + @Column({ + name: 'app_password_id', + type: 'varchar', + length: APP_PASSWORD_ID_COLUMN_LENGTH, + nullable: true, + }) + appPasswordId!: string | null; + + /** 그때의 앱 비밀번호 이름 — 서버 기록이 없으면 null */ + @Column({ + name: 'app_password_name', + type: 'varchar', + length: APP_PASSWORD_NAME_MAX_LENGTH, + nullable: true, + }) + appPasswordName!: string | null; + + /** 엔진 이벤트의 겹침 거르기 키 — 이벤트 내용의 지문 (웹메일 기록은 null). 엔진이 다시 보내면 이 키가 같다 */ + @Index('UQ_login_events_dedupe_key', { unique: true }) + @Column({ + name: 'dedupe_key', + type: 'char', + length: DEDUPE_KEY_LENGTH, + nullable: true, + }) + dedupeKey!: string | null; +} diff --git a/backend/src/modules/account-security/login-events.cleanup.ts b/backend/src/modules/account-security/login-events.cleanup.ts new file mode 100644 index 0000000..7df9cfc --- /dev/null +++ b/backend/src/modules/account-security/login-events.cleanup.ts @@ -0,0 +1,34 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { LoginEventsService } from './login-events.service'; + +/** + * 로그인 기록 정리 — 90일이 지난 기록을 날마다 지운다 (계약 5절 "로그인 기록"). + * + * ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비). + */ +@Injectable() +export class LoginEventsCleanup { + private readonly logger = new Logger(LoginEventsCleanup.name); + + constructor(private readonly loginEvents: LoginEventsService) {} + + @Cron(CronExpression.EVERY_DAY_AT_4AM, { name: 'login-events-cleanup' }) + async purgeOldEvents(now: Date = new Date()): Promise { + try { + const removed = await this.loginEvents.purge(now); + // 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md) + if (removed > 0) { + this.logger.debug(`90일이 지난 로그인 기록 ${removed}건을 지웠습니다.`); + } + return removed; + } catch (error) { + // 정리 작업이 실패해도 앱은 계속 돈다 — 다음 날 다시 시도한다 + this.logger.error( + `[SYS_001] 로그인 기록 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`, + ); + return 0; + } + } +} diff --git a/backend/src/modules/account-security/login-events.controller.ts b/backend/src/modules/account-security/login-events.controller.ts new file mode 100644 index 0000000..fbfa014 --- /dev/null +++ b/backend/src/modules/account-security/login-events.controller.ts @@ -0,0 +1,70 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { describeUserAgent } from '@/shared/utils/user-agent'; +import { LOGIN_EVENT_RETENTION_DAYS } from './account-security.constants'; +import { + LoginEventPageDto, + LoginEventQueryDto, + type LoginEventDto, +} from './dto/login-event.dto'; +import type { LoginEventEntity } from './login-event.entity'; +import { LoginEventsService } from './login-events.service'; + +function toDto(event: LoginEventEntity): LoginEventDto { + return { + id: event.id, + occurredAt: event.occurredAt.toISOString(), + method: event.method, + isSuccess: event.isSuccess, + isTwoFactor: event.isTwoFactor, + failureReason: event.failureReason, + ip: event.ip, + device: + event.method === 'webmail' ? describeUserAgent(event.userAgent) : null, + appPassword: event.appPasswordId + ? { id: event.appPasswordId, name: event.appPasswordName } + : null, + }; +} + +// 로그인 기록 (계약 5절 "로그인 기록") — 웹메일과 메일 프로그램(IMAP · SMTP)의 로그인 +@ApiTags('Account security') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') +@Controller('login-events') +export class LoginEventsController { + constructor(private readonly loginEvents: LoginEventsService) {} + + @Get() + @ApiOperation({ + summary: `로그인 기록 — 최근 ${LOGIN_EVENT_RETENTION_DAYS}일 · 최근 차례 · 커서`, + description: + '웹메일: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' + + '메일 프로그램: 엔진이 알려 준 IMAP · SMTP 로그인(엔진이 다시 뜨거나 서버가 5분 넘게 받지 못한 동안의 기록은 빠진다). ' + + `기록은 ${LOGIN_EVENT_RETENTION_DAYS}일 보존. 커서는 같은 isSuccess 로만 쓴다(다르면 400 VAL_001).`, + }) + @ApiStandardResponse(LoginEventPageDto, { description: '한 쪽' }) + @ApiErrorResponse('VAL_001', '쿼리 형식 오류 · 다른 거르기의 커서') + async list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: LoginEventQueryDto, + ): Promise { + const page = await this.loginEvents.list(user.email, { + cursor: query.cursor, + limit: query.limit, + isSuccess: query.isSuccess, + }); + return { items: page.items.map(toDto), pageInfo: page.pageInfo }; + } +} diff --git a/backend/src/modules/account-security/login-events.service.spec.ts b/backend/src/modules/account-security/login-events.service.spec.ts new file mode 100644 index 0000000..bc9e0a0 --- /dev/null +++ b/backend/src/modules/account-security/login-events.service.spec.ts @@ -0,0 +1,186 @@ +import { Logger } from '@nestjs/common'; +import type { Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { DirectoryService } from '@/modules/contacts/directory.service'; +import { LOGIN_EVENT_INSERT_CHUNK } from './account-security.constants'; +import type { LoginEventEntity } from './login-event.entity'; +import { LoginEventsService, retentionStart } from './login-events.service'; + +const ARA = 'ara.kim@arasystem.test'; +const NOW = new Date('2026-10-31T03:00:00.000Z'); + +describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계약 5절)', () => { + let events: { insert: jest.Mock }; + let directory: { hasEmail: jest.Mock }; + let service: LoginEventsService; + let errors: string[]; + + beforeEach(() => { + events = { insert: jest.fn().mockResolvedValue(undefined) }; + directory = { hasEmail: jest.fn().mockResolvedValue(true) }; + service = new LoginEventsService( + events as unknown as Repository, + directory as unknown as DirectoryService, + ); + errors = []; + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation((message: unknown) => { + errors.push(String(message)); + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + const attempt = ( + overrides: Partial[0]>, + ) => ({ + email: ARA, + isSuccess: false, + isTwoFactor: false, + failureReason: 'wrongPassword' as const, + ip: '211.186.95.252', + userAgent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0', + ...overrides, + }); + + it('성공 · 코드 틀림은 사내 목록을 묻지 않고 남긴다 — User-Agent 원문은 512자까지', async () => { + await service.recordWebmail( + attempt({ + isSuccess: true, + isTwoFactor: true, + failureReason: null, + userAgent: `UA ${'x'.repeat(600)}`, + }), + NOW, + ); + await service.recordWebmail(attempt({ failureReason: 'wrongCode' }), NOW); + + expect(directory.hasEmail).not.toHaveBeenCalled(); + expect(events.insert).toHaveBeenCalledTimes(2); + const [first] = events.insert.mock.calls[0] as [LoginEventEntity]; + expect(first).toMatchObject({ + userEmail: ARA, + occurredAt: NOW, + method: 'webmail', + isSuccess: true, + isTwoFactor: true, + failureReason: null, + ip: '211.186.95.252', + dedupeKey: null, + }); + expect(first.userAgent).toHaveLength(512); + }); + + it('비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 없는 주소의 시도는 버린다, 읽지 못하면 남긴다', async () => { + directory.hasEmail.mockResolvedValueOnce(false); + await service.recordWebmail( + attempt({ email: 'nobody@arasystem.test' }), + NOW, + ); + expect(events.insert).not.toHaveBeenCalled(); + + directory.hasEmail.mockRejectedValueOnce(new BusinessException('MAIL_001')); + await service.recordWebmail(attempt({}), NOW); + expect(events.insert).toHaveBeenCalledTimes(1); + }); + + it('남기지 못해도 던지지 않는다 — error 로그(가린 주소)', async () => { + events.insert.mockRejectedValue(new Error('db down')); + await expect( + service.recordWebmail( + attempt({ isSuccess: true, failureReason: null }), + NOW, + ), + ).resolves.toBeUndefined(); + expect(errors.join('\n')).toMatch(/\[SYS_001\].*a\*\*\*@arasystem\.test/); + expect(errors.join('\n')).not.toContain(ARA); + }); + + it('보존 기간은 90일', () => { + expect(retentionStart(NOW)).toEqual(new Date('2026-08-02T03:00:00.000Z')); + }); +}); + +describe('LoginEventsService.insertEngineEvents (엔진 이벤트 넣기 — 문장마다 1,000행)', () => { + /** createQueryBuilder().insert().into().values().orIgnore().returning().execute() 대역 — 문장마다 넣은 행을 모은다 */ + function fakeEvents(existing: ReadonlySet = new Set()) { + const statements: { dedupeKey: string | null }[][] = []; + const builder = { + rows: [] as { dedupeKey: string | null }[], + insert: () => builder, + into: () => builder, + values: (rows: { dedupeKey: string | null }[]) => { + builder.rows = rows; + return builder; + }, + orIgnore: () => builder, + returning: () => builder, + execute: () => { + statements.push(builder.rows); + // ON CONFLICT DO NOTHING — 이미 있는 키는 돌려주지 않는다 + return Promise.resolve({ + raw: builder.rows + .filter((row) => row.dedupeKey && !existing.has(row.dedupeKey)) + .map((row) => ({ dedupe_key: row.dedupeKey })), + }); + }, + }; + return { + statements, + repository: { createQueryBuilder: () => builder }, + }; + } + + const rowsOf = (count: number) => + Array.from({ length: count }, (_, index) => ({ + userEmail: ARA, + occurredAt: NOW, + method: 'imap' as const, + isSuccess: true, + isTwoFactor: false, + failureReason: null, + ip: '211.186.95.252', + userAgent: null, + appPasswordId: null, + appPasswordName: null, + dedupeKey: `key-${index}`, + })); + + it(`한 문장에 ${LOGIN_EVENT_INSERT_CHUNK}행까지 — 6,000행(행마다 11칸이면 한 문장의 매개변수 상한 65,535 를 넘는다)은 6문장으로 나눠 넣고 새 키를 모두 돌려준다`, async () => { + const { statements, repository } = fakeEvents( + new Set(['key-10', 'key-5999']), + ); + const service = new LoginEventsService( + repository as unknown as Repository, + {} as DirectoryService, + ); + + const inserted = await service.insertEngineEvents(rowsOf(6_000)); + + expect(statements.map((rows) => rows.length)).toEqual([ + 1000, 1000, 1000, 1000, 1000, 1000, + ]); + expect( + Math.max(...statements.map((rows) => rows.length * 11)), + ).toBeLessThan(65_535); + expect(inserted.size).toBe(5_998); + expect(inserted.has('key-10')).toBe(false); + expect(inserted.has('key-1000')).toBe(true); + }); + + it('남은 행만큼 마지막 문장 · 빈 묶음은 DB 에 묻지 않는다', async () => { + const { statements, repository } = fakeEvents(); + const service = new LoginEventsService( + repository as unknown as Repository, + {} as DirectoryService, + ); + await service.insertEngineEvents(rowsOf(2_500)); + expect(statements.map((rows) => rows.length)).toEqual([1000, 1000, 500]); + + statements.length = 0; + await expect(service.insertEngineEvents([])).resolves.toEqual(new Set()); + expect(statements).toEqual([]); + }); +}); diff --git a/backend/src/modules/account-security/login-events.service.ts b/backend/src/modules/account-security/login-events.service.ts new file mode 100644 index 0000000..f9c7cf4 --- /dev/null +++ b/backend/src/modules/account-security/login-events.service.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThan, type Repository } from 'typeorm'; + +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { DirectoryService } from '@/modules/contacts/directory.service'; +import { normalizeEmail } from '@/modules/auth/employee-address'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { truncateUserAgent } from '@/shared/utils/user-agent'; +import { + LOGIN_EVENT_INSERT_CHUNK, + LOGIN_EVENT_RETENTION_DAYS, +} from './account-security.constants'; +import { + LoginEventEntity, + type LoginFailureReason, +} from './login-event.entity'; +import { + decodeLoginEventCursor, + encodeLoginEventCursor, + loginEventScope, +} from './login-event-cursor'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** 웹메일 로그인 시도 하나 — 서버가 판정한 것 */ +export interface WebmailLoginAttempt { + readonly email: string; + readonly isSuccess: boolean; + /** 성공이 코드까지 거쳤는가 */ + readonly isTwoFactor: boolean; + readonly failureReason: LoginFailureReason | null; + readonly ip: string | null; + readonly userAgent: string | null; +} + +/** 로그인 기록 조회 — 커서 · 개수 · 성공만/실패만 */ +export interface LoginEventQuery { + readonly cursor?: string; + readonly limit: number; + readonly isSuccess?: boolean; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 보존 기간의 시작 — 지금부터 90일 전 */ +export function retentionStart(now: Date): Date { + return new Date(now.getTime() - LOGIN_EVENT_RETENTION_DAYS * DAY_MS); +} + +/** + * 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다. + * + * - 남기는 실패는 사내 목록에 있는 주소만 — 없는 주소의 실패는 버린다(공격자가 아무 주소나 뿌려 표를 채우지 못하게, 계정이 나중에 생겨도 + * 그 전의 시도가 보이지 않게). 사내 목록을 읽지 못하면 남긴다(보안 기록을 잃는 쪽보다 낫다). 코드 틀림 · 성공은 늘 진짜 계정이라 바로 남긴다 + * - 기록에 실패해도 로그인은 그대로 간다 (error 로그만) + * - 목록은 최근 90일 · 최근 차례 · 커서(자리) — 성공만 · 실패만으로 거를 수 있다 + * 비밀번호 · 코드 · 앱 비밀번호 값은 기록에 없다. User-Agent 원문은 512자까지 둔다(화면에는 짧은 이름만) + */ +@Injectable() +export class LoginEventsService { + private readonly logger = new Logger(LoginEventsService.name); + + constructor( + @InjectRepository(LoginEventEntity) + private readonly events: Repository, + private readonly directory: DirectoryService, + ) {} + + /** 웹메일 로그인 시도를 남긴다 — 던지지 않는다 */ + async recordWebmail( + attempt: WebmailLoginAttempt, + now: Date = new Date(), + ): Promise { + const address = normalizeEmail(attempt.email); + try { + const keep = + attempt.isSuccess || + attempt.failureReason === 'wrongCode' || + (await this.isKnownEmployee(address)); + if (!keep) return; + await this.events.insert({ + userEmail: address, + occurredAt: now, + method: 'webmail', + isSuccess: attempt.isSuccess, + isTwoFactor: attempt.isTwoFactor, + failureReason: attempt.failureReason, + ip: attempt.ip, + userAgent: truncateUserAgent(attempt.userAgent), + appPasswordId: null, + appPasswordName: null, + dedupeKey: null, + }); + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 웹메일 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + } + } + + /** + * 엔진 이벤트의 기록들을 넣는다 — 같은 겹침 거르기 키가 이미 있으면 건너뛴다(ON CONFLICT DO NOTHING) → 새로 넣은 키. + * 한 문장의 매개변수 상한 때문에 1,000행씩 나눠 넣는다(LOGIN_EVENT_INSERT_CHUNK) — 문장마다 따로 끝나도 다시 받으면 키가 같아 겹치지 않는다. + * DB 실패는 던진다 — 웹훅이 5xx 로 답해 엔진이 다시 보낸다 + */ + async insertEngineEvents( + rows: readonly Omit[], + ): Promise> { + const inserted = new Set(); + for ( + let start = 0; + start < rows.length; + start += LOGIN_EVENT_INSERT_CHUNK + ) { + const result = await this.events + .createQueryBuilder() + .insert() + .into(LoginEventEntity) + .values(rows.slice(start, start + LOGIN_EVENT_INSERT_CHUNK)) + .orIgnore() + .returning(['dedupe_key']) + .execute(); + const raw: unknown = result.raw; + if (!Array.isArray(raw)) continue; + for (const row of raw as { dedupe_key?: unknown }[]) { + if (typeof row.dedupe_key === 'string') inserted.add(row.dedupe_key); + } + } + return inserted; + } + + /** 실패를 남길 주소인가 — 사내 목록에 있는 주소. 사내 목록을 읽지 못하면 남긴다 */ + async isKnownEmployee(address: string): Promise { + try { + return await this.directory.hasEmail(address); + } catch { + return true; + } + } + + /** GET /api/login-events — 최근 90일 · 최근 차례 */ + async list( + user: string, + query: LoginEventQuery, + now: Date = new Date(), + ): Promise> { + const address = normalizeEmail(user); + const scope = loginEventScope(query.isSuccess); + const position = query.cursor ? this.readCursor(query.cursor, scope) : null; + + const base = this.events + .createQueryBuilder('event') + .where('event.user_email = :address', { address }) + .andWhere('event.occurred_at >= :since', { since: retentionStart(now) }); + if (query.isSuccess !== undefined) { + base.andWhere('event.is_success = :isSuccess', { + isSuccess: query.isSuccess, + }); + } + const total = await base.clone().getCount(); + + const page = base + .clone() + .orderBy('event.occurred_at', 'DESC') + .addOrderBy('event.id', 'DESC') + .limit(query.limit + 1); + if (position) { + page.andWhere( + '(event.occurred_at < :at OR (event.occurred_at = :at AND event.id < :id))', + { at: position.occurredAt, id: position.id }, + ); + } + const rows = await page.getMany(); + const hasNext = rows.length > query.limit; + const items = hasNext ? rows.slice(0, query.limit) : rows; + const last = items[items.length - 1]; + return toPaginatedResult(items, { + hasNext, + nextCursor: + hasNext && last + ? encodeLoginEventCursor( + { occurredAt: last.occurredAt, id: last.id }, + scope, + ) + : null, + total, + }); + } + + /** 90일이 지난 기록을 지운다 → 지운 수 */ + async purge(now: Date = new Date()): Promise { + const result = await this.events.delete({ + occurredAt: LessThan(retentionStart(now)), + }); + return result.affected ?? 0; + } + + private readCursor(cursor: string, scope: string) { + const reading = decodeLoginEventCursor(cursor, scope); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-login-event-cursor:${reading.problem}` }, + }); + } + return reading.position; + } +} diff --git a/backend/src/modules/account-security/mail-engine-event.spec.ts b/backend/src/modules/account-security/mail-engine-event.spec.ts new file mode 100644 index 0000000..967c731 --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-event.spec.ts @@ -0,0 +1,237 @@ +import { + addressOfAccountName, + engineEventFingerprint, + failureReasonOf, + methodOfListener, + readEngineEvents, +} from './mail-engine-event'; + +const RECEIVED = new Date('2026-10-31T00:00:10.000Z'); + +/** 엔진이 보낸 모양 그대로 (2026-10-31 개발 엔진 웹훅 — 값은 바꿈) */ +const imapSuccess = { + id: '17619051050001auth.success', + createdAt: '2026-10-31T00:00:05Z', + type: 'auth.success', + data: { + listenerId: 'imaps', + localPort: 993, + remoteIp: '211.186.95.252', + remotePort: 51234, + accountName: 'doyun.lee@arasystem.test', + accountId: 4, + spanId: 12345, + }, +}; + +describe('readEngineEvents (엔진 웹훅 본문 읽기)', () => { + it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 읽는다', () => { + const reading = readEngineEvents( + { + events: [ + imapSuccess, + { + ...imapSuccess, + type: 'auth.failed', + data: { + ...imapSuccess.data, + listenerId: 'submissions', + id: 'a1', + reason: 'Authentication failed', + }, + }, + { + ...imapSuccess, + type: 'auth.mfa-required', + data: { ...imapSuccess.data, listenerId: 'smtp' }, + }, + ], + }, + RECEIVED, + ); + + expect(reading.malformed).toBe(false); + expect(reading.skipped).toBe(0); + expect( + reading.events.map(({ method, outcome }) => [method, outcome]), + ).toEqual([ + ['imap', 'success'], + ['smtp', 'failed'], + ['smtp', 'mfa-required'], + ]); + expect(reading.events[0]).toMatchObject({ + occurredAt: new Date('2026-10-31T00:00:05Z'), + accountName: 'doyun.lee@arasystem.test', + remoteIp: '211.186.95.252', + appPasswordId: null, + }); + }); + + it('빈 묶음 {"events":[]} 은 정상이다 (setup 이 서명한 빈 묶음으로 받는 쪽을 확인한다)', () => { + expect(readEngineEvents({ events: [] }, RECEIVED)).toEqual({ + events: [], + skipped: 0, + malformed: false, + }); + }); + + it('앱 비밀번호로 들어온 성공은 자격 번호를 앱 비밀번호 id 로 — details 가 앱 비밀번호 표시일 때만 (IMAP 실패의 id 는 명령 태그다)', () => { + const appPassword = { + ...imapSuccess, + data: { + ...imapSuccess.data, + accountName: 'doyun.lee', + id: 1, + details: 'Authenticated with app password', + }, + }; + const failedWithTag = { + ...imapSuccess, + type: 'auth.failed', + data: { ...imapSuccess.data, id: 'a1' }, + }; + const [success, failed] = readEngineEvents( + { events: [appPassword, failedWithTag] }, + RECEIVED, + ).events; + + expect(success).toMatchObject({ + accountName: 'doyun.lee', + appPasswordId: 'b', + }); + expect(failed.appPasswordId).toBeNull(); + }); + + it('다른 종류 · http 리스너(웹메일 — 서버가 따로 남긴다) · 모양이 틀린 이벤트는 버린다', () => { + const reading = readEngineEvents( + { + events: [ + { ...imapSuccess, type: 'auth.too-many-attempts' }, + { + ...imapSuccess, + data: { + ...imapSuccess.data, + listenerId: 'http', + details: 'mail-gateway@arasystem.test', + }, + }, + { ...imapSuccess, data: { ...imapSuccess.data, accountName: '' } }, + { ...imapSuccess, data: 'oops' }, + 'not-an-object', + null, + ], + }, + RECEIVED, + ); + expect(reading.events).toEqual([]); + expect(reading.skipped).toBe(6); + }); + + it('본문이 {"events":[…]} 모양이 아니면 malformed', () => { + for (const body of [null, [], {}, { events: 'x' }, 'text']) { + expect(readEngineEvents(body, RECEIVED).malformed).toBe(true); + } + }); + + it('createdAt 을 읽지 못하면 받은 때로 · IPv4 를 담은 IPv6 는 IPv4 로 · IP 가 아니면 null', () => { + const [event] = readEngineEvents( + { + events: [ + { + ...imapSuccess, + createdAt: 'yesterday', + data: { ...imapSuccess.data, remoteIp: '::ffff:211.186.95.252' }, + }, + ], + }, + RECEIVED, + ).events; + expect(event.occurredAt).toEqual(RECEIVED); + expect(event.remoteIp).toBe('211.186.95.252'); + + const [noIp] = readEngineEvents( + { + events: [ + { ...imapSuccess, data: { ...imapSuccess.data, remoteIp: 'nope' } }, + ], + }, + RECEIVED, + ).events; + expect(noIp.remoteIp).toBeNull(); + }); +}); + +describe('engineEventFingerprint (겹침 거르기 — 엔진은 다시 보낼 때 id 를 새로 만든다)', () => { + it('엔진 이벤트 id 가 달라도 내용이 같으면 같은 지문 — 키 차례는 상관없다', () => { + const [first] = readEngineEvents( + { events: [imapSuccess] }, + RECEIVED, + ).events; + const [retried] = readEngineEvents( + { + events: [ + { + ...imapSuccess, + id: '17619051050099auth.success', + data: Object.fromEntries( + Object.entries(imapSuccess.data).reverse(), + ), + }, + ], + }, + RECEIVED, + ).events; + expect(retried.dedupeKey).toBe(first.dedupeKey); + expect(first.dedupeKey).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it('내용이 다르면(다른 연결의 spanId · 다른 때) 다른 지문', () => { + const base = engineEventFingerprint( + 'auth.success', + '2026-10-31T00:00:05Z', + imapSuccess.data, + ); + expect( + engineEventFingerprint('auth.success', '2026-10-31T00:00:05Z', { + ...imapSuccess.data, + spanId: 99, + }), + ).not.toBe(base); + expect( + engineEventFingerprint( + 'auth.success', + '2026-10-31T00:00:06Z', + imapSuccess.data, + ), + ).not.toBe(base); + }); +}); + +describe('작은 판정', () => { + it('리스너 → 방식', () => { + expect(methodOfListener('imaps')).toBe('imap'); + expect(methodOfListener('imap')).toBe('imap'); + expect(methodOfListener('submissions')).toBe('smtp'); + expect(methodOfListener('submission')).toBe('smtp'); + expect(methodOfListener('smtp')).toBe('smtp'); + expect(methodOfListener('http')).toBeNull(); + expect(methodOfListener('pop3s')).toBeNull(); + expect(methodOfListener(undefined)).toBeNull(); + }); + + it('계정 이름 → 주소 (앱 비밀번호의 로컬 이름에는 사내 도메인을 붙인다)', () => { + expect(addressOfAccountName('doyun.lee', 'arasystem.test')).toBe( + 'doyun.lee@arasystem.test', + ); + expect( + addressOfAccountName(' Doyun.Lee@AraSystem.test ', 'arasystem.test'), + ).toBe('doyun.lee@arasystem.test'); + expect(addressOfAccountName('', 'arasystem.test')).toBeNull(); + }); + + it('결과 → 실패 까닭', () => { + expect(failureReasonOf('success')).toBeNull(); + expect(failureReasonOf('failed')).toBe('wrongPassword'); + expect(failureReasonOf('mfa-required')).toBe('appPasswordRequired'); + }); +}); diff --git a/backend/src/modules/account-security/mail-engine-event.ts b/backend/src/modules/account-security/mail-engine-event.ts new file mode 100644 index 0000000..b738a6d --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-event.ts @@ -0,0 +1,177 @@ +import { createHash } from 'crypto'; +import { isIP, isIPv4 } from 'net'; + +import { credentialNumberToId } from './app-password'; +import type { + LoginEventMethod, + LoginFailureReason, +} from './login-event.entity'; + +/** + * 엔진 웹훅 본문 읽기 (순수 함수) — 계약 5절 "엔진 웹훅 받기". + * 본문은 `{"events":[{ "id", "createdAt", "type", "data": {…} }]}` 이다 (Stalwart 0.16.27 — `trc::serializers::json`). + * 쓰는 것: type(auth.success · auth.failed · auth.mfa-required), data 의 listenerId · remoteIp · accountName · details · id. + * + * 겹침 거르기는 엔진 이벤트 id 가 아니라 **이벤트 내용의 지문**으로 한다 — 엔진은 id 를 보낼 때마다 새로 만든다 + * (`타임스탬프 + 프로세스 카운터 + 종류` 를 직렬화할 때 붙인다 → 실패해 다시 보낸 묶음은 같은 이벤트도 id 가 다르다, 0.16.27 소스 확인). + * 내용(type · createdAt · data 전체 — 세션의 spanId · remotePort 가 든다)은 다시 보내도 같다. + */ + +/** 엔진의 앱 비밀번호 로그인 표시 (`details`) — Stalwart `auth/authentication.rs` 의 "Authenticated with app password" */ +const APP_PASSWORD_DETAILS = /\bapp password\b/i; +/** 남기는 이벤트 종류 → 결과 */ +const OUTCOMES: ReadonlyMap = new Map([ + ['auth.success', 'success'], + ['auth.failed', 'failed'], + ['auth.mfa-required', 'mfa-required'], +]); +/** IP 문자열 상한 (표의 열 길이) */ +const MAX_IP_LENGTH = 64; +/** 계정 이름 상한 — 주소 최대 길이 (그보다 길면 사내 주소가 아니다) */ +const MAX_ACCOUNT_NAME_LENGTH = 254; +const IPV4_MAPPED_PREFIX = '::ffff:'; + +export type EngineLoginOutcome = 'success' | 'failed' | 'mfa-required'; + +/** 남길 만한 엔진 로그인 이벤트 하나 */ +export interface EngineLoginEvent { + readonly dedupeKey: string; + readonly occurredAt: Date; + readonly method: Exclude; + readonly outcome: EngineLoginOutcome; + /** 엔진이 적은 계정 이름 — 주소, 앱 비밀번호면 로컬 이름 (소문자) */ + readonly accountName: string; + readonly remoteIp: string | null; + /** 앱 비밀번호로 들어온 성공이면 그 id — `details` 가 앱 비밀번호 표시일 때만 (IMAP 실패의 `id` 는 명령 태그다) */ + readonly appPasswordId: string | null; +} + +export interface EngineEventsReading { + readonly events: EngineLoginEvent[]; + /** 버린 수 — 다른 종류 · 다른 리스너(http 등) · 모양이 틀림 */ + readonly skipped: number; + /** 본문이 `{events: [...]}` 모양이 아니다 — 엔진 형식이 바뀌었을 수 있다 */ + readonly malformed: boolean; +} + +/** 결과 → 실패 까닭 (성공이면 null) */ +export function failureReasonOf( + outcome: EngineLoginOutcome, +): LoginFailureReason | null { + if (outcome === 'failed') return 'wrongPassword'; + if (outcome === 'mfa-required') return 'appPasswordRequired'; + return null; +} + +/** 리스너 → 방식 — imap… → imap, submission… · smtp… → smtp, 그 밖(http 등 — 웹메일은 서버가 따로 남긴다)은 null */ +export function methodOfListener( + listenerId: unknown, +): EngineLoginEvent['method'] | null { + if (typeof listenerId !== 'string') return null; + const id = listenerId.toLowerCase(); + if (id.startsWith('imap')) return 'imap'; + if (id.startsWith('submission') || id.startsWith('smtp')) return 'smtp'; + return null; +} + +/** 엔진의 계정 이름 → 주소 (로컬 이름이면 사내 도메인을 붙인다) — 소문자, 모양이 아니면 null. 직원인지는 부르는 쪽이 본다 */ +export function addressOfAccountName( + accountName: string, + domain: string, +): string | null { + const name = accountName.trim().toLowerCase(); + if (name === '' || name.length > MAX_ACCOUNT_NAME_LENGTH) return null; + return name.includes('@') ? name : `${name}@${domain}`; +} + +/** 사람이 읽을 IP — IPv4 를 담은 IPv6 는 IPv4 로, IP 가 아니면 null */ +function normalizedIp(value: unknown): string | null { + if (typeof value !== 'string' || value.length > MAX_IP_LENGTH) return null; + const trimmed = value.trim(); + const mapped = trimmed.toLowerCase().startsWith(IPV4_MAPPED_PREFIX) + ? trimmed.slice(IPV4_MAPPED_PREFIX.length) + : null; + if (mapped !== null && isIPv4(mapped)) return mapped; + return isIP(trimmed) === 0 ? null : trimmed; +} + +/** 지문용 정규화 — 객체의 키를 차례로 (값은 그대로). 깊이는 본문 모양 한도가 이미 막았다 */ +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (value !== null && typeof value === 'object') { + const record = value as Record; + return Object.keys(record) + .sort() + .map((key) => [key, canonical(record[key])]); + } + return value; +} + +/** 이벤트 내용의 지문 — 종류 · 일어난 때(글 그대로) · data 전체 */ +export function engineEventFingerprint( + type: string, + createdAt: unknown, + data: Record, +): string { + return createHash('sha256') + .update(JSON.stringify([type, createdAt ?? null, canonical(data)])) + .digest('base64url'); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** 웹훅 본문 → 남길 만한 로그인 이벤트 (사내 직원인지 · 사내 목록에 있는지는 부르는 쪽이 본다) */ +export function readEngineEvents( + body: unknown, + receivedAt: Date, +): EngineEventsReading { + const list = isRecord(body) ? body.events : undefined; + if (!Array.isArray(list)) { + return { events: [], skipped: 0, malformed: true }; + } + + const events: EngineLoginEvent[] = []; + let skipped = 0; + for (const item of list as unknown[]) { + const event = readEvent(item, receivedAt); + if (event) events.push(event); + else skipped += 1; + } + return { events, skipped, malformed: false }; +} + +function readEvent(item: unknown, receivedAt: Date): EngineLoginEvent | null { + if (!isRecord(item) || typeof item.type !== 'string') return null; + const outcome = OUTCOMES.get(item.type); + const data = isRecord(item.data) ? item.data : null; + if (!outcome || !data) return null; + + const method = methodOfListener(data.listenerId); + if (method === null) return null; + if (typeof data.accountName !== 'string') return null; + const accountName = data.accountName.trim().toLowerCase(); + if (accountName === '' || accountName.length > MAX_ACCOUNT_NAME_LENGTH) { + return null; + } + + const created = + typeof item.createdAt === 'string' ? new Date(item.createdAt) : null; + const occurredAt = + created && !Number.isNaN(created.getTime()) ? created : receivedAt; + const isAppPassword = + outcome === 'success' && + typeof data.details === 'string' && + APP_PASSWORD_DETAILS.test(data.details); + + return { + dedupeKey: engineEventFingerprint(item.type, item.createdAt, data), + occurredAt, + method, + outcome, + accountName, + remoteIp: normalizedIp(data.remoteIp), + appPasswordId: isAppPassword ? credentialNumberToId(data.id) : null, + }; +} diff --git a/backend/src/modules/account-security/mail-engine-events.controller.ts b/backend/src/modules/account-security/mail-engine-events.controller.ts new file mode 100644 index 0000000..9f5818b --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-events.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { MAIL_ENGINE_EVENTS_ROUTE } from './account-security.constants'; +import { MailEngineEventsGuard } from './mail-engine-events.guard'; +import { MailEngineEventsService } from './mail-engine-events.service'; + +// 엔진만 부르는 길 (계약 5절 "엔진 웹훅 받기") — 앞단 nginx 가 /api/internal/ 을 404 로 닫고, 본문 파서가 소켓 주소(앱 네트워크) · +// 서명(HMAC-SHA256)을 본문을 읽기 전에 확인한다(mail-engine-events.parser.ts — 아니면 401 AUTH_001). 요청 수 한도가 없다 — +// 엔진이 1초에 한 번까지 묶어 보낸다. 출처 검사(OriginGuard)는 엔진 요청에 Origin 이 없어 통과한다 +@ApiTags('Internal') +@SkipThrottle() +@UseGuards(MailEngineEventsGuard) +@Controller(MAIL_ENGINE_EVENTS_ROUTE) +export class MailEngineEventsController { + constructor(private readonly engineEvents: MailEngineEventsService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + '메일 엔진의 로그인 이벤트 받기 (엔진 웹훅 — 브라우저 · 앱은 부르지 않는다)', + description: + '본문 {"events":[{id, createdAt, type, data}]} — auth.success · auth.failed · auth.mfa-required 의 IMAP · SMTP 이벤트만 로그인 기록에 남긴다. ' + + '사내 직원만(사내 목록에 없는 주소의 실패 · 막은 계정은 버린다), 겹침은 이벤트 내용의 지문으로 거른다. ' + + '본문 4MB · 배열 하나 10,000 · 값 200,000 까지. DB 에 남기지 못하면 5xx — 엔진이 다시 보낸다.', + }) + @ApiHeader({ + name: 'X-Signature', + description: '본문 원문의 HMAC-SHA256 (MAIL_WEBHOOK_SECRET), base64', + required: true, + }) + @ApiBody({ + description: '엔진 웹훅 본문', + schema: { + type: 'object', + properties: { + events: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + type: { type: 'string', example: 'auth.success' }, + data: { type: 'object' }, + }, + }, + }, + }, + }, + }) + @ApiStandardResponse(undefined, { description: '받았다 (data: null)' }) + @ApiErrorResponse( + 'AUTH_001', + '앱 네트워크 밖의 접속 · 서명이 없거나 맞지 않음 · 서명 키가 설정되지 않음(개발) · JSON 이 아닌 본문', + ) + @ApiErrorResponse('VAL_001', '본문이 JSON 이 아니거나 모양 한도를 넘음') + async receive(@Body() body: unknown): Promise { + await this.engineEvents.accept(body); + return null; + } +} diff --git a/backend/src/modules/account-security/mail-engine-events.guard.ts b/backend/src/modules/account-security/mail-engine-events.guard.ts new file mode 100644 index 0000000..643921f --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-events.guard.ts @@ -0,0 +1,27 @@ +import { + type CanActivate, + type ExecutionContext, + Injectable, +} from '@nestjs/common'; +import type { Request } from 'express'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { isVerifiedMailEngineEventsRequest } from './mail-engine-events.parser'; + +/** + * 엔진 웹훅 처리기 앞의 가드 — 본문 파서(mail-engine-events.parser.ts)가 접속 주소 · 서명을 확인한 요청만 들인다. + * 파서를 건너뛴 요청(JSON 이 아닌 본문 · 빈 본문 등)은 확인을 거치지 않았으므로 401 AUTH_001 — 본문을 쓰지 않는다 + */ +@Injectable() +export class MailEngineEventsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + if (isVerifiedMailEngineEventsRequest(request)) return true; + throw new BusinessException('AUTH_001', { + context: { + reason: 'mail-engine-events-not-verified', + remoteAddress: request.socket?.remoteAddress ?? 'unknown', + }, + }); + } +} diff --git a/backend/src/modules/account-security/mail-engine-events.parser.spec.ts b/backend/src/modules/account-security/mail-engine-events.parser.spec.ts new file mode 100644 index 0000000..793f912 --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-events.parser.spec.ts @@ -0,0 +1,284 @@ +import { createHmac } from 'crypto'; +import express from 'express'; +import { createServer, type Server } from 'http'; +import { connect } from 'net'; +import request from 'supertest'; + +import { parseCidr } from '@/shared/utils/cidr'; +import type { AccountSecuritySettings } from './account-security.settings'; +import { + MailEngineEventsRejection, + MailEngineRejectionLog, + createMailEngineEventsBodyParser, + hasValidEngineSignature, + isMailEngineEventsRequest, + isVerifiedMailEngineEventsRequest, +} from './mail-engine-events.parser'; + +const SECRET = 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a'; +const sign = (body: string, secret = SECRET) => + createHmac('sha256', Buffer.from(secret, 'utf8')) + .update(body) + .digest('base64'); + +describe('엔진 웹훅 서명 (X-Signature — 본문 원문의 HMAC-SHA256, base64)', () => { + const body = '{"events":[]}'; + + it('같은 키로 서명한 본문이면 true (엔진은 키를 UTF-8 바이트로 쓴다)', () => { + expect(hasValidEngineSignature(Buffer.from(body), sign(body), SECRET)).toBe( + true, + ); + }); + + it('다른 키 · 바뀐 본문 · 없거나 깨진 서명 · 길이가 다른 서명은 false', () => { + expect( + hasValidEngineSignature( + Buffer.from(body), + sign(body, `${SECRET}x`), + SECRET, + ), + ).toBe(false); + expect( + hasValidEngineSignature( + Buffer.from('{"events":[1]}'), + sign(body), + SECRET, + ), + ).toBe(false); + for (const signature of [ + undefined, + '', + 'not-base64!!', + 'AAAA', + ['a', 'b'], + ]) { + expect( + hasValidEngineSignature(Buffer.from(body), signature, SECRET), + ).toBe(false); + } + }); +}); + +describe('엔진 웹훅 경로 판정', () => { + it('POST /api/internal/mail-engine-events 만 (쿼리 · 끝 / 무관, 대소문자 변형은 넓게 — 라우터에서 404)', () => { + for (const url of [ + '/api/internal/mail-engine-events', + '/api/internal/mail-engine-events/', + '/api/internal/mail-engine-events?x=1', + '/API/Internal/Mail-Engine-Events', + ]) { + expect(isMailEngineEventsRequest({ method: 'POST', url })).toBe(true); + } + expect( + isMailEngineEventsRequest({ + method: 'GET', + url: '/api/internal/mail-engine-events', + }), + ).toBe(false); + expect( + isMailEngineEventsRequest({ method: 'POST', url: '/api/messages' }), + ).toBe(false); + }); +}); + +describe('엔진 웹훅 거절의 warn (MailEngineRejectionLog — 같은 까닭은 1분에 한 번)', () => { + const rejection = (reason: string) => + new MailEngineEventsRejection(reason, '172.16.71.2'); + + it('처음 거절은 바로 남기고, 구간 안의 같은 까닭은 세기만 하다가 구간이 지나면 그 수를 붙여 다시 남긴다', () => { + const lines: string[] = []; + const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000); + const secretMissing = rejection('mail-engine-events-secret-missing'); + + log.note(secretMissing, 'trace-1', 0); + for (let second = 1; second < 60; second += 1) { + log.note(secretMissing, `trace-${second}`, second * 1000); + } + expect(lines).toEqual([ + '[AUTH_001][trace-1] POST /api/internal/mail-engine-events {"reason":"mail-engine-events-secret-missing","remoteAddress":"172.16.71.2"} (같은 까닭은 60초에 한 번만 남긴다)', + ]); + + log.note(secretMissing, 'trace-60', 60_000); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain('[trace-60]'); + expect(lines[1]).toContain('앞 구간에 같은 까닭으로 59번 더 거절했다'); + }); + + it('까닭이 다르면 따로 센다 — 서명 틀림은 서명 키 없음에 묻히지 않는다', () => { + const lines: string[] = []; + const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000); + log.note(rejection('mail-engine-events-secret-missing'), undefined, 0); + log.note(rejection('mail-engine-events-bad-signature'), undefined, 1000); + log.note(rejection('mail-engine-events-bad-signature'), undefined, 2000); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain('mail-engine-events-bad-signature'); + expect(lines[1]).not.toContain('[undefined]'); + }); +}); + +describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () => { + const PATH = '/api/internal/mail-engine-events'; + const settings = ( + overrides: Partial = {}, + ): AccountSecuritySettings => ({ + webhookSecret: SECRET, + appNetwork: parseCidr('127.0.0.0/8')!, + isAppNetworkAddress: () => true, + ...overrides, + }); + const appWith = (config: AccountSecuritySettings, lines: string[]) => { + const app = express(); + app.use((req, _res, next) => { + req.headers['x-request-id'] = 'trace-e'; + next(); + }); + app.use( + createMailEngineEventsBodyParser( + config, + new MailEngineRejectionLog((line) => lines.push(line)), + ), + ); + app.post(PATH, (req, res) => { + res.json({ + verified: isVerifiedMailEngineEventsRequest(req), + body: req.body as unknown, + }); + }); + return app; + }; + const AUTH_001_BODY = { + success: false, + error: { + code: 'AUTH_001', + message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.', + traceId: 'trace-e', + }, + }; + + it('서명이 맞으면 본문을 읽고 표시를 남긴다 — 빈 묶음도 (setup 의 확인)', async () => { + const lines: string[] = []; + const raw = '{"events":[]}'; + const res = await request(appWith(settings(), lines)) + .post(PATH) + .set('Content-Type', 'application/json') + .set('X-Signature', sign(raw)) + .set('X-Arasystem-Setup', '[setup.mjs] key=abc') + .send(raw) + .expect(200); + expect(res.body).toEqual({ verified: true, body: { events: [] } }); + expect(lines).toEqual([]); + }); + + it('서명 키가 없거나 · 서명이 틀리거나 · 앱 네트워크 밖이면 표준 실패 형식의 401 AUTH_001 — warn 은 까닭마다 한 번', async () => { + const raw = '{"events":[]}'; + const cases: [AccountSecuritySettings, string | undefined, string][] = [ + [settings({ webhookSecret: null }), sign(raw), 'secret-missing'], + [settings(), sign(raw, `${SECRET}x`), 'bad-signature'], + [settings(), undefined, 'bad-signature'], + [ + settings({ isAppNetworkAddress: () => false }), + sign(raw), + 'outside-app-network', + ], + ]; + for (const [config, signature, reason] of cases) { + const lines: string[] = []; + const app = appWith(config, lines); + for (let attempt = 0; attempt < 3; attempt += 1) { + const call = request(app) + .post(PATH) + .set('Content-Type', 'application/json'); + if (signature !== undefined) call.set('X-Signature', signature); + const res = await call.send(raw).expect(401); + expect(res.body).toEqual(AUTH_001_BODY); + } + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(`mail-engine-events-${reason}`); + expect(lines[0]).not.toContain(SECRET); + } + }); + + describe('본문을 받기 전에 — 접속 주소 · 서명 키 (2026-10-31 리뷰: 앱 네트워크 밖도 4MB 까지 받았다)', () => { + /** 머리글만 보내고(본문은 보내지 않는다) 응답을 기다린다 — 본문을 기다리는 파서라면 답하지 않는다 */ + async function headersOnly( + config: AccountSecuritySettings, + lines: string[], + ): Promise { + const server: Server = createServer(appWith(config, lines)); + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + try { + return await new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1'); + let text = ''; + // 답하지 않는 것을 보는 기한 — 일찍 답하는 경로는 곧바로 끝나므로 이 기한에 기대지 않는다 + const timer = setTimeout(() => { + socket.destroy(); + resolve('NO-ANSWER'); + }, 1_000); + socket.on('data', (chunk: Buffer) => { + text += chunk.toString('utf8'); + }); + socket.on('end', () => { + clearTimeout(timer); + resolve(text); + }); + socket.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + socket.write( + `POST ${PATH} HTTP/1.1\r\nHost: backend\r\nContent-Type: application/json\r\n` + + `X-Signature: ${sign('{"events":[]}')}\r\nContent-Length: 3000000\r\n\r\n`, + ); + }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + } + + it.each([ + [ + '앱 네트워크 밖', + settings({ isAppNetworkAddress: () => false }), + 'outside-app-network', + ], + [ + '서명 키 없음(개발)', + settings({ webhookSecret: null }), + 'secret-missing', + ], + ])( + '%s — 본문을 기다리지 않고 바로 401 AUTH_001 을 쓰고 연결을 닫는다', + async (_label, config, reason) => { + const lines: string[] = []; + const answer = await headersOnly(config, lines); + + expect(answer).toMatch(/^HTTP\/1\.1 401 /); + expect(answer.toLowerCase()).toContain('connection: close'); + expect(answer).toContain('"code":"AUTH_001"'); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain(`mail-engine-events-${reason}`); + }, + ); + + it('주소 · 서명 키가 맞으면 본문을 받는다 — 서명은 원문을 다 받은 뒤에 본다(본문이 오지 않으면 답하지 않는다)', async () => { + const lines: string[] = []; + await expect(headersOnly(settings(), lines)).resolves.toBe('NO-ANSWER'); + expect(lines).toEqual([]); + }); + }); + + it('JSON 이 아닌 본문은 읽지 않고 지나간다 — 처리기 앞의 가드가 표시 없는 요청을 막는다', async () => { + const lines: string[] = []; + const res = await request(appWith(settings(), lines)) + .post(PATH) + .set('Content-Type', 'text/plain') + .send('{"events":[]}') + .expect(200); + expect(res.body).toMatchObject({ verified: false }); + expect(lines).toEqual([]); + }); +}); diff --git a/backend/src/modules/account-security/mail-engine-events.parser.ts b/backend/src/modules/account-security/mail-engine-events.parser.ts new file mode 100644 index 0000000..7c9656d --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-events.parser.ts @@ -0,0 +1,240 @@ +import { HttpStatus, Logger } from '@nestjs/common'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { json, type RequestHandler } from 'express'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { getErrorDefinition } from '@/common/errors/error-codes'; +import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware'; +import { findJsonTextShapeProblem } from '@/shared/utils/json-shape'; +import { + MAIL_ENGINE_EVENTS_JSON_LIMIT, + MAIL_ENGINE_EVENTS_ROUTE, + MAIL_ENGINE_EVENTS_SHAPE_LIMITS, + MAIL_ENGINE_REJECTION_LOG_WINDOW_MS, + MAIL_ENGINE_SIGNATURE_HEADER, +} from './account-security.constants'; +import type { AccountSecuritySettings } from './account-security.settings'; + +/** + * 엔진 웹훅의 본문 파서 (계약 5절 "엔진 웹훅 받기") — 서버 몫의 두 겹: + * 1. 접속 주소 — `X-Forwarded-For` 가 아니라 소켓 주소가 앱 네트워크(`APP_NETWORK_SUBNET`) 안이어야 한다. + * 서명 키가 없는지(개발)와 함께 **본문을 받기 전에** 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다 + * (앱 네트워크 밖의 접속이 4MB 까지 보내게 두지 않는다 — 2026-10-31 리뷰) + * 2. 서명 — 본문 원문의 HMAC-SHA256(`X-Signature`, base64)이 `MAIL_WEBHOOK_SECRET` 으로 맞아야 한다 (상수 시간 비교). + * 원문이 있어야 하므로 본문을 받은 뒤 · JSON 으로 읽기 전에(body-parser 의 verify) 본다 + * 하나라도 아니면 401 AUTH_001(표준 실패 형식)을 이 파서가 바로 쓴다. warn 에는 까닭과 접속 주소만 남기고, + * 같은 까닭은 1분에 한 번만 남긴다(MailEngineRejectionLog — 서명 키가 어긋나면 엔진이 1초마다 보낸다). + * (세 번째 겹 — 앞단 nginx 가 /api/internal/ 을 404 로 닫는 것은 release-ops 몫이다) + * + * 이 경로만 JSON 한도를 따로 둔다(본문 4MB · 배열 하나 10,000 · 값 200,000 — 공격이 오면 엔진의 1초 묶음이 커진다). + * 통과한 요청에는 표시를 남긴다 — 처리기 앞의 가드(MailEngineEventsGuard)가 표시 없는 요청(JSON 이 아니라 이 파서를 건너뛴 본문 등)을 막는다. + * 서명 키 · 본문은 어떤 로그에도 남기지 않는다. + */ + +/** 이 파서를 지난(주소 · 서명이 맞은) 요청의 표시 */ +const VERIFIED = Symbol('mail-engine-events-verified'); + +type MarkedRequest = IncomingMessage & { [VERIFIED]?: true }; + +const MAIL_ENGINE_EVENTS_PATH = `/api/${MAIL_ENGINE_EVENTS_ROUTE}`; +/** UTF-8 의 비교용 이름 — app.setup.ts 의 verifyJsonBody 와 같은 정규화 (body-parser 가 UTF-8 로 읽는 이름들) */ +const UTF8_CHARSET = 'utf8'; +/** SHA-256 의 길이(바이트) */ +const SHA256_BYTES = 32; + +/** 엔진 웹훅 요청인가 — POST /api/internal/mail-engine-events (쿼리 · 끝 `/` 는 가리지 않고, 대소문자는 넓게 — 변형은 라우터에서 404) */ +export function isMailEngineEventsRequest( + req: Pick, +): boolean { + if (req.method !== 'POST') return false; + const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase(); + return path === MAIL_ENGINE_EVENTS_PATH; +} + +/** 파서가 주소 · 서명을 확인한 요청인가 (가드가 본다) */ +export function isVerifiedMailEngineEventsRequest(req: object): boolean { + return (req as MarkedRequest)[VERIFIED] === true; +} + +/** 주소 · 서명 확인에서 거절한 요청 — 파서가 401 AUTH_001 을 바로 쓴다 (전역 필터로 보내지 않는다) */ +export class MailEngineEventsRejection extends Error { + constructor( + readonly reason: string, + readonly remoteAddress: string, + ) { + super(reason); + this.name = 'MailEngineEventsRejection'; + } +} + +function rejected( + reason: string, + req: IncomingMessage, +): MailEngineEventsRejection { + return new MailEngineEventsRejection( + reason, + req.socket?.remoteAddress ?? 'unknown', + ); +} + +/** + * 웹훅 거절의 warn — 같은 까닭은 구간(1분)에 한 번만 남기고, 그사이 거절한 수는 다음 줄에 붙인다. + * 까닭은 셋뿐이라(앱 네트워크 밖 · 서명 키 없음 · 서명 틀림) 기억이 커지지 않는다. 서명 · 본문은 받지 않는다 + */ +export class MailEngineRejectionLog { + private readonly windows = new Map< + string, + { endsAt: number; suppressed: number } + >(); + + constructor( + private readonly write: (message: string) => void, + private readonly windowMs: number = MAIL_ENGINE_REJECTION_LOG_WINDOW_MS, + ) {} + + note( + rejection: MailEngineEventsRejection, + traceId: string | undefined, + now: number = Date.now(), + ): void { + const current = this.windows.get(rejection.reason); + if (current && now < current.endsAt) { + current.suppressed += 1; + return; + } + this.windows.set(rejection.reason, { + endsAt: now + this.windowMs, + suppressed: 0, + }); + const context = JSON.stringify({ + reason: rejection.reason, + remoteAddress: rejection.remoteAddress, + }); + const earlier = + current && current.suppressed > 0 + ? ` — 앞 구간에 같은 까닭으로 ${current.suppressed}번 더 거절했다` + : ''; + this.write( + `[AUTH_001]${traceId ? `[${traceId}]` : ''} POST ${MAIL_ENGINE_EVENTS_PATH} ${context}${earlier} (같은 까닭은 ${Math.round(this.windowMs / 1000)}초에 한 번만 남긴다)`, + ); + } +} + +/** `X-Signature`(base64) 가 본문의 HMAC-SHA256 과 같은가 — 길이가 다르면 바로 false, 같으면 상수 시간 비교 */ +export function hasValidEngineSignature( + body: Buffer, + signature: string | string[] | undefined, + secret: string, +): boolean { + if (typeof signature !== 'string' || signature === '') return false; + const presented = Buffer.from(signature.trim(), 'base64'); + if (presented.length !== SHA256_BYTES) return false; + const expected = createHmac('sha256', Buffer.from(secret, 'utf8')) + .update(body) + .digest(); + return timingSafeEqual(expected, presented); +} + +/** 본문을 읽기 전의 확인 — body-parser 의 verify (던지면 그 상태 코드로 끝난다 — BusinessException 의 status) */ +function verifier(settings: AccountSecuritySettings) { + return ( + req: IncomingMessage, + _res: ServerResponse, + buffer: Buffer, + encoding: string, + ): void => { + // 접속 주소 · 서명 키 유무는 본문을 받기 전에 이미 봤다(createMailEngineEventsBodyParser) — 키는 타입을 좁히려고 한 번 더 + if (settings.webhookSecret === null) { + throw rejected('mail-engine-events-secret-missing', req); + } + if ( + !hasValidEngineSignature( + buffer, + req.headers[MAIL_ENGINE_SIGNATURE_HEADER], + settings.webhookSecret, + ) + ) { + throw rejected('mail-engine-events-bad-signature', req); + } + if (encoding.toLowerCase().replace(/[^0-9a-z]/g, '') !== UTF8_CHARSET) { + throw new BusinessException('VAL_001', { + context: { reason: 'json-not-utf8' }, + }); + } + const problem = findJsonTextShapeProblem( + buffer, + MAIL_ENGINE_EVENTS_SHAPE_LIMITS, + ); + if (problem !== null) { + throw new BusinessException('VAL_001', { + context: { reason: 'json-shape-limit', problem, stage: 'before-parse' }, + }); + } + (req as MarkedRequest)[VERIFIED] = true; + }; +} + +const logger = new Logger('MailEngineEvents'); + +/** + * 엔진 웹훅의 JSON 파서 — configureApp 이 **다른 JSON 파서보다 먼저** 붙인다(이 경로의 본문을 1MB 파서가 413 으로 끊지 않게). + * 이 경로의 JSON 본문만 읽는다 — 그 밖의 요청은 건너뛴다. + * - 이 경로면 본문을 받기 전에 접속 주소(앱 네트워크) · 서명 키 유무를 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다(`Connection: close`) + * - 서명은 원문을 받은 뒤 JSON 으로 읽기 전에 본다 + * 거절은 여기서 표준 실패 형식의 401 AUTH_001 을 쓴다(전역 필터와 같은 모양 — 요청마다 warn 을 남기지 않으려고). + * 서명이 맞은 뒤의 실패(UTF-8 아님 · 모양 한도 — 엔진이 보낸 본문)는 전역 필터로 간다 + */ +export function createMailEngineEventsBodyParser( + settings: AccountSecuritySettings, + rejections: MailEngineRejectionLog = new MailEngineRejectionLog((message) => + logger.warn(message), + ), +): RequestHandler { + const parse = json({ + limit: MAIL_ENGINE_EVENTS_JSON_LIMIT, + inflate: false, + type: (req: IncomingMessage) => + isMailEngineEventsRequest(req) && + (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() === + 'application/json', + verify: verifier(settings), + }); + return (req, res, next) => { + const reject = ( + rejection: MailEngineEventsRejection, + bodyUnread: boolean, + ) => { + const traceId = req.header(REQUEST_ID_HEADER) ?? undefined; + rejections.note(rejection, traceId); + // 본문을 받지 않고 끝낸 응답 — 연결을 닫아 남은 본문도 받지 않는다 + if (bodyUnread) res.setHeader('Connection', 'close'); + res.status(HttpStatus.UNAUTHORIZED).json({ + success: false, + error: { + code: 'AUTH_001', + message: getErrorDefinition('AUTH_001').message, + ...(traceId ? { traceId } : {}), + }, + }); + }; + + if (isMailEngineEventsRequest(req)) { + if (!settings.isAppNetworkAddress(req.socket?.remoteAddress)) { + reject(rejected('mail-engine-events-outside-app-network', req), true); + return; + } + if (settings.webhookSecret === null) { + reject(rejected('mail-engine-events-secret-missing', req), true); + return; + } + } + parse(req, res, (error?: unknown) => { + if (error instanceof MailEngineEventsRejection) { + reject(error, false); + return; + } + next(error); + }); + }; +} diff --git a/backend/src/modules/account-security/mail-engine-events.service.spec.ts b/backend/src/modules/account-security/mail-engine-events.service.spec.ts new file mode 100644 index 0000000..e76b8f5 --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-events.service.spec.ts @@ -0,0 +1,219 @@ +import { Logger } from '@nestjs/common'; + +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { AppPasswordsService } from './app-passwords.service'; +import type { LoginEventEntity } from './login-event.entity'; +import type { LoginEventsService } from './login-events.service'; +import { MailEngineEventsService } from './mail-engine-events.service'; + +const settings: MailSettings = { + jmapBaseUrl: 'http://mail:8080', + domain: 'arasystem.test', + gatewayUser: 'mail-gateway@arasystem.test', + gatewayPassword: 'gateway-secret', + attachmentMaxBytes: 20 * 1024 * 1024, + attachmentTotalMaxBytes: 25 * 1024 * 1024, + blockedAccounts: new Set([ + 'admin@arasystem.test', + 'mail-directory@arasystem.test', + ]), + directoryAccount: 'mail-directory@arasystem.test', +}; + +const event = ( + type: string, + data: Record, + createdAt = '2026-10-31T00:00:05Z', +) => ({ + id: `${Math.random()}`, + createdAt, + type, + data: { + listenerId: 'imaps', + remoteIp: '211.186.95.252', + remotePort: 51234, + spanId: 1, + ...data, + }, +}); + +describe('MailEngineEventsService (엔진 웹훅 — 메일 프로그램의 로그인 기록)', () => { + let inserted: Omit[]; + let existingKeys: Set; + let loginEvents: { + insertEngineEvents: jest.Mock; + isKnownEmployee: jest.Mock; + }; + let appPasswords: { recordUse: jest.Mock }; + let service: MailEngineEventsService; + + beforeEach(() => { + inserted = []; + existingKeys = new Set(); + loginEvents = { + insertEngineEvents: jest.fn((rows: Omit[]) => { + const fresh = rows.filter( + (row) => !existingKeys.has(row.dedupeKey ?? ''), + ); + inserted.push(...fresh); + for (const row of fresh) existingKeys.add(row.dedupeKey ?? ''); + return Promise.resolve( + new Set(fresh.map((row) => row.dedupeKey ?? '')), + ); + }), + isKnownEmployee: jest.fn((address: string) => + Promise.resolve(address !== 'nobody@arasystem.test'), + ), + }; + appPasswords = { recordUse: jest.fn().mockResolvedValue('회사 PC 아웃룩') }; + service = new MailEngineEventsService( + settings, + loginEvents as unknown as LoginEventsService, + appPasswords as unknown as AppPasswordsService, + ); + }); + + it('빈 묶음은 아무것도 남기지 않고 끝난다 (setup 의 서명한 빈 묶음 — 200)', async () => { + await expect(service.accept({ events: [] })).resolves.toEqual({ + kept: 0, + duplicates: 0, + dropped: 0, + }); + expect(inserted).toEqual([]); + }); + + it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 남긴다 — 실패 까닭 · IP · 방식', async () => { + const result = await service.accept({ + events: [ + event('auth.success', { accountName: 'doyun.lee@arasystem.test' }), + event('auth.failed', { + listenerId: 'submissions', + accountName: 'doyun.lee@arasystem.test', + spanId: 2, + }), + event('auth.mfa-required', { + accountName: 'ara.kim@arasystem.test', + spanId: 3, + }), + ], + }); + + expect(result).toEqual({ kept: 3, duplicates: 0, dropped: 0 }); + expect( + inserted.map(({ userEmail, method, isSuccess, failureReason }) => ({ + userEmail, + method, + isSuccess, + failureReason, + })), + ).toEqual([ + { + userEmail: 'doyun.lee@arasystem.test', + method: 'imap', + isSuccess: true, + failureReason: null, + }, + { + userEmail: 'doyun.lee@arasystem.test', + method: 'smtp', + isSuccess: false, + failureReason: 'wrongPassword', + }, + { + userEmail: 'ara.kim@arasystem.test', + method: 'imap', + isSuccess: false, + failureReason: 'appPasswordRequired', + }, + ]); + expect(inserted[0]).toMatchObject({ + ip: '211.186.95.252', + isTwoFactor: false, + userAgent: null, + appPasswordId: null, + }); + }); + + it('사내 직원만 — 다른 도메인 · 막은 계정(관리자 · 사내 목록 계정 · 게이트웨이)은 버린다, 사내 목록에 없는 주소의 실패도 버린다', async () => { + const result = await service.accept({ + events: [ + event('auth.failed', { accountName: 'someone@gmail.com' }), + event('auth.success', { accountName: 'admin@arasystem.test' }), + event('auth.success', { accountName: 'mail-gateway@arasystem.test' }), + event('auth.failed', { accountName: 'nobody@arasystem.test' }), + event('auth.failed', { + accountName: 'nobody@arasystem.test', + spanId: 9, + }), + ], + }); + + expect(result).toEqual({ kept: 0, duplicates: 0, dropped: 5 }); + // 같은 묶음의 같은 주소는 사내 목록을 한 번만 묻는다 + expect(loginEvents.isKnownEmployee).toHaveBeenCalledTimes(1); + }); + + it('앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다', async () => { + await service.accept({ + events: [ + event('auth.success', { + accountName: 'doyun.lee', + id: 1, + details: 'Authenticated with app password', + }), + ], + }); + + expect(appPasswords.recordUse).toHaveBeenCalledWith( + 'doyun.lee@arasystem.test', + 'b', + { + at: new Date('2026-10-31T00:00:05Z'), + method: 'imap', + ip: '211.186.95.252', + }, + ); + expect(inserted[0]).toMatchObject({ + userEmail: 'doyun.lee@arasystem.test', + appPasswordId: 'b', + appPasswordName: '회사 PC 아웃룩', + }); + }); + + it('엔진이 다시 보낸 묶음(이벤트 id 만 다르다)은 겹치지 않는다 · 한 묶음 안의 같은 이벤트도', async () => { + const original = event('auth.success', { + accountName: 'doyun.lee@arasystem.test', + }); + await service.accept({ events: [original, { ...original, id: 'again' }] }); + const retried = await service.accept({ + events: [{ ...original, id: 'retried-with-new-id' }], + }); + + expect(inserted).toHaveLength(1); + expect(retried).toEqual({ kept: 0, duplicates: 1, dropped: 0 }); + }); + + it('본문 모양이 다르면 아무것도 남기지 않고 warn 을 한 번만 남긴다 (본문은 남기지 않는다)', async () => { + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + await service.accept({ nope: true }); + await service.accept('text'); + + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).not.toContain('nope'); + expect(loginEvents.insertEngineEvents).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('DB 에 남기지 못하면 던진다 — 5xx 로 답해 엔진이 다시 보낸다', async () => { + loginEvents.insertEngineEvents.mockRejectedValue(new Error('db down')); + await expect( + service.accept({ + events: [ + event('auth.success', { accountName: 'doyun.lee@arasystem.test' }), + ], + }), + ).rejects.toThrow('db down'); + }); +}); diff --git a/backend/src/modules/account-security/mail-engine-events.service.ts b/backend/src/modules/account-security/mail-engine-events.service.ts new file mode 100644 index 0000000..54efc4c --- /dev/null +++ b/backend/src/modules/account-security/mail-engine-events.service.ts @@ -0,0 +1,128 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import { isEmployeeAddress } from '@/modules/auth/employee-address'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { AppPasswordsService } from './app-passwords.service'; +import type { LoginEventEntity } from './login-event.entity'; +import { LoginEventsService } from './login-events.service'; +import { + addressOfAccountName, + failureReasonOf, + readEngineEvents, +} from './mail-engine-event'; + +/** 웹훅 한 번의 처리 결과 (debug 로그 · 테스트) */ +export interface EngineEventsAccepted { + /** 새로 남긴 수 */ + readonly kept: number; + /** 이미 남긴 것(엔진이 다시 보낸 묶음) */ + readonly duplicates: number; + /** 버린 수 — 다른 종류 · 리스너 · 사내 직원이 아님 · 사내 목록에 없는 주소의 실패 · 막은 계정 */ + readonly dropped: number; +} + +/** + * 엔진 웹훅 받기 (계약 5절 "엔진 웹훅 받기") — 메일 프로그램(IMAP · SMTP)의 로그인을 로그인 기록에 남긴다. + * 접속 주소 · 서명은 본문 파서가 이미 확인했다 (mail-engine-events.parser.ts). + * + * - 사내 도메인의 직원만 — 막은 계정(관리자 · 게이트웨이 · 사내 목록 계정)과 사내 목록에 없는 주소의 실패는 버린다(엔진은 서버 전체의 이벤트를 보낸다) + * - 겹침은 이벤트 내용의 지문으로 거른다(엔진이 다시 보낸 묶음 — 엔진의 이벤트 id 는 보낼 때마다 바뀐다) + * - 앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다 + * - 처리는 기다린 뒤 답한다(DB 한 번 — 수 ms). DB 가 실패하면 5xx 로 답해 엔진이 다시 보낸다(5분까지 — 지문이 같아 겹치지 않는다) + * 웹훅 수신은 로그에 남기지 않는다(반복되는 정상 흐름) — 남긴 수 · 버린 수는 debug + */ +@Injectable() +export class MailEngineEventsService { + private readonly logger = new Logger(MailEngineEventsService.name); + /** 본문 모양이 다르다는 warn 을 이미 남겼는가 (엔진 판이 바뀐 경우 — 한 번만) */ + private malformedReported = false; + + constructor( + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly loginEvents: LoginEventsService, + private readonly appPasswords: AppPasswordsService, + ) {} + + async accept( + body: unknown, + receivedAt: Date = new Date(), + ): Promise { + const reading = readEngineEvents(body, receivedAt); + if (reading.malformed) { + if (!this.malformedReported) { + this.malformedReported = true; + this.logger.warn( + '[MAIL_001] 엔진 웹훅 본문이 {"events":[…]} 모양이 아닙니다 — 메일 엔진 판이 바뀌었는지 확인하세요. (본문은 남기지 않는다)', + ); + } + return { kept: 0, duplicates: 0, dropped: 0 }; + } + + const { domain, gatewayUser, blockedAccounts } = this.mailSettings; + if (!domain) { + return { kept: 0, duplicates: 0, dropped: reading.events.length }; + } + + const rows = new Map>(); + const known = new Map(); + let dropped = reading.skipped; + for (const event of reading.events) { + const address = addressOfAccountName(event.accountName, domain); + if ( + address === null || + !isEmployeeAddress(address, domain, gatewayUser, blockedAccounts) || + rows.has(event.dedupeKey) + ) { + dropped += 1; + continue; + } + if (event.outcome !== 'success') { + let isKnown = known.get(address); + if (isKnown === undefined) { + isKnown = await this.loginEvents.isKnownEmployee(address); + known.set(address, isKnown); + } + if (!isKnown) { + dropped += 1; + continue; + } + } + + const appPasswordName = event.appPasswordId + ? await this.appPasswords.recordUse(address, event.appPasswordId, { + at: event.occurredAt, + method: event.method, + ip: event.remoteIp, + }) + : null; + rows.set(event.dedupeKey, { + userEmail: address, + occurredAt: event.occurredAt, + method: event.method, + isSuccess: event.outcome === 'success', + isTwoFactor: false, + failureReason: failureReasonOf(event.outcome), + ip: event.remoteIp, + userAgent: null, + appPasswordId: event.appPasswordId, + appPasswordName, + dedupeKey: event.dedupeKey, + }); + } + + const inserted = await this.loginEvents.insertEngineEvents([ + ...rows.values(), + ]); + const result = { + kept: inserted.size, + duplicates: rows.size - inserted.size, + dropped, + }; + if (result.kept + result.duplicates + result.dropped > 0) { + this.logger.debug( + `엔진 로그인 이벤트 — 남김 ${result.kept} · 이미 있음 ${result.duplicates} · 버림 ${result.dropped}`, + ); + } + return result; + } +} diff --git a/backend/src/modules/account-security/two-factor-code-guard.spec.ts b/backend/src/modules/account-security/two-factor-code-guard.spec.ts new file mode 100644 index 0000000..63e2c88 --- /dev/null +++ b/backend/src/modules/account-security/two-factor-code-guard.spec.ts @@ -0,0 +1,288 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { TWO_FACTOR_CODE_ADDRESS_LIMIT } from '@/modules/auth/auth.constants'; +import { LoginAttemptService } from '@/modules/auth/login-attempt.service'; +import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants'; +import { TwoFactorCodeGuard } from './two-factor-code-guard'; +import { TwoFactorEnrollments } from './two-factor-enrollments'; +import { TwoFactorLocks } from './two-factor-locks'; + +const ARA = 'ara.kim@arasystem.test'; +const IP = '203.0.113.10'; +const accepted = () => + Promise.resolve({ kind: 'accepted' as const, value: 'ok' }); +const rejected = () => Promise.resolve({ kind: 'rejected' as const }); + +/** 손으로 푸는 약속 — 확인(run)이 걸려 있는 동안 다른 요청을 보낸다 */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +async function codeOf(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } + return undefined; +} + +describe('TwoFactorCodeGuard (코드 한도 · 다시 쓰기 막기 — 계약 5절)', () => { + let now: number; + let guard: TwoFactorCodeGuard; + + beforeEach(() => { + now = 1_800_000_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + guard = new TwoFactorCodeGuard(new LoginAttemptService()); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('받으면 값을 돌려준다 · 받지 않으면 400 AUTH_006 (context 에 코드는 없다)', async () => { + await expect( + guard.check({ email: ARA, ip: IP }, '123456', accepted), + ).resolves.toBe('ok'); + + let failure: unknown; + try { + await guard.check({ email: ARA, ip: IP }, '654321', rejected); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(BusinessException); + expect((failure as BusinessException).code).toBe('AUTH_006'); + expect((failure as BusinessException).getStatus()).toBe(400); + expect( + JSON.stringify((failure as BusinessException).context), + ).not.toContain('654321'); + }); + + it(`성공에 쓴 코드는 ${TWO_FACTOR_CODE_REUSE_MS / 60_000}분 동안 다시 받지 않는다 — 확인(run) 없이 AUTH_006 · 실패로 센다, 다른 직원 · 다른 코드는 그대로`, async () => { + guard.remember(ARA, '123456'); + const run = jest.fn(accepted); + + expect( + await codeOf(guard.check({ email: ARA, ip: IP }, '123456', run)), + ).toBe('AUTH_006'); + expect(run).not.toHaveBeenCalled(); + await expect( + guard.check({ email: 'doyun.lee@arasystem.test', ip: IP }, '123456', run), + ).resolves.toBe('ok'); + await expect( + guard.check({ email: ARA, ip: IP }, '111111', run), + ).resolves.toBe('ok'); + + now += TWO_FACTOR_CODE_REUSE_MS; + await expect( + guard.check({ email: ARA, ip: IP }, '123456', run), + ).resolves.toBe('ok'); + }); + + it(`실패가 ${TWO_FACTOR_CODE_ADDRESS_LIMIT}번 쌓이면 맞는 코드도 REQ_001 — 다시 쓴 코드도 실패로 센다`, async () => { + guard.remember(ARA, '999999'); + expect( + await codeOf(guard.check({ email: ARA, ip: IP }, '999999', accepted)), + ).toBe('AUTH_006'); + for (let i = 1; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) { + expect( + await codeOf( + guard.check( + { email: ARA, ip: `198.51.100.${i}` }, + '000000', + rejected, + ), + ), + ).toBe('AUTH_006'); + } + expect( + await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)), + ).toBe('REQ_001'); + }); + + it('확인이 던지면(엔진 장애) 자리를 돌려주고 그대로 던진다 — 실패로 세지 않는다', async () => { + const down = () => Promise.reject(new BusinessException('MAIL_001')); + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) { + expect( + await codeOf(guard.check({ email: ARA, ip: IP }, '123456', down)), + ).toBe('MAIL_001'); + } + await expect( + guard.check({ email: ARA, ip: IP }, '123456', accepted), + ).resolves.toBe('ok'); + }); + + describe('같은 코드의 동시 요청 — 확인을 시작할 때 "확인 중"을 차지한다 (2026-10-31 리뷰)', () => { + it('앞 요청이 확인하는 동안 같은 코드는 확인(run) 없이 400 AUTH_006 · 실패로 센다 — 앞이 받으면 그 코드는 쓴 코드다', async () => { + const gate = deferred<{ kind: 'accepted'; value: string }>(); + const first = guard.check( + { email: ARA, ip: IP }, + '123456', + () => gate.promise, + ); + expect(guard.isPending(ARA, '123456')).toBe(true); + + const run = jest.fn(accepted); + const second = await codeOf( + guard.check({ email: ARA, ip: '198.51.100.7' }, '123456', run), + ); + expect(second).toBe('AUTH_006'); + expect(run).not.toHaveBeenCalled(); + // 다른 코드 · 다른 직원은 그대로 확인한다 + await expect( + guard.check({ email: ARA, ip: IP }, '654321', accepted), + ).resolves.toBe('ok'); + + gate.resolve({ kind: 'accepted', value: 'first' }); + await expect(first).resolves.toBe('first'); + expect(guard.isPending(ARA, '123456')).toBe(false); + expect(guard.isUsed(ARA, '123456')).toBe(true); + }); + + it('받으면 일이 끝나기 전에(돌려주는 그 자리에서) 기억한다 — 뒤의 일(세션 저장 등)이 걸려 있어도 같은 코드는 막힌다', async () => { + await guard.check({ email: ARA, ip: IP }, '123456', accepted); + // 부른 쪽이 아직 세션을 만드는 중이라고 해도 같은 코드는 이미 쓴 코드다 + expect( + await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)), + ).toBe('AUTH_006'); + }); + + it('앞 요청이 받지 않았거나 던졌으면 차지를 풀어 같은 코드로 다시 확인할 수 있다', async () => { + const rejectedGate = deferred<{ kind: 'rejected' }>(); + const first = guard.check( + { email: ARA, ip: IP }, + '123456', + () => rejectedGate.promise, + ); + rejectedGate.resolve({ kind: 'rejected' }); + expect(await codeOf(first)).toBe('AUTH_006'); + expect(guard.isPending(ARA, '123456')).toBe(false); + expect(guard.isUsed(ARA, '123456')).toBe(false); + + const down = deferred(); + const second = guard.check( + { email: ARA, ip: IP }, + '123456', + () => down.promise, + ); + down.reject(new BusinessException('MAIL_001')); + expect(await codeOf(second)).toBe('MAIL_001'); + expect(guard.isPending(ARA, '123456')).toBe(false); + + await expect( + guard.check({ email: ARA, ip: IP }, '123456', accepted), + ).resolves.toBe('ok'); + }); + }); + + it('쓰지 않고 끝남(unused) — 값을 돌려주고 세지도 기억하지도 않는다 (예: 엔진이 새 비밀번호를 거절)', async () => { + const unused = () => + Promise.resolve({ kind: 'unused' as const, value: 'nothing-changed' }); + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) { + await expect( + guard.check({ email: ARA, ip: IP }, '123456', unused), + ).resolves.toBe('nothing-changed'); + } + expect(guard.isUsed(ARA, '123456')).toBe(false); + await expect( + guard.check({ email: ARA, ip: IP }, '123456', accepted), + ).resolves.toBe('ok'); + }); + + it('코드가 글이 아니어도(검사를 비켜 온 null) 차지한 실패 자리를 끝낸다 — 여러 번 와도 맞는 코드가 막히지 않는다', async () => { + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) { + expect( + await codeOf( + guard.check( + { email: ARA, ip: `198.51.100.${i}` }, + null as unknown as string, + accepted, + ), + ), + ).toBe('NOT_BUSINESS'); + } + await expect( + guard.check({ email: ARA, ip: IP }, '123456', accepted), + ).resolves.toBe('ok'); + }); + + it('정리 작업이 지난 기억을 지운다', () => { + guard.remember(ARA, '123456'); + expect(guard.isUsed(ARA, '123456')).toBe(true); + guard.sweep(now + TWO_FACTOR_CODE_REUSE_MS); + expect(guard.isUsed(ARA, '123456', now + TWO_FACTOR_CODE_REUSE_MS)).toBe( + false, + ); + }); +}); + +describe('TwoFactorLocks (같은 직원의 2단계 인증 바꾸기는 한 번에 하나)', () => { + it('잡혀 있으면 기다리지 않고 REQ_001 — 끝나면 풀린다, 다른 직원은 따로', async () => { + const locks = new TwoFactorLocks(); + let release!: () => void; + const first = locks.run( + ARA, + () => + new Promise((resolve) => { + release = resolve; + }), + ); + + expect(await codeOf(locks.run(ARA, () => Promise.resolve()))).toBe( + 'REQ_001', + ); + await expect( + locks.run('doyun.lee@arasystem.test', () => Promise.resolve('other')), + ).resolves.toBe('other'); + + release(); + await first; + await expect(locks.run(ARA, () => Promise.resolve('next'))).resolves.toBe( + 'next', + ); + }); + + it('일이 던져도 풀린다 · 푸는 함수는 몇 번 불러도 한 번만', async () => { + const locks = new TwoFactorLocks(); + expect( + await codeOf( + locks.run(ARA, () => Promise.reject(new BusinessException('MAIL_001'))), + ), + ).toBe('MAIL_001'); + const release = locks.acquire(ARA); + release(); + release(); + expect(() => locks.acquire(ARA)).not.toThrow(); + }); +}); + +describe('TwoFactorEnrollments (기다리는 등록 — 직원마다 하나 · 10분)', () => { + const secret = () => Buffer.alloc(20, 7); + + it('새 등록이 앞의 것을 바꾼다 — 앞의 id 로는 찾지 못하고 그 비밀 바이트는 지운다', () => { + const enrollments = new TwoFactorEnrollments(); + const first = { id: 'first', secret: secret(), expiresAt: 2_000 }; + enrollments.put(ARA, first); + enrollments.put(ARA, { id: 'second', secret: secret(), expiresAt: 2_000 }); + + expect(enrollments.find(ARA, 'first', 1_000)).toBeNull(); + expect(enrollments.find(ARA, 'second', 1_000)?.id).toBe('second'); + expect(first.secret.every((byte) => byte === 0)).toBe(true); + }); + + it('시간이 지나면 찾지 못하고 버린다 · 정리 작업도 버린다', () => { + const enrollments = new TwoFactorEnrollments(); + enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 }); + expect(enrollments.find(ARA, 'e', 2_000)).toBeNull(); + expect(enrollments.size).toBe(0); + + enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 }); + enrollments.sweep(2_000); + expect(enrollments.size).toBe(0); + }); +}); diff --git a/backend/src/modules/account-security/two-factor-code-guard.ts b/backend/src/modules/account-security/two-factor-code-guard.ts new file mode 100644 index 0000000..b4c00c5 --- /dev/null +++ b/backend/src/modules/account-security/two-factor-code-guard.ts @@ -0,0 +1,156 @@ +import { Injectable } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { createHash } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { LoginAttemptService } from '@/modules/auth/login-attempt.service'; +import { maskEmail } from '@/shared/utils/mask'; +import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants'; + +/** 지난 기억을 지우는 주기 */ +const SWEEP_INTERVAL_MS = 60_000; + +/** + * 코드 확인 한 번(`run`)의 결과 + * - `accepted`: 코드를 받았다 — 쓴 코드로 기억한다(2분). 뒤의 일(세션 · 기록 저장)이 실패해도 그 코드는 쓴 것으로 둔다 + * - `rejected`: 코드 틀림 — 실패로 센다(400 AUTH_006) + * - `unused`: 코드와 상관없이 아무것도 바뀌지 않고 끝났다(엔진이 새 비밀번호를 거절하는 등) — 세지도 기억하지도 않는다 + */ +export type CodeCheck = + | { readonly kind: 'accepted'; readonly value: T } + | { readonly kind: 'rejected' } + | { readonly kind: 'unused'; readonly value: T }; + +/** 코드를 확인하는 사람 — 정규화한 주소 · 클라이언트 IP */ +export interface CodeSource { + readonly email: string; + readonly ip: string; +} + +/** + * 2단계 인증 코드의 한도와 다시 쓰기 막기 (계약 5절 "한도 · 규칙") — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 함께 쓴다. + * + * - 코드 실패: 같은 주소 15분 5회 · 24시간 20회, 같은 IP 는 로그인 실패 한도에 함께 센다 (LoginAttemptService.beginCode) — 넘으면 REQ_001. + * 확인하기 **전에** 실패 한 번을 차지하고, 맞으면 돌려준다 (로그인 실패 한도와 같은 결 — 동시 요청이 한도를 넘지 못하게) + * - 다시 쓴 코드: 같은 직원이 성공에 쓴 코드는 2분 동안 다시 받지 않는다 → 400 AUTH_006 (코드 실패로 센다). + * 엔진은 같은 코드를 다시 받으므로 서버가 막는다. 확인을 시작할 때 (주소, 코드)를 "확인 중"으로 차지하고(확인과 차지 사이에 await 가 없다) + * 같은 코드가 겹치면 AUTH_006 이다 — 동시에 보낸 두 요청이 둘 다 지나가지 못하게 (2026-10-31 리뷰: 로그인 두 번이 모두 signedIn 이었다). + * 받으면 그 자리에서 기억으로 바꾸고(뒤의 일이 실패해도), 받지 않았거나 던졌거나 쓰지 않고 끝났으면 차지를 푼다 + * - 코드 · 기억의 키에 원문을 두지 않는다 (주소 + 코드의 SHA-256). 코드는 로그 · 오류 context 어디에도 남기지 않는다 + * + * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다 (nestjs 스킬 "확장 때 옮길 것"). + */ +@Injectable() +export class TwoFactorCodeGuard { + /** 키(주소 + 코드의 해시) → 다시 받는 때(epoch ms) */ + private readonly usedCodes = new Map(); + /** 지금 확인 중인 키 — 같은 코드의 동시 요청을 막는다 (확인이 끝나면 기억으로 바뀌거나 지워진다) */ + private readonly pendingCodes = new Set(); + + constructor(private readonly attempts: LoginAttemptService) {} + + /** + * 코드 한 번을 확인한다 — 한도(REQ_001) → 다시 쓴 코드 · 확인 중인 같은 코드(AUTH_006) → `run`(엔진 · 등록의 비밀로 실제 확인). + * 여기까지(실패 자리 차지 · 다시 쓴 코드 확인 · 확인 중 차지)는 await 없이 한 번에 일어난다. + * - `accepted`: 차지한 자리를 돌려주고 코드를 기억한 뒤 값을 돌려준다 + * - `rejected`: 실패로 남기고 400 AUTH_006 + * - `unused`: 자리를 돌려주고(세지 않음) 기억하지 않고 값을 돌려준다 + * - `run` 이 던지면(엔진 장애 · 지금 비밀번호 틀림 등) 자리를 돌려주고 그대로 던진다 — 코드는 쓰지 않은 것으로 둔다 + */ + async check( + source: CodeSource, + code: string, + run: () => Promise>, + ): Promise { + const ticket = this.attempts.beginCode(source); + let key: string; + try { + key = codeKey(source.email, code); + if (this.isUsedKey(key) || this.pendingCodes.has(key)) { + ticket.fail(); + throw this.rejected(source.email, 'two-factor-code-reused'); + } + } catch (error) { + // 차지한 실패 자리를 반드시 끝낸다 (이미 fail 했으면 두 번째 부름은 무시된다) — 코드가 글이 아닌 경우 등 + ticket.abort(); + throw error; + } + this.pendingCodes.add(key); + + let result: CodeCheck; + try { + result = await run(); + } catch (error) { + this.pendingCodes.delete(key); + ticket.abort(); + throw error; + } + this.pendingCodes.delete(key); + switch (result.kind) { + case 'accepted': + this.rememberKey(key); + ticket.succeed(); + return result.value; + case 'unused': + ticket.abort(); + return result.value; + case 'rejected': + ticket.fail(); + throw this.rejected(source.email, 'two-factor-code-rejected'); + } + } + + /** 성공에 쓴 코드를 기억한다 (check 가 받은 코드를 스스로 기억한다 — 테스트 · 바깥에서 미리 막을 때) */ + remember(email: string, code: string, now: number = Date.now()): void { + this.rememberKey(codeKey(email, code), now); + } + + /** 2분 안에 성공에 쓴 코드인가 */ + isUsed(email: string, code: string, now: number = Date.now()): boolean { + return this.isUsedKey(codeKey(email, code), now); + } + + /** 지금 확인 중인 코드인가 (테스트) */ + isPending(email: string, code: string): boolean { + return this.pendingCodes.has(codeKey(email, code)); + } + + private rememberKey(key: string, now: number = Date.now()): void { + // 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다) + this.usedCodes.delete(key); + this.usedCodes.set(key, now + TWO_FACTOR_CODE_REUSE_MS); + } + + private isUsedKey(key: string, now: number = Date.now()): boolean { + const until = this.usedCodes.get(key); + if (until === undefined) return false; + if (until <= now) { + this.usedCodes.delete(key); + return false; + } + return true; + } + + /** 지난 기억을 지운다 — 넣은 차례 = 끝나는 차례(기억 시간이 모두 같다) */ + @Interval(SWEEP_INTERVAL_MS) + sweep(now: number = Date.now()): void { + for (const [key, until] of this.usedCodes) { + if (until > now) break; + this.usedCodes.delete(key); + } + } + + private rejected(email: string, reason: string): BusinessException { + return new BusinessException('AUTH_006', { + context: { reason, email: maskEmail(email) }, + }); + } +} + +function codeKey(email: string, code: string): string { + return createHash('sha256') + .update(email) + .update('\0') + .update(code) + .digest('base64url'); +} diff --git a/backend/src/modules/account-security/two-factor-enrollments.ts b/backend/src/modules/account-security/two-factor-enrollments.ts new file mode 100644 index 0000000..e6c1baa --- /dev/null +++ b/backend/src/modules/account-security/two-factor-enrollments.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; + +import { TWO_FACTOR_ENROLLMENT_TTL_MS } from './account-security.constants'; + +/** 지난 등록을 지우는 주기 */ +const SWEEP_INTERVAL_MS = 60_000; + +/** 기다리는 등록 하나 — 비밀은 켤 때까지 여기에만 있다 */ +export interface TwoFactorEnrollment { + readonly id: string; + readonly secret: Buffer; + /** 끝나는 때 (epoch ms) */ + readonly expiresAt: number; +} + +/** + * 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분. + * 새로 시작하면 앞의 것을 버린다. 켜면(또는 시간이 지나면) 지운다 — 켠 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다). + * + * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기가 409 AUTH_008 이다. + */ +@Injectable() +export class TwoFactorEnrollments { + private readonly pending = new Map(); + + /** 등록을 둔다 — 앞의 등록은 버린다 */ + put(email: string, enrollment: TwoFactorEnrollment): void { + this.forget(email); + this.pending.set(email, enrollment); + } + + /** 그 id 의 기다리는 등록 — 없거나 · 시간이 지났거나 · 더 새 등록으로 바뀌었으면 null */ + find( + email: string, + id: string, + now: number = Date.now(), + ): TwoFactorEnrollment | null { + const enrollment = this.pending.get(email); + if (!enrollment) return null; + if (enrollment.expiresAt <= now) { + this.forget(email); + return null; + } + return enrollment.id === id ? enrollment : null; + } + + /** 등록을 버린다 — 비밀 바이트도 지운다 */ + forget(email: string): void { + const enrollment = this.pending.get(email); + if (!enrollment) return; + enrollment.secret.fill(0); + this.pending.delete(email); + } + + @Interval(SWEEP_INTERVAL_MS) + sweep(now: number = Date.now()): void { + for (const [email, enrollment] of this.pending) { + if (enrollment.expiresAt <= now) this.forget(email); + } + } + + get size(): number { + return this.pending.size; + } +} + +/** 등록이 끝나는 때 — 지금부터 10분 */ +export function enrollmentExpiry(now: number): number { + return now + TWO_FACTOR_ENROLLMENT_TTL_MS; +} diff --git a/backend/src/modules/account-security/two-factor-locks.ts b/backend/src/modules/account-security/two-factor-locks.ts new file mode 100644 index 0000000..2d6164f --- /dev/null +++ b/backend/src/modules/account-security/two-factor-locks.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { maskEmail } from '@/shared/utils/mask'; + +/** + * 같은 직원의 2단계 인증 바꾸기는 한 번에 하나 (계약 5절 "2단계 인증") — 등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기. + * 처리 중이면 기다리지 않고 429 REQ_001 (로그인의 진행 중 잠금과 같은 결 — 버튼을 두 번 누른 경우 등). + * + * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 잠금(SET NX PX)으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것"). + */ +@Injectable() +export class TwoFactorLocks { + private readonly busy = new Set(); + + /** 잠금을 잡고 일을 하고 푼다 — 잡혀 있으면 REQ_001 (잡는 확인과 넣기 사이에 await 가 없다) */ + async run(email: string, work: () => Promise): Promise { + const release = this.acquire(email); + try { + return await work(); + } finally { + release(); + } + } + + /** 잠금을 잡는다 → 푸는 함수 (몇 번 불러도 한 번만 푼다). 잡혀 있으면 REQ_001 */ + acquire(email: string): () => void { + if (this.busy.has(email)) { + throw new BusinessException('REQ_001', { + context: { + reason: 'two-factor-change-in-progress', + email: maskEmail(email), + }, + }); + } + this.busy.add(email); + let released = false; + return () => { + if (released) return; + released = true; + this.busy.delete(email); + }; + } +} diff --git a/backend/src/modules/account-security/two-factor-setting.entity.ts b/backend/src/modules/account-security/two-factor-setting.entity.ts new file mode 100644 index 0000000..eff3743 --- /dev/null +++ b/backend/src/modules/account-security/two-factor-setting.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ +const EMAIL_COLUMN_LENGTH = 254; + +/** + * 웹메일에서 2단계 인증을 켠 때 (계약 5절 "2단계 인증") — 직원 주소마다 한 줄. + * **엔진이 기준이다** — 켜짐 여부는 엔진(x:AccountPassword 의 otpUrl)으로 보고, 여기는 켠 때만 둔다. + * 엔진에서 꺼졌으면 지운다. 엔진 관리 화면에서 켠 2단계 인증은 줄이 없다(enabledAt null). 비밀은 두지 않는다 + */ +@Entity({ name: 'two_factor_settings' }) +export class TwoFactorSettingEntity { + @PrimaryColumn({ + name: 'user_email', + type: 'varchar', + length: EMAIL_COLUMN_LENGTH, + }) + userEmail!: string; + + @Column({ name: 'enabled_at', type: 'timestamptz' }) + enabledAt!: Date; +} diff --git a/backend/src/modules/account-security/two-factor.controller.ts b/backend/src/modules/account-security/two-factor.controller.ts new file mode 100644 index 0000000..aaac70d --- /dev/null +++ b/backend/src/modules/account-security/two-factor.controller.ts @@ -0,0 +1,170 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Ip, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { NoStore } from '@/common/decorators/no-store.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + TwoFactorActivationDto, + TwoFactorDeactivationDto, + TwoFactorEnrollmentDto, + TwoFactorStatusDto, +} from './dto/two-factor.dto'; +import { TwoFactorService, type TwoFactorStatus } from './two-factor.service'; + +function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto { + return { + isEnabled: status.isEnabled, + enabledAt: status.enabledAt?.toISOString() ?? null, + }; +} + +// 2단계 인증 (계약 5절 "2단계 인증") — 판단은 모두 TwoFactorService 가 한다. 요청 수 상한은 전역 기본값 — +// 코드 · 비밀번호 맞혀 보기는 로그인과 같은 실패 한도(코드 15분 5회 · 24시간 20회, 비밀번호 15분 10회)가 막는다 +@ApiTags('Account security') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse( + 'AUTH_001', + '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)', +) +@Controller('two-factor') +export class TwoFactorController { + constructor(private readonly twoFactor: TwoFactorService) {} + + @Get() + @ApiOperation({ + summary: '2단계 인증 상태', + description: + '엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다.', + }) + @ApiStandardResponse(TwoFactorStatusDto, { description: '지금 상태' }) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + async status( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return toStatusDto(await this.twoFactor.getStatus(user.email)); + } + + @Post('enrollments') + @NoStore() + @ApiOperation({ + summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)', + description: + '서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' + + '응답은 Cache-Control: private, no-store.', + }) + @ApiStandardResponse(TwoFactorEnrollmentDto, { + status: 201, + description: '등록 — 비밀 · otpauth 주소는 이 응답에만', + }) + @ApiErrorResponse('AUTH_008', '이미 켜져 있음 — 화면은 상태를 다시 받는다') + @ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중') + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + async enroll( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + const enrollment = await this.twoFactor.startEnrollment(user.email); + return { + enrollmentId: enrollment.enrollmentId, + secret: enrollment.secret, + otpauthUrl: enrollment.otpauthUrl, + expiresAt: enrollment.expiresAt.toISOString(), + }; + } + + @Post('activation') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '2단계 인증 켜기 — 첫 코드와 지금 비밀번호', + description: + '코드를 등록의 비밀로 먼저 확인한다(앞뒤 30초) — 엔진은 등록 때 코드를 보지 않는다. 둘 다 틀렸으면 코드부터 알린다(AUTH_006). ' + + '그다음 지금 비밀번호를 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다) 엔진에 넣는다. 켠 코드는 2분 동안 다시 받지 않는다. ' + + '다른 곳의 웹메일 로그인은 끊지 않는다.', + }) + @ApiStandardResponse(TwoFactorStatusDto, { description: '켰다' }) + // 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다) + @ApiErrorResponse( + 'AUTH_006', + 'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)', + ) + @ApiErrorResponse( + 'AUTH_008', + '등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐 — 화면은 처음부터 다시', + ) + @ApiErrorResponse( + 'REQ_001', + '코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 비밀번호 실패 한도 · 처리 중', + ) + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 결과를 알 수 없음(엔진을 다시 읽어 켜졌으면 성공으로 답한다)', + ) + async activate( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: TwoFactorActivationDto, + @Ip() ip: string, + ): Promise { + return toStatusDto( + await this.twoFactor.activate( + user.email, + { + enrollmentId: dto.enrollmentId, + otpCode: dto.otpCode, + currentPassword: dto.currentPassword, + }, + { ip }, + ), + ); + } + + @Post('deactivation') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '2단계 인증 끄기 — 지금 비밀번호와 지금 코드', + description: + '비밀번호를 먼저 확인한다(틀리면 AUTH_005 — 로그인 실패 한도로 센다). 이미 꺼져 있으면 200(같은 결과). ' + + '코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다.', + }) + @ApiStandardResponse(TwoFactorStatusDto, { + description: '껐다 (또는 이미 꺼져 있었다)', + }) + @ApiErrorResponse( + 'AUTH_006', + 'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)', + ) + @ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중') + @ApiErrorResponse( + 'MAIL_001', + '메일 엔진 장애 · 결과를 알 수 없음(엔진을 다시 읽어 꺼졌으면 성공으로 답한다)', + ) + async deactivate( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: TwoFactorDeactivationDto, + @Ip() ip: string, + ): Promise { + return toStatusDto( + await this.twoFactor.deactivate( + user.email, + { currentPassword: dto.currentPassword, otpCode: dto.otpCode }, + { ip }, + ), + ); + } +} diff --git a/backend/src/modules/account-security/two-factor.service.spec.ts b/backend/src/modules/account-security/two-factor.service.spec.ts new file mode 100644 index 0000000..a64a578 --- /dev/null +++ b/backend/src/modules/account-security/two-factor.service.spec.ts @@ -0,0 +1,780 @@ +import { Logger } from '@nestjs/common'; +import type { Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + LOGIN_ADDRESS_FAILURE_LIMIT, + TWO_FACTOR_CODE_ADDRESS_LIMIT, +} from '@/modules/auth/auth.constants'; +import { LoginAttemptService } from '@/modules/auth/login-attempt.service'; +import type { MailProvider } from '@/modules/mail/mail.provider'; +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { CredentialCheck } from '@/modules/mail/mail.types'; +import { totpCode, totpCounterAt } from '@/shared/utils/totp'; +import type { + AccountSecurityProvider, + TwoFactorDisableResult, + TwoFactorEnableResult, +} from './account-security.provider'; +import { TwoFactorCodeGuard } from './two-factor-code-guard'; +import { TwoFactorEnrollments } from './two-factor-enrollments'; +import { TwoFactorLocks } from './two-factor-locks'; +import type { TwoFactorSettingEntity } from './two-factor-setting.entity'; +import { TwoFactorService } from './two-factor.service'; + +const ARA = 'ara.kim@arasystem.test'; +const PASSWORD = 'correct-horse-battery'; +const IP = '203.0.113.10'; +const VALID: CredentialCheck = { kind: 'valid', account: ARA }; +const INVALID: CredentialCheck = { kind: 'invalid' }; +const TWO_FACTOR: CredentialCheck = { kind: 'two-factor-required' }; + +const mailSettings: MailSettings = { + jmapBaseUrl: 'http://mail:8080', + domain: 'arasystem.test', + gatewayUser: 'mail-gateway@arasystem.test', + gatewayPassword: 'gateway-secret', + attachmentMaxBytes: 20 * 1024 * 1024, + attachmentTotalMaxBytes: 25 * 1024 * 1024, + blockedAccounts: new Set(['admin@arasystem.test']), + directoryAccount: null, +}; + +/** base32(RFC 4648) → 바이트 — 등록 응답의 비밀로 코드를 만들 때 */ +function decodeBase32(text: string): Buffer { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; + let value = 0; + const bytes: number[] = []; + for (const char of text) { + value = (value << 5) | alphabet.indexOf(char); + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +/** 손으로 푸는 약속 — 엔진 호출이 걸려 있는 동안 다른 요청을 보낸다 */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** 조건이 될 때까지 이벤트 루프를 돌린다 */ +async function until(condition: () => boolean): Promise { + for (let turn = 0; turn < 1_000 && !condition(); turn += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + if (!condition()) throw new Error('조건이 되지 않았다'); +} + +async function failureOf( + promise: Promise, +): Promise { + try { + await promise; + } catch (error) { + if (error instanceof BusinessException) return error; + throw error; + } + return undefined; +} + +/** 두 Map 기반 설정 표 대역 */ +function fakeSettings() { + const rows = new Map(); + return { + rows, + repository: { + findOneBy: jest.fn(({ userEmail }: { userEmail: string }) => + Promise.resolve(rows.get(userEmail) ?? null), + ), + upsert: jest.fn((row: TwoFactorSettingEntity) => { + rows.set(row.userEmail, { ...row }); + return Promise.resolve(); + }), + delete: jest.fn(({ userEmail }: { userEmail: string }) => { + rows.delete(userEmail); + return Promise.resolve(); + }), + }, + }; +} + +describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', () => { + let engineEnabled: boolean; + let provider: jest.Mocked< + Pick< + AccountSecurityProvider, + 'isTwoFactorEnabled' | 'enableTwoFactor' | 'disableTwoFactor' + > + >; + let mail: { verifyCredentials: jest.Mock }; + let table: ReturnType; + let codes: TwoFactorCodeGuard; + let locks: TwoFactorLocks; + let service: TwoFactorService; + let logs: string[]; + let errors: string[]; + + beforeEach(() => { + engineEnabled = false; + provider = { + isTwoFactorEnabled: jest.fn, [string]>(() => + Promise.resolve(engineEnabled), + ), + enableTwoFactor: jest.fn< + Promise, + [string, string, string] + >(() => { + engineEnabled = true; + return Promise.resolve('enabled'); + }), + disableTwoFactor: jest.fn< + Promise, + [string, string, string] + >(() => { + engineEnabled = false; + return Promise.resolve('disabled'); + }), + }; + mail = { + verifyCredentials: jest.fn(() => + Promise.resolve(engineEnabled ? TWO_FACTOR : VALID), + ), + }; + table = fakeSettings(); + const attempts = new LoginAttemptService(); + codes = new TwoFactorCodeGuard(attempts); + locks = new TwoFactorLocks(); + service = new TwoFactorService( + provider as unknown as AccountSecurityProvider, + mail as unknown as MailProvider, + mailSettings, + table.repository as unknown as Repository, + new TwoFactorEnrollments(), + codes, + locks, + attempts, + ); + logs = []; + errors = []; + jest + .spyOn(Logger.prototype, 'log') + .mockImplementation((message: unknown) => { + logs.push(String(message)); + }); + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation((message: unknown) => { + errors.push(String(message)); + }); + }); + + afterEach(() => jest.restoreAllMocks()); + + /** 등록하고 그 비밀로 지금 코드를 만든다 */ + const enroll = async () => { + const enrollment = await service.startEnrollment(ARA); + const secret = decodeBase32(enrollment.secret); + return { + enrollment, + code: (offset = 0) => + totpCode(secret, totpCounterAt(Date.now()) + offset), + }; + }; + + it('등록 — 160비트 비밀(base32 32자)과 발급자 「ARA SYSTEM」의 otpauth 주소, 10분', async () => { + const before = Date.now(); + const { enrollment } = await enroll(); + + expect(enrollment.secret).toMatch(/^[A-Z2-7]{32}$/); + expect(enrollment.otpauthUrl).toBe( + `otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.test?secret=${enrollment.secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`, + ); + expect(enrollment.expiresAt.getTime()).toBeGreaterThanOrEqual( + before + 10 * 60_000, + ); + // 비밀 · 주소는 로그에 남지 않는다 + expect(logs.join('\n')).not.toContain(enrollment.secret); + }); + + it('켜기 — 코드를 등록의 비밀로 먼저 확인하고 지금 비밀번호를 본 뒤 엔진에 otpauth 주소를 넣는다, 켠 때를 둔다', async () => { + const { enrollment, code } = await enroll(); + + const status = await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP }, + ); + + expect(status.isEnabled).toBe(true); + expect(status.enabledAt).toBeInstanceOf(Date); + expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD); + expect(provider.enableTwoFactor).toHaveBeenCalledWith( + ARA, + PASSWORD, + enrollment.otpauthUrl, + ); + expect(table.rows.get(ARA)?.enabledAt).toEqual(status.enabledAt); + expect(logs).toContain('2단계 인증을 켰습니다: a***@arasystem.test'); + await expect(service.getStatus(ARA)).resolves.toEqual(status); + }); + + it('앞뒤 한 단계(±30초)의 코드도 받는다 · 켠 코드는 2분 동안 다시 받지 않는다', async () => { + const { enrollment, code } = await enroll(); + const used = code(-1); + await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: used, + currentPassword: PASSWORD, + }, + { ip: IP }, + ); + expect(codes.isUsed(ARA, used)).toBe(true); + }); + + it('코드가 틀리면 400 AUTH_006 — 둘 다 틀렸어도 코드부터 알린다(비밀번호를 묻지 않는다), 엔진에 넣지 않는다', async () => { + const { enrollment, code } = await enroll(); + const wrong = code() === '000000' ? '111111' : '000000'; + + const failure = await failureOf( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: wrong, + currentPassword: 'wrong', + }, + { ip: IP }, + ), + ); + + expect(failure?.code).toBe('AUTH_006'); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + expect(provider.enableTwoFactor).not.toHaveBeenCalled(); + }); + + it('지금 비밀번호가 틀리면 400 AUTH_005 — 로그인 실패 한도로 센다 · 앱 비밀번호 모양은 묻지 않고 AUTH_005(세지 않는다)', async () => { + mail.verifyCredentials.mockResolvedValue(INVALID); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) { + const { enrollment, code } = await enroll(); + const failure = await failureOf( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: 'wrong', + }, + { ip: `198.51.100.${i}` }, + ), + ); + expect(failure?.code).toBe('AUTH_005'); + } + const { enrollment, code } = await enroll(); + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('REQ_001'); + + mail.verifyCredentials.mockClear(); + const app = await enroll(); + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: app.enrollment.enrollmentId, + otpCode: app.code(), + currentPassword: `app_${'a'.repeat(36)}`, + }, + { ip: IP }, + ), + ) + )?.context, + ).toMatchObject({ reason: 'app-password-shape' }); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + }); + + it('등록이 없음 · 다른 id(더 새 등록으로 바뀜) · 10분 지남이면 409 AUTH_008', async () => { + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: '00000000-0000-4000-8000-000000000000', + otpCode: '123456', + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('AUTH_008'); + + const first = await enroll(); + await enroll(); + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: first.enrollment.enrollmentId, + otpCode: first.code(), + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('AUTH_008'); + }); + + it('그사이 다른 곳에서 켜졌으면 등록 · 켜기 모두 409 AUTH_008', async () => { + const { enrollment, code } = await enroll(); + engineEnabled = true; + + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('AUTH_008'); + expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe( + 'AUTH_008', + ); + }); + + it('켜기의 결과를 모르면 엔진을 다시 읽어 켜졌으면 성공, 아니면 503 MAIL_001', async () => { + const first = await enroll(); + provider.enableTwoFactor.mockImplementationOnce(() => { + engineEnabled = true; + return Promise.resolve('unknown' as const); + }); + await expect( + service.activate( + ARA, + { + enrollmentId: first.enrollment.enrollmentId, + otpCode: first.code(), + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ).resolves.toMatchObject({ isEnabled: true }); + + engineEnabled = false; + const second = await enroll(); + provider.enableTwoFactor.mockResolvedValueOnce('unknown'); + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: second.enrollment.enrollmentId, + otpCode: second.code(1), + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('MAIL_001'); + }); + + it('켜는 동안(엔진에 넣는 중) 같은 코드는 다른 요청이 쓰지 못한다 — 확인 중이라 400 AUTH_006 (로그인 · 비밀번호 바꾸기와 같은 기억)', async () => { + const { enrollment, code } = await enroll(); + const used = code(); + const engine = deferred(); + provider.enableTwoFactor.mockImplementationOnce(() => engine.promise); + + const activation = service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: used, + currentPassword: PASSWORD, + }, + { ip: IP }, + ); + await until(() => provider.enableTwoFactor.mock.calls.length === 1); + + // 같은 코드로 들어온 다른 요청(같은 순간의 로그인 등) — 확인(run) 없이 막힌다 + const other = jest.fn(() => + Promise.resolve({ kind: 'accepted' as const, value: undefined }), + ); + expect( + (await failureOf(codes.check({ email: ARA, ip: IP }, used, other)))?.code, + ).toBe('AUTH_006'); + expect(other).not.toHaveBeenCalled(); + + engineEnabled = true; + engine.resolve('enabled'); + await expect(activation).resolves.toMatchObject({ isEnabled: true }); + expect(codes.isUsed(ARA, used)).toBe(true); + }); + + it('코드는 맞고 지금 비밀번호가 틀리면 AUTH_005 — 그 코드는 쓰지 않은 것으로 남아 비밀번호만 고쳐 같은 코드로 켤 수 있다', async () => { + const { enrollment, code } = await enroll(); + const used = code(); + mail.verifyCredentials.mockResolvedValueOnce(INVALID); + + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: used, + currentPassword: 'wrong', + }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('AUTH_005'); + expect(codes.isUsed(ARA, used)).toBe(false); + expect(provider.enableTwoFactor).not.toHaveBeenCalled(); + + await expect( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: used, + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ).resolves.toMatchObject({ isEnabled: true }); + }); + + it('엔진에 켠 뒤 켠 때 기록(DB)이 실패해도 성공으로 답한다(enabledAt null · error 로그) — 등록 비밀은 버리고 코드는 기억한다', async () => { + const { enrollment, code } = await enroll(); + const used = code(); + table.repository.upsert.mockRejectedValueOnce(new Error('db down')); + + await expect( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: used, + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ).resolves.toEqual({ isEnabled: true, enabledAt: null }); + expect(errors.join('\n')).toMatch( + /\[SYS_001\].*켠 때를 기록하지 못했습니다.*a\*\*\*@arasystem\.test/, + ); + expect(codes.isUsed(ARA, used)).toBe(true); + + // 등록은 이미 버렸다 — 같은 등록으로 다시 켜지 못한다 + engineEnabled = false; + expect( + ( + await failureOf( + service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(1), + currentPassword: PASSWORD, + }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('AUTH_008'); + }); + + it('클라이언트 IP 를 모르면(빈 값 · 없음) 한 묶음(unknown)으로 센다 — 오류 없이 켠다', async () => { + const first = await enroll(); + await expect( + service.activate( + ARA, + { + enrollmentId: first.enrollment.enrollmentId, + otpCode: first.code(), + currentPassword: PASSWORD, + }, + { ip: undefined }, + ), + ).resolves.toMatchObject({ isEnabled: true }); + await expect( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '654321' }, + { ip: '' }, + ), + ).resolves.toEqual({ isEnabled: false, enabledAt: null }); + }); + + it('같은 직원의 바꾸기가 처리 중이면 429 REQ_001', async () => { + const release = locks.acquire(ARA); + try { + expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe( + 'REQ_001', + ); + } finally { + release(); + } + }); + + describe('끄기', () => { + beforeEach(() => { + engineEnabled = true; + table.rows.set(ARA, { userEmail: ARA, enabledAt: new Date() }); + }); + + it('비밀번호를 본인 Basic 으로 먼저 보고(402) 엔진에 코드와 함께 끈다 — 켠 때를 지우고 코드를 2분 기억한다', async () => { + const status = await service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ); + + expect(status).toEqual({ isEnabled: false, enabledAt: null }); + expect(provider.disableTwoFactor).toHaveBeenCalledWith( + ARA, + PASSWORD, + '123456', + ); + expect(table.rows.has(ARA)).toBe(false); + expect(codes.isUsed(ARA, '123456')).toBe(true); + expect(logs).toContain('2단계 인증을 껐습니다: a***@arasystem.test'); + }); + + it('끄는 동안(엔진에 보내는 중) 같은 코드는 다른 요청이 쓰지 못한다 — 확인 중이라 400 AUTH_006', async () => { + const engine = deferred(); + provider.disableTwoFactor.mockImplementationOnce(() => engine.promise); + + const deactivation = service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ); + await until(() => provider.disableTwoFactor.mock.calls.length === 1); + + const other = jest.fn(() => + Promise.resolve({ kind: 'accepted' as const, value: undefined }), + ); + expect( + (await failureOf(codes.check({ email: ARA, ip: IP }, '123456', other))) + ?.code, + ).toBe('AUTH_006'); + expect(other).not.toHaveBeenCalled(); + + engineEnabled = false; + engine.resolve('disabled'); + await expect(deactivation).resolves.toEqual({ + isEnabled: false, + enabledAt: null, + }); + }); + + describe('결과를 모르게 끝나면(unknown) 엔진을 다시 읽는다', () => { + it('꺼졌으면 성공 — 켠 때를 지우고 코드를 기억한다', async () => { + provider.disableTwoFactor.mockImplementationOnce(() => { + engineEnabled = false; + return Promise.resolve('unknown' as const); + }); + + await expect( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ).resolves.toEqual({ isEnabled: false, enabledAt: null }); + expect(provider.isTwoFactorEnabled).toHaveBeenCalledWith(ARA); + expect(table.rows.has(ARA)).toBe(false); + expect(codes.isUsed(ARA, '123456')).toBe(true); + }); + + it('아직 켜져 있으면 503 MAIL_001 — 켠 때는 그대로, 코드는 쓰지 않은 것으로(기억하지도 세지도 않는다)', async () => { + provider.disableTwoFactor.mockResolvedValue('unknown'); + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 1; i += 1) { + expect( + ( + await failureOf( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('MAIL_001'); + } + expect(table.rows.has(ARA)).toBe(true); + expect(codes.isUsed(ARA, '123456')).toBe(false); + + provider.disableTwoFactor.mockImplementation(() => { + engineEnabled = false; + return Promise.resolve('disabled' as const); + }); + await expect( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ).resolves.toEqual({ isEnabled: false, enabledAt: null }); + }); + + it('다시 읽기도 실패하면 그 실패(MAIL_001)를 그대로 — 코드는 쓰지 않은 것으로', async () => { + provider.disableTwoFactor.mockResolvedValueOnce('unknown'); + provider.isTwoFactorEnabled.mockRejectedValueOnce( + new BusinessException('MAIL_001', { + context: { reason: 'engine-down' }, + }), + ); + + const failure = await failureOf( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ); + expect(failure?.code).toBe('MAIL_001'); + expect(failure?.context).toMatchObject({ reason: 'engine-down' }); + expect(codes.isUsed(ARA, '123456')).toBe(false); + expect(table.rows.has(ARA)).toBe(true); + }); + }); + + it('엔진에서 끈 뒤 켠 때 기록 지우기가 실패해도 200 — error 로그만 (다음 상태 읽기가 다시 맞춘다)', async () => { + table.repository.delete.mockRejectedValueOnce(new Error('db down')); + await expect( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ).resolves.toEqual({ isEnabled: false, enabledAt: null }); + expect(errors.join('\n')).toMatch( + /\[SYS_001\].*기록을 지우지 못했습니다/, + ); + }); + + it('엔진이 코드를 받지 않으면 400 AUTH_006 (코드 실패로 센다 — 15분 5회)', async () => { + provider.disableTwoFactor.mockResolvedValue('code-rejected'); + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) { + expect( + ( + await failureOf( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '000000' }, + { ip: `198.51.100.${i}` }, + ), + ) + )?.code, + ).toBe('AUTH_006'); + } + expect( + ( + await failureOf( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('REQ_001'); + }); + + it('지금 비밀번호가 틀리면 400 AUTH_005 — 엔진에 끄기를 보내지 않는다', async () => { + mail.verifyCredentials.mockResolvedValue(INVALID); + expect( + ( + await failureOf( + service.deactivate( + ARA, + { currentPassword: 'wrong', otpCode: '123456' }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('AUTH_005'); + expect(provider.disableTwoFactor).not.toHaveBeenCalled(); + }); + + it('이미 꺼져 있으면(본인 Basic 200) 200 같은 결과 — 켠 때 기록도 지운다', async () => { + engineEnabled = false; + await expect( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ).resolves.toEqual({ isEnabled: false, enabledAt: null }); + expect(provider.disableTwoFactor).not.toHaveBeenCalled(); + expect(table.rows.has(ARA)).toBe(false); + }); + }); + + it('상태 — 엔진에서 꺼졌으면 켠 때를 지운다 · 엔진 관리 화면에서 켰으면 enabledAt null', async () => { + table.rows.set(ARA, { userEmail: ARA, enabledAt: new Date() }); + await expect(service.getStatus(ARA)).resolves.toEqual({ + isEnabled: false, + enabledAt: null, + }); + expect(table.rows.has(ARA)).toBe(false); + + engineEnabled = true; + await expect(service.getStatus(ARA)).resolves.toEqual({ + isEnabled: true, + enabledAt: null, + }); + }); + + it('웹메일로 다루지 않는 계정은 엔진에 묻지 않고 AUTH_001', async () => { + expect( + (await failureOf(service.getStatus('admin@arasystem.test')))?.code, + ).toBe('AUTH_001'); + expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/account-security/two-factor.service.ts b/backend/src/modules/account-security/two-factor.service.ts new file mode 100644 index 0000000..4e970cb --- /dev/null +++ b/backend/src/modules/account-security/two-factor.service.ts @@ -0,0 +1,418 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomBytes, randomUUID } from 'crypto'; +import type { Repository } from 'typeorm'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + isEmployeeAddress, + normalizeEmail, +} from '@/modules/auth/employee-address'; +import { + LoginAttemptService, + type LoginAttemptTicket, +} from '@/modules/auth/login-attempt.service'; +import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { buildOtpauthUrl, encodeBase32, verifyTotp } from '@/shared/utils/totp'; +import { + TWO_FACTOR_ISSUER, + TWO_FACTOR_SECRET_BYTES, +} from './account-security.constants'; +import { + ACCOUNT_SECURITY_PROVIDER, + type AccountSecurityProvider, +} from './account-security.provider'; +import { isAppPasswordShape } from './app-password'; +import { TwoFactorCodeGuard } from './two-factor-code-guard'; +import { + TwoFactorEnrollments, + enrollmentExpiry, +} from './two-factor-enrollments'; +import { TwoFactorLocks } from './two-factor-locks'; +import { TwoFactorSettingEntity } from './two-factor-setting.entity'; + +/** GET /api/two-factor 의 모양 — 켜기 · 끄기의 응답도 같다 */ +export interface TwoFactorStatus { + readonly isEnabled: boolean; + /** 웹메일에서 켠 때 — 엔진 관리 화면에서 켰으면 null */ + readonly enabledAt: Date | null; +} + +/** 등록 — 비밀 · otpauth 주소는 이 응답에만 (no-store) */ +export interface TwoFactorEnrollmentView { + readonly enrollmentId: string; + /** base32 32자 */ + readonly secret: string; + readonly otpauthUrl: string; + readonly expiresAt: Date; +} + +/** 요청한 클라이언트 — 실패 한도의 IP (모르면 빈 값 · undefined — 'unknown' 한 묶음으로 센다) */ +export interface SecurityClient { + readonly ip: string | undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * 2단계 인증 켜기 · 끄기 (계약 5절 "2단계 인증") — **엔진이 기준이다**(켜짐은 엔진의 x:AccountPassword 로 본다). + * + * - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 이미 켜져 있으면 409 AUTH_008 + * - 켜기: 받은 코드를 등록의 비밀로 **먼저** 확인한다(±1 단계 — 엔진은 등록 때 코드를 보지 않는다, 잘못 등록하면 직원이 끄지도 못한다). + * 둘 다 틀렸으면 코드부터 알린다. 그다음 지금 비밀번호를 본인 Basic 으로 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다, + * 402 면 그사이 다른 곳에서 켜짐 → AUTH_008) 대리 접근으로 엔진에 otpauth 주소를 넣는다. + * 이 셋을 한 번의 코드 확인(TwoFactorCodeGuard.check) 안에서 한다 — 그동안 같은 코드는 "확인 중"이라 다른 요청이 쓰지 못하고, + * 비밀번호가 틀려 끝나면 코드는 쓰지 않은 것으로 둔다(비밀번호만 고쳐 같은 코드로 다시 보낼 수 있게). 엔진에 켜지면 등록 비밀을 버리고 코드를 기억한다. + * 켠 때 기록(DB)은 그 뒤다 — 실패해도 error 로그만 남기고 성공으로 답한다(엔진이 기준이다 — 응답의 enabledAt 은 null) + * - 끄기: 지금 비밀번호를 본인 Basic 으로 먼저(402 = 켜짐 · 비밀번호 맞음, 200 = 이미 꺼짐 → 그대로 200) → 다시 쓴 코드 확인 → + * 대리 접근으로 코드와 함께 끈다(엔진이 코드를 확인한다). 켠 때 기록 지우기가 실패해도 error 로그만(다음 상태 읽기가 다시 맞춘다) + * - 같은 직원의 바꾸기는 한 번에 하나(TwoFactorLocks — REQ_001). 결과를 모르게 끝나면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로) + * - 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 AUTH_005 다(세지 않는다) + * - 켜고 끄는 일은 웹메일 세션을 끊지 않는다 (계약 5절 "정한 것" 3) + * 비밀 · otpauth 주소 · 코드 · 비밀번호는 로그에 남기지 않는다. 켜기 · 끄기는 info 에 가린 주소만 + */ +@Injectable() +export class TwoFactorService { + private readonly logger = new Logger(TwoFactorService.name); + + constructor( + @Inject(ACCOUNT_SECURITY_PROVIDER) + private readonly provider: AccountSecurityProvider, + @Inject(MAIL_PROVIDER) private readonly mail: MailProvider, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + @InjectRepository(TwoFactorSettingEntity) + private readonly settings: Repository, + private readonly enrollments: TwoFactorEnrollments, + private readonly codes: TwoFactorCodeGuard, + private readonly locks: TwoFactorLocks, + private readonly attempts: LoginAttemptService, + ) {} + + /** GET /api/two-factor — 엔진에서 꺼졌으면 켠 때 기록을 지운다 */ + async getStatus(user: string): Promise { + const address = this.requireEmployee(user); + const enabled = await this.provider.isTwoFactorEnabled(address); + return this.reconcile(address, enabled); + } + + /** POST /api/two-factor/enrollments — 꺼져 있을 때만 */ + async startEnrollment(user: string): Promise { + const address = this.requireEmployee(user); + return this.locks.run(address, async () => { + if (await this.provider.isTwoFactorEnabled(address)) { + throw new BusinessException('AUTH_008', { + context: { + reason: 'two-factor-already-enabled', + email: maskEmail(address), + }, + }); + } + const now = Date.now(); + const enrollment = { + id: randomUUID(), + secret: randomBytes(TWO_FACTOR_SECRET_BYTES), + expiresAt: enrollmentExpiry(now), + }; + this.enrollments.put(address, enrollment); + const secret = encodeBase32(enrollment.secret); + return { + enrollmentId: enrollment.id, + secret, + otpauthUrl: this.otpauthUrl(address, secret), + expiresAt: new Date(enrollment.expiresAt), + }; + }); + } + + /** POST /api/two-factor/activation */ + async activate( + user: string, + input: { + readonly enrollmentId: string; + readonly otpCode: string; + readonly currentPassword: string; + }, + client: SecurityClient, + ): Promise { + const address = this.requireEmployee(user); + const ip = client.ip || 'unknown'; + return this.locks.run(address, async () => { + const enrollment = this.enrollments.find(address, input.enrollmentId); + if (!enrollment) { + throw new BusinessException('AUTH_008', { + context: { + reason: 'two-factor-enrollment-missing', + email: maskEmail(address), + }, + }); + } + if (isAppPasswordShape(input.currentPassword)) { + throw this.currentPasswordRejected(address, 'app-password-shape'); + } + + // 지금 비밀번호의 실패 한도 — 코드가 틀리면 비밀번호는 묻지 않고 돌려준다(finally 의 abort) + const ticket = this.attempts.begin({ email: address, ip }); + try { + await this.codes.check( + { email: address, ip }, + input.otpCode, + async () => { + // 1) 코드를 등록의 비밀로 먼저 — 둘 다 틀렸으면 코드부터 알린다 + if ( + verifyTotp(enrollment.secret, input.otpCode, Date.now()) === null + ) { + return { kind: 'rejected' as const }; + } + // 2) 지금 비밀번호 — 틀리면 AUTH_005 (던지면 코드는 쓰지 않은 것으로 남는다) + await this.assertCurrentPassword( + address, + input.currentPassword, + ticket, + ); + // 3) 엔진에 넣는다 — 엔진은 코드를 보지 않고 저장한다 + await this.enableInEngine( + address, + input.currentPassword, + encodeBase32(enrollment.secret), + ticket, + ); + // 엔진에 켜졌다 — 등록 비밀은 지금 버린다(뒤의 기록이 실패해도 메모리에 남지 않게) + this.enrollments.forget(address); + return { kind: 'accepted' as const, value: undefined }; + }, + ); + ticket.succeed(); + } finally { + ticket.abort(); + } + + const enabledAt = await this.recordEnabledAt(address); + this.logger.log(`2단계 인증을 켰습니다: ${maskEmail(address)}`); + return { isEnabled: true, enabledAt }; + }); + } + + /** 엔진에 otpauth 주소를 넣는다 — 거절이면 AUTH_008, 결과를 모르면 다시 읽어 켜지지 않았으면 MAIL_001 */ + private async enableInEngine( + address: string, + currentPassword: string, + secret: string, + ticket: LoginAttemptTicket, + ): Promise { + const result = await this.provider.enableTwoFactor( + address, + currentPassword, + this.otpauthUrl(address, secret), + ); + if (result === 'refused') { + // 비밀번호는 바로 앞에서 맞았다 — 그사이 다른 곳에서 비밀번호 · 2단계 인증이 바뀌었다 + ticket.abort(); + throw new BusinessException('AUTH_008', { + context: { + reason: 'two-factor-enable-refused', + email: maskEmail(address), + }, + }); + } + if (result === 'unknown' && !(await this.engineSays(address, true))) { + ticket.abort(); + throw new BusinessException('MAIL_001', { + context: { + reason: 'two-factor-enable-outcome-unknown', + email: maskEmail(address), + }, + }); + } + } + + /** 켠 때를 둔다 → 그 때. 실패하면 error 로그만 남기고 null — 엔진에는 이미 켜졌다(엔진이 기준이다) */ + private async recordEnabledAt(address: string): Promise { + const enabledAt = new Date(); + try { + await this.settings.upsert({ userEmail: address, enabledAt }, [ + 'userEmail', + ]); + return enabledAt; + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 2단계 인증을 켠 때를 기록하지 못했습니다(엔진에는 켜졌다): user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + return null; + } + } + + /** POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과) */ + async deactivate( + user: string, + input: { readonly currentPassword: string; readonly otpCode: string }, + client: SecurityClient, + ): Promise { + const address = this.requireEmployee(user); + const ip = client.ip || 'unknown'; + return this.locks.run(address, async () => { + if (isAppPasswordShape(input.currentPassword)) { + throw this.currentPasswordRejected(address, 'app-password-shape'); + } + + const ticket = this.attempts.begin({ email: address, ip }); + try { + const check = await this.mail.verifyCredentials( + address, + input.currentPassword, + ); + if (check.kind === 'invalid') { + ticket.fail(); + throw this.currentPasswordRejected( + address, + 'current-password-rejected', + ); + } + if (check.kind === 'valid') { + // 엔진에서 이미 꺼져 있다 — 같은 결과로 답한다 + ticket.succeed(); + await this.forgetEnabledAt(address); + return { isEnabled: false, enabledAt: null }; + } + // 402 — 켜져 있고 비밀번호가 맞다. 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다 + ticket.verified(); + + await this.codes.check( + { email: address, ip }, + input.otpCode, + async () => { + const outcome = await this.provider.disableTwoFactor( + address, + input.currentPassword, + input.otpCode, + ); + if (outcome === 'code-rejected') { + return { kind: 'rejected' as const }; + } + // 결과를 모르면 엔진을 다시 읽는다 — 꺼졌으면 성공(코드를 쓴 것으로), 아니면 MAIL_001(코드는 쓰지 않은 것으로) + if ( + outcome === 'unknown' && + !(await this.engineSays(address, false)) + ) { + throw new BusinessException('MAIL_001', { + context: { + reason: 'two-factor-disable-outcome-unknown', + email: maskEmail(address), + }, + }); + } + return { kind: 'accepted' as const, value: undefined }; + }, + ); + ticket.succeed(); + } finally { + ticket.abort(); + } + + await this.forgetEnabledAt(address); + this.logger.log(`2단계 인증을 껐습니다: ${maskEmail(address)}`); + return { isEnabled: false, enabledAt: null }; + }); + } + + /** 엔진의 켜짐에 기록을 맞춘다 — 꺼졌으면 켠 때를 지운다 */ + private async reconcile( + address: string, + enabled: boolean, + ): Promise { + const row = await this.settings.findOneBy({ userEmail: address }); + if (!enabled) { + if (row) await this.settings.delete({ userEmail: address }); + return { isEnabled: false, enabledAt: null }; + } + return { isEnabled: true, enabledAt: row?.enabledAt ?? null }; + } + + /** 켠 때 기록을 지운다 — 실패해도 error 로그만(엔진에서는 꺼졌다 — 다음 상태 읽기가 다시 지운다) */ + private async forgetEnabledAt(address: string): Promise { + try { + await this.settings.delete({ userEmail: address }); + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 2단계 인증을 켠 때 기록을 지우지 못했습니다(엔진에서는 꺼졌다): user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + } + } + + /** 결과를 모르게 끝난 켜기 · 끄기 — 엔진을 다시 읽어 바라는 상태면 true (읽지 못하면 그 실패를 던진다) */ + private async engineSays( + address: string, + enabled: boolean, + ): Promise { + return (await this.provider.isTwoFactorEnabled(address)) === enabled; + } + + /** + * 켜기의 지금 비밀번호 — 본인 Basic 으로 확인한다. 틀리면 실패로 남기고 AUTH_005, 402(그사이 켜짐)면 AUTH_008. + * 맞으면 아무것도 하지 않는다 (시도는 부르는 쪽이 끝낸다) + */ + private async assertCurrentPassword( + address: string, + password: string, + ticket: LoginAttemptTicket, + ): Promise { + const check = await this.mail.verifyCredentials(address, password); + if (check.kind === 'invalid') { + ticket.fail(); + throw this.currentPasswordRejected(address, 'current-password-rejected'); + } + if (check.kind === 'two-factor-required') { + ticket.abort(); + throw new BusinessException('AUTH_008', { + context: { + reason: 'two-factor-enabled-elsewhere', + email: maskEmail(address), + }, + }); + } + } + + private otpauthUrl(address: string, secret: string): string { + return buildOtpauthUrl({ + issuer: TWO_FACTOR_ISSUER, + account: address, + secret, + }); + } + + private currentPasswordRejected( + address: string, + reason: string, + ): BusinessException { + return new BusinessException('AUTH_005', { + context: { reason, email: maskEmail(address) }, + }); + } + + /** + * 웹메일로 다루는 직원 주소 — 관리자 · 게이트웨이 · 막은 계정은 엔진에 묻지 않고 AUTH_001. + * 메일 엔진 설정이 없으면(비운영) MAIL_001 — 로그인 · 비밀번호 바꾸기와 같다 + */ + private requireEmployee(user: string): string { + const address = normalizeEmail(user); + const { domain, gatewayUser, blockedAccounts } = this.mailSettings; + if (domain === null) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mail-not-configured' }, + }); + } + if (!isEmployeeAddress(address, domain, gatewayUser, blockedAccounts)) { + throw new BusinessException('AUTH_001', { + context: { + reason: 'two-factor-not-allowed', + email: maskEmail(address), + }, + }); + } + return address; + } +} diff --git a/backend/src/modules/auth/auth-session.entity.ts b/backend/src/modules/auth/auth-session.entity.ts index 837615c..99e7b4b 100644 --- a/backend/src/modules/auth/auth-session.entity.ts +++ b/backend/src/modules/auth/auth-session.entity.ts @@ -6,6 +6,10 @@ const EMAIL_COLUMN_LENGTH = 254; const TOKEN_HASH_LENGTH = 64; /** 폐기 사유 길이 */ const REVOKED_REASON_LENGTH = 32; +/** IP 문자열 길이 — IPv6 최대 45자에 여유를 둔다 */ +const IP_COLUMN_LENGTH = 64; +/** User-Agent 원문 길이 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */ +const USER_AGENT_COLUMN_LENGTH = 512; /** 세션을 끝낸 사유 — 로그·문의 대응용 */ export type SessionRevokedReason = @@ -15,13 +19,17 @@ export type SessionRevokedReason = /** 비밀번호를 바꿨다 — 바꾼 세션 밖의 그 직원 세션 (계약 1절 "PUT /api/auth/password") */ | 'password-changed' /** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */ - | 'password-change-uncertain'; + | 'password-change-uncertain' + /** 직원이 로그인된 브라우저 목록에서 끊었다 — 하나 · 다른 곳 모두 (계약 5절 "로그인된 브라우저") */ + | 'ended-by-user'; /** * 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다. * * - 리프레시 토큰 원문은 저장하지 않는다. SHA-256 해시만 둔다 (DB 가 새어도 토큰을 만들 수 없다) - * - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증 — 세션을 끊어도 만료까지는 유효하다) + * - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증). 12단계부터 액세스 토큰에 세션 id(sid)를 싣고, + * 끊은 세션 id 는 서버 메모리가 1시간 기억해 가드가 막는다 (revoked-sessions.ts — 기동 때 이 표의 revoked_at 으로 다시 채운다) + * - IP · User-Agent 는 로그인 · 갱신 때 둔다 — 로그인된 브라우저 목록 (계약 5절). 12단계 전에 연 세션은 둘 다 null * - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts) */ @Entity({ name: 'auth_sessions' }) @@ -30,6 +38,8 @@ export class AuthSessionEntity { @PrimaryColumn({ type: 'uuid' }) id!: string; + /** 직원 주소 — 로그인된 브라우저 목록 · 다른 곳 모두 끊기가 이 열로 찾는다 (12단계에 인덱스를 더했다) */ + @Index('IDX_auth_sessions_user_email') @Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH }) userEmail!: string; @@ -74,4 +84,17 @@ export class AuthSessionEntity { nullable: true, }) revokedReason!: SessionRevokedReason | null; + + /** 마지막으로 로그인 · 갱신한 클라이언트 IP (TRUST_PROXY 기준) — 12단계 전에 연 세션은 null */ + @Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true }) + ip!: string | null; + + /** 마지막으로 로그인 · 갱신한 브라우저의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만 (shared/utils/user-agent.ts) */ + @Column({ + name: 'user_agent', + type: 'varchar', + length: USER_AGENT_COLUMN_LENGTH, + nullable: true, + }) + userAgent!: string | null; } diff --git a/backend/src/modules/auth/auth-session.module.ts b/backend/src/modules/auth/auth-session.module.ts new file mode 100644 index 0000000..4ecc933 --- /dev/null +++ b/backend/src/modules/auth/auth-session.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AuthSessionEntity } from './auth-session.entity'; +import { AuthSessionRepository } from './auth-session.repository'; +import { RevokedSessions } from './revoked-sessions'; + +/** + * 로그인 세션 표와 끊은 세션 기억 — 인증 모듈과 메일 모듈(실시간 알림이 끊은 세션의 연결을 끝낸다)이 함께 쓴다. + * 이 모듈은 다른 기능 모듈을 가져오지 않는다 (인증 → 메일 → 이 모듈 방향만 — 순환 없음). + */ +@Module({ + imports: [TypeOrmModule.forFeature([AuthSessionEntity])], + providers: [AuthSessionRepository, RevokedSessions], + exports: [AuthSessionRepository, RevokedSessions], +}) +export class AuthSessionModule {} diff --git a/backend/src/modules/auth/auth-session.repository.ts b/backend/src/modules/auth/auth-session.repository.ts index 835a7b6..3ea26d0 100644 --- a/backend/src/modules/auth/auth-session.repository.ts +++ b/backend/src/modules/auth/auth-session.repository.ts @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, MoreThan, Not, type Repository } from 'typeorm'; +import { IsNull, MoreThan, type Repository } from 'typeorm'; import { AuthSessionEntity, type SessionRevokedReason, } from './auth-session.entity'; +import { RevokedSessions } from './revoked-sessions'; export interface RotateSessionCommand { readonly id: string; @@ -14,16 +15,27 @@ export interface RotateSessionCommand { readonly nextHash: string; readonly nextExpiresAt: Date; readonly now: Date; + /** 이 갱신의 클라이언트 IP · User-Agent 원문 — 로그인된 브라우저 목록 (계약 5절). 모르면 null */ + readonly ip: string | null; + readonly userAgent: string | null; +} + +/** 조건부 UPDATE … RETURNING id 의 결과 행 */ +interface ReturnedId { + readonly id: string; } /** * 세션 표 접근 — 판단(회전·재사용·만료)은 AuthService 와 auth-session.policy.ts 가 한다. + * 세션을 끝내는 메서드는 **끝낸 id 를 끊은 세션 기억(RevokedSessions)에도 넣는다** — 그 세션의 액세스 토큰을 가드가 바로 막는다 + * (계약 5절 "로그인된 브라우저"). 끝내는 길을 여기 한 곳에 두어 어느 길도 기억을 빠뜨리지 않게 한다. */ @Injectable() export class AuthSessionRepository { constructor( @InjectRepository(AuthSessionEntity) private readonly sessions: Repository, + private readonly revoked: RevokedSessions, ) {} async create(session: AuthSessionEntity): Promise { @@ -34,10 +46,23 @@ export class AuthSessionRepository { return this.sessions.findOneBy({ id }); } + /** 그 직원의 살아 있는 세션 (끊기지도 끝나지도 않은 것) — 로그인된 브라우저 목록. 차례는 부르는 쪽이 정한다 */ + listAliveOf(userEmail: string, now: Date): Promise { + return this.sessions.find({ + where: { + userEmail, + revokedAt: IsNull(), + expiresAt: MoreThan(now), + absoluteExpiresAt: MoreThan(now), + }, + }); + } + /** * 1회용 회전 — **조건부 UPDATE 한 번**으로 한다. * 지금 토큰이 요청의 토큰이고 세션이 살아 있을 때만 바뀐다. 같은 토큰으로 동시에 온 두 요청 중 * 하나만 성공한다 (PostgreSQL 은 먼저 잡은 쪽이 커밋한 뒤 나머지의 WHERE 를 다시 평가한다). + * 갱신한 클라이언트의 IP · User-Agent 를 함께 고친다 (로그인된 브라우저 목록의 마지막 활동) * @returns 바꿨으면 true */ async rotate(command: RotateSessionCommand): Promise { @@ -54,27 +79,49 @@ export class AuthSessionRepository { previousTokenHash: command.presentedHash, rotatedAt: command.now, expiresAt: command.nextExpiresAt, + ip: command.ip, + userAgent: command.userAgent, }, ); return result.affected === 1; } - /** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다 */ + /** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다. 끝냈으면 true */ async revoke( id: string, reason: SessionRevokedReason, now: Date, - ): Promise { - await this.sessions.update( - { id, revokedAt: IsNull() }, - { revokedAt: now, revokedReason: reason }, + ): Promise { + const ids = await this.revokeWhere( + 'id = :id AND revoked_at IS NULL', + { id }, + reason, + now, ); + return ids.length > 0; + } + + /** + * 그 직원의 **살아 있는** 세션 하나를 끝낸다 — 남의 것 · 없는 것 · 이미 끝난 것이면 false (로그인된 브라우저에서 끊기 — 404 RES_001) + */ + async revokeAliveOf( + id: string, + userEmail: string, + reason: SessionRevokedReason, + now: Date, + ): Promise { + const ids = await this.revokeWhere( + 'id = :id AND user_email = :userEmail AND revoked_at IS NULL AND expires_at > :now AND absolute_expires_at > :now', + { id, userEmail, now }, + reason, + now, + ); + return ids.length > 0; } /** * 그 직원의 살아 있는 세션을 모두 끝낸다 — `keepId` 세션만 남긴다 (없으면 모두) → 끝낸 수. - * 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 쓴다. - * user_email 에는 인덱스가 없다 — 표가 작고(끝난 세션은 7일 뒤 정리) 드문 일이라 순차 조회로 충분하다. + * 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 · 다른 곳 모두 로그아웃에 쓴다. */ async revokeAllOf( userEmail: string, @@ -82,17 +129,13 @@ export class AuthSessionRepository { now: Date, keepId: string | null, ): Promise { - const result = await this.sessions.update( - { - userEmail, - revokedAt: IsNull(), - expiresAt: MoreThan(now), - absoluteExpiresAt: MoreThan(now), - ...(keepId !== null ? { id: Not(keepId) } : {}), - }, - { revokedAt: now, revokedReason: reason }, + const ids = await this.revokeWhere( + `user_email = :userEmail AND revoked_at IS NULL AND expires_at > :now AND absolute_expires_at > :now${keepId !== null ? ' AND id <> :keepId' : ''}`, + { userEmail, now, ...(keepId !== null ? { keepId } : {}) }, + reason, + now, ); - return result.affected ?? 0; + return ids.length; } /** @@ -109,4 +152,28 @@ export class AuthSessionRepository { .execute(); return result.affected ?? 0; } + + /** 조건에 맞는 세션을 끝내고(UPDATE … RETURNING id) 끝낸 id 를 끊은 세션 기억에 넣는다 → 끝낸 id */ + private async revokeWhere( + where: string, + parameters: Record, + reason: SessionRevokedReason, + now: Date, + ): Promise { + const result = await this.sessions + .createQueryBuilder() + .update(AuthSessionEntity) + .set({ revokedAt: now, revokedReason: reason }) + .where(where, parameters) + .returning('id') + .execute(); + const rows: unknown = result.raw; + const ids = Array.isArray(rows) + ? (rows as ReturnedId[]) + .map((row) => row.id) + .filter((id): id is string => typeof id === 'string') + : []; + this.revoked.add(ids, now); + return ids; + } } diff --git a/backend/src/modules/auth/auth-sessions.controller.ts b/backend/src/modules/auth/auth-sessions.controller.ts new file mode 100644 index 0000000..548e7da --- /dev/null +++ b/backend/src/modules/auth/auth-sessions.controller.ts @@ -0,0 +1,119 @@ +import { + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import type { Request } from 'express'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { readCookie } from '@/shared/utils/cookies'; +import { REFRESH_TOKEN_COOKIE } from './auth.constants'; +import { AuthSessionsService } from './auth-sessions.service'; +import { AuthSessionPageDto, EndedSessionsDto } from './dto/auth-session.dto'; + +// 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다. +// 판단은 AuthSessionsService 가 한다. 끊은 세션은 바로 막힌다(액세스 토큰의 sid + 서버가 기억한 끊은 세션) +@ApiTags('Auth') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') +@Controller('auth/sessions') +export class AuthSessionsController { + constructor(private readonly authSessions: AuthSessionsService) {} + + @Get() + @ApiOperation({ + summary: '로그인된 브라우저 — 끝나지 않은 웹메일 세션', + description: + '지금 세션(리프레시 쿠키가 가리키는 것)이 맨 앞, 그다음 최근 활동 차례. 마지막 활동은 마지막 갱신(회전) 때 — 없으면 로그인 때. ' + + 'IP · 브라우저는 로그인 · 갱신 때 서버가 둔다(12단계 전에 연 세션은 null). 위치 커서 · 기본 20 · 최대 100.', + }) + @ApiStandardResponse(AuthSessionPageDto, { description: '한 쪽' }) + @ApiErrorResponse('VAL_001', '쿼리 형식 오류 · 다른 목록의 커서') + async list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: CursorPaginationQueryDto, + @Req() req: Request, + ): Promise { + const page = await this.authSessions.list( + user.email, + readCookie(req, REFRESH_TOKEN_COOKIE), + { cursor: query.cursor, limit: query.limit }, + ); + return { + items: page.items.map((session) => ({ + id: session.id, + createdAt: session.createdAt.toISOString(), + lastActiveAt: session.lastActiveAt.toISOString(), + ip: session.ip, + device: session.device, + isCurrent: session.isCurrent, + })), + pageInfo: page.pageInfo, + }; + } + + @Delete(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '로그인된 브라우저 하나 끊기', + description: + '그 세션을 끊는다 — 그 브라우저는 다음 요청에서 갱신이 실패해 로그인 화면으로 간다(열린 실시간 알림은 다음 ping 때 끝난다). 지금 세션은 로그아웃을 쓴다.', + }) + @ApiParam({ name: 'id', description: '세션 id' }) + @ApiStandardResponse(undefined, { description: '끊었다 (data: null)' }) + @ApiErrorResponse('VAL_001', '지금 세션 — 로그아웃을 쓴다') + @ApiErrorResponse('RES_001', '남의 것 · 없는 것 · 이미 끝난 것') + async end( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + @Req() req: Request, + ): Promise { + await this.authSessions.end( + user.email, + id, + readCookie(req, REFRESH_TOKEN_COOKIE), + ); + return null; + } + + @Delete() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '다른 곳 모두 로그아웃 — 이 요청의 세션만 남긴다', + description: + '리프레시 쿠키가 없거나 남의 것이면 모두 끊는다(비밀번호 바꾸기와 같다). 메일 프로그램(IMAP · SMTP)은 끊기지 않는다 — 앱 비밀번호를 지워 끊는다.', + }) + @ApiStandardResponse(EndedSessionsDto, { description: '끊은 세션 수' }) + async endOthers( + @CurrentUser() user: AuthenticatedUser, + @Req() req: Request, + ): Promise { + const ended = await this.authSessions.endOthers( + user.email, + readCookie(req, REFRESH_TOKEN_COOKIE), + ); + return { ended }; + } +} diff --git a/backend/src/modules/auth/auth-sessions.service.spec.ts b/backend/src/modules/auth/auth-sessions.service.spec.ts new file mode 100644 index 0000000..aa302c8 --- /dev/null +++ b/backend/src/modules/auth/auth-sessions.service.spec.ts @@ -0,0 +1,169 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { encodeOffsetCursor } from '@/shared/utils/offset-cursor'; +import type { AuthSessionEntity } from './auth-session.entity'; +import type { AuthSessionRepository } from './auth-session.repository'; +import { AuthSessionsService } from './auth-sessions.service'; +import type { AuthService } from './auth.service'; + +const ARA = 'ara.kim@arasystem.test'; +const CURRENT = '11111111-1111-4111-8111-111111111111'; +const OTHER = '22222222-2222-4222-8222-222222222222'; +const OLDER = '33333333-3333-4333-8333-333333333333'; +const CHROME = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; + +const session = ( + id: string, + lastActive: string, + extra: Partial = {}, +): AuthSessionEntity => ({ + id, + userEmail: ARA, + tokenHash: 'h', + previousTokenHash: null, + rotatedAt: new Date(lastActive), + createdAt: new Date('2026-10-30T00:00:00.000Z'), + expiresAt: new Date('2026-11-10T00:00:00.000Z'), + absoluteExpiresAt: new Date('2026-11-29T00:00:00.000Z'), + revokedAt: null, + revokedReason: null, + ip: '211.186.95.252', + userAgent: CHROME, + ...extra, +}); + +async function failureOf( + promise: Promise, +): Promise { + try { + await promise; + } catch (error) { + if (error instanceof BusinessException) return error; + throw error; + } + return undefined; +} + +describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () => { + let auth: { currentSessionId: jest.Mock }; + let sessions: { + listAliveOf: jest.Mock; + revokeAliveOf: jest.Mock; + revokeAllOf: jest.Mock; + }; + let service: AuthSessionsService; + + beforeEach(() => { + auth = { currentSessionId: jest.fn().mockResolvedValue(CURRENT) }; + sessions = { + listAliveOf: jest.fn().mockResolvedValue([ + session(OLDER, '2026-10-30T01:00:00.000Z', { + ip: null, + userAgent: null, + }), + session(OTHER, '2026-10-31T02:00:00.000Z'), + session(CURRENT, '2026-10-30T05:00:00.000Z'), + ]), + revokeAliveOf: jest.fn().mockResolvedValue(true), + revokeAllOf: jest.fn().mockResolvedValue(2), + }; + service = new AuthSessionsService( + auth as unknown as AuthService, + sessions as unknown as AuthSessionRepository, + ); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('지금 세션이 맨 앞, 그다음 최근 활동 차례 — 브라우저 · 운영체제는 짧은 이름, 12단계 전의 세션은 null', async () => { + const page = await service.list(ARA, 'refresh.cookie', { limit: 20 }); + + expect(auth.currentSessionId).toHaveBeenCalledWith('refresh.cookie', ARA); + expect(page.items.map(({ id, isCurrent }) => [id, isCurrent])).toEqual([ + [CURRENT, true], + [OTHER, false], + [OLDER, false], + ]); + expect(page.items[1]).toMatchObject({ + lastActiveAt: new Date('2026-10-31T02:00:00.000Z'), + ip: '211.186.95.252', + device: { browser: 'Chrome', os: 'Windows' }, + }); + expect(page.items[2]).toMatchObject({ ip: null, device: null }); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 3, + }); + }); + + it('위치 커서로 나눠 준다 — 다른 목록의 커서는 VAL_001', async () => { + const first = await service.list(ARA, undefined, { limit: 2 }); + expect(first.pageInfo.hasNext).toBe(true); + const second = await service.list(ARA, undefined, { + limit: 2, + cursor: first.pageInfo.nextCursor ?? undefined, + }); + expect(second.items).toHaveLength(1); + + expect( + ( + await failureOf( + service.list(ARA, undefined, { + limit: 2, + cursor: encodeOffsetCursor(1, 'contacts'), + }), + ) + )?.code, + ).toBe('VAL_001'); + }); + + it('리프레시 쿠키가 없거나 남의 것이면 isCurrent 는 모두 false', async () => { + auth.currentSessionId.mockResolvedValue(null); + const page = await service.list(ARA, undefined, { limit: 20 }); + expect(page.items.every((item) => !item.isCurrent)).toBe(true); + }); + + it('하나 끊기 — 지금 세션은 400 VAL_001, 모양이 아닌 id · 남의 것 · 없는 것 · 끝난 것은 404 RES_001', async () => { + await expect(service.end(ARA, OTHER, 'cookie')).resolves.toBeUndefined(); + expect(sessions.revokeAliveOf).toHaveBeenCalledWith( + OTHER, + ARA, + 'ended-by-user', + expect.any(Date), + ); + + expect((await failureOf(service.end(ARA, CURRENT, 'cookie')))?.code).toBe( + 'VAL_001', + ); + expect( + (await failureOf(service.end(ARA, 'not-a-uuid', 'cookie')))?.code, + ).toBe('RES_001'); + sessions.revokeAliveOf.mockResolvedValue(false); + expect((await failureOf(service.end(ARA, OLDER, 'cookie')))?.code).toBe( + 'RES_001', + ); + }); + + it('다른 곳 모두 끊기 — 지금 세션만 남긴다, 지금 세션을 모르면 모두', async () => { + await expect(service.endOthers(ARA, 'cookie')).resolves.toBe(2); + expect(sessions.revokeAllOf).toHaveBeenCalledWith( + ARA, + 'ended-by-user', + expect.any(Date), + CURRENT, + ); + + auth.currentSessionId.mockResolvedValue(null); + await service.endOthers(ARA, undefined); + expect(sessions.revokeAllOf).toHaveBeenLastCalledWith( + ARA, + 'ended-by-user', + expect.any(Date), + null, + ); + }); +}); diff --git a/backend/src/modules/auth/auth-sessions.service.ts b/backend/src/modules/auth/auth-sessions.service.ts new file mode 100644 index 0000000..b068a80 --- /dev/null +++ b/backend/src/modules/auth/auth-sessions.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { maskEmail } from '@/shared/utils/mask'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; +import { describeUserAgent, type DeviceNames } from '@/shared/utils/user-agent'; +import type { AuthSessionEntity } from './auth-session.entity'; +import { AuthSessionRepository } from './auth-session.repository'; +import { AuthService } from './auth.service'; +import { normalizeEmail } from './employee-address'; + +/** 커서의 범위 — 이 목록의 커서만 받는다 */ +const SESSION_LIST_SCOPE = 'auth-sessions'; +/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 없는 세션과 같다) */ +const SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** 로그인된 브라우저 하나 (계약 5절 "로그인된 브라우저") */ +export interface AuthSessionView { + readonly id: string; + readonly createdAt: Date; + /** 마지막 갱신(회전) 때 — 없으면 로그인 때 */ + readonly lastActiveAt: Date; + readonly ip: string | null; + readonly device: DeviceNames | null; + /** 이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 */ + readonly isCurrent: boolean; +} + +function lastActiveAt(session: AuthSessionEntity): Date { + return session.rotatedAt ?? session.createdAt; +} + +/** + * 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 끝나지 않은 웹메일 세션의 목록 · 끊기. + * + * - 지금 세션은 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 안다 — 없거나 남의 것이면 isCurrent 는 모두 false, + * "다른 곳 모두 끊기"는 모두 끊는다(비밀번호 바꾸기와 같다) + * - 끊은 세션은 바로 막힌다 — 세션 표의 끊기가 끊은 세션 기억에 넣고 가드가 그 액세스 토큰을 막는다 (auth-session.repository.ts) + * - 목록은 지금 세션이 맨 앞, 그다음 최근 활동 차례 — 위치 커서(shared/utils/offset-cursor.ts), 기본 20 · 최대 100 + * 끊기는 info 로그에 가린 주소와 세션 id · 수만 + */ +@Injectable() +export class AuthSessionsService { + private readonly logger = new Logger(AuthSessionsService.name); + + constructor( + private readonly auth: AuthService, + private readonly sessions: AuthSessionRepository, + ) {} + + /** GET /api/auth/sessions */ + async list( + user: string, + refreshToken: string | undefined, + query: { readonly cursor?: string; readonly limit: number }, + now: Date = new Date(), + ): Promise> { + const address = normalizeEmail(user); + const start = this.offsetOf(query.cursor); + const [currentId, rows] = await Promise.all([ + this.auth.currentSessionId(refreshToken, address), + this.sessions.listAliveOf(address, now), + ]); + + const sorted = rows.sort((a, b) => { + if (a.id === currentId) return -1; + if (b.id === currentId) return 1; + return ( + lastActiveAt(b).getTime() - lastActiveAt(a).getTime() || + a.id.localeCompare(b.id) + ); + }); + const page = sorted.slice(start, start + query.limit); + const next = start + page.length; + const hasNext = next < sorted.length; + return toPaginatedResult( + page.map((session) => ({ + id: session.id, + createdAt: session.createdAt, + lastActiveAt: lastActiveAt(session), + ip: session.ip, + device: describeUserAgent(session.userAgent), + isCurrent: session.id === currentId, + })), + { + hasNext, + nextCursor: hasNext + ? encodeOffsetCursor(next, SESSION_LIST_SCOPE) + : null, + total: sorted.length, + }, + ); + } + + /** + * DELETE /api/auth/sessions/{id} — 지금 세션은 400 VAL_001 (로그아웃을 쓴다), 남의 것 · 없는 것 · 끝난 것은 404 RES_001 + */ + async end( + user: string, + sessionId: string, + refreshToken: string | undefined, + ): Promise { + const address = normalizeEmail(user); + if (!SESSION_ID_PATTERN.test(sessionId)) throw this.sessionNotFound(); + const currentId = await this.auth.currentSessionId(refreshToken, address); + if (sessionId.toLowerCase() === currentId?.toLowerCase()) { + throw new BusinessException('VAL_001', { + context: { reason: 'cannot-end-current-session', sid: sessionId }, + }); + } + const ended = await this.sessions.revokeAliveOf( + sessionId, + address, + 'ended-by-user', + new Date(), + ); + if (!ended) throw this.sessionNotFound(); + this.logger.log( + `로그인된 브라우저를 끊었습니다: ${maskEmail(address)}, sid=${sessionId}`, + ); + } + + /** DELETE /api/auth/sessions — 이 요청의 세션만 남기고 모두 끊는다 → 끊은 수 */ + async endOthers( + user: string, + refreshToken: string | undefined, + ): Promise { + const address = normalizeEmail(user); + const currentId = await this.auth.currentSessionId(refreshToken, address); + const ended = await this.sessions.revokeAllOf( + address, + 'ended-by-user', + new Date(), + currentId, + ); + this.logger.log( + `다른 곳의 로그인을 끊었습니다: ${maskEmail(address)}, ${ended}곳${currentId === null ? ' (지금 세션을 알 수 없어 모두)' : ''}`, + ); + return ended; + } + + private offsetOf(cursor: string | undefined): number { + if (cursor === undefined) return 0; + const reading = decodeOffsetCursor(cursor, SESSION_LIST_SCOPE); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-session-cursor:${reading.problem}` }, + }); + } + return reading.offset; + } + + private sessionNotFound(): BusinessException { + return new BusinessException('RES_001', { + context: { reason: 'session-not-found' }, + }); + } +} diff --git a/backend/src/modules/auth/auth-token.types.ts b/backend/src/modules/auth/auth-token.types.ts index c6db69c..a0afadd 100644 --- a/backend/src/modules/auth/auth-token.types.ts +++ b/backend/src/modules/auth/auth-token.types.ts @@ -10,7 +10,11 @@ export interface AuthTokenPayload { readonly typ: AuthTokenType; /** 표시 이름 — 액세스 토큰에만 넣는다 (/auth/me 가 메일 엔진에 묻지 않고 답한다) */ readonly name?: string; - /** 세션 id — 리프레시 토큰에만 넣는다 (auth_sessions.id). 회전·폐기를 이 세션 기준으로 판단한다 */ + /** + * 세션 id (auth_sessions.id) — 리프레시 토큰은 회전 · 폐기를 이 세션 기준으로 판단한다. + * 액세스 토큰에도 싣는다(12단계) — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (revoked-sessions.ts). + * 12단계 전에 발급한 액세스 토큰에는 없다 — 만료까지 지금처럼 쓰인다 + */ readonly sid?: string; /** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */ readonly jti?: string; diff --git a/backend/src/modules/auth/auth.constants.ts b/backend/src/modules/auth/auth.constants.ts index ac74abe..3580a20 100644 --- a/backend/src/modules/auth/auth.constants.ts +++ b/backend/src/modules/auth/auth.constants.ts @@ -60,6 +60,23 @@ export const LOGIN_IP_FAILURE_WINDOW_MS = 60_000; export const LOGIN_ADDRESS_FAILURE_LIMIT = 10; export const LOGIN_ADDRESS_FAILURE_WINDOW_MS = 15 * 60_000; +/** + * 2단계 인증 **코드** 실패 한도 (계약 5절 "한도 · 규칙") — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 함께 센다. + * 같은 주소 15분 5회 · 24시간 20회 — 비밀번호를 아는 공격자가 1년 내내 시도해도 맞힐 확률이 약 2% (창이 앞뒤 한 단계라 한 번에 3/10⁶). + * 같은 IP 는 따로 세지 않고 로그인 실패 한도(LOGIN_IP_FAILURE_LIMIT — 1분 20회)에 함께 센다. + * 맞으면 차지한 자리만 돌려주고 그 주소의 기록은 지우지 않는다 — 직원이 자주 들어와도 공격자의 하루 20회가 늘지 않게 + */ +export const TWO_FACTOR_CODE_ADDRESS_LIMIT = 5; +export const TWO_FACTOR_CODE_ADDRESS_WINDOW_MS = 15 * 60_000; +export const TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT = 20; +export const TWO_FACTOR_CODE_ADDRESS_DAY_WINDOW_MS = DAY_SECONDS * 1000; + +/** + * 끊은 세션 id 를 기억하는 시간 (계약 5절 "로그인된 브라우저") — 액세스 토큰의 가장 긴 수명(env 검증의 상한)과 같다. + * 액세스 토큰은 세션을 끊은 뒤에는 새로 발급되지 않으므로, 이 시간이 지나면 그 세션의 액세스 토큰은 모두 만료됐다 + */ +export const REVOKED_SESSION_MEMORY_MS = ACCESS_TTL_MAX_SECONDS * 1000; + /** * 로그인 요청 수 상한 (IP 당 1분) — 실패 한도와 별개로 요청 자체를 넉넉하게 제한한다. * 실패 한도가 무차별 대입을 막고, 이 상한은 한 IP 가 엔진 확인을 폭주시키지 못하게 한다. diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 5eb845e..77fc1b4 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -15,6 +15,7 @@ const settings: AuthSettings = { }; const session = { + status: 'signedIn', user: { email: 'ara.kim@arasystem.test', name: '김아라' }, tokens: { accessToken: 'new.access', @@ -27,6 +28,21 @@ function fakeResponse() { return { cookie: jest.fn(), clearCookie: jest.fn() }; } +/** 요청 대역 — 쿠키와 User-Agent 머리글 */ +function fakeRequest( + cookies: Record, + userAgent: string | undefined = undefined, +) { + return { + cookies, + get: (name: string) => + name.toLowerCase() === 'user-agent' ? userAgent : undefined, + }; +} + +const CHROME = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; + /** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */ function handlerOf( name: 'login' | 'me' | 'refresh' | 'logout' | 'changePassword', @@ -85,13 +101,14 @@ describe('AuthController', () => { ).toBeUndefined(); }); - it('로그인은 클라이언트 IP 를 서비스에 넘기고 두 쿠키를 굽는다', async () => { + it('로그인은 클라이언트 IP · User-Agent · 코드를 서비스에 넘기고, signedIn 이면 두 쿠키를 굽는다 (계약 5절 — status 가 붙는다)', async () => { auth.login.mockResolvedValue(session); const res = fakeResponse(); - const user = await controller.login( - { email: 'ara.kim@arasystem.test', password: 'pw' }, + const result = await controller.login( + { email: 'ara.kim@arasystem.test', password: 'pw', otpCode: '123456' }, '203.0.113.10', + fakeRequest({}, CHROME) as unknown as Request, res as unknown as Response, ); @@ -99,22 +116,56 @@ describe('AuthController', () => { 'ara.kim@arasystem.test', 'pw', '203.0.113.10', + { otpCode: '123456', userAgent: CHROME }, ); - expect(user).toEqual(session.user); + expect(result).toEqual({ status: 'signedIn', ...session.user }); expect(res.cookie).toHaveBeenCalledTimes(2); }); - it('리프레시는 리프레시 쿠키만 읽어 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => { - auth.refresh.mockResolvedValue(session); + it('2단계 인증이 필요하면 쿠키 없이 { status: twoFactorRequired, email: null, name: null }', async () => { + auth.login.mockResolvedValue({ + status: 'twoFactorRequired', + user: null, + tokens: null, + }); const res = fakeResponse(); - const req = { cookies: { refresh_token: 'old.refresh' } }; - const user = await controller.refresh( - req as unknown as Request, + const result = await controller.login( + { email: 'ara.kim@arasystem.test', password: 'pw' }, + '203.0.113.10', + fakeRequest({}) as unknown as Request, res as unknown as Response, ); - expect(auth.refresh).toHaveBeenCalledWith('old.refresh'); + expect(auth.login).toHaveBeenCalledWith( + 'ara.kim@arasystem.test', + 'pw', + '203.0.113.10', + { otpCode: undefined, userAgent: null }, + ); + expect(result).toEqual({ + status: 'twoFactorRequired', + email: null, + name: null, + }); + expect(res.cookie).not.toHaveBeenCalled(); + }); + + it('리프레시는 리프레시 쿠키와 클라이언트(IP · User-Agent — 로그인된 브라우저 목록)를 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => { + auth.refresh.mockResolvedValue(session); + const res = fakeResponse(); + const req = fakeRequest({ refresh_token: 'old.refresh' }, CHROME); + + const user = await controller.refresh( + req as unknown as Request, + '203.0.113.10', + res as unknown as Response, + ); + + expect(auth.refresh).toHaveBeenCalledWith('old.refresh', { + ip: '203.0.113.10', + userAgent: CHROME, + }); expect(user).toEqual(session.user); expect(res.cookie).toHaveBeenCalledWith( 'access_token', @@ -133,7 +184,8 @@ describe('AuthController', () => { const res = fakeResponse(); const user = await controller.refresh( - { cookies: { refresh_token: 'old.refresh' } } as unknown as Request, + fakeRequest({ refresh_token: 'old.refresh' }) as unknown as Request, + '203.0.113.10', res as unknown as Response, ); @@ -148,7 +200,8 @@ describe('AuthController', () => { await expect( controller.refresh( - { cookies: {} } as unknown as Request, + fakeRequest({}) as unknown as Request, + '203.0.113.10', res as unknown as Response, ), ).rejects.toBeInstanceOf(BusinessException); @@ -168,7 +221,8 @@ describe('AuthController', () => { await expect( controller.refresh( - { cookies: { refresh_token: 'old.refresh' } } as unknown as Request, + fakeRequest({ refresh_token: 'old.refresh' }) as unknown as Request, + '203.0.113.10', res as unknown as Response, ), ).rejects.toBeInstanceOf(BusinessException); @@ -193,7 +247,11 @@ describe('AuthController', () => { await expect( controller.changePassword( user, - { currentPassword: 'old-password', newPassword: 'new-password-1' }, + { + currentPassword: 'old-password', + newPassword: 'new-password-1', + otpCode: '654321', + }, '203.0.113.10', req as unknown as Request, ), @@ -204,6 +262,7 @@ describe('AuthController', () => { 'new-password-1', '203.0.113.10', 'this.refresh', + '654321', ); }); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index cca9e93..70fd982 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -37,6 +37,7 @@ import { AuthService } from './auth.service'; import { AUTH_SETTINGS, type AuthSettings } from './auth.settings'; import { AuthUserDto } from './dto/auth-user.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; +import { LoginResultDto } from './dto/login-result.dto'; import { LoginDto } from './dto/login.dto'; /** @@ -60,26 +61,44 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다', + description: + '2단계 인증을 켠 직원은 두 단계다 (계약 5절): 비밀번호가 맞으면 200 { status: twoFactorRequired, email: null, name: null } — 쿠키 없음. ' + + '같은 비밀번호에 otpCode 를 더해 다시 보내면 200 { status: signedIn, … } + 쿠키. 2단계 인증을 켜지 않은 직원은 바로 signedIn 이다(코드가 와도 보지 않는다). ' + + '앱 비밀번호는 받지 않는다(AUTH_004). 코드 실패는 같은 주소 15분 5회 · 24시간 20회, 같은 코드는 2분 동안 다시 받지 않는다.', + }) + @ApiStandardResponse(LoginResultDto, { + description: + '로그인 성공(signedIn) 또는 2단계 인증 코드 필요(twoFactorRequired)', }) - @ApiStandardResponse(AuthUserDto, { description: '로그인 성공' }) @ApiErrorResponse( 'AUTH_004', - '계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)', + '계정 없음·비밀번호 틀림(코드 단계에서도)·앱 비밀번호·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)', + ) + // 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다) + @ApiErrorResponse( + 'AUTH_006', + 'AUTH_006 2단계 인증 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 형식 오류(otpCode 가 숫자 6자리가 아님 등) (모두 400)', ) - @ApiErrorResponse('VAL_001', '형식 오류') @ApiErrorResponse( 'REQ_001', - '시도 과다 — 같은 IP 1분에 실패 20회 · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청', + '시도 과다 — 같은 IP 1분에 실패 20회(코드 실패 포함) · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 코드 실패 같은 주소 15분 5회 · 24시간 20회 · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청', ) @ApiErrorResponse('MAIL_001', '메일 엔진 장애') async login( @Body() dto: LoginDto, @Ip() ip: string, + @Req() req: Request, @Res({ passthrough: true }) res: Response, - ): Promise { - const result = await this.auth.login(dto.email, dto.password, ip); - if (result.tokens) setSessionCookies(res, result.tokens, this.settings); - return result.user; + ): Promise { + const result = await this.auth.login(dto.email, dto.password, ip, { + otpCode: dto.otpCode, + userAgent: req.get('user-agent') ?? null, + }); + if (result.status === 'twoFactorRequired') { + return { status: result.status, email: null, name: null }; + } + setSessionCookies(res, result.tokens, this.settings); + return { status: result.status, ...result.user }; } @Get('me') @@ -116,11 +135,13 @@ export class AuthController { @ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 쿠키는 그대로 둔다') async refresh( @Req() req: Request, + @Ip() ip: string, @Res({ passthrough: true }) res: Response, ): Promise { try { const result = await this.auth.refresh( readCookie(req, REFRESH_TOKEN_COOKIE), + { ip, userAgent: req.get('user-agent') ?? null }, ); // 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게 if (result.tokens) setSessionCookies(res, result.tokens, this.settings); @@ -149,7 +170,9 @@ export class AuthController { '메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' + '바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' + '지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' + - '지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다.', + '지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다. ' + + '12단계: 2단계 인증을 켠 직원은 otpCode 가 필수다(엔진이 본인 자격을 받지 않아 대리 접근으로 코드와 함께 보낸다 — 2단계 인증은 그대로 남는다). ' + + '바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다.', }) @ApiStandardResponse(undefined, { description: '바꿨다 (data: null)', @@ -157,13 +180,14 @@ export class AuthController { // 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다) @ApiErrorResponse( 'AUTH_005', - 'AUTH_005 지금 비밀번호가 틀림 · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' + + 'AUTH_005 지금 비밀번호가 틀림 · 앱 비밀번호(세지 않는다) · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' + '401 이 아니다(클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다) · ' + + 'AUTH_006 2단계 인증 코드 틀림 · 2분 안에 다시 쓴 코드(코드 실패로 센다) · AUTH_007 2단계 인증을 켠 직원인데 코드가 없음(다른 탭에서 켠 경우 — 화면이 코드 칸을 보인다) · ' + 'VAL_001 새 비밀번호가 8자 미만 · 128자 초과(JS 문자열 길이) · 지금 비밀번호와 같음 · 메일 엔진 규칙에 어긋남, 형식 오류 (모두 400)', ) @ApiErrorResponse( 'REQ_001', - '지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 같은 주소의 로그인·변경이 처리 중', + '지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 코드 실패 한도 · 같은 주소의 로그인·변경이 처리 중 · 같은 직원의 2단계 인증 바꾸기가 처리 중', ) @ApiErrorResponse( 'AUTH_001', @@ -171,7 +195,7 @@ export class AuthController { ) @ApiErrorResponse( 'MAIL_001', - '메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 두었다)', + '메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊고 앱 비밀번호는 지워 두었다)', ) async changePassword( @CurrentUser() user: AuthenticatedUser, @@ -185,6 +209,7 @@ export class AuthController { dto.newPassword, ip, readCookie(req, REFRESH_TOKEN_COOKIE), + dto.otpCode, ); return null; } @@ -193,6 +218,8 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '로그아웃 — 항상 200, 서버의 세션을 끊고 두 쿠키를 지운다', + description: + '끊은 세션의 액세스 토큰은 서버가 1시간 기억해 바로 막는다(12단계 — 계약 5절 "로그인된 브라우저").', }) @ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' }) async logout( diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 341518b..9ad710a 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -2,18 +2,19 @@ import { Logger, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { AccountSecurityModule } from '@/modules/account-security/account-security.module'; import { MailModule } from '@/modules/mail/mail.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; import { AuthSessionCleanup } from './auth-session.cleanup'; -import { AuthSessionEntity } from './auth-session.entity'; -import { AuthSessionRepository } from './auth-session.repository'; +import { AuthSessionModule } from './auth-session.module'; +import { AuthSessionsController } from './auth-sessions.controller'; +import { AuthSessionsService } from './auth-sessions.service'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { AUTH_SETTINGS, buildAuthSettings } from './auth.settings'; import { JwtStrategy } from './jwt.strategy'; -import { LoginAttemptService } from './login-attempt.service'; +import { LoginAttemptModule } from './login-attempt.module'; /** * 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로). @@ -23,17 +24,22 @@ import { LoginAttemptService } from './login-attempt.service'; * 보호 라우트는 `@UseGuards(JwtAuthGuard)` (common/guards) 로 건다. * * 로그인 세션은 DB(auth_sessions)에 둔다 — 스키마는 마이그레이션으로만 바꾼다 (src/migrations). + * 세션 표와 끊은 세션 기억은 AuthSessionModule 이(메일 모듈의 실시간 알림과 함께 쓴다), 실패 한도는 LoginAttemptModule 이(계정 보안 모듈과 함께 쓴다) 내놓는다. + * 12단계 — 2단계 인증 코드 · 로그인 기록 · 앱 비밀번호는 AccountSecurityModule 을 쓴다 (인증 → 계정 보안 방향만, 순환 없음) */ @Module({ imports: [ PassportModule.register({ session: false }), JwtModule.register({}), - TypeOrmModule.forFeature([AuthSessionEntity]), + AuthSessionModule, + LoginAttemptModule, MailModule, // 로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천) MailDirectoryModule, + // 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기 + AccountSecurityModule, ], - controllers: [AuthController], + controllers: [AuthController, AuthSessionsController], providers: [ { provide: AUTH_SETTINGS, @@ -45,9 +51,8 @@ import { LoginAttemptService } from './login-attempt.service'; ), }, AuthService, - AuthSessionRepository, + AuthSessionsService, AuthSessionCleanup, - LoginAttemptService, JwtStrategy, ], }) diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index cf01df6..d681906 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -2,7 +2,12 @@ import { Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { BusinessException } from '@/common/errors/business.exception'; +import type { AppPasswordsService } from '@/modules/account-security/app-passwords.service'; +import type { LoginEventsService } from '@/modules/account-security/login-events.service'; +import { TwoFactorCodeGuard } from '@/modules/account-security/two-factor-code-guard'; +import { TwoFactorLocks } from '@/modules/account-security/two-factor-locks'; import type { MailProvider } from '@/modules/mail/mail.provider'; +import type { CredentialCheck } from '@/modules/mail/mail.types'; import type { MailSettings } from '@/modules/mail/mail.settings'; import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; import type { AuthSessionEntity } from './auth-session.entity'; @@ -23,6 +28,15 @@ import { LoginAttemptService } from './login-attempt.service'; const ARA = 'ara.kim@arasystem.test'; const PASSWORD = 'correct-horse-battery'; + +/** 엔진의 자격 확인 결과 (CredentialCheck) */ +const valid = (account: string): CredentialCheck => ({ + kind: 'valid', + account, +}); +const INVALID: CredentialCheck = { kind: 'invalid' }; +/** 비밀번호는 맞고 2단계 인증 코드가 필요하다 (엔진 402) */ +const TWO_FACTOR: CredentialCheck = { kind: 'two-factor-required' }; const CLIENT_IP = '203.0.113.10'; const DAY_SECONDS = 24 * 3600; @@ -84,6 +98,8 @@ class FakeSessions { previousTokenHash: command.presentedHash, rotatedAt: command.now, expiresAt: command.nextExpiresAt, + ip: command.ip, + userAgent: command.userAgent, }); } return Promise.resolve(alive); @@ -161,6 +177,15 @@ function countCodes(results: PromiseSettledResult[]) { const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** 손으로 푸는 약속 — 엔진 호출 · DB 저장이 걸려 있는 동안 다른 요청을 보낸다 */ +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + /** 조건이 될 때까지 짧게 다시 본다 — 고정 대기 대신, 기한은 넉넉하게 */ async function until( condition: () => boolean, @@ -175,11 +200,21 @@ async function until( describe('AuthService', () => { let mail: jest.Mocked< - Pick + Pick< + MailProvider, + | 'verifyCredentials' + | 'verifyTwoFactorLogin' + | 'getProfile' + | 'changePassword' + > >; let attempts: LoginAttemptService; let sessions: FakeSessions; let directory: { recordLogin: jest.Mock }; + let codes: TwoFactorCodeGuard; + let twoFactorLocks: TwoFactorLocks; + let loginEvents: { recordWebmail: jest.Mock }; + let appPasswords: { removeAllAfterPasswordChange: jest.Mock }; let jwt: JwtService; let service: AuthService; let logs: string[]; @@ -193,6 +228,10 @@ describe('AuthService', () => { attempts, sessions as unknown as AuthSessionRepository, directory as unknown as MailDirectoryService, + codes, + twoFactorLocks, + loginEvents as unknown as LoginEventsService, + appPasswords as unknown as AppPasswordsService, ); const decode = (token: string) => @@ -221,7 +260,8 @@ describe('AuthService', () => { beforeEach(() => { mail = { - verifyCredentials: jest.fn().mockResolvedValue(ARA), + verifyCredentials: jest.fn().mockResolvedValue(valid(ARA)), + verifyTwoFactorLogin: jest.fn().mockResolvedValue('authenticated'), getProfile: jest .fn() .mockImplementation((email: string) => @@ -232,6 +272,12 @@ describe('AuthService', () => { attempts = new LoginAttemptService(); sessions = new FakeSessions(); directory = { recordLogin: jest.fn().mockResolvedValue(undefined) }; + codes = new TwoFactorCodeGuard(attempts); + twoFactorLocks = new TwoFactorLocks(); + loginEvents = { recordWebmail: jest.fn().mockResolvedValue(undefined) }; + appPasswords = { + removeAllAfterPasswordChange: jest.fn().mockResolvedValue(0), + }; jwt = new JwtService({}); service = build(); @@ -291,7 +337,7 @@ describe('AuthService', () => { }); directory.recordLogin.mockClear(); - mail.verifyCredentials.mockResolvedValue(null); + mail.verifyCredentials.mockResolvedValue(INVALID); expect( (await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code, ).toBe('AUTH_004'); @@ -303,13 +349,13 @@ describe('AuthService', () => { const result = await service.login(ARA, PASSWORD, CLIENT_IP); - expect(result.user.email).toBe(ARA); + expect(result.user?.email).toBe(ARA); expect(logs.some((line) => line.includes('사내 목록'))).toBe(true); expect(logs.join('\n')).not.toContain(ARA); }); it('비밀번호가 틀리면 AUTH_004 이고 세션을 만들지 않는다', async () => { - mail.verifyCredentials.mockResolvedValue(null); + mail.verifyCredentials.mockResolvedValue(INVALID); const error = await failureOf(service.login(ARA, 'wrong', CLIENT_IP)); @@ -357,7 +403,7 @@ describe('AuthService', () => { service = build({ blockedAccounts: new Set(['mail-admin@arasystem.test']), }); - mail.verifyCredentials.mockResolvedValue('admin@arasystem.test'); + mail.verifyCredentials.mockResolvedValue(valid('admin@arasystem.test')); const result = await service.login( 'admin@arasystem.test', @@ -365,7 +411,7 @@ describe('AuthService', () => { CLIENT_IP, ); - expect(result.user.email).toBe('admin@arasystem.test'); + expect(result.user?.email).toBe('admin@arasystem.test'); expect( ( await failureOf( @@ -395,7 +441,7 @@ describe('AuthService', () => { }); it('별칭으로 들어와도 엔진이 알려 준 정식 계정이 관리자면 AUTH_004 — 메일함을 열지 않는다', async () => { - mail.verifyCredentials.mockResolvedValue('admin@arasystem.test'); + mail.verifyCredentials.mockResolvedValue(valid('admin@arasystem.test')); const error = await failureOf( service.login('postmaster@arasystem.test', PASSWORD, CLIENT_IP), @@ -426,7 +472,7 @@ describe('AuthService', () => { it('로그에는 마스킹한 주소만 남고 비밀번호·토큰은 남지 않는다', async () => { const result = await service.login(ARA, PASSWORD, CLIENT_IP); - mail.verifyCredentials.mockResolvedValue(null); + mail.verifyCredentials.mockResolvedValue(INVALID); const failure = await failureOf(service.login(ARA, PASSWORD, CLIENT_IP)); const everything = [...logs, JSON.stringify(failure?.context)].join('\n'); @@ -447,7 +493,7 @@ describe('AuthService', () => { beforeEach(() => { mail.verifyCredentials.mockImplementation(async () => { await delay(80); - return null; + return INVALID; }); }); @@ -475,7 +521,7 @@ describe('AuthService', () => { LOGIN_ADDRESS_FAILURE_LIMIT, ); - mail.verifyCredentials.mockResolvedValue(ARA); + mail.verifyCredentials.mockResolvedValue(valid(ARA)); expect( (await failureOf(service.login(ARA, PASSWORD, '192.0.2.1')))?.code, ).toBe('REQ_001'); @@ -513,7 +559,7 @@ describe('AuthService', () => { it('성공한 로그인은 세지 않는다 — 같은 IP(사무실 NAT)에서 한도보다 많이 성공해도 막히지 않는다', async () => { mail.verifyCredentials.mockImplementation((email: string) => - Promise.resolve(email), + Promise.resolve(valid(email)), ); for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) { @@ -613,7 +659,7 @@ describe('AuthService', () => { const result = await service.refresh(first); expect(result.tokens).toBeNull(); - expect(result.user.email).toBe(ARA); + expect(result.user?.email).toBe(ARA); expect(sessions.only().revokedAt).toBeNull(); // 이긴 쪽의 토큰은 그대로 쓸 수 있다 await expect(rotateFrom(next)).resolves.toEqual(expect.any(String)); @@ -784,7 +830,7 @@ describe('AuthService', () => { beforeEach(() => { // 로그인한 주소 그대로 엔진이 확인해 준다 (다른 직원의 세션도 만들 수 있게) mail.verifyCredentials.mockImplementation((email: string) => - Promise.resolve(email), + Promise.resolve(valid(email)), ); mail.changePassword.mockResolvedValue('changed'); }); @@ -866,7 +912,7 @@ describe('AuthService', () => { }); it('로그인 실패와 한 카운터다 — 로그인 실패가 쌓인 주소는 비밀번호 바꾸기도 그만큼 덜 남는다 · IP 한도도 같다', async () => { - mail.verifyCredentials.mockResolvedValue(null); + mail.verifyCredentials.mockResolvedValue(INVALID); for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) { await failureOf(service.login(ARA, 'wrong', `198.51.100.${i}`)); } @@ -1126,6 +1172,533 @@ describe('AuthService', () => { }); }); + /** + * 12단계 — 2단계 인증 로그인 · 비밀번호 바꾸기 · 로그인 기록 · 세션의 sid · IP · User-Agent (계약 5절). + * 엔진 쪽 판단(402 · 엔진 로그인 요청)은 대역으로 두고(jmap.client.spec.ts 가 본다), 한도 · 기억은 실제 LoginAttemptService · TwoFactorCodeGuard 로 본다. + */ + describe('12단계 — 2단계 인증 · 로그인 기록 · 세션', () => { + const CODE = '123456'; + const CHROME = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'; + /** 엔진의 앱 비밀번호 모양 — app_ + 36자 */ + const APP_PASSWORD = `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`; + + beforeEach(() => { + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + }); + + it('2단계 인증을 켠 직원은 비밀번호가 맞으면 twoFactorRequired — 쿠키(세션) 없이, 비밀번호 실패로 세지 않는다', async () => { + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + + expect(result).toEqual({ + status: 'twoFactorRequired', + user: null, + tokens: null, + }); + expect(sessions.create).not.toHaveBeenCalled(); + expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled(); + // 남기는 것은 판정이 끝난 시도뿐이다 (성공 · AUTH_004 · AUTH_006) + expect(loginEvents.recordWebmail).not.toHaveBeenCalled(); + // 차지한 실패를 돌려줬다 — 주소 한도보다 많이 해도 막히지 않는다 + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + await expect( + service.login(ARA, PASSWORD, `198.51.100.${i}`), + ).resolves.toMatchObject({ status: 'twoFactorRequired' }); + } + }); + + it('코드가 맞으면 엔진의 로그인 요청(비밀번호 + 코드)으로 확인하고 로그인한다 — 로그인 기록에 성공 · 2단계 인증', async () => { + const result = await service.login(ARA, PASSWORD, CLIENT_IP, { + otpCode: CODE, + userAgent: CHROME, + }); + + expect(result.status).toBe('signedIn'); + expect(result.user).toEqual({ email: ARA, name: '김아라' }); + expect(mail.verifyTwoFactorLogin).toHaveBeenCalledWith( + ARA, + PASSWORD, + CODE, + ); + expect(mail.getProfile).toHaveBeenCalledWith(ARA, { fresh: true }); + expect(loginEvents.recordWebmail).toHaveBeenCalledWith({ + email: ARA, + isSuccess: true, + isTwoFactor: true, + failureReason: null, + ip: CLIENT_IP, + userAgent: CHROME, + }); + // 세션에 IP · User-Agent 를 둔다 (로그인된 브라우저 목록) + expect(sessions.only()).toMatchObject({ + ip: CLIENT_IP, + userAgent: CHROME, + }); + }); + + it('같은 직원이 성공에 쓴 코드는 2분 안에 다시 받지 않는다 — 400 AUTH_006 이고 엔진에 묻지 않는다 (엔진은 다시 받는다)', async () => { + await service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }); + mail.verifyTwoFactorLogin.mockClear(); + + const failure = await failureOf( + service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }), + ); + + expect(failure?.code).toBe('AUTH_006'); + expect(failure?.getStatus()).toBe(400); + expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled(); + expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith( + expect.objectContaining({ + isSuccess: false, + failureReason: 'wrongCode', + }), + ); + }); + + it('엔진이 코드를 받지 않으면 400 AUTH_006 — 코드 실패는 같은 주소 15분 5회, 넘으면 맞는 코드도 REQ_001', async () => { + mail.verifyTwoFactorLogin.mockResolvedValue('code-rejected'); + for (let i = 0; i < 5; i += 1) { + // IP 를 나눠 IP 한도(1분 20회)와 섞이지 않게 한다 + const failure = await failureOf( + service.login(ARA, PASSWORD, `198.51.100.${i}`, { + otpCode: '000000', + }), + ); + expect(failure?.code).toBe('AUTH_006'); + } + + mail.verifyTwoFactorLogin.mockResolvedValue('authenticated'); + const blocked = await failureOf( + service.login(ARA, PASSWORD, '192.0.2.80', { otpCode: CODE }), + ); + expect(blocked?.code).toBe('REQ_001'); + expect(blocked?.context).toMatchObject({ + reason: 'two-factor-code-failures-exceeded', + }); + // 코드 실패는 비밀번호 실패와 따로 센다 — 비밀번호 단계(twoFactorRequired)는 그대로 된다 + await expect( + service.login(ARA, PASSWORD, '192.0.2.81'), + ).resolves.toMatchObject({ status: 'twoFactorRequired' }); + }); + + it('코드가 맞아도 엔진 장애면 MAIL_001 — 코드 실패로 세지 않고 기억하지 않는다 (같은 코드로 다시 할 수 있다)', async () => { + mail.verifyTwoFactorLogin.mockRejectedValueOnce( + new BusinessException('MAIL_001'), + ); + expect( + ( + await failureOf( + service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }), + ) + )?.code, + ).toBe('MAIL_001'); + + await expect( + service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }), + ).resolves.toMatchObject({ status: 'signedIn' }); + }); + + describe('같은 코드의 동시 요청 (2026-10-31 리뷰 — 두 번째도 signedIn 이었다)', () => { + it('첫 로그인이 세션을 저장하는 동안 같은 코드로 들어온 두 번째 로그인은 400 AUTH_006 — 엔진 확인은 한 번', async () => { + const write = deferred(); + sessions.create.mockImplementationOnce(async (session) => { + await write.promise; + sessions.rows.set(session.id, { ...session }); + }); + + const first = service.login(ARA, PASSWORD, CLIENT_IP, { + otpCode: CODE, + }); + await until(() => sessions.create.mock.calls.length === 1); + + const second = await failureOf( + service.login(ARA, PASSWORD, '198.51.100.7', { otpCode: CODE }), + ); + write.resolve(); + + expect(second?.code).toBe('AUTH_006'); + await expect(first).resolves.toMatchObject({ status: 'signedIn' }); + expect(mail.verifyTwoFactorLogin).toHaveBeenCalledTimes(1); + }); + + it('별칭과 정식 주소로 같은 코드를 동시에 보내면 하나만 들어온다 — 확인 중인 코드는 정식 주소로 잡는다', async () => { + const ALIAS = 'kim.ara@arasystem.test'; + mail.getProfile.mockImplementation(() => + Promise.resolve({ email: ARA, name: '김아라' }), + ); + const engine = deferred<'authenticated'>(); + mail.verifyTwoFactorLogin.mockImplementationOnce(() => engine.promise); + + const first = service.login(ALIAS, PASSWORD, CLIENT_IP, { + otpCode: CODE, + }); + await until(() => mail.verifyTwoFactorLogin.mock.calls.length === 1); + // 엔진에는 직원이 넣은 이름으로 묻는다 (Basic 확인과 같은 이름) + expect(mail.verifyTwoFactorLogin).toHaveBeenCalledWith( + ALIAS, + PASSWORD, + CODE, + ); + + const second = await failureOf( + service.login(ARA, PASSWORD, '198.51.100.8', { otpCode: CODE }), + ); + engine.resolve('authenticated'); + + expect(second?.code).toBe('AUTH_006'); + await expect(first).resolves.toMatchObject({ + status: 'signedIn', + user: { email: ARA }, + }); + expect(mail.verifyTwoFactorLogin).toHaveBeenCalledTimes(1); + expect(codes.isUsed(ARA, CODE)).toBe(true); + expect(codes.isUsed(ALIAS, CODE)).toBe(false); + }); + }); + + describe('402 의 정식 주소 (엔진은 MFA 판정을 별칭의 권한 판정보다 먼저 한다)', () => { + it('막는 계정의 별칭이면 맞는 비밀번호에도 twoFactorRequired 가 아니라 AUTH_004 — 비밀번호 실패로 세고 기록에 남기지 않는다', async () => { + mail.getProfile.mockImplementation(() => + Promise.resolve({ email: 'admin@arasystem.test', name: 'admin' }), + ); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) { + const failure = await failureOf( + service.login('boss@arasystem.test', PASSWORD, `198.51.100.${i}`), + ); + expect(failure?.code).toBe('AUTH_004'); + } + expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled(); + expect(loginEvents.recordWebmail).not.toHaveBeenCalled(); + expect( + ( + await failureOf( + service.login('boss@arasystem.test', PASSWORD, '192.0.2.70'), + ) + )?.code, + ).toBe('REQ_001'); + }); + + it('메일함을 쓸 수 없는 계정(대리 접근 AUTH_001)도 AUTH_004 — twoFactorRequired 를 주지 않는다', async () => { + mail.getProfile.mockRejectedValueOnce( + new BusinessException('AUTH_001', { + context: { reason: 'impersonation-refused' }, + }), + ); + expect( + (await failureOf(service.login(ARA, PASSWORD, CLIENT_IP)))?.code, + ).toBe('AUTH_004'); + }); + + it('별칭으로 들어와도 코드 실패 한도 · 로그인 기록은 정식 주소로 — 별칭으로 5번 틀리면 정식 주소의 맞는 코드도 REQ_001', async () => { + const ALIAS = 'kim.ara@arasystem.test'; + mail.getProfile.mockImplementation(() => + Promise.resolve({ email: ARA, name: '김아라' }), + ); + mail.verifyTwoFactorLogin.mockResolvedValue('code-rejected'); + for (let i = 0; i < 5; i += 1) { + expect( + ( + await failureOf( + service.login(ALIAS, PASSWORD, `198.51.100.${i}`, { + otpCode: '000000', + }), + ) + )?.code, + ).toBe('AUTH_006'); + } + expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith( + expect.objectContaining({ email: ARA, failureReason: 'wrongCode' }), + ); + + mail.verifyTwoFactorLogin.mockResolvedValue('authenticated'); + expect( + ( + await failureOf( + service.login(ARA, PASSWORD, '192.0.2.71', { otpCode: CODE }), + ) + )?.code, + ).toBe('REQ_001'); + }); + }); + + it('otpCode 가 null 이면(검사를 비켜 온 값) 코드를 보내지 않은 것과 같다 — twoFactorRequired, 실패 자리를 남기지 않는다', async () => { + for (let i = 0; i < 7; i += 1) { + await expect( + service.login(ARA, PASSWORD, `198.51.100.${i}`, { + otpCode: null as unknown as string, + }), + ).resolves.toMatchObject({ status: 'twoFactorRequired' }); + } + await expect( + service.login(ARA, PASSWORD, '192.0.2.72', { otpCode: CODE }), + ).resolves.toMatchObject({ status: 'signedIn' }); + }); + + it('2단계 인증을 켜지 않은 직원은 코드가 와도 보지 않는다', async () => { + mail.verifyCredentials.mockResolvedValue(valid(ARA)); + + await expect( + service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }), + ).resolves.toMatchObject({ status: 'signedIn' }); + expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled(); + expect(loginEvents.recordWebmail).toHaveBeenCalledWith( + expect.objectContaining({ isSuccess: true, isTwoFactor: false }), + ); + }); + + it('앱 비밀번호 모양은 엔진에 묻지 않고 AUTH_004 — 비밀번호 실패로 세고 기록에 비밀번호 틀림으로 남긴다', async () => { + const failure = await failureOf( + service.login(ARA, APP_PASSWORD, CLIENT_IP), + ); + + expect(failure?.code).toBe('AUTH_004'); + expect(failure?.context).toMatchObject({ reason: 'app-password-shape' }); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + expect(loginEvents.recordWebmail).toHaveBeenCalledWith( + expect.objectContaining({ + isSuccess: false, + failureReason: 'wrongPassword', + }), + ); + // 세었다 — 주소 한도(15분 10회)에 닿으면 맞는 비밀번호도 REQ_001 + for (let i = 1; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) { + await failureOf(service.login(ARA, APP_PASSWORD, `198.51.100.${i}`)); + } + expect( + (await failureOf(service.login(ARA, PASSWORD, '192.0.2.90')))?.code, + ).toBe('REQ_001'); + }); + + it('틀린 비밀번호는 기록에 남긴다 · 사내 도메인 밖 · 막은 계정 · 한도로 막은 요청은 남기지 않는다', async () => { + mail.verifyCredentials.mockResolvedValue(INVALID); + await failureOf(service.login(ARA, 'wrong', CLIENT_IP)); + expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1); + + await failureOf(service.login('ara.kim@gmail.com', 'wrong', CLIENT_IP)); + await failureOf( + service.login('admin@arasystem.test', 'wrong', CLIENT_IP), + ); + expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1); + }); + + it('액세스 토큰에도 세션 id(sid)를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 막는다', async () => { + mail.verifyCredentials.mockResolvedValue(valid(ARA)); + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + + const access = decode(result.tokens!.accessToken); + const refresh = decode(result.tokens!.refreshToken); + expect(access.sid).toBe(sessions.only().id); + expect(access.sid).toBe(refresh.sid); + }); + + it('갱신하면 그 클라이언트의 IP · User-Agent 로 세션을 고친다 (마지막 활동)', async () => { + mail.verifyCredentials.mockResolvedValue(valid(ARA)); + const token = await loginToken(); + + await service.refresh(token, { ip: '198.51.100.200', userAgent: CHROME }); + + expect(sessions.rotate).toHaveBeenCalledWith( + expect.objectContaining({ ip: '198.51.100.200', userAgent: CHROME }), + ); + expect(sessions.only()).toMatchObject({ + ip: '198.51.100.200', + userAgent: CHROME, + }); + }); + + describe('비밀번호 바꾸기 (2단계 인증을 켠 직원)', () => { + const NEW_PASSWORD = 'new-horse-battery-staple'; + const me = { email: ARA, name: '김아라' }; + const change = (otpCode?: string, currentPassword = PASSWORD) => + service.changePassword( + me, + currentPassword, + NEW_PASSWORD, + CLIENT_IP, + undefined, + otpCode, + ); + + beforeEach(() => { + mail.changePassword.mockResolvedValue('changed'); + }); + + it('비밀번호를 먼저 본인 Basic 으로 보고(402) 코드와 함께 엔진에 보낸다 — 바뀌면 앱 비밀번호를 모두 지운다', async () => { + await expect(change(CODE)).resolves.toBeUndefined(); + + expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD); + expect(mail.changePassword).toHaveBeenCalledWith( + ARA, + PASSWORD, + NEW_PASSWORD, + { otpCode: CODE }, + ); + expect(appPasswords.removeAllAfterPasswordChange).toHaveBeenCalledWith( + ARA, + ); + // 바꾸는 데 쓴 코드는 2분 동안 다시 받지 않는다 — 로그인에서도 + expect( + ( + await failureOf( + service.login(ARA, NEW_PASSWORD, CLIENT_IP, { otpCode: CODE }), + ) + )?.code, + ).toBe('AUTH_006'); + }); + + it('코드가 없으면 400 AUTH_007 — 비밀번호는 맞았으니 비밀번호 실패로 세지 않고, 엔진에 바꾸기를 보내지 않는다', async () => { + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + const failure = await failureOf(change()); + expect(failure?.code).toBe('AUTH_007'); + expect(failure?.getStatus()).toBe(400); + } + expect(mail.changePassword).not.toHaveBeenCalled(); + expect( + appPasswords.removeAllAfterPasswordChange, + ).not.toHaveBeenCalled(); + }); + + it('엔진이 코드를 받지 않으면 400 AUTH_006 (코드 실패로 센다) — 앱 비밀번호 · 세션은 그대로', async () => { + mail.changePassword.mockResolvedValue('code-rejected'); + + const failure = await failureOf(change('000000')); + + expect(failure?.code).toBe('AUTH_006'); + expect(sessions.revokeAllOf).not.toHaveBeenCalled(); + expect( + appPasswords.removeAllAfterPasswordChange, + ).not.toHaveBeenCalled(); + }); + + it('바꾸는 동안(엔진에 보내는 중) 같은 코드로 들어온 로그인은 400 AUTH_006 — 받으면 그 코드는 쓴 코드다', async () => { + const ALIAS = 'kim.ara@arasystem.test'; + mail.getProfile.mockImplementation(() => + Promise.resolve({ email: ARA, name: '김아라' }), + ); + const engine = deferred<'changed'>(); + mail.changePassword.mockImplementationOnce(() => engine.promise); + + const changing = change(CODE); + await until(() => mail.changePassword.mock.calls.length === 1); + // 같은 주소의 로그인은 진행 중(REQ_001)이라 별칭으로 — 코드 단계까지 가서 확인 중인 코드에 막힌다 + expect( + ( + await failureOf( + service.login(ALIAS, PASSWORD, '198.51.100.9', { otpCode: CODE }), + ) + )?.code, + ).toBe('AUTH_006'); + expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled(); + + engine.resolve('changed'); + await expect(changing).resolves.toBeUndefined(); + expect(codes.isUsed(ARA, CODE)).toBe(true); + }); + + it('엔진이 새 비밀번호를 거절하면(400 VAL_001) 코드는 쓰지 않은 것으로 남는다 — 비밀번호만 고쳐 같은 코드로 바꾼다', async () => { + mail.changePassword.mockResolvedValueOnce('new-password-rejected'); + expect((await failureOf(change(CODE)))?.code).toBe('VAL_001'); + expect(codes.isUsed(ARA, CODE)).toBe(false); + + await expect(change(CODE)).resolves.toBeUndefined(); + expect(codes.isUsed(ARA, CODE)).toBe(true); + }); + + it('결과를 모르게 끝나면(503) 그 코드는 쓴 것으로 둔다 — 엔진이 바꿨을 수 있다', async () => { + mail.changePassword.mockResolvedValueOnce('outcome-unknown'); + expect((await failureOf(change(CODE)))?.code).toBe('MAIL_001'); + expect((await failureOf(change(CODE)))?.code).toBe('AUTH_006'); + }); + + it('otpCode 가 null 이면(검사를 비켜 온 값) 코드가 없는 것과 같다 — 400 AUTH_007', async () => { + expect((await failureOf(change(null as unknown as string)))?.code).toBe( + 'AUTH_007', + ); + expect(mail.changePassword).not.toHaveBeenCalled(); + }); + + it('같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기)가 처리 중이면 429 REQ_001', async () => { + const release = twoFactorLocks.acquire(ARA); + try { + expect((await failureOf(change(CODE)))?.code).toBe('REQ_001'); + expect(mail.changePassword).not.toHaveBeenCalled(); + } finally { + release(); + } + await expect(change(CODE)).resolves.toBeUndefined(); + }); + + it('결과를 모르게 끝나도(503) 앱 비밀번호를 지운다 — 다른 세션을 끊는 규칙과 같다', async () => { + mail.changePassword.mockResolvedValue('outcome-unknown'); + + const failure = await failureOf(change(CODE)); + + expect(failure?.code).toBe('MAIL_001'); + expect(appPasswords.removeAllAfterPasswordChange).toHaveBeenCalledWith( + ARA, + ); + expect(failure?.context).toMatchObject({ removedAppPasswords: 0 }); + }); + + it('앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 AUTH_005 — 세지 않는다', async () => { + const appPassword = `app_${'k'.repeat(36)}`; + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) { + const failure = await failureOf(change(CODE, appPassword)); + expect(failure?.code).toBe('AUTH_005'); + expect(failure?.context).toMatchObject({ + reason: 'app-password-shape', + }); + } + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + await expect(change(CODE)).resolves.toBeUndefined(); + }); + + it('2단계 인증을 켜지 않은 직원이면(본인 Basic 200) 코드가 와도 본인 자격으로 바꾼다 — 앱 비밀번호는 지운다', async () => { + mail.verifyCredentials.mockResolvedValue(valid(ARA)); + + await expect(change(CODE)).resolves.toBeUndefined(); + + expect(mail.changePassword).toHaveBeenCalledWith( + ARA, + PASSWORD, + NEW_PASSWORD, + ); + expect(appPasswords.removeAllAfterPasswordChange).toHaveBeenCalledWith( + ARA, + ); + }); + + it('그사이 다른 곳에서 2단계 인증을 켰으면(본인 자격의 바꾸기가 402) 400 AUTH_007', async () => { + mail.verifyCredentials.mockResolvedValue(valid(ARA)); + mail.changePassword.mockResolvedValue('two-factor-required'); + + expect((await failureOf(change()))?.code).toBe('AUTH_007'); + }); + + it('지금 비밀번호가 틀리면(본인 Basic 401) 엔진에 바꾸기를 보내지 않고 AUTH_005 — 로그인 실패로 센다', async () => { + mail.verifyCredentials.mockResolvedValue(INVALID); + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) { + expect( + ( + await failureOf( + service.changePassword( + me, + 'wrong', + NEW_PASSWORD, + `198.51.100.${i}`, + undefined, + CODE, + ), + ) + )?.code, + ).toBe('AUTH_005'); + } + expect(mail.changePassword).not.toHaveBeenCalled(); + expect((await failureOf(change(CODE)))?.code).toBe('REQ_001'); + }); + }); + }); + describe('로그아웃', () => { it('서버의 세션을 끊는다 — 그 뒤 리프레시는 AUTH_001', async () => { const token = await loginToken(); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index fee31d3..3d17c4b 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -4,6 +4,17 @@ import { randomUUID } from 'crypto'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; +import { isAppPasswordShape } from '@/modules/account-security/app-password'; +import { AppPasswordsService } from '@/modules/account-security/app-passwords.service'; +import { + LoginEventsService, + type WebmailLoginAttempt, +} from '@/modules/account-security/login-events.service'; +import { + type CodeCheck, + TwoFactorCodeGuard, +} from '@/modules/account-security/two-factor-code-guard'; +import { TwoFactorLocks } from '@/modules/account-security/two-factor-locks'; import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { @@ -13,6 +24,7 @@ import type { import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; +import { truncateUserAgent } from '@/shared/utils/user-agent'; import type { SessionTokens } from './auth-cookies'; import type { AuthSessionEntity, @@ -47,6 +59,36 @@ export interface AuthResult { readonly tokens: SessionTokens | null; } +/** + * 로그인 결과 (계약 5절 "로그인") — 2단계 인증을 켠 직원은 비밀번호가 맞으면 코드 단계(twoFactorRequired — 쿠키 없음)를 거친다 + */ +export type LoginResult = + | { + readonly status: 'signedIn'; + readonly user: AuthenticatedUser; + readonly tokens: SessionTokens; + } + | { + readonly status: 'twoFactorRequired'; + readonly user: null; + readonly tokens: null; + }; + +/** 로그인 · 갱신한 클라이언트 — 로그인된 브라우저 목록 · 로그인 기록 (계약 5절) */ +export interface ClientInfo { + /** TRUST_PROXY 기준 클라이언트 IP — 모르면 빈 글 */ + readonly ip: string; + /** User-Agent 원문 — 없으면 null */ + readonly userAgent?: string | null; +} + +/** 로그인의 선택 값 */ +export interface LoginOptions { + /** 2단계 인증 코드 (숫자 6자리 — 형식은 DTO 가 본다) */ + readonly otpCode?: string; + readonly userAgent?: string | null; +} + /** 리프레시 토큰에서 꺼낸 값 */ interface RefreshClaims { readonly sub: string; @@ -65,6 +107,21 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * 2단계 인증을 켠 직원의 비밀번호 바꾸기 결과 → 코드 확인 결과 (TwoFactorCodeGuard). + * 바뀜 · 결과 모름(엔진이 바꿨을 수 있다)은 코드를 쓴 것으로, 코드 틀림은 실패로, 그 밖(새 비밀번호 거절 등 — 바뀐 것이 없다)은 + * 코드를 쓰지 않은 것으로 둔다 — 비밀번호만 고쳐 같은 코드로 다시 보낼 수 있게 + */ +function codeCheckOfPasswordChange( + outcome: PasswordChangeOutcome, +): CodeCheck { + if (outcome === 'code-rejected') return { kind: 'rejected' }; + if (outcome === 'changed' || outcome === 'outcome-unknown') { + return { kind: 'accepted', value: outcome }; + } + return { kind: 'unused', value: outcome }; +} + /** * 직원 로그인 — 직원 계정 = 메일 계정. * 비밀번호는 로그인할 때만 메일 엔진에 확인하고 저장하지 않는다. @@ -84,39 +141,99 @@ export class AuthService { private readonly attempts: LoginAttemptService, private readonly sessions: AuthSessionRepository, private readonly directory: MailDirectoryService, + /** 12단계 — 2단계 인증 코드의 한도 · 다시 쓰기 막기, 같은 직원의 2단계 바꾸기 잠금, 로그인 기록, 앱 비밀번호 */ + private readonly codes: TwoFactorCodeGuard, + private readonly twoFactorLocks: TwoFactorLocks, + private readonly loginEvents: LoginEventsService, + private readonly appPasswords: AppPasswordsService, ) {} /** * 계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소는 구분하지 않고 AUTH_004 (.claude/rules/security.md). * 메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 미리 차지한다 — 동시 요청으로 한도를 넘지 못하게 (LoginAttemptService). * 성공·엔진 장애는 차지한 자리를 돌려준다 (성공은 세지 않는다). + * + * 2단계 인증 (계약 5절 "로그인") — 서버의 차례: + * 1. 앱 비밀번호 모양이면 엔진에 묻지 않고 실패로 센다 (AUTH_004 — 엔진에는 앱 비밀번호를 웹메일에서 빼는 설정이 없다) + * 2. 실패 한 번을 차지하고 엔진에 본인 Basic 으로 묻는다 + * 3. 맞음 → 2단계 인증이 꺼진 직원 — 로그인한다(코드가 와도 보지 않는다) + * 4. 402(비밀번호는 맞고 엔진이 코드를 요구) → 402 는 정식 주소를 알려 주지 않으므로 대리 접근 프로필로 정식 주소를 먼저 얻는다 — + * 막는 계정이면(별칭으로 들어온 관리자 등 — 엔진은 MFA 판정을 별칭의 권한 판정보다 먼저 한다) AUTH_004(비밀번호 실패로 센다). + * 그다음 차지한 비밀번호 실패를 돌려준다. 코드가 없으면 twoFactorRequired(쿠키 없음). + * 있으면 다시 쓴 코드 · 확인 중인 같은 코드인지 보고 → 코드 실패 한 번을 차지하고 → 엔진의 로그인 요청(mfaToken)에 묻는다. + * 맞으면 그 자리에서 코드를 2분 기억하고 로그인한다. 틀리면 400 AUTH_006 (코드 실패 — 같은 주소 15분 5회 · 24시간 20회). + * 코드의 한도 · 기억 · 로그인 기록은 정식 주소로 잡는다 + * 5. 틀림 → AUTH_004 + * 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006)는 로그인 기록에 남긴다 — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다 */ async login( email: string, password: string, clientIp: string, - ): Promise { + options: LoginOptions = {}, + ): Promise { const address = normalizeEmail(email); if (!this.mailSettings.domain) { throw new BusinessException('MAIL_001', { context: { reason: 'mail-not-configured' }, }); } + const client: ClientInfo = { + ip: clientIp, + userAgent: options.userAgent ?? null, + }; // IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다. // 로그인할 수 없는 주소(사내 도메인 밖·게이트웨이·관리자)는 엔진에 묻지 않고 바로 거절하므로 주소 카운터에 넣지 않는다(IP 만 센다) — // 아무 주소나 뿌려 주소 카운터를 채우지 못하게. 응답은 같은 AUTH_004 라 이 차이로 드러나는 것은 없다 + const loginAddress = this.isLoginAddress(address); const ticket = this.attempts.begin({ - email: this.isLoginAddress(address) ? address : null, + email: loginAddress ? address : null, ip: clientIp || 'unknown', }); let profile: MailAccountProfile; + let isTwoFactor = false; try { - profile = await this.authenticate(address, password); + if (!loginAddress) { + throw this.loginFailed(address, 'not-employee-address'); + } + if (isAppPasswordShape(password)) { + ticket.fail(); + await this.recordLoginFailure(address, client, 'wrongPassword'); + throw this.loginFailed(address, 'app-password-shape'); + } + + const check = await this.mail.verifyCredentials(address, password); + if (check.kind === 'invalid') { + ticket.fail(); + await this.recordLoginFailure(address, client, 'wrongPassword'); + throw this.loginFailed(address, 'rejected'); + } + if (check.kind === 'valid') { + profile = await this.profileOf(check.account, address); + } else { + // 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다) + profile = await this.profileOf(address, address); + // 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다 + ticket.verified(); + if (options.otpCode == null) { + ticket.abort(); + return { status: 'twoFactorRequired', user: null, tokens: null }; + } + await this.verifyLoginCode( + normalizeEmail(profile.email), + address, + password, + options.otpCode, + client, + ); + isTwoFactor = true; + } ticket.succeed(); } catch (error) { // AUTH_004 로 응답하는 경우만 실패로 센다 — 엔진 장애(MAIL_001)·엔진 한도(REQ_001)는 사용자 잘못이 아니다 + // (위에서 이미 fail 했으면 두 번째 부름은 무시된다. 코드 단계의 실패는 코드 한도가 따로 셌다) if (error instanceof BusinessException && error.code === 'AUTH_004') { ticket.fail(); } else { @@ -125,10 +242,84 @@ export class AuthService { throw error; } - const tokens = await this.startSession(profile); + const tokens = await this.startSession(profile, client); await this.recordInDirectory(profile); - this.logger.log(`로그인: ${maskEmail(profile.email)}`); - return { user: toUser(profile), tokens }; + await this.loginEvents.recordWebmail({ + email: profile.email, + isSuccess: true, + isTwoFactor, + failureReason: null, + ip: client.ip || null, + userAgent: client.userAgent ?? null, + }); + this.logger.log( + `로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}`, + ); + return { status: 'signedIn', user: toUser(profile), tokens }; + } + + /** + * 2단계 인증 로그인의 코드 단계 — 다시 쓴 코드 · 확인 중인 같은 코드 · 코드 실패 한도(REQ_001) → 엔진의 로그인 요청(비밀번호 + 코드). + * 한도 · 기억 · 기록은 정식 주소(`account`)로, 엔진에는 직원이 넣은 이름(`loginName` — Basic 확인과 같은 이름)으로 묻는다. + * 틀리면 로그인 기록에 남기고 400 AUTH_006. 받은 코드는 그 자리에서 기억한다(뒤의 세션 저장이 실패해도 — 같은 코드로 두 번 들어오지 못하게) + */ + private async verifyLoginCode( + account: string, + loginName: string, + password: string, + otpCode: string, + client: ClientInfo, + ): Promise { + try { + await this.codes.check( + { email: account, ip: client.ip || 'unknown' }, + otpCode, + async () => + (await this.mail.verifyTwoFactorLogin( + loginName, + password, + otpCode, + )) === 'authenticated' + ? { kind: 'accepted' as const, value: undefined } + : { kind: 'rejected' as const }, + ); + } catch (error) { + if (error instanceof BusinessException && error.code === 'AUTH_006') { + await this.recordLoginFailure(account, client, 'wrongCode'); + } + throw error; + } + } + + /** 엔진이 알려 준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다. 메일함에 접근할 수 없으면 AUTH_004 */ + private async profileOf( + account: string, + address: string, + ): Promise { + if (!this.isLoginAddress(account)) { + throw this.loginFailed(address, 'rejected'); + } + const profile = await this.loadProfileForLogin(account, address); + if (!this.isLoginAddress(profile.email)) { + throw this.loginFailed(address, 'rejected'); + } + return profile; + } + + /** 웹메일 로그인 실패를 기록에 남긴다 (던지지 않는다) */ + private async recordLoginFailure( + address: string, + client: ClientInfo, + failureReason: NonNullable, + ): Promise { + await this.loginEvents.recordWebmail({ + email: address, + isSuccess: false, + isTwoFactor: false, + failureReason, + ip: client.ip || null, + userAgent: client.userAgent ?? null, + }); } /** @@ -157,7 +348,10 @@ export class AuthService { * - 계정이 아직 살아 있는지 메일 엔진에 다시 확인한다 (사라졌으면 세션을 끊고 AUTH_001) * - 메일 엔진 장애는 MAIL_001 그대로 — 회전하기 전에 확인하므로 같은 토큰으로 다시 시도할 수 있다 */ - async refresh(refreshToken: string | undefined): Promise { + async refresh( + refreshToken: string | undefined, + client: ClientInfo = { ip: '' }, + ): Promise { if (!refreshToken) { throw new BusinessException('AUTH_001', { context: { reason: 'refresh-token-missing' }, @@ -175,23 +369,30 @@ export class AuthService { claims, presentedHash, now, + client, ); } /** - * 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 메일 엔진에 그 직원 **본인의 자격**(지금 비밀번호)으로 보낸다. + * 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" · 5절 "비밀번호 바꾸기"). * * - 새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001 * - 막는 계정(관리자 등 로그인할 수 없는 주소)은 엔진에 묻지 않고 AUTH_001 — 웹메일로 다루지 않는 계정이다 - * - 지금 비밀번호 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고 + * - 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 AUTH_005 (세지 않는다) + * - 지금 비밀번호를 먼저 본인 Basic 으로 확인한다 — 엔진은 지금 비밀번호 틀림과 코드 틀림을 같은 forbidden 으로 거절하기 때문이다. + * 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고 * (LoginAttemptService), 틀리면 남기고(400 AUTH_005 — 401 이 아니다: 클라이언트는 401 이면 갱신하고 다시 보낸다), * 바뀌면 로그인 성공처럼 돌려준다. 같은 주소의 로그인·변경이 처리 중이면 REQ_001 + * - 맞음(2단계 꺼짐) → 본인 자격으로 바꾼다 + * - 402(2단계 켜짐, 비밀번호 맞음) → 코드가 없으면 400 AUTH_007(다른 탭에서 켠 경우). 있으면 같은 직원의 2단계 바꾸기 잠금을 잡고 + * 다시 쓴 코드 · 코드 실패 한도를 본 뒤 대리 접근으로 지금 비밀번호 · 새 비밀번호 · 코드를 보낸다 — 틀리면 400 AUTH_006(코드 실패로 센다). + * 2단계 인증은 그대로 남는다 * - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애 - * - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊는다 — 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다. - * 쿠키로 이 세션을 알 수 없으면 모두 끊는다 (이 탭도 액세스 토큰이 끝나면 다시 로그인한다). - * 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 다른 세션은 끊어 두고 503 MAIL_001 로 답한다. + * - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊고(끊은 세션의 액세스 토큰은 가드가 바로 막는다) **앱 비밀번호를 모두 지운다** — + * 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다. 쿠키로 이 세션을 알 수 없으면 모두 끊는다. + * 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 둘 다 해 두고 503 MAIL_001 로 답한다. 끊기 · 지우기에 실패해도 던지지 않는다(error 로그). * 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다 - * 비밀번호는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 수만. + * 비밀번호 · 코드는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 · 지운 앱 비밀번호 수만. */ async changePassword( user: AuthenticatedUser, @@ -199,6 +400,7 @@ export class AuthService { newPassword: string, clientIp: string, refreshToken: string | undefined, + otpCode?: string, ): Promise { const address = normalizeEmail(user.email); if (newPassword === currentPassword) { @@ -219,47 +421,91 @@ export class AuthService { }, }); } + if (isAppPasswordShape(currentPassword)) { + // 앱 비밀번호로는 바꾸지 못한다 — 엔진에 묻지 않고, 맞혀 보기가 아니라 세지 않는다 (4단계 규칙과 같다) + throw this.currentPasswordRejected(address, 'app-password-shape'); + } // 로그인과 같은 카운터 — 지금 비밀번호를 맞혀 보는 창구가 로그인 한도를 비켜 가지 못하게 const ticket = this.attempts.begin({ email: address, ip: clientIp || 'unknown', }); - - let outcome: PasswordChangeOutcome; + // 2단계 인증을 켠 직원이면 같은 직원의 2단계 바꾸기(등록 · 켜기 · 끄기)와 겹치지 않게 잠근다 + let releaseLock: (() => void) | null = null; try { - outcome = await this.mail.changePassword( - address, - currentPassword, - newPassword, - ); - } catch (error) { - // 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) — 바뀌지 않았고 사용자 잘못도 아니다. 세지 않는다 - ticket.abort(); - throw error; - } + let outcome: PasswordChangeOutcome; + try { + const check = await this.mail.verifyCredentials( + address, + currentPassword, + ); + if (check.kind === 'invalid') { + outcome = 'current-password-rejected'; + } else if (check.kind === 'valid') { + outcome = await this.mail.changePassword( + address, + currentPassword, + newPassword, + ); + } else { + // 402 — 비밀번호는 맞았다 (코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다) + ticket.verified(); + if (otpCode == null) { + throw this.otpCodeRequired(address); + } + releaseLock = this.twoFactorLocks.acquire(address); + outcome = await this.codes.check( + { email: address, ip: clientIp || 'unknown' }, + otpCode, + async () => { + const result = await this.mail.changePassword( + address, + currentPassword, + newPassword, + { otpCode }, + ); + return codeCheckOfPasswordChange(result); + }, + ); + } + } catch (error) { + // 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) · 코드 단계의 실패(AUTH_006 · AUTH_007 — 코드 한도가 따로 셌다)는 + // 비밀번호 맞혀 보기가 아니다. 세지 않는다 + ticket.abort(); + throw error; + } + await this.settlePasswordChange(outcome, ticket, address, refreshToken); + } finally { + releaseLock?.(); + } + } + + /** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기) */ + private async settlePasswordChange( + outcome: PasswordChangeOutcome, + ticket: LoginAttemptTicket, + address: string, + refreshToken: string | undefined, + ): Promise { switch (outcome) { case 'current-password-rejected': ticket.fail(); - throw new BusinessException('AUTH_005', { - context: { - reason: 'current-password-rejected', - email: maskEmail(address), - }, - }); + throw this.currentPasswordRejected( + address, + 'current-password-rejected', + ); case 'current-password-refused': // 같은 값으로 엔진에 들어가기는 했다 — 맞혀 보기가 아니라 세지 않는다 (변경 권한을 빼면 맞는 비밀번호로 로그인까지 막히지 않게). // 엔진의 거절 종류는 어댑터가 warn 으로 남겼다 ticket.abort(); - throw new BusinessException('AUTH_005', { - context: { - reason: 'current-password-refused-by-mail-engine', - email: maskEmail(address), - }, - }); + throw this.currentPasswordRejected( + address, + 'current-password-refused-by-mail-engine', + ); case 'new-password-rejected': - // 지금 비밀번호는 맞았다 — 실패로 세지 않는다 + // 지금 비밀번호(와 코드)는 맞았다 — 실패로 세지 않는다 ticket.abort(); throw new BusinessException('VAL_001', { context: { @@ -267,9 +513,22 @@ export class AuthService { email: maskEmail(address), }, }); + case 'two-factor-required': + // 그사이 다른 곳에서 2단계 인증을 켰다 — 화면은 코드 칸을 보인다 + ticket.abort(); + throw this.otpCodeRequired(address); + case 'code-rejected': + // 코드 단계(codes.check)가 이미 AUTH_006 으로 바꾼다 — 여기는 빠진 길이 없게 + ticket.abort(); + throw new BusinessException('AUTH_006', { + context: { + reason: 'two-factor-code-rejected', + email: maskEmail(address), + }, + }); case 'outcome-unknown': { - // 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션은 끊어 두고 실패로 답한다 (이 세션은 남긴다) - const revoked = await this.revokeBeforeSettling( + // 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션 · 앱 비밀번호는 정리해 두고 실패로 답한다 (이 세션은 남긴다) + const cleaned = await this.cleanUpBeforeSettling( ticket, 'abort', address, @@ -280,12 +539,13 @@ export class AuthService { context: { reason: 'password-change-outcome-unknown', email: maskEmail(address), - revokedOtherSessions: revoked, + revokedOtherSessions: cleaned.revokedSessions, + removedAppPasswords: cleaned.removedAppPasswords, }, }); } case 'changed': { - const revoked = await this.revokeBeforeSettling( + const cleaned = await this.cleanUpBeforeSettling( ticket, 'succeed', address, @@ -293,31 +553,57 @@ export class AuthService { 'password-changed', ); this.logger.log( - `비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${revoked}개`, + `비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${cleaned.revokedSessions}개, 지운 앱 비밀번호 ${cleaned.removedAppPasswords}개`, ); } } } /** - * 다른 세션을 끊은 **뒤에** 시도를 끝낸다 → 끊은 수. 끊는 동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀, - * 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 끊다 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게) + * 다른 세션을 끊고 앱 비밀번호를 지운 **뒤에** 시도를 끝낸다 → 끊은 수 · 지운 수. 그동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀, + * 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게) */ - private async revokeBeforeSettling( + private async cleanUpBeforeSettling( ticket: LoginAttemptTicket, settle: 'succeed' | 'abort', address: string, refreshToken: string | undefined, reason: SessionRevokedReason, - ): Promise { + ): Promise<{ revokedSessions: number; removedAppPasswords: number }> { try { - return await this.revokeOtherSessions(address, refreshToken, reason); + const revokedSessions = await this.revokeOtherSessions( + address, + refreshToken, + reason, + ); + const removedAppPasswords = + await this.appPasswords.removeAllAfterPasswordChange(address); + return { revokedSessions, removedAppPasswords }; } finally { if (settle === 'succeed') ticket.succeed(); else ticket.abort(); } } + private currentPasswordRejected( + address: string, + reason: string, + ): BusinessException { + return new BusinessException('AUTH_005', { + context: { reason, email: maskEmail(address) }, + }); + } + + /** 2단계 인증을 켠 직원의 요청에 코드가 없다 — 400 AUTH_007 */ + private otpCodeRequired(address: string): BusinessException { + return new BusinessException('AUTH_007', { + context: { + reason: 'two-factor-code-required', + email: maskEmail(address), + }, + }); + } + /** * 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수. * 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해 @@ -328,7 +614,7 @@ export class AuthService { refreshToken: string | undefined, reason: SessionRevokedReason, ): Promise { - const keepId = await this.sessionIdOf(refreshToken, address); + const keepId = await this.currentSessionId(refreshToken, address); try { return await this.sessions.revokeAllOf( address, @@ -344,8 +630,11 @@ export class AuthService { } } - /** 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다) */ - private async sessionIdOf( + /** + * 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다). + * 비밀번호 바꾸기 · 로그인된 브라우저 목록(지금 세션 — 계약 5절)이 쓴다 + */ + async currentSessionId( refreshToken: string | undefined, address: string, ): Promise { @@ -395,22 +684,6 @@ export class AuthService { ); } - private async authenticate( - address: string, - password: string, - ): Promise { - if (!this.isLoginAddress(address)) { - throw this.loginFailed(address, 'not-employee-address'); - } - - const account = await this.mail.verifyCredentials(address, password); - // 엔진이 돌려준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다 - if (!account || !this.isLoginAddress(account)) { - throw this.loginFailed(address, 'rejected'); - } - return this.loadProfileForLogin(account, address); - } - private classify( session: AuthSessionEntity | null, claims: RefreshClaims, @@ -432,9 +705,10 @@ export class AuthService { claims: RefreshClaims, presentedHash: string, now: Date, + client: ClientInfo, ): Promise { if (state === 'current' && session) { - return this.rotate(session, claims, presentedHash, now); + return this.rotate(session, claims, presentedHash, now, client); } if (state === 'grace') { @@ -459,6 +733,7 @@ export class AuthService { claims: RefreshClaims, presentedHash: string, now: Date, + client: ClientInfo, ): Promise { const profile = await this.loadProfileForRefresh(claims, now); const expiresAt = nextIdleExpiry( @@ -474,6 +749,8 @@ export class AuthService { nextHash: hashRefreshToken(tokens.refreshToken), nextExpiresAt: expiresAt, now, + ip: client.ip || null, + userAgent: truncateUserAgent(client.userAgent), }); if (rotated) return { user: toUser(profile), tokens }; @@ -489,7 +766,14 @@ export class AuthService { context: { reason: 'rotation-conflict', sid: claims.sid }, }); } - return this.resolveRefresh(state, latest, claims, presentedHash, now); + return this.resolveRefresh( + state, + latest, + claims, + presentedHash, + now, + client, + ); } /** 계정이 사라졌으면 세션을 끊는다. 메일 엔진 장애는 그대로 던진다 (세션은 남는다) */ @@ -522,9 +806,10 @@ export class AuthService { } } - /** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간 */ + /** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록) */ private async startSession( profile: MailAccountProfile, + client: ClientInfo, ): Promise { const now = new Date(); const id = randomUUID(); @@ -549,6 +834,8 @@ export class AuthService { absoluteExpiresAt, revokedAt: null, revokedReason: null, + ip: client.ip || null, + userAgent: truncateUserAgent(client.userAgent), }); return tokens; } @@ -603,10 +890,12 @@ export class AuthService { 1, Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000), ); + // 액세스 토큰에도 세션 id 를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (계약 5절 "로그인된 브라우저") const access: AuthTokenPayload = { sub: profile.email, typ: 'access', name: profile.name, + sid: sessionId, }; const refresh: AuthTokenPayload = { sub: profile.email, diff --git a/backend/src/modules/auth/dto/auth-session.dto.ts b/backend/src/modules/auth/dto/auth-session.dto.ts new file mode 100644 index 0000000..f071f9c --- /dev/null +++ b/backend/src/modules/auth/dto/auth-session.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { DeviceDto } from '@/modules/account-security/dto/login-event.dto'; + +/** 로그인된 브라우저 하나 (계약 5절 "로그인된 브라우저") */ +export class AuthSessionDto { + @ApiProperty({ + description: '세션 id — 끊기에 쓴다', + example: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + }) + id!: string; + + @ApiProperty({ + description: '로그인한 때 (ISO 8601 UTC)', + type: String, + format: 'date-time', + example: '2026-10-31T00:12:00.000Z', + }) + createdAt!: string; + + @ApiProperty({ + description: '마지막 활동 — 마지막 갱신(회전) 때, 없으면 로그인 때', + type: String, + format: 'date-time', + example: '2026-10-31T02:40:00.000Z', + }) + lastActiveAt!: string; + + @ApiProperty({ + description: '로그인 · 갱신한 클라이언트 IP — 12단계 전에 연 세션은 null', + type: String, + nullable: true, + example: '211.186.95.252', + }) + ip!: string | null; + + @ApiProperty({ + description: + '브라우저 · 운영체제의 짧은 이름 — 12단계 전에 연 세션이거나 모르면 null', + type: DeviceDto, + nullable: true, + }) + device!: DeviceDto | null; + + @ApiProperty({ + description: + '이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 — 맨 앞에 온다. 쿠키가 없거나 남의 것이면 모두 false', + example: true, + }) + isCurrent!: boolean; +} + +export class AuthSessionPageDto { + @ApiProperty({ + type: [AuthSessionDto], + description: '지금 세션이 맨 앞, 그다음 최근 활동 차례', + }) + items!: AuthSessionDto[]; + + @ApiProperty({ + type: PageInfoDto, + description: 'total — 끝나지 않은 세션 수', + }) + pageInfo!: PageInfoDto; +} + +/** DELETE /api/auth/sessions → 200 */ +export class EndedSessionsDto { + @ApiProperty({ description: '끊은 세션 수', example: 2 }) + ended!: number; +} diff --git a/backend/src/modules/auth/dto/change-password.dto.ts b/backend/src/modules/auth/dto/change-password.dto.ts index 0f21145..9ec72fb 100644 --- a/backend/src/modules/auth/dto/change-password.dto.ts +++ b/backend/src/modules/auth/dto/change-password.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; import { @@ -10,9 +10,10 @@ import { NEW_PASSWORD_MAX_LENGTH, NEW_PASSWORD_MIN_LENGTH, } from '@/modules/auth/auth.constants'; +import { OptionalOtpCodeField } from '@/modules/auth/dto/security-fields'; /** - * PUT /api/auth/password — 계약 1절. + * PUT /api/auth/password — 계약 1절 · 5절 "비밀번호 바꾸기"(2단계 인증을 켠 직원은 otpCode). * 지금 비밀번호는 로그인(dto/login.dto.ts)과 같은 검사 — 로그인할 수 있었던 비밀번호는 여기서도 통과하고, 그보다 긴 값은 들어올 수 없다. * 새 비밀번호의 길이는 JS 문자열 길이로 센다(웹 클라이언트와 같다). 지금 비밀번호와 같으면 서비스가 400 VAL_001 (엔진에 묻지 않는다). * 비밀번호는 저장하지 않고 로그에 남기지 않는다. @@ -44,4 +45,12 @@ export class ChangePasswordDto { message: `새 비밀번호는 ${NEW_PASSWORD_MAX_LENGTH}자 이하여야 합니다.`, }) newPassword!: string; + + @ApiPropertyOptional({ + description: + '인증 앱 코드(숫자 6자리) — 2단계 인증을 켠 직원만 필수 (없으면 400 AUTH_007, 틀리거나 2분 안에 다시 쓴 코드면 400 AUTH_006 — 계약 5절)', + example: '123456', + }) + @OptionalOtpCodeField() + otpCode?: string; } diff --git a/backend/src/modules/auth/dto/login-result.dto.ts b/backend/src/modules/auth/dto/login-result.dto.ts new file mode 100644 index 0000000..a2c28b7 --- /dev/null +++ b/backend/src/modules/auth/dto/login-result.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * POST /api/auth/login 의 응답 (계약 5절 "로그인") — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다. + * twoFactorRequired 는 비밀번호가 맞았다는 뜻이다 — 쿠키가 없고 email · name 은 null (코드와 비밀번호를 함께 다시 보낸다) + */ +export class LoginResultDto { + @ApiProperty({ + description: + 'signedIn — 로그인했다(쿠키를 구웠다) · twoFactorRequired — 2단계 인증 코드가 필요하다(쿠키 없음)', + enum: ['signedIn', 'twoFactorRequired'], + example: 'signedIn', + }) + status!: 'signedIn' | 'twoFactorRequired'; + + @ApiProperty({ + description: '직원 메일 주소 — twoFactorRequired 면 null', + type: String, + nullable: true, + example: 'ara.kim@arasystem.it.kr', + }) + email!: string | null; + + @ApiProperty({ + description: '표시 이름 — twoFactorRequired 면 null', + type: String, + nullable: true, + example: '김아라', + }) + name!: string | null; +} diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts index a7d8771..c4f9daa 100644 --- a/backend/src/modules/auth/dto/login.dto.ts +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -1,10 +1,11 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator'; import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants'; +import { OptionalOtpCodeField } from '@/modules/auth/dto/security-fields'; import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants'; -/** POST /api/auth/login — 직원 메일 계정 */ +/** POST /api/auth/login — 직원 메일 계정 (계약 1절 · 5절 "로그인") */ export class LoginDto { @ApiProperty({ description: '직원 메일 주소 (사내 도메인)', @@ -15,7 +16,8 @@ export class LoginDto { email!: string; @ApiProperty({ - description: '메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다', + description: + '메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다. 코드 단계에서도 다시 싣는다(서버는 비밀번호를 들고 있지 않다). 앱 비밀번호는 받지 않는다', format: 'password', example: 'your-password', }) @@ -23,4 +25,12 @@ export class LoginDto { @IsNotEmpty({ message: '비밀번호를 입력해 주세요.' }) @MaxLength(MAX_PASSWORD_LENGTH, { message: '비밀번호가 너무 깁니다.' }) password!: string; + + @ApiPropertyOptional({ + description: + '인증 앱 코드(숫자 6자리) — 2단계 인증을 켠 직원의 둘째 단계에서만. 2단계 인증을 켜지 않은 직원이면 보지 않는다. 칸을 빼고 보낸다(null 은 400 VAL_001)', + example: '123456', + }) + @OptionalOtpCodeField() + otpCode?: string; } diff --git a/backend/src/modules/auth/dto/security-fields.spec.ts b/backend/src/modules/auth/dto/security-fields.spec.ts new file mode 100644 index 0000000..ac9fa1f --- /dev/null +++ b/backend/src/modules/auth/dto/security-fields.spec.ts @@ -0,0 +1,86 @@ +import { BadRequestException } from '@nestjs/common'; + +import { createValidationPipe } from '@/app.setup'; +import { + TwoFactorActivationDto, + TwoFactorDeactivationDto, +} from '@/modules/account-security/dto/two-factor.dto'; +import { ChangePasswordDto } from './change-password.dto'; +import { LoginDto } from './login.dto'; + +/** 전역 ValidationPipe 그대로(createValidationPipe) — 통과하면 변환된 본문, 아니면 400 (전역 필터가 VAL_001 로 바꾼다) */ +async function through( + metatype: new () => object, + body: object, +): Promise { + try { + return (await createValidationPipe().transform(body, { + type: 'body', + metatype, + })) as object; + } catch (error) { + if (error instanceof BadRequestException) return 'VAL_001'; + throw error; + } +} + +describe('인증 앱 코드 칸 (계약 5절 — otpCode)', () => { + const login = { email: 'ara.kim@arasystem.test', password: 'pw-1234' }; + const change = { currentPassword: 'old-password', newPassword: 'new-pass' }; + const activation = { + enrollmentId: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44', + currentPassword: 'old-password', + }; + const deactivation = { currentPassword: 'old-password' }; + + it('로그인 · 비밀번호 바꾸기 — 칸이 없으면 보지 않는다, 숫자 6자리면 통과', async () => { + await expect(through(LoginDto, login)).resolves.toEqual(login); + await expect(through(ChangePasswordDto, change)).resolves.toEqual(change); + await expect( + through(LoginDto, { ...login, otpCode: '012345' }), + ).resolves.toMatchObject({ otpCode: '012345' }); + await expect( + through(ChangePasswordDto, { ...change, otpCode: '012345' }), + ).resolves.toMatchObject({ otpCode: '012345' }); + }); + + it('로그인 · 비밀번호 바꾸기 — null 은 400 VAL_001 (IsOptional 은 null 도 건너뛰어 서비스에서 500 이 났다 — 2026-10-31 리뷰)', async () => { + await expect(through(LoginDto, { ...login, otpCode: null })).resolves.toBe( + 'VAL_001', + ); + await expect( + through(ChangePasswordDto, { ...change, otpCode: null }), + ).resolves.toBe('VAL_001'); + }); + + it('숫자 6자리가 아니거나 글이 아니면 400 VAL_001 — 네 본문 모두', async () => { + for (const otpCode of ['12345', '1234567', '12 456', 'abcdef', 123456]) { + await expect(through(LoginDto, { ...login, otpCode })).resolves.toBe( + 'VAL_001', + ); + await expect( + through(ChangePasswordDto, { ...change, otpCode }), + ).resolves.toBe('VAL_001'); + await expect( + through(TwoFactorActivationDto, { ...activation, otpCode }), + ).resolves.toBe('VAL_001'); + await expect( + through(TwoFactorDeactivationDto, { ...deactivation, otpCode }), + ).resolves.toBe('VAL_001'); + } + }); + + it('2단계 켜기 · 끄기 — 코드는 꼭 있어야 한다: 없거나 null 이면 400 VAL_001', async () => { + for (const otpCode of [undefined, null]) { + await expect( + through(TwoFactorActivationDto, { ...activation, otpCode }), + ).resolves.toBe('VAL_001'); + await expect( + through(TwoFactorDeactivationDto, { ...deactivation, otpCode }), + ).resolves.toBe('VAL_001'); + } + await expect( + through(TwoFactorActivationDto, { ...activation, otpCode: '123456' }), + ).resolves.toMatchObject({ otpCode: '123456' }); + }); +}); diff --git a/backend/src/modules/auth/dto/security-fields.ts b/backend/src/modules/auth/dto/security-fields.ts new file mode 100644 index 0000000..8cde768 --- /dev/null +++ b/backend/src/modules/auth/dto/security-fields.ts @@ -0,0 +1,46 @@ +import { applyDecorators } from '@nestjs/common'; +import { + IsNotEmpty, + IsString, + Matches, + MaxLength, + ValidateIf, +} from 'class-validator'; + +import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants'; +import { TOTP_CODE_PATTERN } from '@/shared/utils/totp'; + +/** + * 계정 보안 요청 본문의 공용 칸 검사 (계약 5절) — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 같은 규칙을 쓴다. + * 검사는 적은 차례대로 돈다(applyDecorators — 형식 → 길이 → 모양). + */ + +/** 인증 앱 코드 — 숫자 6자리 (아니면 400 VAL_001). 붙여 넣은 글의 공백 · `-` 정리는 화면 몫이다 */ +export function OtpCodeField(): PropertyDecorator { + return applyDecorators( + IsString({ message: '인증 앱의 코드를 입력해 주세요.' }), + Matches(TOTP_CODE_PATTERN, { + message: '인증 앱의 코드는 숫자 6자리입니다.', + }), + ); +} + +/** + * 선택 칸의 인증 앱 코드 (로그인 · 비밀번호 바꾸기) — 보내지 않았으면(undefined) 보지 않고, `null` 이면 400 VAL_001. + * `IsOptional` 은 null 도 건너뛰어 서비스까지 가고 코드 확인에서 500 이 났다 (2026-10-31 리뷰) + */ +export function OptionalOtpCodeField(): PropertyDecorator { + return applyDecorators( + ValidateIf((_object: object, value: unknown) => value !== undefined), + OtpCodeField(), + ); +} + +/** 지금 비밀번호 — 로그인 비밀번호와 같은 한도 (비지 않고 256자 이하) */ +export function CurrentPasswordField(): PropertyDecorator { + return applyDecorators( + IsString({ message: '지금 비밀번호를 입력해 주세요.' }), + IsNotEmpty({ message: '지금 비밀번호를 입력해 주세요.' }), + MaxLength(MAX_PASSWORD_LENGTH, { message: '지금 비밀번호가 너무 깁니다.' }), + ); +} diff --git a/backend/src/modules/auth/jwt.strategy.spec.ts b/backend/src/modules/auth/jwt.strategy.spec.ts index 1d6b18d..d0c7cea 100644 --- a/backend/src/modules/auth/jwt.strategy.spec.ts +++ b/backend/src/modules/auth/jwt.strategy.spec.ts @@ -1,14 +1,35 @@ import { BusinessException } from '@/common/errors/business.exception'; import { JwtStrategy } from './jwt.strategy'; +import type { RevokedSessions } from './revoked-sessions'; -const strategy = new JwtStrategy({ - jwtSecret: 'unit-test-secret-unit-test-secret-1234', - accessTtlSeconds: 3600, - refreshTtlSeconds: 1209600, - secureCookies: false, -}); +/** 끊은 세션 기억 대역 */ +const revokedIds = new Set(); +const revoked = { + isRevoked: (sessionId: string) => revokedIds.has(sessionId), +} as unknown as RevokedSessions; + +const strategy = new JwtStrategy( + { + jwtSecret: 'unit-test-secret-unit-test-secret-1234', + accessTtlSeconds: 3600, + refreshTtlSeconds: 1209600, + secureCookies: false, + }, + revoked, +); + +function codeOf(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } + return undefined; +} describe('JwtStrategy.validate', () => { + afterEach(() => revokedIds.clear()); + it('액세스 토큰이면 직원 정보를 돌려준다', () => { expect( strategy.validate({ @@ -34,6 +55,55 @@ describe('JwtStrategy.validate', () => { }); }); + it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', () => { + expect( + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + }), + ).toStrictEqual({ + email: 'ara.kim@arasystem.test', + name: '김아라', + sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + }); + }); + + it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', () => { + revokedIds.add('9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a'); + expect( + codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + }), + ), + ).toBe('AUTH_001'); + // 다른 세션 · sid 가 없는 12단계 전의 토큰은 그대로 통과한다 + expect( + codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + sid: '00000000-0000-4000-8000-000000000000', + }), + ), + ).toBeUndefined(); + expect( + codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + }), + ), + ).toBeUndefined(); + }); + it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => { expect(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }), diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index f085fad..658c5fc 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -10,6 +10,7 @@ import { readCookie } from '@/shared/utils/cookies'; import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants'; import { AUTH_SETTINGS, type AuthSettings } from './auth.settings'; import type { AuthTokenPayload } from './auth-token.types'; +import { RevokedSessions } from './revoked-sessions'; /** JWT 의 시각(exp)은 초 단위다 */ const MS_PER_SECOND = 1000; @@ -21,11 +22,15 @@ function accessTokenFromCookie(request: Request): string | null { /** * 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다. - * 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양을 확인한다. + * 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, 그리고 **끊은 세션인지**를 확인한다 + * (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다). */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { - constructor(@Inject(AUTH_SETTINGS) settings: AuthSettings) { + constructor( + @Inject(AUTH_SETTINGS) settings: AuthSettings, + private readonly revoked: RevokedSessions, + ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]), secretOrKey: settings.jwtSecret, @@ -45,6 +50,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { context: { reason: 'not-an-access-token' }, }); } + const sessionId = typeof payload.sid === 'string' ? payload.sid : null; + if (sessionId !== null && this.revoked.isRevoked(sessionId)) { + // 로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊은 세션 — 클라이언트는 갱신을 한 번 시도하고(세션이 끊겨 401) 로그인 화면으로 간다 + throw new BusinessException('AUTH_001', { + context: { reason: 'session-revoked', sid: sessionId }, + }); + } return { email: payload.sub, name: payload.name, @@ -52,6 +64,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { ...(typeof payload.exp === 'number' ? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND } : {}), + ...(sessionId !== null ? { sessionId } : {}), }; } } diff --git a/backend/src/modules/auth/login-attempt.module.ts b/backend/src/modules/auth/login-attempt.module.ts new file mode 100644 index 0000000..0eefc6f --- /dev/null +++ b/backend/src/modules/auth/login-attempt.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { LoginAttemptService } from './login-attempt.service'; + +/** + * 로그인 · 2단계 인증 코드 실패 한도 — 인증 모듈과 계정 보안 모듈(2단계 켜기 · 끄기의 지금 비밀번호 · 코드)이 **같은 카운터**를 쓴다 + * (계약 5절 "한도 · 규칙"). 한 인스턴스만 있어야 하므로 이 모듈 하나가 내놓는다. + */ +@Module({ + providers: [LoginAttemptService], + exports: [LoginAttemptService], +}) +export class LoginAttemptModule {} diff --git a/backend/src/modules/auth/login-attempt.service.spec.ts b/backend/src/modules/auth/login-attempt.service.spec.ts index df30711..c02c95e 100644 --- a/backend/src/modules/auth/login-attempt.service.spec.ts +++ b/backend/src/modules/auth/login-attempt.service.spec.ts @@ -6,6 +6,9 @@ import { LOGIN_ADDRESS_FAILURE_WINDOW_MS, LOGIN_IP_FAILURE_LIMIT, LOGIN_IP_FAILURE_WINDOW_MS, + TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT, + TWO_FACTOR_CODE_ADDRESS_LIMIT, + TWO_FACTOR_CODE_ADDRESS_WINDOW_MS, } from './auth.constants'; import { FixedWindowCounter } from './fixed-window-counter'; import { @@ -196,6 +199,112 @@ describe('LoginAttemptService', () => { expect(warn).not.toHaveBeenCalled(); }); + describe('비밀번호는 맞았다(verified — 2단계 인증의 코드 단계, 12단계)', () => { + it('차지한 실패를 돌려주되 진행 중 표시는 남긴다 — 코드 단계가 끝날 때까지 같은 주소는 REQ_001', () => { + const ticket = service.begin({ email: ARA, ip: IP }); + ticket.verified(); + + expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual( + expect.objectContaining({ + code: 'REQ_001', + reason: 'login-in-progress', + }), + ); + ticket.fail(); + // 실패로 남지 않았다 — 주소 한도만큼 다시 시도할 수 있다 + for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) { + attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail'); + } + expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual( + expect.objectContaining({ reason: 'login-failures-exceeded' }), + ); + }); + + it('verified 뒤의 succeed · abort 는 자리를 두 번 돌려주지 않는다 — IP 한도가 늘지 않는다', () => { + for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 1; i += 1) { + attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail'); + } + const ticket = service.begin({ email: ARA, ip: IP }); + ticket.verified(); + ticket.succeed(); + ticket.abort(); + + // 실패 19회가 그대로 — 한 번 더 실패하면 한도 + attempt({ email: 'one-more@arasystem.test', ip: IP }, 'fail'); + expect( + reasonOf(() => service.begin({ email: 'next@arasystem.test', ip: IP })), + ).toEqual(expect.objectContaining({ scope: 'ip' })); + }); + }); + + describe('2단계 인증 코드 실패 (beginCode — 12단계)', () => { + const code = (ip: string, outcome: 'fail' | 'succeed' | 'abort') => + service.beginCode({ email: ARA, ip })[outcome](); + + it(`같은 주소 15분 ${TWO_FACTOR_CODE_ADDRESS_LIMIT}회를 넘으면 REQ_001 — IP 를 바꿔도, 비밀번호 한도와 따로 센다`, () => { + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) { + code(`198.51.100.${i}`, 'fail'); + } + expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toEqual( + { + code: 'REQ_001', + reason: 'two-factor-code-failures-exceeded', + scope: 'address', + email: 'a***@arasystem.test', + }, + ); + // 비밀번호 시도는 그대로 된다 + expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe( + 'allowed', + ); + // 15분이 지나면 다시 받는다 + now += TWO_FACTOR_CODE_ADDRESS_WINDOW_MS; + expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toBe( + 'allowed', + ); + }); + + it(`24시간에 ${TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT}회 — 15분 창을 넘겨 가며 시도해도 하루 상한에 닿는다`, () => { + let failures = 0; + while (failures < TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT) { + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) { + code(`198.51.100.${failures}`, 'fail'); + failures += 1; + } + now += TWO_FACTOR_CODE_ADDRESS_WINDOW_MS; + } + expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toEqual( + expect.objectContaining({ + reason: 'two-factor-code-failures-exceeded', + }), + ); + }); + + it('맞으면(succeed) 차지한 자리만 돌려준다 — 주소의 기록은 지우지 않는다(하루 상한이 늘지 않게)', () => { + for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT - 1; i += 1) { + code(`198.51.100.${i}`, 'fail'); + } + code(IP, 'succeed'); + code(IP, 'fail'); + expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toEqual( + expect.objectContaining({ + reason: 'two-factor-code-failures-exceeded', + }), + ); + }); + + it('같은 IP 는 로그인 실패 한도에 함께 센다', () => { + for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) { + service.beginCode({ email: `staff${i}@arasystem.test`, ip: IP }).fail(); + } + expect( + reasonOf(() => + service.begin({ email: 'other@arasystem.test', ip: IP }), + ), + ).toEqual(expect.objectContaining({ scope: 'ip' })); + }); + }); + describe('IP 묶기 (요청 수 한도와 같은 clientIpKey)', () => { it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔 가며 IP 한도를 피하지 못한다', () => { for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) { diff --git a/backend/src/modules/auth/login-attempt.service.ts b/backend/src/modules/auth/login-attempt.service.ts index d5a4548..4a35cce 100644 --- a/backend/src/modules/auth/login-attempt.service.ts +++ b/backend/src/modules/auth/login-attempt.service.ts @@ -10,6 +10,10 @@ import { LOGIN_ADDRESS_FAILURE_WINDOW_MS, LOGIN_IP_FAILURE_LIMIT, LOGIN_IP_FAILURE_WINDOW_MS, + TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT, + TWO_FACTOR_CODE_ADDRESS_DAY_WINDOW_MS, + TWO_FACTOR_CODE_ADDRESS_LIMIT, + TWO_FACTOR_CODE_ADDRESS_WINDOW_MS, } from './auth.constants'; import { FixedWindowCounter, @@ -29,6 +33,7 @@ const MAX_TRACKED_KEYS = 100_000; type Scope = 'ip' | 'address'; type RejectReason = | 'login-failures-exceeded' + | 'two-factor-code-failures-exceeded' | 'login-in-progress' | 'login-tracking-full'; @@ -48,12 +53,30 @@ export interface LoginAttemptSource { * 진행 중인 로그인 시도 하나. 결과가 정해지면 셋 중 하나를 **한 번** 부른다 (두 번째부터는 무시). */ export interface LoginAttemptTicket { - /** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 */ + /** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 (verified 뒤면 남길 것이 없다 — 진행 중 표시만 끝낸다) */ fail(): void; /** 성공 — 그 주소의 실패 기록을 지우고, 차지했던 IP 자리를 돌려준다 (성공은 세지 않는다) */ succeed(): void; /** 판단하지 못함(메일 엔진 장애 등) — 차지했던 자리를 모두 돌려준다 */ abort(): void; + /** + * 비밀번호는 맞았다(엔진이 2단계 인증을 요구 — 계약 5절) — 차지한 실패를 돌려주되 **진행 중 표시는 남긴다**. + * 코드 단계가 끝날 때까지 같은 주소의 다른 로그인 · 비밀번호 바꾸기는 REQ_001 이다. 끝나면 셋 중 하나를 부른다 + */ + verified(): void; +} + +/** + * 진행 중인 2단계 인증 코드 확인 하나 (계약 5절 "한도 · 규칙") — 결과가 정해지면 셋 중 하나를 **한 번** 부른다. + * 진행 중 표시는 없다 — 같은 주소의 동시 시도는 비밀번호 시도(LoginAttemptTicket)나 2단계 바꾸기 잠금이 막는다 + */ +export interface TwoFactorCodeTicket { + /** 코드 틀림 · 다시 쓴 코드 — 차지한 실패를 그대로 남긴다 */ + fail(): void; + /** 코드가 맞았다 — 차지한 자리를 돌려준다(주소의 기록은 지우지 않는다 — 하루 20회 상한이 늘지 않게) */ + succeed(): void; + /** 판단하지 못함(엔진 장애 등) — 차지한 자리를 돌려준다 */ + abort(): void; } /** @@ -87,6 +110,17 @@ export class LoginAttemptService { LOGIN_ADDRESS_FAILURE_WINDOW_MS, MAX_TRACKED_KEYS, ); + /** 2단계 인증 코드 실패 — 같은 주소 15분 5회 · 24시간 20회 (같은 IP 는 ipFailures 에 함께 센다) */ + private readonly codeFailures = new FixedWindowCounter( + TWO_FACTOR_CODE_ADDRESS_LIMIT, + TWO_FACTOR_CODE_ADDRESS_WINDOW_MS, + MAX_TRACKED_KEYS, + ); + private readonly dailyCodeFailures = new FixedWindowCounter( + TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT, + TWO_FACTOR_CODE_ADDRESS_DAY_WINDOW_MS, + MAX_TRACKED_KEYS, + ); /** 엔진 확인이 진행 중인 주소 */ private readonly inFlight = new Set(); /** 상한이 차서 거절하기 시작했다는 error 로그를 이번 정리 주기에 이미 남겼는가 (로그 폭주 방지) */ @@ -120,6 +154,14 @@ export class LoginAttemptService { } let settled = false; + // 차지한 실패를 이미 돌려줬는가 (verified) — 두 번 돌려주지 않는다 + let released = false; + const releaseReservation = () => { + if (released) return; + released = true; + this.ipFailures.release(ip.slot); + if (addressSlot !== null) this.addressFailures.release(addressSlot); + }; const settle = (finish: () => void) => { if (settled) return; settled = true; @@ -131,14 +173,57 @@ export class LoginAttemptService { fail: () => settle(() => undefined), succeed: () => settle(() => { - this.ipFailures.release(ip.slot); + if (!released) this.ipFailures.release(ip.slot); + released = true; if (addressKey !== null) this.addressFailures.clear(addressKey); }), - abort: () => - settle(() => { - this.ipFailures.release(ip.slot); - if (addressSlot !== null) this.addressFailures.release(addressSlot); - }), + abort: () => settle(releaseReservation), + verified: () => { + if (!settled) releaseReservation(); + }, + }; + } + + /** + * 2단계 인증 코드 확인을 시작한다 — 같은 IP 의 로그인 실패 한도(ipFailures)와 같은 주소의 코드 실패 한도(15분 · 24시간)에 + * 실패 한 번을 미리 차지한다. 어느 한도든 넘었으면 REQ_001 (차지한 자리는 돌려준다). 이 메서드 안에는 await 가 없다 + */ + beginCode(source: { + readonly email: string; + readonly ip: string; + }): TwoFactorCodeTicket { + const now = Date.now(); + const ipKey = digest(clientIpKey(source.ip)); + const addressKey = digest(source.email); + + const ip = this.ipFailures.reserve(ipKey, now); + if (!ip.ok) throw this.rejected(source, 'ip', this.reasonOf(ip)); + + const shortTerm = this.codeFailures.reserve(addressKey, now); + if (!shortTerm.ok) { + this.ipFailures.release(ip.slot, now); + throw this.rejected(source, 'address', this.codeReasonOf(shortTerm)); + } + const daily = this.dailyCodeFailures.reserve(addressKey, now); + if (!daily.ok) { + this.ipFailures.release(ip.slot, now); + this.codeFailures.release(shortTerm.slot, now); + throw this.rejected(source, 'address', this.codeReasonOf(daily)); + } + + let settled = false; + const settle = (keep: boolean) => { + if (settled) return; + settled = true; + if (keep) return; + this.ipFailures.release(ip.slot); + this.codeFailures.release(shortTerm.slot); + this.dailyCodeFailures.release(daily.slot); + }; + return { + fail: () => settle(true), + succeed: () => settle(false), + abort: () => settle(false), }; } @@ -150,15 +235,19 @@ export class LoginAttemptService { sweep(now: number = Date.now()): void { const ip = this.ipFailures.sweep(now); const address = this.addressFailures.sweep(now); + const code = this.codeFailures.sweep(now); + const dailyCode = this.dailyCodeFailures.sweep(now); this.capacityAlarmRaised = false; - const evicted = ip.evicted + address.evicted; + const evicted = + ip.evicted + address.evicted + code.evicted + dailyCode.evicted; if (evicted > 0) { this.logger.warn( `[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿아 잠기지 않은 기록 ${evicted}건을 일찍 지웠습니다 — 많은 IP·주소로 시도하는 공격일 수 있습니다.`, ); } - const rejected = ip.rejected + address.rejected; + const rejected = + ip.rejected + address.rejected + code.rejected + dailyCode.rejected; if (rejected > 0) { this.logger.error( `[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿고 밀어낼 기록이 없어 지난 주기에 새 IP·주소의 로그인 ${rejected}건을 거절했습니다 (fail-closed).`, @@ -166,6 +255,15 @@ export class LoginAttemptService { } } + /** 코드 한도의 거절 까닭 — 상한이 찬 것은 로그인 한도와 같게 다룬다 */ + private codeReasonOf( + result: Extract, + ): RejectReason { + return result.reason === 'limit' + ? 'two-factor-code-failures-exceeded' + : this.reasonOf(result); + } + private reasonOf( result: Extract, ): RejectReason { diff --git a/backend/src/modules/auth/revoked-sessions.spec.ts b/backend/src/modules/auth/revoked-sessions.spec.ts new file mode 100644 index 0000000..e02eee7 --- /dev/null +++ b/backend/src/modules/auth/revoked-sessions.spec.ts @@ -0,0 +1,65 @@ +import { MoreThan, type Repository } from 'typeorm'; + +import { REVOKED_SESSION_MEMORY_MS } from './auth.constants'; +import type { AuthSessionEntity } from './auth-session.entity'; +import { RevokedSessions } from './revoked-sessions'; + +const NOW = Date.parse('2026-10-31T03:00:00Z'); + +describe('RevokedSessions (끊은 세션 id 기억 — 계약 5절 "로그인된 브라우저")', () => { + let repository: { find: jest.Mock }; + let revoked: RevokedSessions; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + repository = { find: jest.fn().mockResolvedValue([]) }; + revoked = new RevokedSessions( + repository as unknown as Repository, + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('넣은 세션은 1시간(액세스 토큰의 가장 긴 수명) 동안 끊긴 것으로 본다', () => { + revoked.add(['s1'], new Date(NOW)); + + expect(revoked.isRevoked('s1', NOW)).toBe(true); + expect(revoked.isRevoked('s2', NOW)).toBe(false); + expect(revoked.isRevoked('s1', NOW + REVOKED_SESSION_MEMORY_MS)).toBe(true); + expect(revoked.isRevoked('s1', NOW + REVOKED_SESSION_MEMORY_MS + 1)).toBe( + false, + ); + }); + + it('서버가 다시 뜨면 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다', async () => { + repository.find.mockResolvedValue([ + { id: 'recent', revokedAt: new Date(NOW - 60_000) }, + ]); + + await revoked.onApplicationBootstrap(); + + expect(repository.find).toHaveBeenCalledWith({ + select: { id: true, revokedAt: true }, + where: { + revokedAt: MoreThan(new Date(NOW - REVOKED_SESSION_MEMORY_MS)), + }, + order: { revokedAt: 'ASC' }, + }); + expect(revoked.isRevoked('recent', NOW)).toBe(true); + }); + + it('DB 를 읽지 못하면 기동을 멈춘다 — 끊은 세션의 토큰이 다시 쓰이지 않게', async () => { + repository.find.mockRejectedValue(new Error('db down')); + await expect(revoked.onApplicationBootstrap()).rejects.toThrow('db down'); + }); + + it('정리 작업은 1시간이 지난 기억만 지운다', () => { + revoked.add(['old'], new Date(NOW - REVOKED_SESSION_MEMORY_MS - 1)); + revoked.add(['fresh'], new Date(NOW)); + + revoked.sweep(NOW); + + expect(revoked.size).toBe(1); + expect(revoked.isRevoked('fresh', NOW)).toBe(true); + }); +}); diff --git a/backend/src/modules/auth/revoked-sessions.ts b/backend/src/modules/auth/revoked-sessions.ts new file mode 100644 index 0000000..4419770 --- /dev/null +++ b/backend/src/modules/auth/revoked-sessions.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MoreThan, type Repository } from 'typeorm'; + +import { REVOKED_SESSION_MEMORY_MS } from './auth.constants'; +import { AuthSessionEntity } from './auth-session.entity'; + +/** 지난 기억을 지우는 주기 */ +const SWEEP_INTERVAL_MS = 60_000; + +/** + * 끊은 세션 id 기억 (계약 5절 "로그인된 브라우저" — 끊은 세션은 바로 막는다). + * + * - 액세스 토큰은 DB 를 묻지 않고 검증한다. 대신 토큰에 세션 id(`sid`)를 싣고, 끊은 세션 id 를 여기서 1시간 + * (액세스 토큰의 가장 긴 수명 — REVOKED_SESSION_MEMORY_MS) 기억해 가드(JwtStrategy)가 401 AUTH_001 로 막는다. + * 실시간 알림도 다음 ping 때 그 세션의 연결을 끝낸다 (mail-events.service.ts) + * - 세션을 끊는 모든 길(로그아웃 · 비밀번호 바꾸기 · 이미 쓴 리프레시 토큰 · 계정을 쓸 수 없음 · 로그인된 브라우저에서 끊기)은 + * 세션 표의 조건부 UPDATE 가 끝낸 id 를 여기에 넣는다 (auth-session.repository.ts — 한 곳) + * - 서버가 다시 뜨면(onApplicationBootstrap) 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다 — 읽지 못하면 기동을 멈춘다 + * (끊은 세션의 토큰이 만료까지 다시 쓰이는 일이 없게. 운영은 마이그레이션도 기동 때 DB 에 하므로 DB 없이는 어차피 뜨지 않는다) + * - 12단계 전에 발급한 토큰(`sid` 없음)은 막지 못한다 — 만료(최대 1시간)까지 쓰인다 + * + * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 한 인스턴스에서 끊은 세션을 다른 인스턴스가 모른다(공유 저장소로 옮긴다 — nestjs 스킬 "확장 때 옮길 것"). + */ +@Injectable() +export class RevokedSessions implements OnApplicationBootstrap { + private readonly logger = new Logger(RevokedSessions.name); + /** 세션 id → 끊은 시각(epoch ms) */ + private readonly revokedAt = new Map(); + + constructor( + @InjectRepository(AuthSessionEntity) + private readonly sessions: Repository, + ) {} + + /** 기동 — 최근 1시간 안에 끊은 세션을 채운다 (revoked_at 부분 인덱스를 탄다) */ + async onApplicationBootstrap(): Promise { + const since = new Date(Date.now() - REVOKED_SESSION_MEMORY_MS); + const rows = await this.sessions.find({ + select: { id: true, revokedAt: true }, + where: { revokedAt: MoreThan(since) }, + order: { revokedAt: 'ASC' }, + }); + for (const row of rows) { + if (row.revokedAt) this.remember(row.id, row.revokedAt.getTime()); + } + if (rows.length > 0) { + this.logger.log( + `최근 1시간 안에 끊은 세션 ${rows.length}개를 불러왔습니다.`, + ); + } + } + + /** 끊은 세션들을 기억한다 (세션 표가 끝낸 뒤에만 부른다) */ + add(sessionIds: readonly string[], revokedAt: Date): void { + for (const id of sessionIds) this.remember(id, revokedAt.getTime()); + } + + /** 1시간 안에 끊은 세션인가 — 가드 · 실시간 알림이 부른다 (DB 를 묻지 않는다) */ + isRevoked(sessionId: string, now: number = Date.now()): boolean { + const at = this.revokedAt.get(sessionId); + if (at === undefined) return false; + if (now - at > REVOKED_SESSION_MEMORY_MS) { + this.revokedAt.delete(sessionId); + return false; + } + return true; + } + + /** 1시간이 지난 기억을 지운다 — 넣은 차례가 거의 끊은 차례라 앞에서부터 보다가 아직인 것을 만나면 멈춘다 */ + @Interval(SWEEP_INTERVAL_MS) + sweep(now: number = Date.now()): void { + for (const [id, at] of this.revokedAt) { + if (now - at <= REVOKED_SESSION_MEMORY_MS) break; + this.revokedAt.delete(id); + } + } + + get size(): number { + return this.revokedAt.size; + } + + private remember(id: string, at: number): void { + // 늦게 끊은 시각이 이긴다 — 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다) + const previous = this.revokedAt.get(id); + if (previous !== undefined && previous >= at) return; + this.revokedAt.delete(id); + this.revokedAt.set(id, at); + } +} diff --git a/backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts new file mode 100644 index 0000000..1f46445 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-account-security.provider.spec.ts @@ -0,0 +1,389 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { JmapAccountSecurityProvider } from './jmap-account-security.provider'; +import type { AccountChangeReply, JmapClient } from './jmap.client'; +import type { JmapInvocation } from './jmap.types'; + +const ARA = 'ara.kim@arasystem.test'; +const OTPAUTH = + 'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.test?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30'; + +async function failureOf( + promise: Promise, +): Promise { + try { + await promise; + } catch (error) { + if (error instanceof BusinessException) return error; + throw error; + } + return undefined; +} + +describe('JmapAccountSecurityProvider (x:AccountPassword · x:AppPassword — 대리 접근)', () => { + let callAccount: jest.Mock; + let callAccountChange: jest.Mock; + let provider: JmapAccountSecurityProvider; + /** 마지막 호출의 메서드 목록 (계정 id 'x1' 로 만든다) */ + const lastCalls = (mock: jest.Mock): JmapInvocation[] => { + const calls = mock.mock.calls as [ + string, + (accountId: string) => JmapInvocation[], + ][]; + const [, build] = calls[calls.length - 1]; + return build('x1'); + }; + const answered = (...responses: JmapInvocation[]): AccountChangeReply => ({ + kind: 'answered', + responses, + }); + + beforeEach(() => { + callAccount = jest.fn(); + callAccountChange = jest.fn(); + provider = new JmapAccountSecurityProvider({ + callAccount, + callAccountChange, + } as unknown as JmapClient); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('2단계 인증', () => { + it('켜짐은 비밀번호 객체의 otpAuth.otpUrl 이 있는지로 본다 (가린 값 "****")', async () => { + callAccount.mockResolvedValueOnce([ + [ + 'x:AccountPassword/get', + { list: [{ id: 'singleton', otpAuth: { otpUrl: '****' } }] }, + 'g', + ], + ]); + await expect(provider.isTwoFactorEnabled(ARA)).resolves.toBe(true); + expect(lastCalls(callAccount)).toEqual([ + ['x:AccountPassword/get', { accountId: 'x1', ids: ['singleton'] }, 'g'], + ]); + + callAccount.mockResolvedValueOnce([ + [ + 'x:AccountPassword/get', + { list: [{ id: 'singleton', otpAuth: {} }] }, + 'g', + ], + ]); + await expect(provider.isTwoFactorEnabled(ARA)).resolves.toBe(false); + }); + + it('비밀번호 객체를 읽지 못하면 MAIL_001', async () => { + callAccount.mockResolvedValueOnce([ + ['error', { type: 'forbidden' }, 'g'], + ]); + expect((await failureOf(provider.isTwoFactorEnabled(ARA)))?.code).toBe( + 'MAIL_001', + ); + callAccount.mockResolvedValueOnce([ + ['x:AccountPassword/get', { list: [] }, 'g'], + ]); + expect((await failureOf(provider.isTwoFactorEnabled(ARA)))?.code).toBe( + 'MAIL_001', + ); + }); + + it('켜기 — currentSecret + otpAuth.otpUrl, updated → enabled · forbidden → refused · 결과를 모르면 unknown', async () => { + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { updated: { singleton: null } }, + 's', + ]), + ); + await expect(provider.enableTwoFactor(ARA, 'pw', OTPAUTH)).resolves.toBe( + 'enabled', + ); + expect(lastCalls(callAccountChange)).toEqual([ + [ + 'x:AccountPassword/set', + { + accountId: 'x1', + update: { + singleton: { currentSecret: 'pw', otpAuth: { otpUrl: OTPAUTH } }, + }, + }, + 's', + ], + ]); + + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { + notUpdated: { + singleton: { + type: 'forbidden', + description: 'Current secret is incorrect.', + }, + }, + }, + 's', + ]), + ); + await expect(provider.enableTwoFactor(ARA, 'pw', OTPAUTH)).resolves.toBe( + 'refused', + ); + + callAccountChange.mockResolvedValueOnce({ + kind: 'unknown', + detail: 'timeout', + }); + await expect(provider.enableTwoFactor(ARA, 'pw', OTPAUTH)).resolves.toBe( + 'unknown', + ); + }); + + it('끄기 — currentSecret + otpAuth {otpCode, otpUrl: null}, forbidden → code-rejected, 그 밖의 거절은 MAIL_001 (설명 문장은 남기지 않는다)', async () => { + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { updated: { singleton: null } }, + 's', + ]), + ); + await expect( + provider.disableTwoFactor(ARA, 'pw', '123456'), + ).resolves.toBe('disabled'); + expect(lastCalls(callAccountChange)[0][1]).toEqual({ + accountId: 'x1', + update: { + singleton: { + currentSecret: 'pw', + otpAuth: { otpCode: '123456', otpUrl: null }, + }, + }, + }); + + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { notUpdated: { singleton: { type: 'forbidden' } } }, + 's', + ]), + ); + await expect( + provider.disableTwoFactor(ARA, 'pw', '000000'), + ).resolves.toBe('code-rejected'); + + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AccountPassword/set', + { + notUpdated: { + singleton: { type: 'serverFail', description: 'secret details' }, + }, + }, + 's', + ]), + ); + const failure = await failureOf( + provider.disableTwoFactor(ARA, 'pw', '1'), + ); + expect(failure?.code).toBe('MAIL_001'); + expect(JSON.stringify(failure?.context)).not.toContain('secret details'); + }); + }); + + describe('앱 비밀번호', () => { + it('목록 — id · 이름 · 만든 때(ISO 8601), 읽지 못하는 항목은 뺀다', async () => { + callAccount.mockResolvedValueOnce([ + [ + 'x:AppPassword/get', + { + list: [ + { + id: 'b', + description: '회사 PC', + createdAt: '2026-10-30T19:54:08Z', + secret: '****', + }, + { id: 'c', description: '휴대폰', createdAt: 'nope' }, + ], + }, + 'g', + ], + ]); + await expect(provider.listAppPasswords(ARA)).resolves.toEqual([ + { id: 'b', name: '회사 PC', createdAt: '2026-10-30T19:54:08.000Z' }, + ]); + }); + + it('만들기 — 권한을 메일 프로그램만으로 좁혀(Replace) 만들고, 같은 요청의 목록에서 만든 때를 읽는다 · 값은 그대로 돌려준다', async () => { + callAccountChange.mockResolvedValueOnce( + answered( + [ + 'x:AppPassword/set', + { + created: { + k: { + id: 'c', + secret: 'app_abcdefghijklmnopqrstuvwxyz0123791203', + }, + }, + }, + 's', + ], + [ + 'x:AppPassword/get', + { + list: [ + { + id: 'c', + description: '휴대폰', + createdAt: '2026-10-31T00:20:00Z', + }, + ], + }, + 'g', + ], + ), + ); + + await expect(provider.createAppPassword(ARA, '휴대폰')).resolves.toEqual({ + id: 'c', + name: '휴대폰', + createdAt: '2026-10-31T00:20:00.000Z', + password: 'app_abcdefghijklmnopqrstuvwxyz0123791203', + }); + expect(lastCalls(callAccountChange)).toEqual([ + [ + 'x:AppPassword/set', + { + accountId: 'x1', + create: { + k: { + description: '휴대폰', + permissions: { + '@type': 'Replace', + permissions: { + authenticate: true, + imapAuthenticate: true, + emailSend: true, + }, + }, + }, + }, + }, + 's', + ], + [ + 'x:AppPassword/get', + { + accountId: 'x1', + ids: null, + properties: ['description', 'createdAt'], + }, + 'g', + ], + ]); + }); + + it('상한(overQuota)은 409 AUTH_009, 결과를 모르면 MAIL_001, 그 밖의 거절도 MAIL_001', async () => { + callAccountChange.mockResolvedValueOnce( + answered( + [ + 'x:AppPassword/set', + { notCreated: { k: { type: 'overQuota' } } }, + 's', + ], + ['x:AppPassword/get', { list: [] }, 'g'], + ), + ); + expect( + (await failureOf(provider.createAppPassword(ARA, 'x')))?.code, + ).toBe('AUTH_009'); + + callAccountChange.mockResolvedValueOnce({ + kind: 'unknown', + detail: 'timeout', + }); + expect( + (await failureOf(provider.createAppPassword(ARA, 'x')))?.code, + ).toBe('MAIL_001'); + + callAccountChange.mockResolvedValueOnce( + answered([ + 'x:AppPassword/set', + { notCreated: { k: { type: 'validationFailed' } } }, + 's', + ]), + ); + expect( + (await failureOf(provider.createAppPassword(ARA, 'x')))?.code, + ).toBe('MAIL_001'); + }); + + it('지우기 — 이미 없는 것(notFound)은 지운 것과 같다, 그 밖의 거절은 MAIL_001', async () => { + callAccount.mockResolvedValueOnce([ + [ + 'x:AppPassword/set', + { notDestroyed: { b: { type: 'notFound' } } }, + 'd', + ], + ]); + await expect( + provider.deleteAppPassword(ARA, 'b'), + ).resolves.toBeUndefined(); + expect(lastCalls(callAccount)).toEqual([ + ['x:AppPassword/set', { accountId: 'x1', destroy: ['b'] }, 'd'], + ]); + + callAccount.mockResolvedValueOnce([ + [ + 'x:AppPassword/set', + { notDestroyed: { b: { type: 'forbidden' } } }, + 'd', + ], + ]); + expect( + (await failureOf(provider.deleteAppPassword(ARA, 'b')))?.code, + ).toBe('MAIL_001'); + }); + + it('모두 지우기 — 목록의 id 를 한 번에(같은 id 를 두 번 넣지 않는다), 없으면 묻지 않는다', async () => { + callAccount + .mockResolvedValueOnce([ + [ + 'x:AppPassword/get', + { + list: [ + { + id: 'b', + description: 'a', + createdAt: '2026-10-30T00:00:00Z', + }, + { + id: 'c', + description: 'b', + createdAt: '2026-10-30T00:00:00Z', + }, + ], + }, + 'g', + ], + ]) + .mockResolvedValueOnce([ + ['x:AppPassword/set', { destroyed: ['b', 'c'] }, 'd'], + ]); + await expect(provider.deleteAllAppPasswords(ARA)).resolves.toBe(2); + expect(lastCalls(callAccount)).toEqual([ + ['x:AppPassword/set', { accountId: 'x1', destroy: ['b', 'c'] }, 'd'], + ]); + + callAccount.mockReset(); + callAccount.mockResolvedValueOnce([ + ['x:AppPassword/get', { list: [] }, 'g'], + ]); + await expect(provider.deleteAllAppPasswords(ARA)).resolves.toBe(0); + expect(callAccount).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-account-security.provider.ts b/backend/src/modules/mail/jmap/jmap-account-security.provider.ts new file mode 100644 index 0000000..05592e6 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-account-security.provider.ts @@ -0,0 +1,360 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { APP_PASSWORD_PERMISSIONS } from '@/modules/account-security/account-security.constants'; +import type { + AccountSecurityProvider, + AppPasswordRecord, + CreatedAppPassword, + TwoFactorDisableResult, + TwoFactorEnableResult, +} from '@/modules/account-security/account-security.provider'; +import { traceTag } from '@/shared/logger/request-context'; +import { maskEmail } from '@/shared/utils/mask'; +import { JmapClient, type AccountChangeReply } from './jmap.client'; +import { findResult, firstSetError } from './jmap-results'; +import type { JmapError, JmapInvocation, JmapSetResult } from './jmap.types'; + +/** 본인 비밀번호 객체 (계정마다 하나 — id singleton) */ +const PASSWORD_GET = 'x:AccountPassword/get'; +const PASSWORD_SET = 'x:AccountPassword/set'; +const PASSWORD_OBJECT_ID = 'singleton'; +const APP_PASSWORD_GET = 'x:AppPassword/get'; +const APP_PASSWORD_SET = 'x:AppPassword/set'; +/** 만들기 요청 안의 이름 */ +const CREATE_KEY = 'k'; + +/** x:AccountPassword/get 의 항목 — otpAuth.otpUrl 은 켜졌으면 "****"(가린 값), 꺼졌으면 없다 */ +interface JmapAccountPassword { + readonly id?: string; + readonly otpAuth?: { readonly otpUrl?: unknown } | null; +} + +/** x:AppPassword/get 의 항목 — secret 은 늘 "****" (만들 때만 진짜 값) */ +interface JmapAppPassword { + readonly id?: string; + readonly description?: unknown; + readonly createdAt?: unknown; +} + +interface JmapGetResult { + readonly list?: T[]; +} + +function engineFailure(context: Record): BusinessException { + return new BusinessException('MAIL_001', { context }); +} + +/** 엔진 시각(UTC 초 — "2026-10-30T19:54:08Z") → ISO 8601 (ms) — 읽지 못하면 null */ +function toIso(value: unknown): string | null { + if (typeof value !== 'string') return null; + const time = new Date(value); + return Number.isNaN(time.getTime()) ? null : time.toISOString(); +} + +/** + * Stalwart(JMAP) 계정 보안 어댑터 — AccountSecurityProvider 의 구현 (계약 5절 — 2단계 인증 · 앱 비밀번호). + * 모두 게이트웨이 대리 접근으로 계정 관리 기능(`urn:stalwart:jmap`)을 부른다 — 직원이 2단계 인증을 켜면 본인 Basic 은 402 라 쓸 수 없다. + * 엔진 동작(2026-10-31 개발 엔진 · 0.16.27 소스 확인): + * - 켜기: `x:AccountPassword/set` 에 currentSecret + otpAuth.otpUrl — 엔진은 코드를 보지 않고 저장한다. 지금 비밀번호가 틀리거나 + * 이미 켜져 있으면(지금 코드를 요구) forbidden + * - 끄기: currentSecret + otpAuth {otpCode, otpUrl: null} — 코드 틀림도 비밀번호 틀림과 같은 forbidden "Current secret is incorrect." + * - 앱 비밀번호: 값은 엔진이 만들어 만들 때 한 번만 준다(우리가 넣으면 거절), 상한(기본 5)을 넘으면 overQuota, 지운 번호를 다시 쓴다 + * 엔진 오류의 설명 문장 · 비밀번호 · 코드 · otpauth 주소 · 앱 비밀번호 값 · 이름은 로그 · 오류 context 에 남기지 않는다 (type 만) + */ +@Injectable() +export class JmapAccountSecurityProvider implements AccountSecurityProvider { + private readonly logger = new Logger(JmapAccountSecurityProvider.name); + + constructor(private readonly jmap: JmapClient) {} + + async isTwoFactorEnabled(user: string): Promise { + const responses = await this.jmap.callAccount(user, (accountId) => [ + [PASSWORD_GET, { accountId, ids: [PASSWORD_OBJECT_ID] }, 'g'], + ]); + const result = findResult(responses, 'g', PASSWORD_GET); + if (!result.ok) { + throw engineFailure({ + reason: 'password-object-unreadable', + jmapType: result.error.type ?? 'unknown', + }); + } + const [item] = + (result.args as JmapGetResult).list ?? []; + if (!item) { + // 비밀번호 자격 증명이 없는 계정(외부 디렉터리 등) — 웹메일 계정이 아니다 + throw engineFailure({ reason: 'password-object-missing' }); + } + const otpUrl = item.otpAuth?.otpUrl; + return typeof otpUrl === 'string' && otpUrl !== ''; + } + + async enableTwoFactor( + user: string, + currentPassword: string, + otpauthUrl: string, + ): Promise { + const reply = await this.jmap.callAccountChange(user, (accountId) => [ + [ + PASSWORD_SET, + { + accountId, + update: { + [PASSWORD_OBJECT_ID]: { + currentSecret: currentPassword, + otpAuth: { otpUrl: otpauthUrl }, + }, + }, + }, + 's', + ], + ]); + const outcome = this.passwordUpdate(user, reply, 'two-factor-enable'); + if (outcome === 'updated') return 'enabled'; + return outcome === 'forbidden' ? 'refused' : 'unknown'; + } + + async disableTwoFactor( + user: string, + currentPassword: string, + otpCode: string, + ): Promise { + const reply = await this.jmap.callAccountChange(user, (accountId) => [ + [ + PASSWORD_SET, + { + accountId, + update: { + [PASSWORD_OBJECT_ID]: { + currentSecret: currentPassword, + otpAuth: { otpCode, otpUrl: null }, + }, + }, + }, + 's', + ], + ]); + const outcome = this.passwordUpdate(user, reply, 'two-factor-disable'); + if (outcome === 'updated') return 'disabled'; + return outcome === 'forbidden' ? 'code-rejected' : 'unknown'; + } + + async listAppPasswords(user: string): Promise { + const responses = await this.jmap.callAccount(user, (accountId) => [ + [ + APP_PASSWORD_GET, + { accountId, ids: null, properties: ['description', 'createdAt'] }, + 'g', + ], + ]); + return this.readAppPasswords(responses, 'g'); + } + + async createAppPassword( + user: string, + name: string, + ): Promise { + const reply = await this.jmap.callAccountChange(user, (accountId) => [ + [ + APP_PASSWORD_SET, + { + accountId, + create: { + [CREATE_KEY]: { + description: name, + permissions: { + '@type': 'Replace', + permissions: { ...APP_PASSWORD_PERMISSIONS }, + }, + }, + }, + }, + 's', + ], + // 같은 요청에서 만든 것의 createdAt 을 읽는다 (엔진 상한이 작아 모두 읽어도 가볍다) + [ + APP_PASSWORD_GET, + { accountId, ids: null, properties: ['description', 'createdAt'] }, + 'g', + ], + ]); + if (reply.kind === 'unknown') { + // 엔진이 만들었을 수 있지만 값을 받지 못했다 — 직원은 목록에서 지우고 다시 만든다 + this.logger.warn( + `[MAIL_001]${traceTag()} 앱 비밀번호 만들기의 결과를 알 수 없습니다 — 만들어졌다면 값 없이 목록에 남습니다: user=${maskEmail(user)}, 원인=${reply.detail}`, + ); + throw engineFailure({ + reason: 'app-password-create-outcome-unknown', + detail: reply.detail, + }); + } + + const set = findResult(reply.responses, 's', APP_PASSWORD_SET); + if (!set.ok) { + throw engineFailure({ + reason: 'app-password-create-failed', + jmapType: set.error.type ?? 'unknown', + }); + } + const result = set.args as JmapSetResult; + const refusal = firstSetError(result.notCreated); + if (refusal) throw this.createRefusal(refusal); + + // 엔진이 이 응답에만 secret 을 싣는다 (createdAt 은 싣지 않는다 — 같은 요청의 get 으로 읽는다) + const created = result.created?.[CREATE_KEY]; + if ( + !created || + typeof created.id !== 'string' || + typeof created.secret !== 'string' || + created.secret === '' + ) { + throw engineFailure({ reason: 'app-password-create-unanswered' }); + } + // 읽기가 실패해도 값은 이미 만들어졌다 — 만든 때를 모르면 지금으로 둔다 (목록에서 엔진의 값으로 다시 맞춘다) + const listed = this.tryReadAppPasswords(reply.responses, 'g').find( + (record) => record.id === created.id, + ); + return { + id: created.id, + name: listed?.name ?? name, + createdAt: listed?.createdAt ?? new Date().toISOString(), + password: created.secret, + }; + } + + async deleteAppPassword(user: string, id: string): Promise { + const responses = await this.jmap.callAccount(user, (accountId) => [ + [APP_PASSWORD_SET, { accountId, destroy: [id] }, 'd'], + ]); + this.assertDestroyed(responses, 'd'); + } + + async deleteAllAppPasswords(user: string): Promise { + const records = await this.listAppPasswords(user); + if (records.length === 0) return 0; + // 한 요청의 destroy 에 같은 id 를 두 번 넣지 않는다 — 목록의 id 는 서로 다르다 + const ids = [...new Set(records.map((record) => record.id))]; + const responses = await this.jmap.callAccount(user, (accountId) => [ + [APP_PASSWORD_SET, { accountId, destroy: ids }, 'd'], + ]); + return this.assertDestroyed(responses, 'd'); + } + + /** + * 비밀번호 객체 바꾸기의 해석 — updated · forbidden(엔진이 받지 않음) · unknown(결과를 모름). + * 그 밖의 SetError · 메서드 오류는 MAIL_001 (바뀌지 않았다 — type 만 남긴다) + */ + private passwordUpdate( + user: string, + reply: AccountChangeReply, + call: string, + ): 'updated' | 'forbidden' | 'unknown' { + if (reply.kind === 'unknown') { + this.logger.warn( + `[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`, + ); + return 'unknown'; + } + const set = findResult(reply.responses, 's', PASSWORD_SET); + if (!set.ok) { + throw engineFailure({ + reason: `${call}-failed`, + jmapType: set.error.type ?? 'unknown', + }); + } + const result = set.args as JmapSetResult; + if ( + result.updated && + Object.prototype.hasOwnProperty.call(result.updated, PASSWORD_OBJECT_ID) + ) { + return 'updated'; + } + const failure = + result.notUpdated?.[PASSWORD_OBJECT_ID] ?? + firstSetError(result.notUpdated); + if (failure?.type === 'forbidden') return 'forbidden'; + if (!failure) { + this.logger.warn( + `[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}`, + ); + return 'unknown'; + } + throw engineFailure({ + reason: `${call}-refused`, + jmapType: failure.type ?? 'unknown', + }); + } + + /** 만들기 거절 → 코드 (상한 AUTH_009, 그 밖은 MAIL_001 — type 만 남긴다) */ + private createRefusal(error: JmapError): BusinessException { + if (error.type === 'overQuota') { + return new BusinessException('AUTH_009', { + context: { reason: 'app-password-limit' }, + }); + } + return engineFailure({ + reason: 'app-password-create-refused', + jmapType: error.type ?? 'unknown', + }); + } + + /** 지우기 응답 — 지운 수. 이미 없는 것(notFound)은 지운 것과 같다. 그 밖의 거절 · 메서드 오류는 MAIL_001 */ + private assertDestroyed(responses: JmapInvocation[], callId: string): number { + const set = findResult(responses, callId, APP_PASSWORD_SET); + if (!set.ok) { + throw engineFailure({ + reason: 'app-password-delete-failed', + jmapType: set.error.type ?? 'unknown', + }); + } + const result = set.args as JmapSetResult; + const refusal = Object.values(result.notDestroyed ?? {}).find( + (error) => error?.type !== 'notFound', + ); + if (refusal) { + throw engineFailure({ + reason: 'app-password-delete-refused', + jmapType: refusal.type ?? 'unknown', + }); + } + return (result.destroyed ?? []).length; + } + + private readAppPasswords( + responses: JmapInvocation[], + callId: string, + ): AppPasswordRecord[] { + const result = findResult(responses, callId, APP_PASSWORD_GET); + if (!result.ok) { + throw engineFailure({ + reason: 'app-passwords-unreadable', + jmapType: result.error.type ?? 'unknown', + }); + } + return ((result.args as JmapGetResult).list ?? []).flatMap( + (item): AppPasswordRecord[] => { + const createdAt = toIso(item.createdAt); + if (typeof item.id !== 'string' || createdAt === null) return []; + return [ + { + id: item.id, + name: typeof item.description === 'string' ? item.description : '', + createdAt, + }, + ]; + }, + ); + } + + /** 만들기와 함께 읽은 목록 — 읽지 못했으면 빈 목록 (값은 이미 만들어졌다) */ + private tryReadAppPasswords( + responses: JmapInvocation[], + callId: string, + ): AppPasswordRecord[] { + try { + return this.readAppPasswords(responses, callId); + } catch { + return []; + } + } +} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 30519e8..601ed11 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -20,6 +20,7 @@ import type { AttachmentContent, BlockedSender, Correspondence, + CredentialCheck, DraftContent, DraftSource, MailAccountProfile, @@ -61,6 +62,7 @@ import type { SendHold, SentMessage, SentMessageRecord, + TwoFactorLoginResult, UploadedFile, } from '@/modules/mail/mail.types'; import { traceTag } from '@/shared/logger/request-context'; @@ -807,17 +809,37 @@ export class JmapMailProvider implements MailProvider { private readonly filterRuns: JmapFilterRunner, ) {} - verifyCredentials(email: string, password: string): Promise { + verifyCredentials(email: string, password: string): Promise { return this.jmap.verifyCredentials(email, password); } - /** 본인 자격으로만 바꾼다 — 게이트웨이 대리 접근을 쓰지 않는다 (jmap.client.ts 의 changePassword) */ + /** 엔진의 로그인 요청에 비밀번호 + 코드 (jmap.client.ts 의 verifyTwoFactorLogin — 계약 5절 "로그인") */ + verifyTwoFactorLogin( + email: string, + password: string, + otpCode: string, + ): Promise { + return this.jmap.verifyTwoFactorLogin(email, password, otpCode); + } + + /** + * 코드가 없으면 본인 자격으로만 바꾼다 — 게이트웨이 대리 접근을 쓰지 않는다 (jmap.client.ts 의 changePassword). + * 코드가 있으면(2단계 인증을 켠 직원 — 엔진이 본인 자격을 402 로 받지 않는다) 대리 접근으로 코드와 함께 (changePasswordWithCode) + */ changePassword( email: string, currentPassword: string, newPassword: string, + options: { readonly otpCode?: string } = {}, ): Promise { - return this.jmap.changePassword(email, currentPassword, newPassword); + return options.otpCode === undefined + ? this.jmap.changePassword(email, currentPassword, newPassword) + : this.jmap.changePasswordWithCode( + email, + currentPassword, + newPassword, + options.otpCode, + ); } async getProfile( diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index 2c79db4..3c810b9 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -109,19 +109,41 @@ describe('JmapClient', () => { await expect( client.verifyCredentials(ARA, 'pw:with:colon'), - ).resolves.toBe(ARA); + ).resolves.toEqual({ kind: 'valid', account: ARA }); const { url, authorization } = requestAt(0); expect(url).toBe('http://mail:8080/jmap/session'); expect(decodeBasic(authorization)).toBe(`${ARA}:pw:with:colon`); }); - it('401·403 이면 null (틀린 자격 증명)', async () => { + it('401·403 이면 틀린 자격 증명(invalid)', async () => { fetchMock.mockResolvedValueOnce(json({}, 401)); - await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull(); + await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toEqual({ + kind: 'invalid', + }); fetchMock.mockResolvedValueOnce(json({}, 403)); - await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull(); + await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toEqual({ + kind: 'invalid', + }); + }); + + it('402 면 비밀번호는 맞고 2단계 인증 코드가 필요하다(two-factor-required) — 엔진 장애(MAIL_001)로 다루지 않는다 (12단계, 2026-10-31 확인)', async () => { + fetchMock.mockResolvedValueOnce( + json( + { + type: 'about:blank', + title: 'MFA code required', + status: 402, + detail: 'This account requires multi-factor authentication.', + }, + 402, + ), + ); + + await expect(client.verifyCredentials(ARA, 'pw')).resolves.toEqual({ + kind: 'two-factor-required', + }); }); it('엔진 5xx·네트워크 오류는 MAIL_001, 429 는 REQ_001', async () => { @@ -271,6 +293,17 @@ describe('JmapClient', () => { } }); + it('2단계 인증을 켠 직원이면 세션 요청이 402 — 바꾸기를 보내지 않고 two-factor-required (12단계 — 코드와 함께 대리 접근으로)', async () => { + fetchMock.mockResolvedValueOnce( + json({ title: 'MFA code required' }, 402), + ); + + await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe( + 'two-factor-required', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('들어간 뒤 엔진이 바꾸기를 거절하면(forbidden · 지금 비밀번호 속성 거절 · 그사이 401) current-password-refused — warn 로그에는 가린 주소와 종류만', async () => { const answers: [Response, string][] = [ [ @@ -437,6 +470,255 @@ describe('JmapClient', () => { }); }); + describe('2단계 인증 로그인 (verifyTwoFactorLogin — 12단계)', () => { + const answer = (type: string) => + json({ type, client_code: 'engine-one-time-code', iss: 'https://mail' }); + + it('엔진의 로그인 요청(POST /api/auth)에 비밀번호 · 코드를 싣는다 — 고정 clientId · https redirectUri, 인증 머리글 없음', async () => { + fetchMock.mockResolvedValueOnce(answer('authenticated')); + + await expect( + client.verifyTwoFactorLogin(ARA, 'pw', '123456'), + ).resolves.toBe('authenticated'); + + const { url, init } = requestAt(0); + expect(url).toBe('http://mail:8080/api/auth'); + expect(init.method).toBe('POST'); + expect((init.headers as Record).authorization).toBe( + undefined, + ); + expect(JSON.parse(init.body as string)).toEqual({ + type: 'authCode', + accountName: ARA, + accountSecret: 'pw', + mfaToken: '123456', + clientId: 'arasystem-webmail', + redirectUri: 'https://arasystem.test/', + }); + }); + + it('failure 는 코드 틀림(code-rejected) — 엔진은 비밀번호 틀림과 가르지 않는다', async () => { + fetchMock.mockResolvedValueOnce(answer('failure')); + await expect( + client.verifyTwoFactorLogin(ARA, 'pw', '000000'), + ).resolves.toBe('code-rejected'); + }); + + it('코드를 실었는데 mfaRequired · 모르는 type · 4xx(redirectUri 거절 401 등)는 MAIL_001, 429 는 REQ_001 — 응답의 client_code 는 오류 context 에 남기지 않는다', async () => { + fetchMock.mockResolvedValueOnce(answer('mfaRequired')); + let caught: unknown; + try { + await client.verifyTwoFactorLogin(ARA, 'pw', '123456'); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(BusinessException); + expect((caught as BusinessException).code).toBe('MAIL_001'); + expect( + JSON.stringify((caught as BusinessException).context), + ).not.toContain('engine-one-time-code'); + + fetchMock.mockResolvedValueOnce(json({}, 401)); + expect(await codeOf(client.verifyTwoFactorLogin(ARA, 'pw', '1'))).toBe( + 'MAIL_001', + ); + fetchMock.mockResolvedValueOnce(json({}, 429)); + expect(await codeOf(client.verifyTwoFactorLogin(ARA, 'pw', '1'))).toBe( + 'REQ_001', + ); + }); + }); + + describe('2단계 인증을 켠 직원의 비밀번호 바꾸기 (changePasswordWithCode — 12단계)', () => { + /** 대리 접근 세션 — 계정 관리 기능의 계정 id 를 알려 준다 */ + const accountSession = { + ...sessionBody, + primaryAccounts: { + 'urn:ietf:params:jmap:mail': 'd', + 'urn:stalwart:jmap': 'x1', + }, + }; + const passwordAnswer = (result: object) => + json({ methodResponses: [['x:AccountPassword/set', result, 'p']] }); + + it('본인 Basic 은 402 라 대리 접근으로 지금 · 새 비밀번호와 코드(otpAuth.otpCode)를 보낸다 — otpUrl 은 보내지 않는다(엔진이 칸별로 합쳐 2단계 인증이 남는다)', async () => { + fetchMock + .mockResolvedValueOnce(json(accountSession)) + .mockResolvedValueOnce( + passwordAnswer({ updated: { singleton: null } }), + ); + + await expect( + client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'), + ).resolves.toBe('changed'); + + expect(decodeBasic(requestAt(0).authorization)).toBe( + `${ARA}%mail-gateway@arasystem.test:gateway-secret`, + ); + const { url, init, authorization } = requestAt(1); + expect(url).toBe('http://mail:8080/jmap/'); + expect(decodeBasic(authorization)).toBe( + `${ARA}%mail-gateway@arasystem.test:gateway-secret`, + ); + expect(JSON.parse(init.body as string)).toEqual({ + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [ + [ + 'x:AccountPassword/set', + { + accountId: 'x1', + update: { + singleton: { + currentSecret: 'old-pw', + secret: 'new-pw-1234', + otpAuth: { otpCode: '123456' }, + }, + }, + }, + 'p', + ], + ], + }); + }); + + it('forbidden 은 코드 틀림(code-rejected), 새 비밀번호 거절은 new-password-rejected, 5xx · 시간 초과는 outcome-unknown', async () => { + fetchMock + .mockResolvedValueOnce(json(accountSession)) + .mockResolvedValueOnce( + passwordAnswer({ + notUpdated: { + singleton: { + type: 'forbidden', + description: 'Current secret is incorrect.', + }, + }, + }), + ); + await expect( + client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '000000'), + ).resolves.toBe('code-rejected'); + + fetchMock.mockResolvedValueOnce( + passwordAnswer({ + notUpdated: { + singleton: { type: 'invalidProperties', properties: ['secret'] }, + }, + }), + ); + await expect( + client.changePasswordWithCode(ARA, 'old-pw', 'weak', '123456'), + ).resolves.toBe('new-password-rejected'); + + const logger = Reflect.get(client, 'logger') as { + warn: (message: string) => void; + }; + jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + fetchMock.mockResolvedValueOnce(json({}, 502)); + await expect( + client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'), + ).resolves.toBe('outcome-unknown'); + }); + + it('엔진이 계정 관리 기능을 알려 주지 않으면 세션을 새로 받고, 그래도 없으면 MAIL_001 — 바꾸기를 보내지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(json(sessionBody)) + .mockResolvedValueOnce(json(sessionBody)); + + expect( + await codeOf( + client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'), + ), + ).toBe('MAIL_001'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('계정 관리 호출 (callAccount · callAccountChange — 12단계)', () => { + const accountSession = { + ...sessionBody, + primaryAccounts: { + 'urn:ietf:params:jmap:mail': 'd', + 'urn:stalwart:jmap': 'x1', + }, + }; + + it('대리 접근으로 core · 계정 관리 기능만 선언하고 계정 관리 계정 id 로 부른다', async () => { + fetchMock + .mockResolvedValueOnce(json(accountSession)) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:AppPassword/get', { list: [] }, 'g']], + }), + ); + + const responses = await client.callAccount(ARA, (accountId) => [ + ['x:AppPassword/get', { accountId, ids: null }, 'g'], + ]); + + expect(responses).toEqual([['x:AppPassword/get', { list: [] }, 'g']]); + expect(JSON.parse(requestAt(1).init.body as string)).toEqual({ + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [ + ['x:AppPassword/get', { accountId: 'x1', ids: null }, 'g'], + ], + }); + }); + + it('바꾸는 호출은 응답 전에 끊기거나 5xx 면 unknown — 엔진이 바꿨을 수 있다. 답했으면 answered', async () => { + fetchMock + .mockResolvedValueOnce(json(accountSession)) + .mockResolvedValueOnce(json({}, 503)); + await expect( + client.callAccountChange(ARA, (accountId) => [ + ['x:AccountPassword/set', { accountId }, 's'], + ]), + ).resolves.toEqual({ kind: 'unknown', detail: 'http-503' }); + + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')); + await expect( + client.callAccountChange(ARA, (accountId) => [ + ['x:AccountPassword/set', { accountId }, 's'], + ]), + ).resolves.toEqual({ kind: 'unknown', detail: 'network' }); + + fetchMock.mockResolvedValueOnce( + json({ methodResponses: [['x:AccountPassword/set', {}, 's']] }), + ); + await expect( + client.callAccountChange(ARA, (accountId) => [ + ['x:AccountPassword/set', { accountId }, 's'], + ]), + ).resolves.toEqual({ + kind: 'answered', + responses: [['x:AccountPassword/set', {}, 's']], + }); + }); + + it('바뀌지 않은 것이 확실한 실패는 던진다 — 4xx 는 MAIL_001, 429 는 REQ_001', async () => { + fetchMock + .mockResolvedValueOnce(json(accountSession)) + .mockResolvedValueOnce( + json({ type: 'urn:ietf:params:jmap:error:notRequest' }, 400), + ); + expect( + await codeOf( + client.callAccountChange(ARA, (accountId) => [ + ['x:AccountPassword/set', { accountId }, 's'], + ]), + ), + ).toBe('MAIL_001'); + + fetchMock.mockResolvedValueOnce(json({}, 429)); + expect( + await codeOf( + client.callAccountChange(ARA, (accountId) => [ + ['x:AccountPassword/set', { accountId }, 's'], + ]), + ), + ).toBe('REQ_001'); + }); + }); + describe('대리 접근 세션 (getSession)', () => { it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => { fetchMock.mockResolvedValueOnce(json(sessionBody)); @@ -457,6 +739,8 @@ describe('JmapClient', () => { maxAttachmentsBytes: null, eventSourcePath: '/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}', + // 계정 관리 기능(urn:stalwart:jmap)을 알려 주지 않는 응답 — 2단계 인증 · 앱 비밀번호 호출이 MAIL_001 (12단계) + managementAccountId: null, }); }); @@ -1419,6 +1703,24 @@ describe('비밀번호 바꾸기 응답 해석 (classifyPasswordChange)', () => }); }); + it('코드를 함께 보냈으면(withCode — 12단계) forbidden 은 코드 틀림(code-rejected) — 비밀번호는 바로 앞의 본인 Basic(402)이 맞혔다', () => { + const withCode = (error: object) => + classifyPasswordChange( + ['x:AccountPassword/set', { notUpdated: { singleton: error } }, 'p'], + { withCode: true }, + ); + expect(withCode({ type: 'forbidden' })).toEqual({ kind: 'code-rejected' }); + expect( + withCode({ type: 'invalidProperties', properties: ['secret'] }), + ).toEqual({ kind: 'new-password-rejected' }); + expect( + classifyPasswordChange( + ['x:AccountPassword/set', { updated: { singleton: null } }, 'p'], + { withCode: true }, + ), + ).toEqual({ kind: 'changed' }); + }); + it('그 밖의 SetError · 메서드 오류는 바뀌지 않은 것이 확실한 실패(failed)', () => { expect(notUpdated({ type: 'serverFail' })).toEqual({ kind: 'failed', diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index 39424e9..5b01bc7 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -12,7 +12,11 @@ import { type ConfiguredMailSettings, type MailSettings, } from '@/modules/mail/mail.settings'; -import type { PasswordChangeOutcome } from '@/modules/mail/mail.types'; +import type { + CredentialCheck, + PasswordChangeOutcome, + TwoFactorLoginResult, +} from '@/modules/mail/mail.types'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; import type { @@ -84,7 +88,7 @@ const PASSWORD_OBJECT_ID = 'singleton'; * - unknown: 응답으로는 바뀌었는지 알 수 없다 — 엔진이 바꿨을 수 있다 */ export type PasswordChangeReply = - | { readonly kind: 'changed' | 'new-password-rejected' } + | { readonly kind: 'changed' | 'new-password-rejected' | 'code-rejected' } | { readonly kind: 'current-password-refused' | 'failed' | 'unknown'; readonly detail: string; @@ -98,9 +102,12 @@ export type PasswordChangeReply = * - forbidden — 엔진이 바꾸기를 받지 않았다(지금 비밀번호가 빠지면 이 오류다, 확인함) → current-password-refused. * 세션은 그 비밀번호로 열렸으므로 맞혀 보기가 아니다 — 계정 비밀번호가 아닌 값(앱 비밀번호 등)으로 들어왔거나 변경 권한이 없다 * - 그 밖의 SetError · 메서드 오류는 failed (바뀌지 않았다), 응답이 바꾸기 결과를 말하지 않으면 unknown + * - `withCode`(2단계 인증 코드를 함께 보냄 — 12단계): forbidden 은 code-rejected. 엔진은 지금 비밀번호 틀림과 코드 틀림을 같은 + * forbidden "Current secret is incorrect." 로 거절하는데, 비밀번호는 바로 앞에서 본인 자격(402)으로 맞았다 (계약 5절) */ export function classifyPasswordChange( invocation: JmapInvocation | undefined, + options: { readonly withCode?: boolean } = {}, ): PasswordChangeReply { const [name, args] = invocation ?? []; if (name === PASSWORD_SET_METHOD) { @@ -121,7 +128,9 @@ export function classifyPasswordChange( : { kind: 'new-password-rejected' }; } if (failure.type === 'forbidden') { - return { kind: 'current-password-refused', detail: 'forbidden' }; + return options.withCode + ? { kind: 'code-rejected' } + : { kind: 'current-password-refused', detail: 'forbidden' }; } return { kind: 'failed', detail: `notUpdated/${String(failure.type)}` }; } @@ -160,6 +169,20 @@ const EVENT_SOURCE_HEADERS_TIMEOUT_MS = 10_000; /** 변화 소식 스트림의 형식 — 다른 형식으로 답하면(엔진 설정 · 앞단 문제) 엔진 장애로 본다 */ const EVENT_STREAM_TYPE = /^text\/event-stream\s*(?:;|$)/i; +/** + * 엔진의 로그인 요청(OAuth `authCode`) — 2단계 인증 코드를 받는 곳은 이것뿐이다(Basic · IMAP · SMTP 에는 코드를 실을 자리가 없다). + * 결과는 authenticated · mfaRequired · failure 셋이고, 성공하면 엔진 메모리에 1회용 OAuth 코드가 남는다(10분 — 쓰지 않는다). + * redirectUri 는 https 여야 하고(아니면 401), 등록하지 않은 clientId 도 받는다 (2026-10-31 개발 엔진 · 0.16.27 소스 확인 — 계약 5절 "로그인") + */ +const ENGINE_LOGIN_PATH = '/api/auth'; +/** 엔진에 알리는 클라이언트 이름 — 고정 이름 (계약 5절) */ +export const ENGINE_LOGIN_CLIENT_ID = 'arasystem-webmail'; + +/** 엔진 로그인 요청의 응답 — type 만 읽는다 (client_code 는 읽지도 남기지도 않는다) */ +interface EngineLoginReply { + readonly type?: unknown; +} + /** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */ const SESSION_CACHE_TTL_MS = 5 * 60_000; @@ -169,6 +192,11 @@ const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals'; const DIRECTORY_SESSION_CACHE_KEY = 'mail:jmap-directory-session'; const HTTP_UNAUTHORIZED = 401; +/** + * 비밀번호는 맞고 2단계 인증 코드가 필요하다 — Stalwart 0.16 은 2단계 인증을 켠 계정의 Basic 로그인에 402 "MFA code required" 로 답한다 + * (WWW-Authenticate 없음, 2026-10-31 개발 엔진에서 확인 — 계약 5절 "로그인") + */ +const HTTP_PAYMENT_REQUIRED = 402; const HTTP_BAD_REQUEST = 400; const HTTP_FORBIDDEN = 403; const HTTP_NOT_FOUND = 404; @@ -192,8 +220,26 @@ export interface JmapSession { * 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다 — openEventSource 가 세션을 다시 받는다) */ readonly eventSourcePath?: string | null; + /** + * 계정 관리 기능(`urn:stalwart:jmap` — x:AccountPassword · x:AppPassword)의 계정 id. 알려 주지 않으면 null + * (캐시된 옛 세션에는 없을 수 있다 — callAccount 가 세션을 다시 받는다, 12단계) + */ + readonly managementAccountId?: string | null; } +/** + * 계정 관리 호출(바꾸는 것)의 결과 — 엔진이 답했으면 메서드 응답, 답을 받기 전에 끝났거나(시간 초과 · 연결 끊김) 5xx · + * 해석할 수 없는 본문이면 unknown (엔진이 바꿨을 수 있다). 바뀌지 않은 것이 확실한 실패는 던진다 (callAccountChange) + */ +export type AccountChangeReply = + | { readonly kind: 'answered'; readonly responses: JmapInvocation[] } + | { readonly kind: 'unknown'; readonly detail: string }; + +/** 계정 관리 기능의 계정 id 가 있는 세션 */ +type AccountJmapSession = JmapSession & { + readonly managementAccountId: string; +}; + /** * 사내 목록 계정의 세션 (캐시에 저장되므로 평범한 객체로 둔다) — 이 세션에는 메일 계정이 없고 계정 목록(principals) 계정만 있다 * (2026-10-29 개발 엔진에서 확인). 직원 세션(JmapSession)으로 쓰지 않는다 @@ -352,34 +398,88 @@ export class JmapClient { ) {} /** - * 직원 자격 증명을 확인한다. - * @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null. 엔진 장애는 MAIL_001. + * 직원 자격 증명을 확인한다 — `<직원 주소>:<비밀번호>` Basic 으로 세션을 조회한다. + * 200 이면 맞음(엔진이 아는 정식 계정 주소), 401 · 403 이면 틀림, **402 면 비밀번호는 맞고 2단계 인증 코드가 필요**하다 + * (계약 5절 — 12단계 전에는 402 를 엔진 장애로 다뤄 엔진 관리 화면에서 OTP 를 켠 직원이 웹메일에 들어오지 못했다). + * 엔진 장애는 MAIL_001, 엔진 한도는 REQ_001. */ async verifyCredentials( email: string, password: string, - ): Promise { + ): Promise { return this.exchange( SESSION_PATH, { headers: { authorization: basicAuthorization(email, password) } }, REQUEST_TIMEOUT_MS, 'verify', - async (response) => { + async (response): Promise => { if ( response.status === HTTP_UNAUTHORIZED || response.status === HTTP_FORBIDDEN ) { await discard(response); - return null; + return { kind: 'invalid' }; + } + if (response.status === HTTP_PAYMENT_REQUIRED) { + await discard(response); + return { kind: 'two-factor-required' }; } await this.assertOk(response, 'verify'); const session = await readJson(response, 'verify'); - return session.username?.trim().toLowerCase() || email; + return { + kind: 'valid', + account: session.username?.trim().toLowerCase() || email, + }; }, ); } + /** + * 2단계 인증을 켠 직원의 로그인 — 엔진의 로그인 요청(`POST /api/auth`, OAuth authCode)에 비밀번호와 코드(mfaToken)를 함께 보낸다 + * (계약 5절 "로그인"). 엔진은 앱 네트워크 안의 백엔드 요청이라 자동 차단에 세지 않는다 — 코드 실패 한도는 서버가 센다. + * - authenticated → authenticated (엔진에 남는 1회용 OAuth 코드는 쓰지 않는다 — 10분 뒤 사라진다) + * - failure → code-rejected (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다 — 비밀번호는 바로 앞의 Basic 이 맞혔다) + * - mfaRequired(코드를 실었는데 코드를 요구함) · 그 밖의 type · 401(redirectUri 거절 — 설정) · 4xx → MAIL_001, 429 → REQ_001 + * 비밀번호 · 코드 · 엔진의 client_code 는 어떤 로그 · 오류 context 에도 남기지 않는다 + */ + async verifyTwoFactorLogin( + email: string, + password: string, + otpCode: string, + ): Promise { + const { domain } = this.requireConfigured(); + const reply = await this.exchange( + ENGINE_LOGIN_PATH, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'authCode', + accountName: email, + accountSecret: password, + mfaToken: otpCode, + clientId: ENGINE_LOGIN_CLIENT_ID, + redirectUri: `https://${domain}/`, + }), + }, + REQUEST_TIMEOUT_MS, + 'two-factor-login', + async (response) => { + await this.assertOk(response, 'two-factor-login'); + return readJson(response, 'two-factor-login'); + }, + ); + + if (reply.type === 'authenticated') return 'authenticated'; + if (reply.type === 'failure') return 'code-rejected'; + throw engineFailure({ + reason: 'unexpected-login-reply', + call: 'two-factor-login', + replyType: typeof reply.type === 'string' ? reply.type : 'missing', + }); + } + /** * 직원 **본인의 자격으로** 비밀번호를 바꾼다 (계약 1절 "PUT /api/auth/password"). * 게이트웨이 대리 접근·관리자 권한으로는 바꾸지 않는다 — 엔진은 본인 자격의 요청만 받는다 (2026-10-28 개발 엔진에서 확인). @@ -413,11 +513,17 @@ export class JmapClient { await discard(response); return null; } + // 비밀번호는 맞고 2단계 인증을 켠 직원이다 — 본인 자격으로는 세션이 열리지 않는다 (코드와 함께 changePasswordWithCode 로) + if (response.status === HTTP_PAYMENT_REQUIRED) { + await discard(response); + return 'two-factor-required' as const; + } await this.assertOk(response, 'password-session'); return readJson(response, 'password-session'); }, ); if (session === null) return 'current-password-rejected'; + if (session === 'two-factor-required') return 'two-factor-required'; const accountId = session.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY]; if (!accountId || !session.apiUrl) { @@ -430,7 +536,7 @@ export class JmapClient { const reply = await this.sendPasswordChange( toRelativeReference(session.apiUrl), - authorization, + { authorization, user: null }, JSON.stringify({ using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY], methodCalls: [ @@ -451,9 +557,60 @@ export class JmapClient { }), ); + return this.passwordChangeOutcome(reply, email); + } + + /** + * 2단계 인증을 켠 직원의 비밀번호 바꾸기 (계약 5절 "비밀번호 바꾸기") — 엔진이 본인 자격(Basic 402)을 받지 않으므로 + * 게이트웨이 대리 접근으로 `x:AccountPassword/set` 에 지금 비밀번호(currentSecret) · 새 비밀번호(secret) · 코드(otpAuth.otpCode)를 보낸다. + * otpAuth 는 칸별로 합쳐져 otpUrl 이 그대로 남는다 — 2단계 인증은 켜진 채다 (0.16.27 소스 `registry/mapping/account.rs`). + * 엔진은 코드를 확인한 뒤에 새 비밀번호 규칙을 본다 — new-password-rejected 면 코드는 맞았다. + * 부르는 쪽이 먼저 비밀번호를 본인 Basic 으로 확인한다(402) — 그래서 forbidden 은 코드 틀림(code-rejected)이다. + * 결과 해석은 본인 자격의 바꾸기와 같다 (changed · new-password-rejected · outcome-unknown · 확실한 실패는 던진다) + */ + async changePasswordWithCode( + email: string, + currentPassword: string, + newPassword: string, + otpCode: string, + ): Promise { + const session = await this.accountSession(email); + const reply = await this.sendPasswordChange( + session.apiPath, + { authorization: this.impersonation(email), user: email }, + JSON.stringify({ + using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY], + methodCalls: [ + [ + PASSWORD_SET_METHOD, + { + accountId: session.managementAccountId, + update: { + [PASSWORD_OBJECT_ID]: { + currentSecret: currentPassword, + secret: newPassword, + otpAuth: { otpCode }, + }, + }, + }, + 'p', + ], + ], + }), + { withCode: true }, + ); + return this.passwordChangeOutcome(reply, email); + } + + /** 바꾸기 응답의 해석 → 결과 (확실한 실패는 던진다) — 본인 자격 · 대리 접근 두 길이 함께 쓴다 */ + private passwordChangeOutcome( + reply: PasswordChangeReply, + email: string, + ): PasswordChangeOutcome { switch (reply.kind) { case 'changed': case 'new-password-rejected': + case 'code-rejected': return reply.kind; case 'current-password-refused': this.logger.warn( @@ -480,9 +637,15 @@ export class JmapClient { */ private async sendPasswordChange( path: string, - authorization: string, + auth: { + readonly authorization: string; + /** 대리 접근이면 대상 직원 주소 — 401 · 403 을 다른 대리 접근 호출처럼 해석한다 (본인 자격이면 null) */ + readonly user: string | null; + }, body: string, + options: { readonly withCode?: boolean } = {}, ): Promise { + const { authorization, user } = auth; // 응답 머리를 받았는가 — 받기 전에 끝난 실패(시간 초과 · 연결 끊김)는 엔진에 닿았을 수 있다 const progress = { answered: false }; try { @@ -497,8 +660,11 @@ export class JmapClient { 'password-change', async (response): Promise => { progress.answered = true; - // 세션을 연 뒤 지금 비밀번호가 다른 곳에서 바뀌었다 — 맞혀 보기가 아니다 - if (response.status === HTTP_UNAUTHORIZED) { + if (user !== null) { + // 대리 접근 — 401 · 403 은 계정 · 게이트웨이 문제다 (다른 대리 접근 호출과 같은 분류 · 로그) + await this.assertImpersonationAccepted(response, user); + } else if (response.status === HTTP_UNAUTHORIZED) { + // 세션을 연 뒤 지금 비밀번호가 다른 곳에서 바뀌었다 — 맞혀 보기가 아니다 await discard(response); return { kind: 'current-password-refused', detail: 'http-401' }; } @@ -523,7 +689,7 @@ export class JmapClient { } catch { return { kind: 'unknown', detail: 'malformed-response' }; } - return classifyPasswordChange(parsed.methodResponses?.[0]); + return classifyPasswordChange(parsed.methodResponses?.[0], options); }, ); } catch (error) { @@ -580,6 +746,125 @@ export class JmapClient { return session; } + /** + * 계정 관리 기능(`urn:stalwart:jmap` — x:AccountPassword · x:AppPassword)을 대리 접근으로 부른다 (12단계 — 2단계 인증 · 앱 비밀번호). + * 계정 id 는 세션의 `primaryAccounts['urn:stalwart:jmap']`(메일 계정 id 와 따로 본다), `using` 은 core · 그 기능뿐이다. + * 직원이 2단계 인증을 켰어도 대리 접근은 그대로 된다 (2026-10-31 확인). 메서드 오류는 그대로 돌려주고 해석은 부른 쪽(어댑터)이 한다. + * 읽기에 쓴다 — 실패는 모두 던진다(엔진 장애 MAIL_001 · 한도 REQ_001 · 계정 없음 AUTH_001) + */ + async callAccount( + user: string, + build: (accountId: string) => JmapInvocation[], + ): Promise { + const session = await this.accountSession(user); + return this.exchange( + session.apiPath, + this.accountRequest(user, build(session.managementAccountId)), + REQUEST_TIMEOUT_MS, + 'account', + async (response) => { + await this.assertImpersonationAccepted(response, user); + await this.assertOk(response, 'account'); + const parsed = await readJson(response, 'account'); + if (!Array.isArray(parsed.methodResponses)) { + throw engineFailure({ + reason: 'malformed-response', + call: 'account', + }); + } + return parsed.methodResponses; + }, + ); + } + + /** + * 계정 관리 기능으로 **바꾼다**(2단계 켜기 · 끄기, 앱 비밀번호 만들기) — callAccount 와 같되, 결과를 알 수 없게 끝난 것은 + * 던지지 않고 unknown 으로 돌려준다(응답 전에 끊김 · 시간 초과 · 5xx · 해석할 수 없는 본문 — 엔진이 바꿨을 수 있다). + * 바뀌지 않은 것이 확실한 실패만 던진다: 대리 접근 거절(AUTH_001 · MAIL_001) · 그 밖의 4xx(MAIL_001) · 429(REQ_001) + */ + async callAccountChange( + user: string, + build: (accountId: string) => JmapInvocation[], + ): Promise { + const session = await this.accountSession(user); + // 응답 머리를 받았는가 — 받기 전에 끝난 실패(시간 초과 · 연결 끊김)는 엔진에 닿았을 수 있다 + const progress = { answered: false }; + try { + return await this.exchange( + session.apiPath, + this.accountRequest(user, build(session.managementAccountId)), + REQUEST_TIMEOUT_MS, + 'account-change', + async (response): Promise => { + progress.answered = true; + await this.assertImpersonationAccepted(response, user); + if (response.status >= HTTP_SERVER_ERROR) { + await discard(response); + return { kind: 'unknown', detail: `http-${response.status}` }; + } + await this.assertOk(response, 'account-change'); + let parsed: JmapResponseBody; + try { + parsed = (await response.json()) as JmapResponseBody; + } catch { + return { kind: 'unknown', detail: 'malformed-response' }; + } + return Array.isArray(parsed.methodResponses) + ? { kind: 'answered', responses: parsed.methodResponses } + : { kind: 'unknown', detail: 'malformed-response' }; + }, + ); + } catch (error) { + if ( + !progress.answered && + error instanceof BusinessException && + error.code === 'MAIL_001' + ) { + // exchange 가 붙인 사유 — timeout · network (로그용 요약) + const reason: unknown = error.context?.reason; + return { + kind: 'unknown', + detail: typeof reason === 'string' ? reason : 'no-response', + }; + } + throw error; + } + } + + /** 계정 관리 호출의 요청 — 대리 접근 자격 · core 와 계정 관리 기능 */ + private accountRequest( + user: string, + methodCalls: JmapInvocation[], + ): RequestInit { + return { + method: 'POST', + headers: { + authorization: this.impersonation(user), + 'content-type': 'application/json', + }, + body: JSON.stringify({ + using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY], + methodCalls, + }), + }; + } + + /** 계정 관리 기능의 계정 id 가 있는 대리 접근 세션 — 이 값을 알기 전에 캐시한 세션이면 다시 받는다. 엔진이 알려 주지 않으면 MAIL_001 */ + private async accountSession(user: string): Promise { + let session = await this.getSession(user); + if (!session.managementAccountId) { + session = await this.getSession(user, { fresh: true }); + } + const { managementAccountId } = session; + if (!managementAccountId) { + throw engineFailure({ + reason: 'account-capability-missing', + call: 'account-session', + }); + } + return { ...session, managementAccountId }; + } + /** * 대상 계정에 메일 엔진의 **관리 권한**이 있으면 AUTH_001 — 웹메일로 관리자 계정을 다루지 않는다. * 게이트웨이는 관리자도 대리 접근할 수 있고 그 세션은 관리 권한을 가지므로(확인함), 정적 목록(blockedAccounts)에 없는 @@ -1274,6 +1559,9 @@ export class JmapClient { eventSourcePath: eventSourceUrl ? toRelativeReference(eventSourceUrl) : null, + // 2단계 인증 · 앱 비밀번호만 쓴다 — 없다고 다른 기능까지 막지 않는다 (callAccount 가 MAIL_001) + managementAccountId: + resource.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY] ?? null, }; } } diff --git a/backend/src/modules/mail/jmap/jmap.types.ts b/backend/src/modules/mail/jmap/jmap.types.ts index d96798a..c848242 100644 --- a/backend/src/modules/mail/jmap/jmap.types.ts +++ b/backend/src/modules/mail/jmap/jmap.types.ts @@ -190,6 +190,8 @@ export interface JmapChangesResult { /** set 의 created 항목 — 엔진이 만든 뒤 알려 주는 속성. id 는 늘 오고, 제출(EmailSubmission)은 보낼 시각 · 상태도 준다 (2026-10-29 확인) */ export interface JmapCreatedObject { id?: string; + /** x:AppPassword — 엔진이 만든 앱 비밀번호 값 (만들 때 이 응답에만 — 12단계). 로그 · 오류 context 에 넣지 않는다 */ + secret?: string; /** EmailSubmission — 엔진이 보낼 시각 (붙잡은 제출은 풀리는 시각) */ sendAt?: string | null; /** EmailSubmission — pending · final · canceled */ diff --git a/backend/src/modules/mail/mail-events.controller.ts b/backend/src/modules/mail/mail-events.controller.ts index a20daeb..2259838 100644 --- a/backend/src/modules/mail/mail-events.controller.ts +++ b/backend/src/modules/mail/mail-events.controller.ts @@ -120,6 +120,7 @@ export class MailEventsController { await this.events.open(user.email, eventStreamSink(res), { tokenExpiresAt: user.accessTokenExpiresAt ?? null, + sessionId: user.sessionId ?? null, signal: disconnected.signal, }); } diff --git a/backend/src/modules/mail/mail-events.service.spec.ts b/backend/src/modules/mail/mail-events.service.spec.ts index 1a93f80..72fcc91 100644 --- a/backend/src/modules/mail/mail-events.service.spec.ts +++ b/backend/src/modules/mail/mail-events.service.spec.ts @@ -14,6 +14,7 @@ import { type MailEvent, type MailEventSink, } from './mail-events.service'; +import type { RevokedSessions } from '@/modules/auth/revoked-sessions'; import type { MailProvider } from './mail.provider'; import type { MailLiveState, @@ -129,14 +130,18 @@ async function flush(): Promise { describe('MailEventsService (실시간 알림의 연결 관리)', () => { let fake: ReturnType; let service: MailEventsService; + /** 끊은 세션 기억 대역 — 테스트가 넣는다 */ + let revokedIds: Set; const connect = ( user: string, tokenExpiresAt: number | null = NOW + 3_600_000, + sessionId: string | null = null, ) => { const connection = client(); const opening = service.open(user, connection.sink, { tokenExpiresAt, + sessionId, signal: connection.signal, }); return { connection, opening }; @@ -162,7 +167,14 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => { doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'], }); fake = fakeProvider(); - service = new MailEventsService(fake.provider); + revokedIds = new Set(); + const revoked = { + isRevoked: (sessionId: string) => revokedIds.has(sessionId), + }; + service = new MailEventsService( + fake.provider, + revoked as unknown as RevokedSessions, + ); }); afterEach(() => { @@ -503,6 +515,42 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => { expect(fake.opened[0].close).toHaveBeenCalledTimes(1); }); + it('끊은 세션(로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊기)의 연결은 다음 ping 때 end 없이 닫힌다 — 다른 세션 · sid 없는 옛 토큰의 연결은 그대로 (계약 5절)', async () => { + const { connection: revoked, opening: first } = connect( + ARA, + undefined, + 'session-revoked', + ); + await first; + const { connection: alive, opening: second } = connect( + ARA, + undefined, + 'session-alive', + ); + await second; + const legacy = await connected(ARA); + + revokedIds.add('session-revoked'); + // 다음 ping 전에는 그대로다 (가드가 새 요청을 막고, 열린 연결은 ping 때 본다) + expect(revoked.closed).toBe(false); + + await advance(MAIL_EVENTS_PING_MS); + expect(revoked.closed).toBe(true); + // end 없이 닫는다 — 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다 + expect(revoked.events.map(({ type }) => type)).toEqual(['state']); + for (const connection of [alive, legacy]) { + expect(connection.closed).toBe(false); + expect(connection.events.at(-1)).toEqual({ + type: 'ping', + data: { upstream: true }, + }); + } + + // 닫은 자리는 돌려준다 — 같은 직원이 다시 열 수 있다 + await connected(ARA, undefined); + expect(fake.watchMail).toHaveBeenCalledTimes(1); + }); + it('열려 있다가 끊긴 응답은 다음에 보낼 때(소식 · ping) 빠진다 — 그 자리를 다시 쓸 수 있다', async () => { const open = await Promise.all( Array.from({ length: MAX_MAIL_EVENT_CONNECTIONS }, () => connected(ARA)), diff --git a/backend/src/modules/mail/mail-events.service.ts b/backend/src/modules/mail/mail-events.service.ts index 9efbe54..484be6c 100644 --- a/backend/src/modules/mail/mail-events.service.ts +++ b/backend/src/modules/mail/mail-events.service.ts @@ -7,6 +7,7 @@ import { import { BusinessException } from '@/common/errors/business.exception'; import { ACCESS_TTL_MAX_SECONDS } from '@/modules/auth/auth.constants'; +import { RevokedSessions } from '@/modules/auth/revoked-sessions'; import { runOutsideRequestContext, traceTag, @@ -70,6 +71,11 @@ export interface MailEventSink { export interface MailEventsOpenOptions { /** 액세스 토큰이 끝나는 시각(epoch ms) — 모르면 null (액세스 토큰의 가장 긴 수명 뒤에 닫는다) */ readonly tokenExpiresAt: number | null; + /** + * 이 연결을 연 로그인 세션 id(액세스 토큰의 sid) — 그 세션을 끊으면 다음 ping 때 연결을 끝낸다 (계약 5절 "로그인된 브라우저"). + * 12단계 전에 발급한 토큰이면 null (토큰이 끝날 때 닫는다) + */ + readonly sessionId?: string | null; /** 클라이언트가 끊으면 abort — 기다리는 중이든 연결한 뒤든 바로 정리한다 */ readonly signal: AbortSignal; } @@ -92,6 +98,8 @@ function expiryDelay(tokenExpiresAt: number | null): number { interface Client { readonly sink: MailEventSink; + /** 이 연결의 로그인 세션 id — 모르면 null */ + readonly sessionId: string | null; expiryTimer: NodeJS.Timeout | null; removed: boolean; } @@ -125,6 +133,8 @@ interface Hub { * 응답이 이미 끊겼으면(보내기가 false) 그 연결을 빼고 자리를 돌려준다 * - 서버 종료(beforeApplicationShutdown — HTTP 서버를 닫기 전)에 모든 연결에 end{server-shutdown} 을 보내고 닫는다 — * 오래 열린 연결이 종료를 붙잡지 않게 + * - 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊기 — 12단계)의 연결은 다음 ping 때 `end` 없이 닫는다 — + * 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다 (계정을 더 쓸 수 없을 때와 같은 길 — 계약 2절 "실시간 알림") * - 열고 닫기는 로그에 남기지 않는다 (반복되는 정상 흐름) * * ⚠️ 연결 상한 · 엔진 연결은 서버 인스턴스마다다 — 인스턴스를 늘려도 기능은 맞다(인스턴스마다 엔진 연결 하나), @@ -136,7 +146,10 @@ export class MailEventsService implements BeforeApplicationShutdown { private readonly hubs = new Map(); private shuttingDown = false; - constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {} + constructor( + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + private readonly revokedSessions: RevokedSessions, + ) {} /** * 연결 하나를 연다 — 처음 state 를 보낸 뒤부터 소식을 보낸다. 돌아온 뒤로 연결은 `signal` 이 끊길 때까지 이어진다. @@ -195,7 +208,12 @@ export class MailEventsService implements BeforeApplicationShutdown { return; } - const client: Client = { sink, expiryTimer: null, removed: false }; + const client: Client = { + sink, + sessionId: options.sessionId ?? null, + expiryTimer: null, + removed: false, + }; hub.clients.add(client); this.ensurePing(hub); this.send(hub, client, { type: 'state', data: hub.live }); @@ -384,18 +402,28 @@ export class MailEventsService implements BeforeApplicationShutdown { private ensurePing(hub: Hub): void { if (hub.pingTimer) return; hub.pingTimer = runOutsideRequestContext(() => - setInterval( - () => - this.broadcast(hub, { - type: 'ping', - data: { upstream: hub.upstream }, - }), - MAIL_EVENTS_PING_MS, - ), + setInterval(() => this.ping(hub), MAIL_EVENTS_PING_MS), ); hub.pingTimer.unref(); } + /** ping 한 번 — 끊은 세션의 연결은 보내지 않고 닫는다(end 없이), 나머지에는 ping */ + private ping(hub: Hub): void { + for (const client of [...hub.clients]) { + if ( + client.sessionId !== null && + this.revokedSessions.isRevoked(client.sessionId) + ) { + this.closeClient(hub, client); + continue; + } + this.send(hub, client, { + type: 'ping', + data: { upstream: hub.upstream }, + }); + } + } + /** 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다) */ private closeHub(hub: Hub): void { if (hub.closed) return; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index b5c243c..c4258bd 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/platform-express'; import { BusinessException } from '@/common/errors/business.exception'; +import { AuthSessionModule } from '@/modules/auth/auth-session.module'; import { ContactsModule } from '@/modules/contacts/contacts.module'; import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; @@ -121,6 +122,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailUserSettingsModule, // 대용량 첨부(11단계) — 공유 링크 · 공개 내려받기(파일 저장 모듈을 함께 내보낸다). 메일 → 공유 → 저장 한 방향 FileSharingModule, + // 끊은 세션 기억 (12단계) — 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝낸다. 이 모듈은 메일을 가져오지 않는다 + AuthSessionModule, ], controllers: [ MailboxController, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index e965522..6c3885f 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -3,6 +3,7 @@ import type { AttachmentContent, BlockedSender, Correspondence, + CredentialCheck, DraftContent, DraftSource, MailAccountProfile, @@ -39,6 +40,7 @@ import type { SavedDraft, SentMessage, SentMessageRecord, + TwoFactorLoginResult, UploadedFile, } from './mail.types'; @@ -64,21 +66,35 @@ export class SendNotStartedException extends BusinessException {} export interface MailProvider { /** - * 직원 자격 증명을 메일 엔진에 확인한다. 비밀번호는 저장하지 않는다. - * @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null + * 직원 자격 증명을 메일 엔진에 확인한다 (CredentialCheck — 맞음 · 틀림 · 2단계 인증 코드 필요). 비밀번호는 저장하지 않는다. + * 2단계 인증을 켠 직원은 비밀번호가 맞아도 `two-factor-required` 다 — 코드는 verifyTwoFactorLogin 으로 (계약 5절 "로그인") */ - verifyCredentials(email: string, password: string): Promise; + verifyCredentials(email: string, password: string): Promise; /** - * 직원 **본인의 자격으로**(지금 비밀번호) 비밀번호를 바꾼다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다 (계약 1절). - * 엔진의 판단(틀림 · 거절 · 규칙 위반)과 결과를 알 수 없음(바꿨을 수 있다)은 결과로 돌려주고, - * 결과가 확실한 엔진 장애(바뀌지 않았다)는 MAIL_001(한도는 REQ_001)로 던진다 (PasswordChangeOutcome). - * 비밀번호는 저장하지 않고 어떤 로그에도 남기지 않는다. + * 2단계 인증을 켠 직원의 로그인 — 비밀번호와 인증 앱 코드를 함께 엔진에 확인한다 (계약 5절 "로그인"). + * 엔진은 같은 코드를 다시 받는다(재사용을 막지 않는다) — 막기는 부르는 쪽 몫이다. 비밀번호 · 코드는 로그에 남기지 않는다 + */ + verifyTwoFactorLogin( + email: string, + password: string, + otpCode: string, + ): Promise; + + /** + * 비밀번호를 바꾼다 (계약 1절 · 5절 "비밀번호 바꾸기"). 엔진의 판단(틀림 · 거절 · 규칙 위반 · 코드 필요 · 코드 틀림)과 + * 결과를 알 수 없음(바꿨을 수 있다)은 결과로 돌려주고, 결과가 확실한 엔진 장애(바뀌지 않았다)는 MAIL_001(한도는 REQ_001)로 던진다. + * - `otpCode` 없음: 직원 **본인의 자격으로**(지금 비밀번호) — 게이트웨이 대리 접근 · 관리자 권한을 쓰지 않는다. + * 2단계 인증을 켠 직원이면 바꾸지 않고 `two-factor-required` + * - `otpCode` 있음(2단계 인증을 켠 직원 — 비밀번호는 부르는 쪽이 먼저 확인했다): 엔진이 본인 자격(402)을 받지 않아 대리 접근으로 + * 지금 비밀번호 · 새 비밀번호 · 코드를 보낸다. 2단계 인증은 그대로 남는다 + * 비밀번호 · 코드는 저장하지 않고 어떤 로그에도 남기지 않는다. */ changePassword( email: string, currentPassword: string, newPassword: string, + options?: { readonly otpCode?: string }, ): Promise; /** diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 977d59d..035dbf8 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -77,6 +77,7 @@ function fakeProvider(): ProviderMocks { return { verifyCredentials: mockOf<'verifyCredentials'>(), + verifyTwoFactorLogin: mockOf<'verifyTwoFactorLogin'>(), changePassword: mockOf<'changePassword'>(), getProfile: mockOf<'getProfile'>(), listMailboxes: mockOf<'listMailboxes'>(), diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 7d44fa4..e4ead21 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -371,13 +371,34 @@ export interface MessageSource { } /** - * 비밀번호 바꾸기의 엔진 판단 (계약 1절 "PUT /api/auth/password"). + * 직원 자격 확인의 엔진 판단 (계약 1절 "로그인" · 5절 "로그인") — 엔진 장애는 결과가 아니라 실패(MAIL_001 · REQ_001)로 던진다. + * - valid: 맞다 — 엔진이 아는 정식 계정 주소(별칭으로 들어와도 정식 주소) + * - invalid: 계정 없음 · 비밀번호 틀림 (구분하지 않는다) + * - two-factor-required: 비밀번호는 맞고 엔진이 2단계 인증 코드를 요구한다 (Stalwart 의 HTTP 402 — 2026-10-31 확인). + * 코드는 verifyTwoFactorLogin 으로 따로 확인한다 + */ +export type CredentialCheck = + | { readonly kind: 'valid'; readonly account: string } + | { readonly kind: 'invalid' } + | { readonly kind: 'two-factor-required' }; + +/** + * 2단계 인증 로그인의 엔진 판단 (계약 5절 "로그인") — 비밀번호 + 코드. 엔진 장애는 실패(MAIL_001 · REQ_001)로 던진다. + * - authenticated: 둘 다 맞다 + * - code-rejected: 엔진이 받지 않았다 — 코드 틀림(비밀번호는 바로 앞에서 맞았다. 그사이 바뀐 비밀번호도 엔진은 가르지 않는다) + */ +export type TwoFactorLoginResult = 'authenticated' | 'code-rejected'; + +/** + * 비밀번호 바꾸기의 엔진 판단 (계약 1절 "PUT /api/auth/password" · 5절 "비밀번호 바꾸기"). * 결과가 확실한 엔진 장애(바뀌지 않았다)는 결과가 아니라 실패(MAIL_001 · REQ_001)로 던진다. * - changed: 바뀌었다 * - current-password-rejected: 지금 비밀번호로 엔진에 들어가지 못했다 (틀림) — 맞혀 보기라 로그인 실패처럼 센다 * - current-password-refused: 지금 비밀번호로 들어가기는 했는데 엔진이 바꾸기를 거절했다 * (지금 비밀번호 확인 거절 · 변경 권한 없음 · 그사이 다른 곳에서 바뀜) — 맞혀 보기가 아니다 - * - new-password-rejected: 새 비밀번호가 엔진 규칙에 어긋난다 (8자 미만 등) — 지금 비밀번호는 맞았다 + * - new-password-rejected: 새 비밀번호가 엔진 규칙에 어긋난다 (8자 미만 · 강도 등) — 지금 비밀번호(와 코드)는 맞았다 + * - two-factor-required: 비밀번호는 맞지만 2단계 인증을 켠 직원이라 코드가 없으면 바꾸지 않는다 (코드 없이 보냈을 때) + * - code-rejected: 코드를 함께 보냈는데 엔진이 받지 않았다 — 코드 틀림 (12단계) * - outcome-unknown: 바꾸기 요청을 보낸 뒤 결과를 알 수 없게 끝났다 (시간 초과 · 연결 끊김 · 5xx · 해석할 수 없는 응답) — 엔진이 바꿨을 수 있다 */ export type PasswordChangeOutcome = @@ -385,6 +406,8 @@ export type PasswordChangeOutcome = | 'current-password-rejected' | 'current-password-refused' | 'new-password-rejected' + | 'two-factor-required' + | 'code-rejected' | 'outcome-unknown'; /** 새 메일 확인 (계약 2절 "새 메일 확인") — 메일함이나 메일이 하나라도 바뀌면 달라지는 불투명 값 */ diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index b0bc365..a465e90 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -21,6 +21,7 @@ const prod = (extra: Record = {}) => ({ TRUST_PROXY: '2', SHARE_PUBLIC_URL: 'https://files.arasystem.it.kr', CLAMAV_HOST: 'clamav', + MAIL_WEBHOOK_SECRET: 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a', ...extra, }); @@ -398,6 +399,88 @@ describe('validateEnv', () => { }); }); + describe('계정 보안 (12단계)', () => { + const dev = (extra: Record) => ({ + NODE_ENV: 'development', + ...extra, + }); + + it('비면 기동한다 — 개발은 웹훅 서명 키 없이(그때 웹훅은 모두 401), 앱 네트워크 대역은 기본값', () => { + expect(() => + validateEnv(dev({ MAIL_WEBHOOK_SECRET: '', APP_NETWORK_SUBNET: '' })), + ).not.toThrow(); + }); + + it('웹훅 서명 키는 영문 · 숫자 32자 이상 — 기호 · 공백 · 짧은 값은 환경과 무관하게 실패하고 값은 메시지에 싣지 않는다', () => { + for (const value of [ + 'short1234', + 'has-hyphen-0000000000000000000000', + 'has#hash00000000000000000000000000', + 'has space 0000000000000000000000000', + ]) { + try { + validateEnv(dev({ MAIL_WEBHOOK_SECRET: value })); + fail('예외가 발생해야 한다'); + } catch (error) { + const message = (error as Error).message; + expect(message).toMatch( + /MAIL_WEBHOOK_SECRET 은 영문 · 숫자 32자 이상/, + ); + expect(message).not.toContain(value); + } + } + expect(() => + validateEnv(dev({ MAIL_WEBHOOK_SECRET: 'a'.repeat(32) })), + ).not.toThrow(); + }); + + it('앱 네트워크 대역은 주소/접두 길이 — 아니면 실패한다', () => { + for (const value of [ + '172.16.71.0', + '172.16.71.0/33', + 'app-network', + '172.16.71.0/024', + ]) { + expect(() => validateEnv(dev({ APP_NETWORK_SUBNET: value }))).toThrow( + /APP_NETWORK_SUBNET 은 대역/, + ); + } + for (const value of ['172.16.71.0/24', '10.10.0.0/16', 'fd00::/64']) { + expect(() => + validateEnv(dev({ APP_NETWORK_SUBNET: value })), + ).not.toThrow(); + } + }); + + it('운영은 웹훅 서명 키가 필수다', () => { + expect(() => validateEnv(omit(prod(), 'MAIL_WEBHOOK_SECRET'))).toThrow( + /MAIL_WEBHOOK_SECRET 가 설정되지 않았습니다/, + ); + }); + + it('운영은 예시 파일의 서명 키를 거부한다 — 하이픈 없는 changeme 도 대소문자 없이 (개발 · 운영 예시 값 그대로)', () => { + for (const placeholder of [ + 'changemeinlocalenv00000000000000', + 'CHANGEMEBEFOREDEPLOY000000000000', + ]) { + // 형식(영문 · 숫자 32자 이상)은 맞는다 — 예시 값 검사가 따로 막는다 + expect(() => + validateEnv(dev({ MAIL_WEBHOOK_SECRET: placeholder })), + ).not.toThrow(); + try { + validateEnv(prod({ MAIL_WEBHOOK_SECRET: placeholder })); + fail('예외가 발생해야 한다'); + } catch (error) { + const message = (error as Error).message; + expect(message).toMatch( + /MAIL_WEBHOOK_SECRET 가 예시 값 그대로입니다/, + ); + expect(message).not.toContain(placeholder); + } + } + }); + }); + describe('대용량 첨부 (11단계)', () => { const dev = (extra: Record) => ({ NODE_ENV: 'development', diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 2be44f1..9a5c6f7 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -13,6 +13,7 @@ import { DEFAULT_REFRESH_TTL_SECONDS, REFRESH_TTL_MAX_SECONDS, } from '@/modules/auth/auth.constants'; +import { MAIL_WEBHOOK_SECRET_PATTERN } from '@/modules/account-security/account-security.constants'; import { isLocalPart } from '@/modules/auth/employee-address'; import { SHARE_DOWNLOAD_LIMIT_MAX } from '@/modules/file-sharing/file-sharing.settings'; import { @@ -29,6 +30,7 @@ import { DEFAULT_MAIL_DIRECTORY_LOCAL_PART, parseAccountList, } from '@/modules/mail/mail.settings'; +import { parseCidr } from '@/shared/utils/cidr'; import { parseDurationSeconds } from '@/shared/utils/duration'; import { isPositiveIntString } from '@/shared/utils/env'; import { @@ -66,6 +68,8 @@ const REQUIRED_IN_PRODUCTION = [ 'SHARE_PUBLIC_URL', // 대용량 첨부의 악성 검사기(clamd) — 운영은 검사 없이 받을 수 있게 되는 일이 없게 (계약 4절 "악성 검사") 'CLAMAV_HOST', + // 엔진 웹훅의 서명 키(12단계) — 메일 프로그램의 로그인 기록 · 앱 비밀번호의 마지막 사용 (계약 5절 "환경 변수") + 'MAIL_WEBHOOK_SECRET', ] as const; /** 값이 있다면 양의 정수(앞자리 0 없이)여야 하는 키 — shared/utils/env.ts 의 isPositiveIntString */ @@ -114,6 +118,8 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [ 'JWT_SECRET', // 전 직원 메일함의 열쇠다 (mail/README.md "보안") 'MAIL_GATEWAY_PASSWORD', + // 엔진 웹훅의 서명 키 — 이것을 알면 로그인 기록을 꾸밀 수 있다 (12단계) + 'MAIL_WEBHOOK_SECRET', ] as const; /** @@ -451,6 +457,31 @@ function validateLargeFileSettings( } } +/** + * 계정 보안 설정 (12단계 — 계약 5절 "환경 변수") — 값이 있을 때 형식을 본다(모든 환경). 운영 필수 · 예시 값 거부는 아래 공통 검사가 한다. + * - MAIL_WEBHOOK_SECRET: 영문 · 숫자 32자 이상 (Node 의 --env-file 이 `#` 부터 자르므로 기호를 받지 않는다 — setup 과 같은 값을 읽는다). + * 비우면(개발) 엔진 웹훅을 모두 401 로 거절한다 + * - APP_NETWORK_SUBNET: 대역(주소/접두 길이) — 엔진 웹훅의 소켓 주소를 이 안에서만 받는다. 비우면 compose 기본값 172.16.71.0/24 + * 값 자체(서명 키)는 오류 메시지에 싣지 않는다 + */ +function validateAccountSecuritySettings( + config: Record, + errors: string[], +): void { + const secret = readString(config, 'MAIL_WEBHOOK_SECRET'); + if (secret !== undefined && !MAIL_WEBHOOK_SECRET_PATTERN.test(secret)) { + errors.push( + `MAIL_WEBHOOK_SECRET 은 영문 · 숫자 32자 이상이어야 합니다 — 기호 · 공백 없이. (현재 ${secret.length}자)`, + ); + } + const subnet = readString(config, 'APP_NETWORK_SUBNET'); + if (subnet !== undefined && parseCidr(subnet) === null) { + errors.push( + `APP_NETWORK_SUBNET 은 대역(주소/접두 길이)이어야 합니다. (예: 172.16.71.0/24, 현재 값: "${subnet}")`, + ); + } +} + /** 도메인 이름 형식 (예: arasystem.it.kr, arasystem.test) — '@' 나 스킴이 섞이면 거부한다 */ const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i; @@ -620,6 +651,9 @@ export function validateEnv( // 1-5. 대용량 첨부 (11단계) validateLargeFileSettings(config, isProduction, errors); + // 1-6. 계정 보안 (12단계) + validateAccountSecuritySettings(config, errors); + if (isProduction) { // 2. 운영 필수값 — 없으면 기동 실패 for (const key of REQUIRED_IN_PRODUCTION) { diff --git a/backend/src/shared/utils/cidr.spec.ts b/backend/src/shared/utils/cidr.spec.ts new file mode 100644 index 0000000..f38377b --- /dev/null +++ b/backend/src/shared/utils/cidr.spec.ts @@ -0,0 +1,57 @@ +import { createCidrMatcher, parseCidr } from './cidr'; + +describe('parseCidr', () => { + it('주소/접두 길이를 읽는다 (IPv4 · IPv6)', () => { + expect(parseCidr('172.16.71.0/24')).toEqual({ + address: '172.16.71.0', + prefix: 24, + family: 4, + }); + expect(parseCidr(' fd00::/64 ')).toEqual({ + address: 'fd00::', + prefix: 64, + family: 6, + }); + }); + + it.each([ + '172.16.71.0', + '172.16.71.0/', + '172.16.71.0/33', + '172.16.71.0/024', + '172.16.71/24', + 'fd00::/129', + 'mail/24', + '172.16.71.0/24/8', + '', + ])('대역이 아니면 null — %j', (value) => { + expect(parseCidr(value)).toBeNull(); + }); +}); + +describe('createCidrMatcher', () => { + const inAppNetwork = createCidrMatcher(parseCidr('172.16.71.0/24')!); + + it('대역 안의 주소만 true — IPv4 를 담은 IPv6 표기(소켓 주소)도 같은 주소로 본다', () => { + expect(inAppNetwork('172.16.71.2')).toBe(true); + expect(inAppNetwork('::ffff:172.16.71.2')).toBe(true); + expect(inAppNetwork('::FFFF:172.16.71.254')).toBe(true); + expect(inAppNetwork('172.16.72.2')).toBe(false); + expect(inAppNetwork('127.0.0.1')).toBe(false); + expect(inAppNetwork('::1')).toBe(false); + }); + + it('주소가 아니면 false', () => { + expect(inAppNetwork(undefined)).toBe(false); + expect(inAppNetwork(null)).toBe(false); + expect(inAppNetwork('')).toBe(false); + expect(inAppNetwork('mail')).toBe(false); + }); + + it('IPv6 대역', () => { + const inV6 = createCidrMatcher(parseCidr('fd00:1::/64')!); + expect(inV6('fd00:1::5')).toBe(true); + expect(inV6('fd00:2::5')).toBe(false); + expect(inV6('172.16.71.2')).toBe(false); + }); +}); diff --git a/backend/src/shared/utils/cidr.ts b/backend/src/shared/utils/cidr.ts new file mode 100644 index 0000000..451e39d --- /dev/null +++ b/backend/src/shared/utils/cidr.ts @@ -0,0 +1,63 @@ +import { BlockList, isIP, isIPv4 } from 'net'; + +/** + * 대역(CIDR) 해석 · 들어 있는지 보기 (순수 함수) — 엔진 웹훅의 접속 주소가 앱 네트워크(`APP_NETWORK_SUBNET`)인지 본다 (계약 5절 "엔진 웹훅 받기"), + * env 검증도 같은 해석을 쓴다. + */ + +export interface Cidr { + readonly address: string; + readonly prefix: number; + readonly family: 4 | 6; +} + +/** 접두 길이 — 앞자리 0 없이 (env 숫자 규칙과 같다) */ +const PREFIX_PATTERN = /^(?:0|[1-9]\d{0,2})$/; +const IPV4_MAX_PREFIX = 32; +const IPV6_MAX_PREFIX = 128; +/** IPv4 를 담은 IPv6 표기 — `::ffff:172.16.71.3` (소켓 주소가 이렇게 올 수 있다) */ +const IPV4_MAPPED_PREFIX = '::ffff:'; + +/** `주소/접두 길이` 하나 — 아니면 null (접두 길이를 빼면 대역이 아니다) */ +export function parseCidr(value: string): Cidr | null { + const parts = value.trim().split('/'); + if (parts.length !== 2) return null; + const [address, prefixText] = parts; + const family = isIP(address); + if (family === 0 || !PREFIX_PATTERN.test(prefixText)) return null; + const prefix = Number(prefixText); + const max = family === 4 ? IPV4_MAX_PREFIX : IPV6_MAX_PREFIX; + return prefix <= max + ? { address, prefix, family: family === 4 ? 4 : 6 } + : null; +} + +/** 견줄 주소 — 앞뒤 공백 · 영역 표시(`%eth0`)를 떼고, IPv4 를 담은 IPv6 는 IPv4 로 */ +function comparableAddress(address: string): string { + const trimmed = address.trim().split('%')[0]; + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith(IPV4_MAPPED_PREFIX)) { + const ipv4 = trimmed.slice(IPV4_MAPPED_PREFIX.length); + if (isIPv4(ipv4)) return ipv4; + } + return trimmed; +} + +/** 대역에 든 주소인지 보는 함수 — 주소가 아니거나(빈 값 · undefined) 다른 갈래(IPv4 대역에 IPv6)면 false */ +export function createCidrMatcher( + cidr: Cidr, +): (address: string | null | undefined) => boolean { + const list = new BlockList(); + list.addSubnet( + cidr.address, + cidr.prefix, + cidr.family === 4 ? 'ipv4' : 'ipv6', + ); + return (address) => { + if (!address) return false; + const candidate = comparableAddress(address); + const family = isIP(candidate); + if (family === 0) return false; + return list.check(candidate, family === 4 ? 'ipv4' : 'ipv6'); + }; +} diff --git a/backend/src/shared/utils/request-target.spec.ts b/backend/src/shared/utils/request-target.spec.ts new file mode 100644 index 0000000..1511462 --- /dev/null +++ b/backend/src/shared/utils/request-target.spec.ts @@ -0,0 +1,35 @@ +import { hasForbiddenRequestTargetChar } from './request-target'; + +describe('hasForbiddenRequestTargetChar (요청 대상 원문 — 보이는 ASCII 중 \\ · # 을 뺀 것만)', () => { + it.each([ + ['/api/health'], + ['/api/messages?q=%ED%95%9C%EA%B8%80&limit=20'], + ['/api/%ED%95%9C'], + ['/api/mail-blocks/kim%40spam.example'], + ['/api/contacts?cursor=eyJ2IjoxfQ%3D%3D'], + ['/'], + ['*'], + ['http://portal.example/api/health'], + ])('%s 는 통과', (target) => { + expect(hasForbiddenRequestTargetChar(target)).toBe(false); + }); + + it.each([ + ['날 \\', '/api/internal\\mail-engine-events'], + ['# (조각)', '/api/health#a'], + ['\\ 와 #', '/api/health\\#a'], + ['절대 주소 꼴의 \\', 'http://x/api/internal\\mail-engine-events'], + ['0xA0', '/api/a b'], + ['0x80', '/api/a\u0080b'], + ['0xFF', '/api/aÿb'], + ['BOM(0xFEFF)', '/api/ab'], + ['NUL', '/api/a\u0000b'], + ['탭', '/api/a\tb'], + ['줄바꿈', '/api/a\nb'], + ['공백', '/api/a b'], + ['DEL(0x7F)', '/api/a\u007fb'], + ['한글 날 글자', '/api/한'], + ])('%s 가 있으면 막는다', (_label, target) => { + expect(hasForbiddenRequestTargetChar(target)).toBe(true); + }); +}); diff --git a/backend/src/shared/utils/request-target.ts b/backend/src/shared/utils/request-target.ts new file mode 100644 index 0000000..7f11a0a --- /dev/null +++ b/backend/src/shared/utils/request-target.ts @@ -0,0 +1,31 @@ +/** + * 요청 대상(request-target) 원문 검사 (순수 함수) — 받는 글자는 보이는 ASCII(0x21–0x7E) 중 `\` · `#` 을 뺀 것뿐이다. + * + * Express 의 parseurl 은 대상에 `#` · 공백류 · 0xA0 같은 글자가 있거나 `/` 로 시작하지 않으면(절대 주소 꼴) 옛 `url.parse` 로 다시 읽는데, + * 그때 `\` 를 `/` 로 바꾼다 — 앞단 nginx 가 정규식으로 닫은 경로가 백엔드에서는 열렸다 + * (2026-10-31 인프라 리뷰: `POST /api/internal\mail-engine-events#x` 가 웹훅 가드까지 닿음, `/api/health\#a` 도 같다). + * 브라우저 · HTTP 클라이언트는 이런 글자를 퍼센트 인코딩해 보낸다(한글은 `%ED%95%9C` 꼴로 들어와 통과한다). + * 지금 Node 는 제어 문자 · 0x80 이상 바이트가 든 대상을 HTTP 파서에서 먼저 거절하지만(HPE_INVALID_URL), 파서가 느슨해져도 여기서 막는다. + * 길이에 비례해 한 번 훑는다. + */ + +const FIRST_VISIBLE_ASCII = 0x21; +const LAST_VISIBLE_ASCII = 0x7e; +const BACKSLASH = 0x5c; +const HASH = 0x23; + +/** 요청 대상에 받지 않는 글자(`\` · `#` · 제어 문자 · 공백 · 0x7F · 0x80 이상)가 있는가 */ +export function hasForbiddenRequestTargetChar(target: string): boolean { + for (let index = 0; index < target.length; index += 1) { + const code = target.charCodeAt(index); + if ( + code < FIRST_VISIBLE_ASCII || + code > LAST_VISIBLE_ASCII || + code === BACKSLASH || + code === HASH + ) { + return true; + } + } + return false; +} diff --git a/backend/src/shared/utils/totp.spec.ts b/backend/src/shared/utils/totp.spec.ts new file mode 100644 index 0000000..4759097 --- /dev/null +++ b/backend/src/shared/utils/totp.spec.ts @@ -0,0 +1,135 @@ +import { + TOTP_PERIOD_SECONDS, + buildOtpauthUrl, + encodeBase32, + totpCode, + totpCounterAt, + verifyTotp, +} from './totp'; + +/** RFC 6238 부록 B 의 SHA-1 비밀 ("12345678901234567890") */ +const RFC_SECRET = Buffer.from('12345678901234567890', 'ascii'); + +describe('encodeBase32 (RFC 4648, 채움 없음)', () => { + it.each([ + ['', ''], + ['f', 'MY'], + ['fo', 'MZXQ'], + ['foo', 'MZXW6'], + ['foob', 'MZXW6YQ'], + ['fooba', 'MZXW6YTB'], + ['foobar', 'MZXW6YTBOI'], + ])('RFC 4648 표본 %j → %s', (input, expected) => { + expect(encodeBase32(Buffer.from(input, 'ascii'))).toBe(expected); + }); + + it('160비트(20바이트)는 32자 — 2단계 인증 등록의 비밀 길이', () => { + const encoded = encodeBase32(Buffer.alloc(20, 0xff)); + expect(encoded).toHaveLength(32); + expect(encoded).toMatch(/^[A-Z2-7]+$/); + expect(encodeBase32(Buffer.alloc(20))).toBe('A'.repeat(32)); + }); +}); + +describe('totpCode (RFC 6238 SHA-1 · 6자리)', () => { + // 부록 B 의 8자리 값의 아래 6자리 — 같은 HMAC 을 10^6 으로 나눈 나머지다 + it.each([ + [59, '287082'], + [1111111109, '081804'], + [1111111111, '050471'], + [1234567890, '005924'], + [2000000000, '279037'], + [20000000000, '353130'], + ])('T=%d 초 → %s', (seconds, expected) => { + expect(totpCode(RFC_SECRET, totpCounterAt(seconds * 1000))).toBe(expected); + }); +}); + +describe('verifyTotp', () => { + const at = 1_800_000_000_000; // 30초 단계 안의 어느 순간 + const counter = totpCounterAt(at); + + it('지금 단계와 앞뒤 한 단계(±30초)의 코드를 받고, 맞은 단계 번호를 돌려준다', () => { + expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, counter), at)).toBe( + counter, + ); + expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, counter - 1), at)).toBe( + counter - 1, + ); + expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, counter + 1), at)).toBe( + counter + 1, + ); + }); + + it('두 단계 이상 떨어진 코드 · 다른 비밀의 코드는 받지 않는다 (엔진과 같은 창)', () => { + for (const offset of [-3, -2, 2, 3]) { + const code = totpCode(RFC_SECRET, counter + offset); + // 우연히 창 안의 코드와 같은 값이면 건너뛴다 (10^6 분의 3) + const inWindow = [-1, 0, 1].some( + (step) => totpCode(RFC_SECRET, counter + step) === code, + ); + if (!inWindow) expect(verifyTotp(RFC_SECRET, code, at)).toBeNull(); + } + const other = Buffer.from('another-secret-20byt', 'ascii'); + const foreign = totpCode(other, counter); + if (foreign !== totpCode(RFC_SECRET, counter)) { + expect(verifyTotp(RFC_SECRET, foreign, at)).toBeNull(); + } + }); + + it('숫자 6자리가 아니면 견주지 않고 null', () => { + const code = totpCode(RFC_SECRET, counter); + for (const bad of [ + '', + '12345', + '1234567', + ` ${code}`, + 'abcdef', + '12 456', + ]) { + expect(verifyTotp(RFC_SECRET, bad, at)).toBeNull(); + } + }); + + it('단계 경계 바로 앞뒤에서도 앞뒤 한 단계를 받는다', () => { + const boundary = (counter + 1) * TOTP_PERIOD_SECONDS * 1000; + const before = totpCode(RFC_SECRET, counter); + expect(verifyTotp(RFC_SECRET, before, boundary - 1)).toBe(counter); + expect(verifyTotp(RFC_SECRET, before, boundary)).toBe(counter); + }); +}); + +describe('buildOtpauthUrl', () => { + it('계약 5절의 모양 — 라벨은 「발급자:주소」(각각 인코딩, 사이 콜론은 그대로) · SHA1 · 6자리 · 30초', () => { + expect( + buildOtpauthUrl({ + issuer: 'ARA SYSTEM', + account: 'ara.kim@arasystem.it.kr', + secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', + }), + ).toBe( + 'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30', + ); + }); + + it('URL 로 다시 읽으면 발급자 · 주소 · 값이 그대로 나온다 (엔진의 totp-rs from_url 이 읽는 방식)', () => { + const url = new URL( + buildOtpauthUrl({ + issuer: 'ARA SYSTEM', + account: 'doyun.lee@arasystem.test', + secret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + }), + ); + expect(url.protocol).toBe('otpauth:'); + expect(url.host).toBe('totp'); + const [issuer, account] = decodeURIComponent( + url.pathname.replace(/^\//, ''), + ).split(':'); + expect(issuer).toBe('ARA SYSTEM'); + expect(account).toBe('doyun.lee@arasystem.test'); + expect(url.searchParams.get('issuer')).toBe(issuer); + expect(url.searchParams.get('secret')).toBe( + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + ); + }); +}); diff --git a/backend/src/shared/utils/totp.ts b/backend/src/shared/utils/totp.ts new file mode 100644 index 0000000..aaa56e4 --- /dev/null +++ b/backend/src/shared/utils/totp.ts @@ -0,0 +1,124 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +/** + * 인증 앱 코드(TOTP — RFC 6238) · base32(RFC 4648) · otpauth 주소 (순수 함수) — 2단계 인증 등록의 첫 코드 확인에 쓴다 + * (계약 5절 "2단계 인증"). 새 의존성 없이 node crypto 로 만든다. + * + * 값은 인증 앱 기본값이자 엔진(Stalwart — totp-rs `from_url`)이 읽는 값과 같다: SHA-1 · 숫자 6자리 · 30초 · 앞뒤 한 단계. + * 비밀 · 코드 · otpauth 주소는 어떤 로그에도 남기지 않는다 (부르는 쪽 몫 — 여기는 값을 만들고 견주기만 한다). + */ + +/** RFC 4648 base32 글자 (채움 `=` 은 쓰지 않는다) */ +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const BITS_PER_BYTE = 8; +const BITS_PER_BASE32_CHAR = 5; +const BASE32_CHAR_MASK = 0b11111; + +/** 코드 자릿수 · 한 단계 길이(초) · HMAC — otpauth 주소의 digits · period · algorithm 과 같아야 한다 */ +export const TOTP_DIGITS = 6; +export const TOTP_PERIOD_SECONDS = 30; +const TOTP_HMAC = 'sha1'; +const TOTP_ALGORITHM_NAME = 'SHA1'; +/** 지금 단계의 앞뒤로 받는 단계 수 — 엔진(totp-rs 의 skew 1)과 같다: ±30초 */ +export const TOTP_WINDOW_STEPS = 1; + +/** 숫자 6자리 — 요청 검사(VAL_001)와 같은 모양 */ +export const TOTP_CODE_PATTERN = /^\d{6}$/; + +const MS_PER_SECOND = 1000; +/** RFC 4226 의 동적 잘라내기 — 마지막 바이트의 아래 4비트가 자리, 맨 위 비트는 뗀다 */ +const DYNAMIC_OFFSET_MASK = 0x0f; +const SIGN_BIT_MASK = 0x7f; +const COUNTER_BYTES = 8; + +/** 바이트 → base32 (RFC 4648, 채움 없음) — 20바이트(160비트)면 32자 */ +export function encodeBase32(bytes: Uint8Array): string { + let output = ''; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = ((buffer << BITS_PER_BYTE) | byte) & 0xffff; + bits += BITS_PER_BYTE; + while (bits >= BITS_PER_BASE32_CHAR) { + output += + BASE32_ALPHABET[ + (buffer >>> (bits - BITS_PER_BASE32_CHAR)) & BASE32_CHAR_MASK + ]; + bits -= BITS_PER_BASE32_CHAR; + } + } + if (bits > 0) { + output += + BASE32_ALPHABET[ + (buffer << (BITS_PER_BASE32_CHAR - bits)) & BASE32_CHAR_MASK + ]; + } + return output; +} + +/** 그 시각(epoch ms)의 단계 번호 */ +export function totpCounterAt(epochMs: number): number { + return Math.floor(epochMs / MS_PER_SECOND / TOTP_PERIOD_SECONDS); +} + +/** 단계 하나의 코드 (RFC 4226 HOTP — 숫자 6자리, 앞자리 0 을 채운다) */ +export function totpCode(secret: Uint8Array, counter: number): string { + const message = Buffer.alloc(COUNTER_BYTES); + message.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac(TOTP_HMAC, secret).update(message).digest(); + const offset = digest[digest.length - 1] & DYNAMIC_OFFSET_MASK; + const binary = + ((digest[offset] & SIGN_BIT_MASK) << 24) | + (digest[offset + 1] << 16) | + (digest[offset + 2] << 8) | + digest[offset + 3]; + return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, '0'); +} + +/** + * 코드가 맞는가 — 지금 단계와 앞뒤 TOTP_WINDOW_STEPS 단계 가운데 하나와 같으면 그 단계 번호, 아니면 null. + * 모든 단계를 늘 같은 방식으로 견준다(상수 시간 비교 — 맞은 단계에서 멈추지 않는다). + * 다시 쓴 코드 막기는 부르는 쪽 몫이다 (엔진도 막지 않는다 — 계약 5절) + */ +export function verifyTotp( + secret: Uint8Array, + code: string, + epochMs: number, +): number | null { + if (!TOTP_CODE_PATTERN.test(code)) return null; + const presented = Buffer.from(code); + const current = totpCounterAt(epochMs); + let matched: number | null = null; + for (let step = -TOTP_WINDOW_STEPS; step <= TOTP_WINDOW_STEPS; step += 1) { + const counter = current + step; + if (counter < 0) continue; + const expected = Buffer.from(totpCode(secret, counter)); + if (timingSafeEqual(expected, presented) && matched === null) { + matched = counter; + } + } + return matched; +} + +/** + * 인증 앱에 넣을 otpauth 주소 (Key Uri Format) — 라벨은 `발급자:주소`(각각 퍼센트 인코딩, 사이의 `:` 는 그대로), 발급자는 issuer 에도. + * 예: otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30 + * (계약 5절 — 엔진이 이 주소를 저장하고 그 뒤 코드를 이 주소로 확인한다) + */ +export function buildOtpauthUrl(options: { + readonly issuer: string; + readonly account: string; + /** base32 비밀 (encodeBase32) */ + readonly secret: string; +}): string { + const issuer = encodeURIComponent(options.issuer); + const label = `${issuer}:${encodeURIComponent(options.account)}`; + const query = [ + `secret=${options.secret}`, + `issuer=${issuer}`, + `algorithm=${TOTP_ALGORITHM_NAME}`, + `digits=${TOTP_DIGITS}`, + `period=${TOTP_PERIOD_SECONDS}`, + ].join('&'); + return `otpauth://totp/${label}?${query}`; +} diff --git a/backend/src/shared/utils/user-agent.spec.ts b/backend/src/shared/utils/user-agent.spec.ts new file mode 100644 index 0000000..f66774c --- /dev/null +++ b/backend/src/shared/utils/user-agent.spec.ts @@ -0,0 +1,116 @@ +import { + USER_AGENT_MAX_LENGTH, + describeUserAgent, + truncateUserAgent, +} from './user-agent'; + +/** 실제 브라우저가 보내는 모양 (2026 기준 대표값) */ +const SAMPLES: readonly [ + label: string, + ua: string, + browser: string | null, + os: string | null, +][] = [ + [ + 'Chrome · Windows', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + 'Chrome', + 'Windows', + ], + [ + 'Edge · Windows', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0', + 'Edge', + 'Windows', + ], + [ + 'Whale · Windows', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Whale/4.33.325.17 Safari/537.36', + 'Whale', + 'Windows', + ], + [ + 'Firefox · Linux', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0', + 'Firefox', + 'Linux', + ], + [ + 'Safari · macOS', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15', + 'Safari', + 'macOS', + ], + [ + 'Safari · iPhone', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1', + 'Safari', + 'iOS', + ], + [ + 'Chrome · iPhone (CriOS)', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/141.0.7390.41 Mobile/15E148 Safari/604.1', + 'Chrome', + 'iOS', + ], + [ + 'Firefox · iPad (FxiOS)', + 'Mozilla/5.0 (iPad; CPU OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/143.0 Mobile/15E148 Safari/605.1.15', + 'Firefox', + 'iOS', + ], + [ + 'Samsung Internet · Android', + 'Mozilla/5.0 (Linux; Android 15; SM-S928N) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/28.0 Chrome/130.0.0.0 Mobile Safari/537.36', + 'Samsung Internet', + 'Android', + ], + [ + 'Chrome · Android', + 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36', + 'Chrome', + 'Android', + ], + [ + 'Chrome · ChromeOS', + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + 'Chrome', + 'ChromeOS', + ], + [ + 'Opera · Windows — 목록에 없는 브라우저는 Chrome 이라 부르지 않는다', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 OPR/125.0.0.0', + null, + 'Windows', + ], +]; + +describe('describeUserAgent', () => { + it.each(SAMPLES)('%s', (_label, ua, browser, os) => { + expect(describeUserAgent(ua)).toEqual({ browser, os }); + }); + + it('원문이 없거나(12단계 전의 세션 · 메일 프로그램) 둘 다 모르면 null', () => { + expect(describeUserAgent(null)).toBeNull(); + expect(describeUserAgent(undefined)).toBeNull(); + expect(describeUserAgent(' ')).toBeNull(); + expect(describeUserAgent('curl/8.10.1')).toBeNull(); + }); + + it('긴 원문도 앞 512자만 본다 — 입력 길이에 비례해 끝난다', () => { + const ua = `Mozilla/5.0 (Windows NT 10.0) ${'x'.repeat(10_000)} Chrome/141.0`; + // Chrome/ 이 512자 뒤에 있어 브라우저는 모른다 + expect(describeUserAgent(ua)).toEqual({ browser: null, os: 'Windows' }); + }); +}); + +describe('truncateUserAgent', () => { + it(`앞뒤 공백을 지우고 ${USER_AGENT_MAX_LENGTH}자까지 — 비면 null`, () => { + expect(truncateUserAgent(' Mozilla/5.0 ')).toBe('Mozilla/5.0'); + expect(truncateUserAgent('a'.repeat(600))).toHaveLength( + USER_AGENT_MAX_LENGTH, + ); + expect(truncateUserAgent('')).toBeNull(); + expect(truncateUserAgent(undefined)).toBeNull(); + }); +}); diff --git a/backend/src/shared/utils/user-agent.ts b/backend/src/shared/utils/user-agent.ts new file mode 100644 index 0000000..cbf9cf6 --- /dev/null +++ b/backend/src/shared/utils/user-agent.ts @@ -0,0 +1,101 @@ +/** + * User-Agent → 짧은 이름 (순수 함수) — 로그인된 브라우저 · 로그인 기록의 `device` (계약 5절 "로그인된 브라우저"). + * 이름 목록은 계약 그대로다 — 브라우저 `Edge` · `Chrome` · `Firefox` · `Safari` · `Whale` · `Samsung Internet`, + * 운영체제 `Windows` · `macOS` · `iOS` · `Android` · `Linux` · `ChromeOS`. 모르면 null(목록에 없는 이름을 지어내지 않는다). + * + * 서버에는 원문(앞 512자 — USER_AGENT_MAX_LENGTH)을 두고 응답할 때 이 함수로 바꾼다 — 판정을 고치면 지난 기록에도 맞게 보인다. + * 낱말 찾기(includes)만 쓴다 — 정규식 되돌아가기 비용이 없고 입력 길이에 비례한다. + */ + +/** 저장하는 원문 길이 상한 (계약 5절 "User-Agent 원문은 512자까지") */ +export const USER_AGENT_MAX_LENGTH = 512; + +export type BrowserName = + | 'Edge' + | 'Chrome' + | 'Firefox' + | 'Safari' + | 'Whale' + | 'Samsung Internet'; + +export type OsName = + | 'Windows' + | 'macOS' + | 'iOS' + | 'Android' + | 'Linux' + | 'ChromeOS'; + +export interface DeviceNames { + readonly browser: BrowserName | null; + readonly os: OsName | null; +} + +/** + * Chrome 엔진을 쓰지만 목록에 없는 브라우저의 표시 — 이것이 있으면 Chrome 이라고 부르지 않는다(모른다 — null). + * Whale · Samsung · Edge 는 앞에서 먼저 알아본다 + */ +const OTHER_CHROMIUM_MARKERS = [ + 'OPR/', + 'Opera', + 'YaBrowser/', + 'Vivaldi/', + 'UCBrowser/', +] as const; + +/** 저장할 원문 — 앞뒤 공백을 지우고 512자까지, 비면 null */ +export function truncateUserAgent( + userAgent: string | null | undefined, +): string | null { + const trimmed = userAgent?.trim() ?? ''; + return trimmed === '' ? null : trimmed.slice(0, USER_AGENT_MAX_LENGTH); +} + +function browserOf(ua: string): BrowserName | null { + // 차례가 중요하다 — 앞의 것들은 모두 "Chrome/" · "Safari/" 도 함께 적는다 + if (ua.includes('Whale/')) return 'Whale'; + if (ua.includes('SamsungBrowser/')) return 'Samsung Internet'; + if ( + ua.includes('Edg/') || + ua.includes('EdgA/') || + ua.includes('EdgiOS/') || + ua.includes('Edge/') + ) { + return 'Edge'; + } + if (ua.includes('Firefox/') || ua.includes('FxiOS/')) return 'Firefox'; + if (OTHER_CHROMIUM_MARKERS.some((marker) => ua.includes(marker))) { + return null; + } + if (ua.includes('CriOS/') || ua.includes('Chrome/')) return 'Chrome'; + // 사파리 — Version/ 과 Safari/ 가 함께 있고 Chrome 계열 표시가 없다(위에서 걸렀다) + if (ua.includes('Safari/') && ua.includes('Version/')) return 'Safari'; + return null; +} + +function osOf(ua: string): OsName | null { + // 차례가 중요하다 — iPhone 은 "like Mac OS X" 를, Android · ChromeOS 는 "Linux" 를 함께 적는다 + if (ua.includes('Windows')) return 'Windows'; + if (ua.includes('iPhone') || ua.includes('iPad') || ua.includes('iPod')) { + return 'iOS'; + } + if (ua.includes('CrOS')) return 'ChromeOS'; + if (ua.includes('Android')) return 'Android'; + if (ua.includes('Macintosh') || ua.includes('Mac OS X')) return 'macOS'; + if (ua.includes('Linux')) return 'Linux'; + return null; +} + +/** + * 원문 → 짧은 이름. 원문이 없거나(12단계 전의 세션 · 메일 프로그램) 둘 다 모르면 null — + * 화면은 "알 수 없는 브라우저"로 보인다. 원문은 앞 USER_AGENT_MAX_LENGTH 자만 본다 + */ +export function describeUserAgent( + userAgent: string | null | undefined, +): DeviceNames | null { + const ua = truncateUserAgent(userAgent); + if (ua === null) return null; + const browser = browserOf(ua); + const os = osOf(ua); + return browser === null && os === null ? null : { browser, os }; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index cbe7c00..59d52f6 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,9 +1,10 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { createHash, randomBytes, randomUUID } from 'crypto'; +import { createHash, createHmac, randomBytes, randomUUID } from 'crypto'; import { existsSync, mkdtempSync, rmSync, unlinkSync } from 'fs'; import { get as httpGet, type IncomingMessage, type Server } from 'http'; +import { connect } from 'net'; import { tmpdir } from 'os'; import { basename, join } from 'path'; import { Readable } from 'stream'; @@ -15,8 +16,20 @@ import { DataSource } from 'typeorm'; import { AppModule } from '@/app.module'; import { configureApp, createHttpAdapter } from '@/app.setup'; import { BusinessException } from '@/common/errors/business.exception'; +import { + ACCOUNT_SECURITY_PROVIDER, + type AppPasswordRecord, + type CreatedAppPassword, + type TwoFactorDisableResult, + type TwoFactorEnableResult, +} from '@/modules/account-security/account-security.provider'; +import { + ACCOUNT_SECURITY_SETTINGS, + type AccountSecuritySettings, +} from '@/modules/account-security/account-security.settings'; import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants'; +import { DirectoryService } from '@/modules/contacts/directory.service'; import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER, @@ -90,6 +103,7 @@ import type { MessageSummary, MessageUpdate, OutgoingMessage, + CredentialCheck, PasswordChangeOutcome, PendingSend, RecipientRejectionReason, @@ -104,7 +118,9 @@ import { } from '@/modules/mail-signature/mail-signature.constants'; import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository'; import { UPLOAD_DIR } from '@/modules/upload/upload.config'; +import { createCidrMatcher, parseCidr } from '@/shared/utils/cidr'; import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; +import { totpCode, totpCounterAt, verifyTotp } from '@/shared/utils/totp'; import { pngBytes } from './image-fixtures'; /** @@ -279,6 +295,46 @@ const PROFILE_NAMES = new Map([ ['colleague.b@e2e.arasystem.test', '박서준'], ]); +/** + * 12단계 — 엔진 웹훅 서명 키(영문 · 숫자 32자 이상)와 e2e 의 접속 주소(supertest 는 127.0.0.1 로 붙는다)를 받는 앱 네트워크 대역. + * 테스트가 대역 밖으로 바꿔 볼 수 있게 열어 둔다 (e2eAppNetworkOpen) + */ +const E2E_WEBHOOK_SECRET = 'E2eWebhookSecret0000000000000000000'; +let e2eAppNetworkOpen = true; +const e2eLoopback = createCidrMatcher(parseCidr('127.0.0.0/8')!); +const TEST_ACCOUNT_SECURITY_SETTINGS: AccountSecuritySettings = { + webhookSecret: E2E_WEBHOOK_SECRET, + appNetwork: parseCidr('127.0.0.0/8')!, + isAppNetworkAddress: (address) => e2eAppNetworkOpen && e2eLoopback(address), +}; + +/** base32(RFC 4648) → 바이트 — 등록 응답의 비밀로 인증 앱 코드를 만든다 */ +function decodeBase32(text: string): Buffer { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; + let value = 0; + const bytes: number[] = []; + for (const char of text) { + value = ((value << 5) | alphabet.indexOf(char)) & 0xffff; + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +/** otpauth 주소의 비밀(바이트) */ +function secretOfOtpauth(url: string): Buffer { + return decodeBase32(new URL(url).searchParams.get('secret') ?? ''); +} + +/** 비밀로 만든 인증 앱 코드 — offset 단계만큼 앞 · 뒤 (앞뒤 한 단계는 받는다) */ +function otpCodeOf(secret: Buffer, offset = 0): string { + return totpCode(secret, totpCounterAt(Date.now()) + offset); +} + /** 문을 열 조건을 다시 보는 간격 */ const GATE_POLL_MS = 10; /** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */ @@ -584,6 +640,87 @@ describe('응답 계약 (e2e)', () => { }, }; + /** 12단계 — 대역 엔진의 2단계 인증(직원 → otpauth 주소) · 앱 비밀번호(직원마다) */ + const twoFactorUrls = new Map(); + const appPasswordsByUser = new Map(); + let appPasswordSequence = 0; + /** 엔진 기본 상한 (x:Authentication.maxAppPasswords) */ + const E2E_MAX_APP_PASSWORDS = 5; + const fakeAccountSecurityProvider = { + isTwoFactorEnabled: (user: string) => + Promise.resolve(twoFactorUrls.has(user)), + enableTwoFactor: ( + user: string, + currentPassword: string, + otpauthUrl: string, + ): Promise => { + // 엔진처럼 코드를 보지 않고 저장한다 — 이미 켜져 있으면(지금 코드를 요구) 거절 + if (currentPassword !== CORRECT_PASSWORD || twoFactorUrls.has(user)) { + return Promise.resolve('refused'); + } + twoFactorUrls.set(user, otpauthUrl); + return Promise.resolve('enabled'); + }, + disableTwoFactor: ( + user: string, + currentPassword: string, + otpCode: string, + ): Promise => { + const url = twoFactorUrls.get(user); + if ( + currentPassword !== CORRECT_PASSWORD || + url === undefined || + verifyTotp(secretOfOtpauth(url), otpCode, Date.now()) === null + ) { + return Promise.resolve('code-rejected'); + } + twoFactorUrls.delete(user); + return Promise.resolve('disabled'); + }, + listAppPasswords: (user: string) => + Promise.resolve([...(appPasswordsByUser.get(user) ?? [])]), + createAppPassword: ( + user: string, + name: string, + ): Promise => { + const list = appPasswordsByUser.get(user) ?? []; + if (list.length >= E2E_MAX_APP_PASSWORDS) { + return Promise.reject( + new BusinessException('AUTH_009', { + context: { reason: 'e2e-app-password-limit' }, + }), + ); + } + appPasswordSequence += 1; + const record = { + id: String.fromCharCode(97 + (appPasswordSequence % 26)) + 'e2e', + name, + createdAt: new Date( + Date.UTC(2026, 9, 31, 0, 0, appPasswordSequence), + ).toISOString(), + }; + appPasswordsByUser.set(user, [...list, record]); + return Promise.resolve({ + ...record, + password: `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`, + }); + }, + deleteAppPassword: (user: string, id: string) => { + appPasswordsByUser.set( + user, + (appPasswordsByUser.get(user) ?? []).filter( + (record) => record.id !== id, + ), + ); + return Promise.resolve(); + }, + deleteAllAppPasswords: (user: string) => { + const count = (appPasswordsByUser.get(user) ?? []).length; + appPasswordsByUser.set(user, []); + return Promise.resolve(count); + }, + }; + /** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */ const liveWatches: { user: string; @@ -775,11 +912,33 @@ describe('응답 계약 (e2e)', () => { email, name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name, }), - verifyCredentials: async (email: string, password: string) => { + verifyCredentials: async ( + email: string, + password: string, + ): Promise => { engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1); // 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다 if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate; - return password === CORRECT_PASSWORD ? email : null; + if (password !== CORRECT_PASSWORD) return { kind: 'invalid' }; + // 12단계 — 2단계 인증을 켠 직원은 비밀번호가 맞아도 402 (엔진이 코드를 요구한다) + return twoFactorUrls.has(email) + ? { kind: 'two-factor-required' } + : { kind: 'valid', account: email }; + }, + // 12단계 — 엔진의 로그인 요청(비밀번호 + 코드). 엔진처럼 같은 코드를 다시 받는다(막기는 서버 몫) + verifyTwoFactorLogin: ( + email: string, + password: string, + otpCode: string, + ) => { + const url = twoFactorUrls.get(email); + return Promise.resolve( + password === CORRECT_PASSWORD && + url !== undefined && + verifyTotp(secretOfOtpauth(url), otpCode, Date.now()) !== null + ? ('authenticated' as const) + : ('code-rejected' as const), + ); }, // 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다. // 받는 사람별 결과는 ENGINE_REJECTIONS 로 흉내 낸다 — 모두 받지 않으면 어댑터처럼 MAIL_010(5xx 가 섞임) · MAIL_011(모두 4xx) @@ -964,7 +1123,21 @@ describe('응답 계약 (e2e)', () => { email: string, currentPassword: string, newPassword: string, + options: { readonly otpCode?: string } = {}, ): Promise => { + // 12단계 — 2단계 인증을 켠 직원은 코드가 없으면 바꾸지 않고(402), 코드가 틀리면 엔진이 거절한다 + const url = twoFactorUrls.get(email); + if (url !== undefined) { + if (options.otpCode === undefined) { + return Promise.resolve('two-factor-required'); + } + if ( + verifyTotp(secretOfOtpauth(url), options.otpCode, Date.now()) === + null + ) { + return Promise.resolve('code-rejected'); + } + } passwordChanges.push({ email, newPassword }); if (currentPassword !== CORRECT_PASSWORD) { return Promise.resolve('current-password-rejected'); @@ -1339,6 +1512,12 @@ describe('응답 계약 (e2e)', () => { .useValue(testFileStorageSettings(fileDir)) .overrideProvider(FILE_SHARING_SETTINGS) .useValue(TEST_FILE_SHARING_SETTINGS) + // 12단계 — 엔진의 2단계 인증 · 앱 비밀번호는 대역으로(어댑터는 jmap-account-security.provider.spec.ts), + // 웹훅 서명 키 · 앱 네트워크 대역은 env 와 무관하게 고정한다 + .overrideProvider(ACCOUNT_SECURITY_PROVIDER) + .useValue(fakeAccountSecurityProvider) + .overrideProvider(ACCOUNT_SECURITY_SETTINGS) + .useValue(TEST_ACCOUNT_SECURITY_SETTINGS) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) @@ -1381,6 +1560,10 @@ describe('응답 계약 (e2e)', () => { // 11단계 — 링크(파일 묶음은 함께 지워진다) · 파일(조각 기록도 함께) 'DELETE FROM share_links WHERE owner_email LIKE $1', 'DELETE FROM stored_files WHERE owner_email LIKE $1', + // 12단계 — 로그인 기록 · 앱 비밀번호 기록 · 2단계 인증을 켠 때 + 'DELETE FROM login_events WHERE user_email LIKE $1', + 'DELETE FROM app_password_usages WHERE user_email LIKE $1', + 'DELETE FROM two_factor_settings WHERE user_email LIKE $1', ]) { await dataSource.query(statement, [TEST_DOMAIN_PATTERN]); } @@ -6055,8 +6238,6 @@ describe('응답 계약 (e2e)', () => { for (const [target, host] of [ [`http://${E2E_SHARE_HOST}/api/public-links/${token}`, portalHost], [`HTTPS://${E2E_SHARE_HOST}:443/api/public-links/${token}`, portalHost], - [`/api\\public-links\\${token}#a`, portalHost], - [`/api\\public-links\\${token}`, portalHost], [`/api%2Fpublic-links/${token}`, portalHost], [`http://${E2E_SHARE_HOST}/api/public-links/${token}`, E2E_SHARE_HOST], [`/api/public-links/${token}/%2e%2e/%2e%2e/users`, E2E_SHARE_HOST], @@ -6068,6 +6249,19 @@ describe('응답 계약 (e2e)', () => { code: 'RES_001', }); } + // 날 `\` · `#` 이 든 대상은 그보다 앞에서(요청 대상 원문 검사 — 12단계 인프라 리뷰) 400 VAL_001 — 공개 호스트 막기까지 가지 않는다 + for (const [target, host] of [ + [`/api\\public-links\\${token}#a`, portalHost], + [`/api\\public-links\\${token}`, portalHost], + [`/api/public-links/${token}#a`, E2E_SHARE_HOST], + ]) { + expect({ target, host, ...(await rawGet(target, host)) }).toEqual({ + target, + host, + status: 400, + code: 'VAL_001', + }); + } expect( await rawGet(`/api/public-links/${token}`, E2E_SHARE_HOST), ).toEqual({ status: 200, code: undefined }); @@ -6584,6 +6778,701 @@ describe('응답 계약 (e2e)', () => { * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). */ + /** + * 12단계 — 계정 보안 (계약 5절). 엔진의 2단계 인증 · 앱 비밀번호는 대역(fakeAccountSecurityProvider)이고, 세션 · 로그인 기록은 실제 DB 다. + * 클라이언트 IP 는 X-Forwarded-For 로 시나리오마다 나눈다(198.18.x.x — 다른 시나리오와 겹치지 않게) + */ + describe('12단계 — 계정 보안 (2단계 인증 · 앱 비밀번호 · 로그인된 브라우저 · 로그인 기록 · 엔진 웹훅)', () => { + const CHROME = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0'; + const SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1'; + + const loginAs = ( + email: string, + from: string, + extra: object = {}, + userAgent = CHROME, + ) => + request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', from) + .set('User-Agent', userAgent) + .send({ email, password: CORRECT_PASSWORD, ...extra }); + + /** 로그인해 두 쿠키를 받는다 — 세션 id(sid)는 리프레시 토큰에서 읽는다 */ + const signIn = async (email: string, from: string, userAgent = CHROME) => { + const res = await loginAs(email, from, {}, userAgent).expect(200); + const access = cookieValue(setCookie(res, 'access_token')); + const refresh = cookieValue(setCookie(res, 'refresh_token')); + const { sid } = jwt.decode<{ sid: string }>(refresh); + return { + sid, + cookies: [`access_token=${access}`, `refresh_token=${refresh}`], + access: `access_token=${access}`, + }; + }; + + afterEach(() => { + e2eAppNetworkOpen = true; + }); + + describe('2단계 인증', () => { + const ME = 'otp.flow@e2e.arasystem.test'; + + it('등록 → 켜기 → 로그인 두 단계(쿠키는 코드 뒤에만) → 2분 안에 다시 쓴 코드는 AUTH_006 → 끄기', async () => { + const session = await signIn(ME, '198.18.1.1'); + const asMe = (req: HttpTest) => req.set('Cookie', session.cookies); + + const before = await asMe( + request(server).get('/api/two-factor'), + ).expect(200); + expect(body(before).data).toEqual({ + isEnabled: false, + enabledAt: null, + }); + + const enrolled = await asMe( + request(server).post('/api/two-factor/enrollments'), + ).expect(201); + expect(enrolled.headers['cache-control']).toBe('private, no-store'); + const enrollment = dataOf<{ + enrollmentId: string; + secret: string; + otpauthUrl: string; + expiresAt: string; + }>(enrolled); + expect(enrollment.secret).toMatch(/^[A-Z2-7]{32}$/); + expect(enrollment.otpauthUrl).toBe( + `otpauth://totp/ARA%20SYSTEM:otp.flow%40e2e.arasystem.test?secret=${enrollment.secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`, + ); + const secret = decodeBase32(enrollment.secret); + + // 틀린 코드 — 엔진에 넣지 않는다 + const wrongCode = otpCodeOf(secret) === '000000' ? '111111' : '000000'; + const wrong = await asMe( + request(server).post('/api/two-factor/activation'), + ) + .send({ + enrollmentId: enrollment.enrollmentId, + otpCode: wrongCode, + currentPassword: CORRECT_PASSWORD, + }) + .expect(400); + expect(body(wrong).error?.code).toBe('AUTH_006'); + expect(twoFactorUrls.has(ME)).toBe(false); + + const activationCode = otpCodeOf(secret); + const activated = await asMe( + request(server).post('/api/two-factor/activation'), + ) + .send({ + enrollmentId: enrollment.enrollmentId, + otpCode: activationCode, + currentPassword: CORRECT_PASSWORD, + }) + .expect(200); + expect(body(activated).data).toEqual({ + isEnabled: true, + enabledAt: expect.any(String) as unknown, + }); + expect(twoFactorUrls.get(ME)).toBe(enrollment.otpauthUrl); + + // 1) 비밀번호만 — twoFactorRequired, 쿠키 없음 + const first = await loginAs(ME, '198.18.1.2').expect(200); + expect(body(first).data).toEqual({ + status: 'twoFactorRequired', + email: null, + name: null, + }); + expect(first.headers['set-cookie']).toBeUndefined(); + + // 켤 때 쓴 코드는 2분 동안 다시 받지 않는다 (엔진은 받지만 서버가 막는다) + const reused = await loginAs(ME, '198.18.1.3', { + otpCode: activationCode, + }).expect(400); + expect(body(reused).error?.code).toBe('AUTH_006'); + + // 2) 비밀번호 + 새 코드 — signedIn + 쿠키 + const loginCode = otpCodeOf(secret, 1); + const second = await loginAs(ME, '198.18.1.4', { + otpCode: loginCode, + }).expect(200); + expect(body(second).data).toEqual({ + status: 'signedIn', + email: ME, + name: EMPLOYEE.name, + }); + expect(cookieValue(setCookie(second, 'access_token'))).not.toBe(''); + + const again = await loginAs(ME, '198.18.1.5', { + otpCode: loginCode, + }).expect(400); + expect(body(again).error?.code).toBe('AUTH_006'); + + const malformed = await loginAs(ME, '198.18.1.6', { + otpCode: '12-34', + }).expect(400); + expect(body(malformed).error?.code).toBe('VAL_001'); + + const status = await asMe( + request(server).get('/api/two-factor'), + ).expect(200); + expect(body(status).data).toMatchObject({ isEnabled: true }); + + const off = await asMe( + request(server).post('/api/two-factor/deactivation'), + ) + .send({ + currentPassword: CORRECT_PASSWORD, + otpCode: otpCodeOf(secret, -1), + }) + .expect(200); + expect(body(off).data).toEqual({ isEnabled: false, enabledAt: null }); + expect(twoFactorUrls.has(ME)).toBe(false); + + // 다시 비밀번호만으로 로그인한다 + const plain = await loginAs(ME, '198.18.1.7').expect(200); + expect(body(plain).data).toMatchObject({ status: 'signedIn' }); + }); + + it('otpCode 가 null 이면 400 VAL_001 — 칸을 빼고 보낸다(검사를 비켜 서비스에서 500 이 나던 길)', async () => { + const res = await loginAs('otp.null@e2e.arasystem.test', '198.18.1.9', { + otpCode: null, + }).expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + + const changed = await request(server) + .put('/api/auth/password') + .set('Cookie', cookieOf('otp.null@e2e.arasystem.test')) + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-1234', + otpCode: null, + }) + .expect(400); + expect(body(changed).error?.code).toBe('VAL_001'); + }); + + it('등록이 없거나 다른 등록 id 면 409 AUTH_008 · 지금 비밀번호가 틀리면 400 AUTH_005 · 앱 비밀번호도 AUTH_005', async () => { + const OTHER = 'otp.errors@e2e.arasystem.test'; + const session = await signIn(OTHER, '198.18.2.1'); + const enrolled = await request(server) + .post('/api/two-factor/enrollments') + .set('Cookie', session.cookies) + .expect(201); + const { enrollmentId, secret } = dataOf<{ + enrollmentId: string; + secret: string; + }>(enrolled); + const code = otpCodeOf(decodeBase32(secret)); + + const missing = await request(server) + .post('/api/two-factor/activation') + .set('Cookie', session.cookies) + .send({ + enrollmentId: '00000000-0000-4000-8000-000000000000', + otpCode: code, + currentPassword: CORRECT_PASSWORD, + }) + .expect(409); + expect(body(missing).error?.code).toBe('AUTH_008'); + + for (const currentPassword of [ + 'wrong-password', + `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`, + ]) { + const rejected = await request(server) + .post('/api/two-factor/activation') + .set('Cookie', session.cookies) + .send({ enrollmentId, otpCode: code, currentPassword }) + .expect(400); + expect(body(rejected).error?.code).toBe('AUTH_005'); + } + expect(twoFactorUrls.has(OTHER)).toBe(false); + }); + + it('2단계 인증을 켠 직원의 비밀번호 바꾸기 — 코드가 없으면 AUTH_007, 코드와 함께 바꾸면 앱 비밀번호가 모두 지워진다', async () => { + const OTP_USER = 'otp.password@e2e.arasystem.test'; + const session = await signIn(OTP_USER, '198.18.3.1'); + // 엔진 관리 화면에서 켠 것처럼 대역에 바로 둔다 + const secret = Buffer.alloc(20, 3); + twoFactorUrls.set( + OTP_USER, + `otpauth://totp/ARA%20SYSTEM:x?secret=${'AMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYD'}&issuer=ARA%20SYSTEM`, + ); + appPasswordsByUser.set(OTP_USER, [ + { id: 'b', name: '회사 PC', createdAt: '2026-10-30T00:00:00.000Z' }, + ]); + + const noCode = await request(server) + .put('/api/auth/password') + .set('Cookie', session.cookies) + .set('X-Forwarded-For', '198.18.3.2') + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-1234', + }) + .expect(400); + expect(body(noCode).error?.code).toBe('AUTH_007'); + + await request(server) + .put('/api/auth/password') + .set('Cookie', session.cookies) + .set('X-Forwarded-For', '198.18.3.3') + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-1234', + otpCode: otpCodeOf(secret), + }) + .expect(200); + expect(appPasswordsByUser.get(OTP_USER)).toEqual([]); + // 2단계 인증은 그대로 남는다 + expect(twoFactorUrls.has(OTP_USER)).toBe(true); + twoFactorUrls.delete(OTP_USER); + }); + }); + + describe('앱 비밀번호 (/api/app-passwords)', () => { + const ME = 'apppw.me@e2e.arasystem.test'; + + it('만들기(값은 이 응답에만 · no-store) → 목록(최근 것이 위) → 같은 이름 VAL_001 → 상한 AUTH_009 → 지우기(이미 없어도 200)', async () => { + const session = await signIn(ME, '198.18.4.1'); + const create = (name: string) => + request(server) + .post('/api/app-passwords') + .set('Cookie', session.cookies) + .send({ name }); + + const created = await create(' 회사 PC 아웃룩 ').expect(201); + expect(created.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(created)).toEqual({ + id: expect.any(String) as unknown, + name: '회사 PC 아웃룩', + createdAt: expect.any(String) as unknown, + lastUsed: null, + password: expect.stringMatching(/^app_.{36}$/) as unknown, + }); + const firstId = dataOf<{ id: string }>(created).id; + + const taken = await create('회사 PC 아웃룩').expect(400); + expect(body(taken).error?.code).toBe('VAL_001'); + const empty = await create(' ').expect(400); + expect(body(empty).error?.code).toBe('VAL_001'); + const long = await create('가'.repeat(41)).expect(400); + expect(body(long).error?.code).toBe('VAL_001'); + + for (const name of ['휴대폰', '태블릿', '집 PC', '노트북']) { + await create(name).expect(201); + } + const full = await create('여섯째').expect(409); + expect(body(full).error?.code).toBe('AUTH_009'); + + const listed = await request(server) + .get('/api/app-passwords') + .set('Cookie', session.cookies) + .expect(200); + const page = dataOf<{ + items: { id: string; name: string; password?: string }[]; + pageInfo: unknown; + }>(listed); + expect(page.items.map(({ name }) => name)).toEqual([ + '노트북', + '집 PC', + '태블릿', + '휴대폰', + '회사 PC 아웃룩', + ]); + expect(page.items.every((item) => item.password === undefined)).toBe( + true, + ); + expect(page.pageInfo).toEqual({ + hasNext: false, + nextCursor: null, + total: 5, + }); + + await request(server) + .delete(`/api/app-passwords/${firstId}`) + .set('Cookie', session.cookies) + .expect(200); + await request(server) + .delete(`/api/app-passwords/${firstId}`) + .set('Cookie', session.cookies) + .expect(200); + expect(appPasswordsByUser.get(ME)).toHaveLength(4); + }); + + it('앱 비밀번호로는 웹메일에 로그인할 수 없다 — 엔진에 묻지 않고 401 AUTH_004', async () => { + const checksBefore = engineChecks.get(ME) ?? 0; + const res = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.4.9') + .send({ + email: ME, + password: `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`, + }) + .expect(401); + expect(body(res).error?.code).toBe('AUTH_004'); + expect(engineChecks.get(ME) ?? 0).toBe(checksBefore); + }); + }); + + describe('로그인된 브라우저 (/api/auth/sessions)', () => { + const ME = 'sessions.me@e2e.arasystem.test'; + + it('지금 세션이 맨 앞 · 하나 끊으면 그 액세스 토큰이 바로 401 · 지금 세션은 VAL_001 · 없는 것은 RES_001 · 다른 곳 모두 끊기', async () => { + const current = await signIn(ME, '198.18.5.1', CHROME); + const phone = await signIn(ME, '198.18.5.2', SAFARI); + const third = await signIn(ME, '198.18.5.3', CHROME); + + const listed = await request(server) + .get('/api/auth/sessions') + .set('Cookie', current.cookies) + .expect(200); + const items = dataOf<{ + items: { + id: string; + isCurrent: boolean; + ip: string | null; + device: { browser: string | null; os: string | null } | null; + }[]; + pageInfo: { total: number }; + }>(listed); + expect(items.items[0]).toMatchObject({ + id: current.sid, + isCurrent: true, + ip: '198.18.5.1', + device: { browser: 'Edge', os: 'Windows' }, + }); + expect(items.items.find(({ id }) => id === phone.sid)).toMatchObject({ + isCurrent: false, + device: { browser: 'Safari', os: 'iOS' }, + }); + expect(items.pageInfo.total).toBe(3); + + // 끊기 전에는 그 브라우저의 액세스 토큰이 된다 + await request(server) + .get('/api/auth/me') + .set('Cookie', phone.access) + .expect(200); + await request(server) + .delete(`/api/auth/sessions/${phone.sid}`) + .set('Cookie', current.cookies) + .expect(200); + // 끊은 세션의 액세스 토큰은 만료 전이어도 바로 401 + const revoked = await request(server) + .get('/api/auth/me') + .set('Cookie', phone.access) + .expect(401); + expect(body(revoked).error?.code).toBe('AUTH_001'); + + const self = await request(server) + .delete(`/api/auth/sessions/${current.sid}`) + .set('Cookie', current.cookies) + .expect(400); + expect(body(self).error?.code).toBe('VAL_001'); + const unknown = await request(server) + .delete(`/api/auth/sessions/${randomUUID()}`) + .set('Cookie', current.cookies) + .expect(404); + expect(body(unknown).error?.code).toBe('RES_001'); + await request(server) + .delete(`/api/auth/sessions/${phone.sid}`) + .set('Cookie', current.cookies) + .expect(404); + + const ended = await request(server) + .delete('/api/auth/sessions') + .set('Cookie', current.cookies) + .expect(200); + expect(body(ended).data).toEqual({ ended: 1 }); + await request(server) + .get('/api/auth/me') + .set('Cookie', third.access) + .expect(401); + // 이 세션은 그대로 쓴다 + await request(server) + .get('/api/auth/me') + .set('Cookie', current.access) + .expect(200); + }); + + it('로그아웃한 세션의 액세스 토큰도 바로 401 (12단계 — 만료까지 기다리지 않는다)', async () => { + const session = await signIn(ME, '198.18.5.9'); + await request(server) + .post('/api/auth/logout') + .set('Cookie', session.cookies) + .expect(200); + await request(server) + .get('/api/auth/me') + .set('Cookie', session.access) + .expect(401); + }); + }); + + describe('로그인 기록 (/api/login-events)', () => { + const ME = 'history.me@e2e.arasystem.test'; + /** + * 비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 사내 목록은 10분 기억하고 다른 시나리오(8단계)가 채우므로, + * 이 시나리오는 사내 목록의 답을 직접 정한다(테스트 차례와 상관없게) + */ + let directorySpy: jest.SpyInstance | null = null; + const directoryHas = (known: boolean) => { + directorySpy = jest + .spyOn(app.get(DirectoryService, { strict: false }), 'hasEmail') + .mockResolvedValue(known); + }; + + afterEach(() => { + directorySpy?.mockRestore(); + directorySpy = null; + }); + + it('사내 목록에 없는 주소의 비밀번호 틀림은 남기지 않는다 (아무 주소나 뿌려 표를 채우지 못하게)', async () => { + const STRANGER = 'history.stranger@e2e.arasystem.test'; + directoryHas(false); + await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.6.9') + .send({ email: STRANGER, password: 'wrong-password' }) + .expect(401); + const rows: unknown[] = await dataSource.query( + 'SELECT id FROM login_events WHERE user_email = $1', + [STRANGER], + ); + expect(rows).toEqual([]); + }); + + it('웹메일 성공 · 실패가 최근 차례로 · isSuccess=false 는 실패만 · 커서로 이어 읽는다 · 다른 거르기의 커서는 VAL_001', async () => { + directoryHas(true); + await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.6.1') + .send({ email: ME, password: 'wrong-password' }) + .expect(401); + const session = await signIn(ME, '198.18.6.2'); + + const all = await request(server) + .get('/api/login-events?limit=1') + .set('Cookie', session.cookies) + .expect(200); + const firstPage = dataOf<{ + items: Record[]; + pageInfo: { + hasNext: boolean; + nextCursor: string | null; + total: number; + }; + }>(all); + expect(firstPage.pageInfo).toMatchObject({ hasNext: true, total: 2 }); + expect(firstPage.items[0]).toMatchObject({ + method: 'webmail', + isSuccess: true, + isTwoFactor: false, + failureReason: null, + ip: '198.18.6.2', + device: { browser: 'Edge', os: 'Windows' }, + appPassword: null, + }); + + const next = await request(server) + .get( + `/api/login-events?limit=1&cursor=${firstPage.pageInfo.nextCursor ?? ''}`, + ) + .set('Cookie', session.cookies) + .expect(200); + expect(dataOf<{ items: unknown[] }>(next).items).toEqual([ + expect.objectContaining({ + isSuccess: false, + failureReason: 'wrongPassword', + ip: '198.18.6.1', + }), + ]); + + const failures = await request(server) + .get('/api/login-events?isSuccess=false') + .set('Cookie', session.cookies) + .expect(200); + expect( + dataOf<{ items: { isSuccess: boolean }[] }>(failures).items.every( + ({ isSuccess }) => !isSuccess, + ), + ).toBe(true); + + const otherScope = await request(server) + .get( + `/api/login-events?isSuccess=false&cursor=${firstPage.pageInfo.nextCursor ?? ''}`, + ) + .set('Cookie', session.cookies) + .expect(400); + expect(body(otherScope).error?.code).toBe('VAL_001'); + const badFilter = await request(server) + .get('/api/login-events?isSuccess=maybe') + .set('Cookie', session.cookies) + .expect(400); + expect(body(badFilter).error?.code).toBe('VAL_001'); + }); + }); + + describe('요청 대상 원문 검사 (인프라 리뷰 — nginx 가 닫은 경로가 parseurl 의 옛 url.parse 로 열리던 길)', () => { + /** 원문 바이트 그대로 보낸다 — supertest 는 백슬래시를 `/` 로 고쳐 보낸다 */ + const sendRaw = ( + httpServer: Server, + method: string, + target: string, + ): Promise<{ status: number; text: string }> => { + const address = httpServer.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return new Promise((resolve, reject) => { + const socket = connect(port, '127.0.0.1'); + const chunks: Buffer[] = []; + socket.on('data', (chunk: Buffer) => chunks.push(chunk)); + socket.on('error', reject); + socket.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + resolve({ status: Number(text.split(' ')[1]), text }); + }); + socket.write( + `${method} ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}`, + 'latin1', + ); + }); + }; + + it('날 `\\` · `#` 이 든 대상은 라우팅 전에 표준 실패 형식의 400 VAL_001 — 웹훅 가드 · 헬스체크에 닿지 않는다, 정상 경로는 그대로', async () => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + try { + for (const [method, target] of [ + ['POST', '/api/internal\\mail-engine-events#x'], + ['GET', '/api/health\\#a'], + ['GET', '/api/auth/me#x'], + ] as const) { + const res = await sendRaw(httpServer, method, target); + expect(res.status).toBe(400); + expect(res.text).toContain('"code":"VAL_001"'); + expect(res.text).toMatch(/"traceId":"[^"]+"/); + } + // 정상 경로 · 퍼센트 인코딩한 한글은 라우터까지 간다 (로그인하지 않았으니 401) + const normal = await sendRaw( + httpServer, + 'GET', + '/api/messages?q=%ED%95%9C%EA%B8%80', + ); + expect(normal.status).toBe(401); + expect(normal.text).toContain('"code":"AUTH_001"'); + } finally { + if (startedServer) await closeServer(httpServer); + } + }); + }); + + describe('엔진 웹훅 (POST /api/internal/mail-engine-events)', () => { + const ME = 'webhook.me@e2e.arasystem.test'; + const signature = (raw: string, secret = E2E_WEBHOOK_SECRET) => + createHmac('sha256', Buffer.from(secret, 'utf8')) + .update(raw) + .digest('base64'); + const deliver = (raw: string, signed = signature(raw)) => + request(server) + .post('/api/internal/mail-engine-events') + .set('Content-Type', 'application/json') + .set('X-Signature', signed) + // setup 이 붙이는 표시 머리글 — 서버는 쓰지 않는다 + .set('X-Arasystem-Setup', '[setup.mjs] key=e2e') + .send(raw); + + it('서명한 빈 묶음은 200 (setup 이 받는 쪽을 확인하는 방법)', async () => { + const res = await deliver('{"events":[]}').expect(200); + expect(body(res)).toEqual({ success: true, data: null }); + }); + + it('서명한 IMAP 로그인 이벤트는 로그인 기록에 남는다 — 엔진이 다시 보낸 묶음(이벤트 id 만 다르다)은 겹치지 않는다', async () => { + const event = (id: string) => ({ + id, + createdAt: '2026-10-31T00:00:05Z', + type: 'auth.success', + data: { + listenerId: 'imaps', + localPort: 993, + remoteIp: '211.186.95.252', + remotePort: 51234, + accountName: ME, + accountId: 9, + spanId: 777, + }, + }); + await deliver(JSON.stringify({ events: [event('first')] })).expect(200); + await deliver(JSON.stringify({ events: [event('retried')] })).expect( + 200, + ); + + const rows: { method: string; ip: string; is_success: boolean }[] = + await dataSource.query( + 'SELECT method, ip, is_success FROM login_events WHERE user_email = $1', + [ME], + ); + expect(rows).toEqual([ + { method: 'imap', ip: '211.186.95.252', is_success: true }, + ]); + }); + + // 6,000행을 넣는다 — 개발 DB 의 쿼리 로그가 켜져 있으면 혼자 3초 남짓이라 기본 한도(5초)보다 넉넉히 둔다 + it('큰 묶음(6,000건 — 한 문장에 넣으면 매개변수 상한 65,535 를 넘는 크기)도 200 — 나눠 넣어 모두 남는다', async () => { + const BIG = 'webhook.big@e2e.arasystem.test'; + const events = Array.from({ length: 6_000 }, (_, index) => ({ + id: `big-${index}`, + createdAt: '2026-10-31T00:10:00Z', + type: 'auth.success', + data: { + listenerId: 'imaps', + localPort: 993, + remoteIp: '211.186.95.252', + remotePort: 10_000 + index, + accountName: BIG, + accountId: 9, + spanId: 50_000 + index, + }, + })); + + await deliver(JSON.stringify({ events })).expect(200); + + const [{ count }]: { count: number }[] = await dataSource.query( + 'SELECT count(*)::int AS count FROM login_events WHERE user_email = $1', + [BIG], + ); + expect(count).toBe(6_000); + }, 60_000); + + it('서명이 없거나 틀리면 · 앱 네트워크 밖의 접속이면 · JSON 이 아니면 401 AUTH_001 — 아무것도 남기지 않는다', async () => { + const raw = JSON.stringify({ events: [] }); + const unsigned = await request(server) + .post('/api/internal/mail-engine-events') + .set('Content-Type', 'application/json') + .send(raw) + .expect(401); + expect(body(unsigned).error?.code).toBe('AUTH_001'); + await deliver(raw, signature(raw, `${E2E_WEBHOOK_SECRET}x`)).expect( + 401, + ); + await deliver(raw, signature('{"events":[1]}')).expect(401); + + const plain = await request(server) + .post('/api/internal/mail-engine-events') + .set('Content-Type', 'text/plain') + .set('X-Signature', signature(raw)) + .send(raw) + .expect(401); + expect(body(plain).error?.code).toBe('AUTH_001'); + + e2eAppNetworkOpen = false; + await deliver(raw).expect(401); + }); + }); + }); + describe('출처 검사 (상태 변경 요청)', () => { const EVIL_ORIGIN = 'https://evil.example'; diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 1b71207..4927838 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -8,6 +8,10 @@ const Map kErrorCodeMessages = { 'AUTH_003': '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', 'AUTH_004': '이메일 또는 비밀번호가 올바르지 않습니다.', 'AUTH_005': '지금 비밀번호가 올바르지 않습니다.', + 'AUTH_006': '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.', + 'AUTH_007': '인증 앱의 코드를 입력해 주세요.', + 'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', + 'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', 'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 6b453f2..06352d8 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -8,6 +8,10 @@ export const ERROR_CODE_MESSAGES = { AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', AUTH_004: '이메일 또는 비밀번호가 올바르지 않습니다.', AUTH_005: '지금 비밀번호가 올바르지 않습니다.', + AUTH_006: '코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.', + AUTH_007: '인증 앱의 코드를 입력해 주세요.', + AUTH_008: '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', + AUTH_009: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.', BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', -- 2.52.0 From c31442e639a7acbe7a29d062a690a5f85362afc3 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 17:25:53 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(frontend):=2012=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=B3=B4=EC=95=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=C2=B7=202=EB=8B=A8=EA=B3=84=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=C2=B7=20=EC=95=B1=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=C2=B7=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인은 비밀번호가 맞고 2단계를 켠 직원만 코드 단계로 간다. 설정 › 계정에 2단계 인증 · 앱 비밀번호 · 로그인 기록을 더하고, 비밀번호 화면에 2단계를 켠 직원의 코드 칸을 둔다. 비밀(코드 단계의 비밀번호 · 2단계 키 · 앱 비밀번호 값)은 화면 메모리에만 두고, 세션이 바뀌면 그 창을 모두 닫는다 — 12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다. 비밀이 드는 요청은 Pinia 액션 밖에서 보낸다(devtools 기록). QR 은 uqr 0.1.3(MIT · 의존성 없음 — 2단계 인증 화면 청크에만)으로 SVG 를 그린다(v-html 없음). Co-Authored-By: Claude Opus 5.5 --- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/components/LinkedText.vue | 31 + frontend/src/components/QrCodeImage.vue | 68 ++ .../src/components/auth/LoginCodeStep.vue | 344 ++++++++++ .../settings/AppPasswordCreateDialog.vue | 184 ++++++ .../settings/AppPasswordDeleteDialog.vue | 66 ++ .../settings/AppPasswordResultDialog.vue | 200 ++++++ .../components/settings/AppPasswordTable.vue | 208 ++++++ .../settings/EndOtherSessionsDialog.vue | 77 +++ .../components/settings/LoginEventTable.vue | 435 +++++++++++++ .../components/settings/LoginSessionList.vue | 439 +++++++++++++ .../settings/MailClientSettings.vue | 117 ++++ .../src/components/settings/OtpCodeField.vue | 137 ++++ .../settings/PasswordChangeForm.vue | 74 ++- .../src/components/settings/PasswordField.vue | 10 +- .../components/settings/SettingsPageFrame.vue | 5 +- .../settings/TwoFactorDisableDialog.vue | 229 +++++++ .../settings/TwoFactorEnableDialog.vue | 403 ++++++++++++ .../settings/TwoFactorEnrollmentScan.vue | 300 +++++++++ .../src/composables/useAccountSecurity.ts | 129 ++++ frontend/src/composables/useApi.spec.ts | 5 + frontend/src/composables/useAuth.spec.ts | 36 +- frontend/src/composables/useAuth.ts | 25 +- frontend/src/composables/useLoginChallenge.ts | 89 +++ frontend/src/pages/LoginPage.spec.ts | 303 ++++++++- frontend/src/pages/LoginPage.vue | 298 ++++++--- .../settings/AccountAppPasswordsPage.spec.ts | 427 ++++++++++++ .../settings/AccountAppPasswordsPage.vue | 430 +++++++++++++ .../settings/AccountLoginHistoryPage.spec.ts | 506 +++++++++++++++ .../settings/AccountLoginHistoryPage.vue | 94 +++ .../settings/AccountPasswordPage.spec.ts | 116 +++- .../pages/settings/AccountPasswordPage.vue | 23 +- .../settings/AccountTwoFactorPage.spec.ts | 608 ++++++++++++++++++ .../pages/settings/AccountTwoFactorPage.vue | 459 +++++++++++++ .../src/pages/settings/SettingsLayout.spec.ts | 46 +- frontend/src/router/routes.ts | 23 +- .../src/shared/constants/account-security.ts | 294 +++++++++ frontend/src/shared/constants/account.ts | 8 +- frontend/src/shared/constants/icons.ts | 16 + .../src/shared/constants/settings-menu.ts | 16 +- frontend/src/shared/types/account-security.ts | 109 ++++ frontend/src/shared/types/auth.ts | 23 +- .../src/shared/utils/account-security.spec.ts | 219 +++++++ frontend/src/shared/utils/account-security.ts | 172 +++++ frontend/src/shared/utils/format.ts | 7 + frontend/src/shared/utils/qr-code.spec.ts | 80 +++ frontend/src/shared/utils/qr-code.ts | 40 ++ .../src/stores/app-passwords.store.spec.ts | 79 +++ frontend/src/stores/app-passwords.store.ts | 97 +++ .../src/stores/login-history.store.spec.ts | 84 +++ frontend/src/stores/login-history.store.ts | 213 ++++++ frontend/src/stores/two-factor.store.spec.ts | 73 +++ frontend/src/stores/two-factor.store.ts | 89 +++ frontend/src/test-utils/router.ts | 19 + 55 files changed, 8475 insertions(+), 115 deletions(-) create mode 100644 frontend/src/components/LinkedText.vue create mode 100644 frontend/src/components/QrCodeImage.vue create mode 100644 frontend/src/components/auth/LoginCodeStep.vue create mode 100644 frontend/src/components/settings/AppPasswordCreateDialog.vue create mode 100644 frontend/src/components/settings/AppPasswordDeleteDialog.vue create mode 100644 frontend/src/components/settings/AppPasswordResultDialog.vue create mode 100644 frontend/src/components/settings/AppPasswordTable.vue create mode 100644 frontend/src/components/settings/EndOtherSessionsDialog.vue create mode 100644 frontend/src/components/settings/LoginEventTable.vue create mode 100644 frontend/src/components/settings/LoginSessionList.vue create mode 100644 frontend/src/components/settings/MailClientSettings.vue create mode 100644 frontend/src/components/settings/OtpCodeField.vue create mode 100644 frontend/src/components/settings/TwoFactorDisableDialog.vue create mode 100644 frontend/src/components/settings/TwoFactorEnableDialog.vue create mode 100644 frontend/src/components/settings/TwoFactorEnrollmentScan.vue create mode 100644 frontend/src/composables/useAccountSecurity.ts create mode 100644 frontend/src/composables/useLoginChallenge.ts create mode 100644 frontend/src/pages/settings/AccountAppPasswordsPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountAppPasswordsPage.vue create mode 100644 frontend/src/pages/settings/AccountLoginHistoryPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountLoginHistoryPage.vue create mode 100644 frontend/src/pages/settings/AccountTwoFactorPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountTwoFactorPage.vue create mode 100644 frontend/src/shared/constants/account-security.ts create mode 100644 frontend/src/shared/types/account-security.ts create mode 100644 frontend/src/shared/utils/account-security.spec.ts create mode 100644 frontend/src/shared/utils/account-security.ts create mode 100644 frontend/src/shared/utils/qr-code.spec.ts create mode 100644 frontend/src/shared/utils/qr-code.ts create mode 100644 frontend/src/stores/app-passwords.store.spec.ts create mode 100644 frontend/src/stores/app-passwords.store.ts create mode 100644 frontend/src/stores/login-history.store.spec.ts create mode 100644 frontend/src/stores/login-history.store.ts create mode 100644 frontend/src/stores/two-factor.store.spec.ts create mode 100644 frontend/src/stores/two-factor.store.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a4a8c6..51a3ef7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "dompurify": "^3.4.17", "pinia": "^3.0.4", "pretendard": "^1.3.9", + "uqr": "0.1.3", "vue": "^3.5.43", "vue-router": "^4.4.5" }, @@ -6811,6 +6812,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fab549e..51aa287 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "dompurify": "^3.4.17", "pinia": "^3.0.4", "pretendard": "^1.3.9", + "uqr": "0.1.3", "vue": "^3.5.43", "vue-router": "^4.4.5" }, diff --git a/frontend/src/components/LinkedText.vue b/frontend/src/components/LinkedText.vue new file mode 100644 index 0000000..5c95e4c --- /dev/null +++ b/frontend/src/components/LinkedText.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/QrCodeImage.vue b/frontend/src/components/QrCodeImage.vue new file mode 100644 index 0000000..314bacb --- /dev/null +++ b/frontend/src/components/QrCodeImage.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/components/auth/LoginCodeStep.vue b/frontend/src/components/auth/LoginCodeStep.vue new file mode 100644 index 0000000..2698faf --- /dev/null +++ b/frontend/src/components/auth/LoginCodeStep.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/frontend/src/components/settings/AppPasswordCreateDialog.vue b/frontend/src/components/settings/AppPasswordCreateDialog.vue new file mode 100644 index 0000000..b8553e8 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordCreateDialog.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend/src/components/settings/AppPasswordDeleteDialog.vue b/frontend/src/components/settings/AppPasswordDeleteDialog.vue new file mode 100644 index 0000000..afbabf1 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordDeleteDialog.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/components/settings/AppPasswordResultDialog.vue b/frontend/src/components/settings/AppPasswordResultDialog.vue new file mode 100644 index 0000000..a4f7a14 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordResultDialog.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/src/components/settings/AppPasswordTable.vue b/frontend/src/components/settings/AppPasswordTable.vue new file mode 100644 index 0000000..ee85d70 --- /dev/null +++ b/frontend/src/components/settings/AppPasswordTable.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/frontend/src/components/settings/EndOtherSessionsDialog.vue b/frontend/src/components/settings/EndOtherSessionsDialog.vue new file mode 100644 index 0000000..46f457f --- /dev/null +++ b/frontend/src/components/settings/EndOtherSessionsDialog.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/components/settings/LoginEventTable.vue b/frontend/src/components/settings/LoginEventTable.vue new file mode 100644 index 0000000..70a4a4b --- /dev/null +++ b/frontend/src/components/settings/LoginEventTable.vue @@ -0,0 +1,435 @@ + + + + + diff --git a/frontend/src/components/settings/LoginSessionList.vue b/frontend/src/components/settings/LoginSessionList.vue new file mode 100644 index 0000000..d9bf48a --- /dev/null +++ b/frontend/src/components/settings/LoginSessionList.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/frontend/src/components/settings/MailClientSettings.vue b/frontend/src/components/settings/MailClientSettings.vue new file mode 100644 index 0000000..8f0ba55 --- /dev/null +++ b/frontend/src/components/settings/MailClientSettings.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/settings/OtpCodeField.vue b/frontend/src/components/settings/OtpCodeField.vue new file mode 100644 index 0000000..89d8626 --- /dev/null +++ b/frontend/src/components/settings/OtpCodeField.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend/src/components/settings/PasswordChangeForm.vue b/frontend/src/components/settings/PasswordChangeForm.vue index 8c49795..b3e0ac3 100644 --- a/frontend/src/components/settings/PasswordChangeForm.vue +++ b/frontend/src/components/settings/PasswordChangeForm.vue @@ -7,46 +7,62 @@ // 사전 문구로 (알림은 끈다). 그 밖(요청 과다 · 메일 서버 장애)은 인터셉터의 알림. 기다리는 사이 화면을 떠났으면 끈 거절도 알림으로 (notifyApiError). // 요청 한도는 30초다 (useAuth — 서버가 엔진을 두 번 부르고 다른 세션을 끊는다) // - 바꾸면 "비밀번호를 바꿨습니다. 다른 기기 · 브라우저의 로그인은 끊었습니다." — 세 칸을 비우고 보기를 끈다. 무엇이든 다시 치면 내린다 -// 고정 id — #password-current · #password-next · #password-confirm · #password-submit · #password-status -import { computed, onBeforeUnmount, ref } from 'vue' +// - 2단계 인증을 켠 직원(12단계 — 계약 5절 "비밀번호 바꾸기")은 맨 아래 「인증 앱 코드」 칸(코드가 30초마다 바뀌어 다른 칸을 다 쓴 뒤 넣는다). +// 6자리가 아니면 보내지 않는다(사전 문구 AUTH_007). 코드 틀림(AUTH_006)은 그 칸 아래 · 칸을 비운다. +// 다른 탭에서 켜서 코드 없이 보냈으면(AUTH_007) 2단계 상태를 다시 받고 칸을 보인다 — 못 받아도 칸은 보인다 +// 고정 id — #password-current · #password-next · #password-confirm · #password-code · #password-submit · #password-status +import { computed, nextTick, onBeforeUnmount, ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' +import OtpCodeField from '@/components/settings/OtpCodeField.vue' import PasswordField from '@/components/settings/PasswordField.vue' import { getErrorMessage, notifyApiError, toApiError } from '@/composables/useApi' import { useAuth } from '@/composables/useAuth' import { PASSWORD_TEXT } from '@/shared/constants/account' import type { ErrorCode } from '@/shared/constants/error-codes.generated' +import { isCompleteOtp } from '@/shared/utils/account-security' import { hasPasswordChangeProblem, passwordChangeProblems, type PasswordChangeProblems, } from '@/shared/utils/password-change' import { useAuthStore } from '@/stores/auth.store' +import { useTwoFactorStore } from '@/stores/two-factor.store' /** 칸 아래에 보이는 서버 거절 — 알림은 끈다 */ -const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001'] +const INLINE_CODES: readonly ErrorCode[] = ['AUTH_005', 'VAL_001', 'AUTH_006', 'AUTH_007'] const NO_PROBLEMS: PasswordChangeProblems = { current: null, next: null, confirm: null } const { changePassword } = useAuth() const authStore = useAuthStore() +const twoFactorStore = useTwoFactorStore() const currentRef = ref | null>(null) const nextRef = ref | null>(null) const confirmRef = ref | null>(null) +const codeRef = ref | null>(null) const current = ref('') const next = ref('') const confirm = ref('') +const code = ref('') const showCurrent = ref(false) const showNext = ref(false) const problems = ref(NO_PROBLEMS) /** 서버가 거절한 까닭 — 그 칸 아래에 (사전 문구) */ const serverError = ref<{ field: 'current' | 'next'; message: string } | null>(null) +/** 코드 칸 아래 — 6자리가 아님(AUTH_007 문구) · 서버의 AUTH_006 · AUTH_007 */ +const codeError = ref(null) +/** 서버가 코드를 요구했다(AUTH_007) — 상태를 다시 받지 못해도 칸을 보인다 */ +const codeRequired = ref(false) const submitting = ref(false) const done = ref(false) /** 화면이 사라졌다 — 칸 아래 글을 볼 곳이 없다 */ let unmounted = false +/** 「인증 앱 코드」 칸을 보인다 — 2단계 인증을 켠 직원 */ +const showCode = computed(() => twoFactorStore.isEnabled || codeRequired.value) + const currentError = computed(() => { if (problems.value.current === 'missing') return getErrorMessage('VAL_001') return serverError.value?.field === 'current' ? serverError.value.message : null @@ -78,6 +94,12 @@ function setConfirm(value: string) { done.value = false } +function setCode(value: string) { + code.value = value + codeError.value = null + done.value = false +} + function focusFirstProblem(found: PasswordChangeProblems) { if (found.current) currentRef.value?.focus() else if (found.next) nextRef.value?.focus() @@ -88,23 +110,52 @@ function clearFields() { current.value = '' next.value = '' confirm.value = '' + code.value = '' showCurrent.value = false showNext.value = false } +/** 코드 칸 아래에 알리고 칸을 비운 뒤 초점 — 다른 탭에서 켜 칸이 막 생겼으면 그린 뒤에 */ +async function showCodeError(message: string) { + codeError.value = message + code.value = '' + await nextTick() + codeRef.value?.focus() +} + +/** 다른 탭에서 2단계 인증을 켰다(AUTH_007) — 상태를 다시 받고 칸을 보인다 */ +async function requireCode(message: string) { + codeRequired.value = true + await showCodeError(message) + void twoFactorStore.refresh() +} + async function submit() { if (submitting.value) return done.value = false serverError.value = null const found = passwordChangeProblems({ current: current.value, next: next.value, confirm: confirm.value }) problems.value = found + const codeMissing = showCode.value && !isCompleteOtp(code.value) + codeError.value = codeMissing ? getErrorMessage('AUTH_007') : null if (hasPasswordChangeProblem(found)) { focusFirstProblem(found) return } + if (codeMissing) { + codeRef.value?.focus() + return + } submitting.value = true try { - await changePassword({ currentPassword: current.value, newPassword: next.value }, { silent: INLINE_CODES }) + await changePassword( + { + currentPassword: current.value, + newPassword: next.value, + ...(showCode.value ? { otpCode: code.value } : {}), + }, + { silent: INLINE_CODES }, + ) clearFields() problems.value = NO_PROBLEMS done.value = true @@ -120,6 +171,10 @@ async function submit() { } else if (apiError.code === 'VAL_001') { serverError.value = { field: 'next', message: apiError.message } nextRef.value?.focus() + } else if (apiError.code === 'AUTH_006') { + await showCodeError(apiError.message) + } else if (apiError.code === 'AUTH_007') { + await requireCode(apiError.message) } } finally { submitting.value = false @@ -182,6 +237,17 @@ onBeforeUnmount(() => { :error="confirmError" @update:model-value="setConfirm" /> +