From f0e7890bf1f22de2ebd156338f2599fe298354b0 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 22:47:30 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs:=2013=EB=8B=A8=EA=B3=84=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=A4=80=EB=B9=84=20=E2=80=94=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=206=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단계 인증 필수 · 메일 프로그램 연결 안내 · 받은편지함 알림 메일 · 휴지통 30일 안내와 서버 안 운영 도구를 정했다. 엔진 실험(Stalwart 0.16.27): 계정 끄기 칸이 없어 로그인 권한을 빼서 잠그고, 비밀번호가 만료되면 웹메일(대리 접근)도 메일 메서드가 막혀 서버가 새 비밀번호 단계로 보낸다. 사용자 결정(2026-10-31): 새 비밀번호 단계는 다음 코드를 기다려 넣고, 필수 직원의 휴대폰 바꾸기는 관리자 해제, 알림 메일에는 링크를 넣지 않고, 새 Outlook 은 권하지 않는다. Co-Authored-By: Claude Opus 5.5 --- .../webmail/LoginPasswordChange.dc.html | 439 +++++++++ .../webmail/LoginTwoFactorSetup.dc.html | 723 ++++++++++++++ .../design/webmail/MailNoticesCutover.dc.html | 881 ++++++++++++++++++ docs/design/webmail/README.md | 224 +++++ .../webmail/SettingsMailClients.dc.html | 725 ++++++++++++++ docs/design/webmail/canvas.json | 11 +- docs/mail/api-contract.md | 163 +++- 7 files changed, 3160 insertions(+), 6 deletions(-) create mode 100644 docs/design/webmail/LoginPasswordChange.dc.html create mode 100644 docs/design/webmail/LoginTwoFactorSetup.dc.html create mode 100644 docs/design/webmail/MailNoticesCutover.dc.html create mode 100644 docs/design/webmail/SettingsMailClients.dc.html diff --git a/docs/design/webmail/LoginPasswordChange.dc.html b/docs/design/webmail/LoginPasswordChange.dc.html new file mode 100644 index 0000000..9bf5bb8 --- /dev/null +++ b/docs/design/webmail/LoginPasswordChange.dc.html @@ -0,0 +1,439 @@ + + + + +로그인 · 새 비밀번호 정하기 + + + + + + + +
+
+ +
+ ARA SYSTEM +
+

사내 업무를
한 계정으로

+

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

+
+
+ +
+
+
+

새 비밀번호를 정해 주세요

+

관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.

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

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

+
+
+ +
+ +
+
+ +
+ +

메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다.

+
+
+
+ +
+ +
+

로그인 · 새 비밀번호 정하기

+

관리자가 임시 비밀번호를 준 직원(입사 · 재설정)은 비밀번호가 맞으면 위 화면으로 옵니다. 화면 틀 · 칸 · 단추는 로그인 화면과 같고, 칸 규칙은 설정 › 비밀번호와 같습니다. 오류 문구는 에러 코드 사전에서 가져옵니다. 2026-10-31 확정.

+
+ +
+ +
+
+

규칙에 맞지 않음

+

안내 글이 그대로 빨간 오류가 된다 — 알림 없음

+
+
+
+
+

새 비밀번호를 정해 주세요

+

관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.

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

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

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

8자보다 짧거나 임시 비밀번호와 같으면 보내지 않고 이렇게 알립니다. 메일 서버가 규칙에 어긋난다고 거절하면 같은 자리에 사전 문구(VAL_001)입니다.

+
+ +
+
+

확인 칸이 다름

+

확인 칸 아래 빨간 글 · 초점은 확인 칸

+
+
+
+
+

새 비밀번호를 정해 주세요

+

관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.

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

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

+
+
+ +
+ +
+

+ + 새 비밀번호가 서로 다릅니다. +

+
+ +
+
+
+

확인 칸은 새 비밀번호의 [보기]를 따라 함께 보이거나 가려집니다(설정 › 비밀번호와 같다).

+
+ +
+
+

바꾸는 중

+

단추 바쁨 · 칸은 읽기만

+
+
+
+
+

새 비밀번호를 정해 주세요

+

관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.

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

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

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

로그인 단추와 같은 모양입니다(「바꾸는 중…」 · aria-busy). 메일 서버에서 바꾸고 다른 곳의 로그인을 끊느라 길게는 30초 걸립니다.

+
+
+ +
+ +
+
+

시도가 너무 많음

+

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

+
+
+
+
+

새 비밀번호를 정해 주세요

+

관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.

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

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

+ +
+
+ +
+ + +
+

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

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

임시 비밀번호 틀림은 로그인 실패 한도로, 코드 틀림은 코드 실패 한도로 셉니다(설정 › 비밀번호와 같은 규칙). 문구는 사전의 「요청이 너무 많습니다」. 칸 · 단추는 그대로 둡니다.

+
+ +
+
+

2단계 인증을 켠 직원

+

「인증 앱 코드」 칸을 맨 아래에

+
+
+
+
+

새 비밀번호를 정해 주세요

+

관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.

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

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

+
+
+ +
+ +
+
+
+ + +

로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요.

+
+ +
+
+
+

관리자가 비밀번호만 재설정하면 2단계 인증은 그대로라 코드가 필요합니다. 틀리면 그 칸 아래 「코드가 올바르지 않습니다. …」(AUTH_006) · 칸을 비웁니다.

+
+ +
+
+

새로고침한 뒤

+

화면 메모리에 임시 비밀번호가 없을 때 — 맨 위에 칸 하나

+
+
+
+
+

새 비밀번호를 정해 주세요

+

관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요.

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

관리자에게 받은 임시 비밀번호를 다시 넣어 주세요.

+
+
+ +
+ + +
+

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

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

틀리면 그 칸 아래 「지금 비밀번호가 올바르지 않습니다.」(AUTH_005). 바꾸지 않고 탭을 닫았다 다시 열어도 이 화면입니다.

+
+
+ +
+ +
+
+

끝나면

+

받은편지함으로 · 알림

+
+
+ + 새 비밀번호를 정했습니다. +
+

제한이 풀리면 받은편지함으로 갑니다 — 로그인 전에 가려던 주소가 있으면 그곳으로. 회사가 2단계 인증을 필수로 정했는데 아직 켜지 않은 직원은 받은편지함 대신 「2단계 인증 켜기」 단계로 이어집니다(다음 보드).

+

다른 곳의 웹메일 로그인을 끊고 앱 비밀번호를 지우는 것은 설정 › 비밀번호와 같습니다 — 위 화면 맨 아래 안내 줄이 그 까닭입니다.

+
+ +
+
+

흐름 · 규칙

+
+
    +
  • 누가 오나: 관리자가 운영 도구로 입사 · 비밀번호 재설정을 한 직원 — 받은 임시 비밀번호로 처음 로그인할 때입니다. 서버가 로그인 때 알아채 세션에 제한을 붙이고, 이 단계를 마칠 때까지 메일 · 주소록 · 설정을 열 수 없습니다(AUTH_010).
  • +
  • 새로고침 · 다른 탭 · 주소를 바꿔 들어가도 이 단계로 옵니다 — 서버가 막습니다.
  • +
  • 2단계 인증을 켠 직원은 비밀번호 → 코드 → 이 단계입니다(비밀번호만 재설정하면 2단계 인증은 그대로).
  • +
  • 칸 규칙은 설정 › 비밀번호와 같습니다 — 8자 이상 · 임시 비밀번호와 다름 · 확인 칸 일치. 보내기 전에 화면이 먼저 보고, 처음 걸린 칸에 초점을 둡니다.
  • +
  • [다른 계정으로 로그인] → 로그아웃하고 처음 로그인 화면으로(주소 칸은 비움).
  • +
+
+ +
+
+

임시 비밀번호 · 코드

+
+
    +
  • 임시 비밀번호는 로그인 때 넣은 것을 화면 메모리에서 씁니다 — 다시 묻지 않습니다. 메모리에 없으면(새로고침 · 다른 탭) 맨 위에 「임시 비밀번호」 칸.
  • +
  • 화면 메모리의 임시 비밀번호는 10분 뒤 지웁니다(12단계 코드 단계와 같은 10분) — 그 뒤에는 맨 위에 「임시 비밀번호」 칸이 생깁니다.
  • +
  • 2단계 인증을 켠 직원은 앱에 다음 코드가 뜨면 넣습니다 — 엔진이 비밀번호를 바꿀 때 코드를 꼭 요구하고, 로그인 때 쓴 코드를 서버가 기억해 다시 써도 새 비밀번호를 치는 사이 코드의 유효 시간(앞뒤 30초)을 지날 수 있습니다.
  • +
  • 바꾸는 요청은 30초까지 기다립니다(설정 › 비밀번호와 같은 한도).
  • +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/LoginTwoFactorSetup.dc.html b/docs/design/webmail/LoginTwoFactorSetup.dc.html new file mode 100644 index 0000000..ddc06c9 --- /dev/null +++ b/docs/design/webmail/LoginTwoFactorSetup.dc.html @@ -0,0 +1,723 @@ + + + + +로그인 · 2단계 인증 켜기 + + + + + + + +
+
+ +
+ ARA SYSTEM +
+

사내 업무를
한 계정으로

+

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

+
+
+ +
+
+
+

2단계 인증을 켜야 합니다

+

회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +
+
    +
  1. + +
    + 인증 앱으로 QR 코드를 찍어 주세요 +

    인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.

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

인증 앱을 쓸 수 없나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+ +

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

+
+
+
+ +
+ +
+

로그인 · 2단계 인증 켜기(필수)

+

회사가 2단계 인증을 필수로 정한 뒤 아직 켜지 않은 직원은 비밀번호가 맞으면 위 화면으로 옵니다. 12단계 켜기 창의 ① QR · 키와 ② 코드를 한 화면에 두고, 지금 비밀번호는 방금 확인했으므로 묻지 않습니다. 틀 · 칸 · 단추는 로그인 화면과 같습니다. 2026-10-31 확정.

+
+ +
+ +
+
+

코드 틀림

+

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

+
+
+
+
+

2단계 인증을 켜야 합니다

+

회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +
+
    +
  1. + +
    + 인증 앱으로 QR 코드를 찍어 주세요 +

    인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.

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

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

    +
    +
  4. +
+ +
+ +

인증 앱을 쓸 수 없나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+
+
+

QR · 키는 그대로라 다시 찍지 않아도 됩니다. 앱에 다음 코드가 뜨면 넣습니다(AUTH_006).

+
+ +
+
+

시간이 지남

+

위쪽 빨간 상자 · 새 키로 다시 그린다 · 코드 칸은 비움

+
+
+
+
+

2단계 인증을 켜야 합니다

+

회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.

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

+ + 2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요. +

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

    인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.

    +
    +
    + +
    +
    + 찍을 수 없으면 이 키를 입력하세요 + R4WN 6HMQ T2XB ZK7D
    PL3F JC5Y VQ2G NEA6
    + +
    +
    + 앱에는 「ARA SYSTEM (ara.kim@arasystem.it.kr)」로 저장됩니다. +
    +
  2. +
  3. + +
    + + +
    +
  4. +
+ +
+ +

인증 앱을 쓸 수 없나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+
+
+

10분이 지났거나 다른 곳에서 켜기를 다시 시작했을 때(AUTH_008)입니다. QR · 키가 바뀌므로 앱에 먼저 저장한 항목은 지우고 새로 찍어야 합니다 — 다른 곳에서 이미 켰으면 이 화면을 건너뛰고 받은편지함으로.

+
+ +
+
+

켜는 중

+

단추 바쁨 · 칸은 읽기만

+
+
+
+
+

2단계 인증을 켜야 합니다

+

회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +
+
    +
  1. + +
    + 인증 앱으로 QR 코드를 찍어 주세요 +

    인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.

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

인증 앱을 쓸 수 없나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+
+
+

로그인 단추와 같은 모양입니다(「켜는 중…」 · aria-busy). 메일 서버에 등록하느라 길게는 30초 걸립니다(12단계와 같은 한도).

+
+
+ +
+ +
+
+

새로고침한 뒤

+

화면 메모리에 비밀번호가 없을 때 — ③ 지금 비밀번호

+
+
+
+
+

2단계 인증을 켜야 합니다

+

회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다.

+
+ +
+ + ara.kim@arasystem.it.kr + 다른 계정으로 로그인 +
+ +
+
    +
  1. + +
    + 인증 앱으로 QR 코드를 찍어 주세요 +

    인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요.

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

    새로고침해서 다시 넣어야 합니다.

    +
    +
  6. +
+ +
+ +

인증 앱을 쓸 수 없나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+
+
+

새로고침하면 키도 새로 만듭니다 — 앞에서 찍어 둔 항목이 앱에 있으면 지우고 다시 찍습니다. 비밀번호가 틀리면 그 칸 아래 「지금 비밀번호가 올바르지 않습니다.」(AUTH_005).

+
+ +
+
+
+

켠 뒤

+

받은편지함으로 · 알림에 [앱 비밀번호 만들기](10초)

+
+
+
+ + 2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다. + +
+
+

제한이 풀리면 받은편지함으로 갑니다(가려던 주소가 있으면 그곳). 도착하면 이 알림 — 단추가 있는 알림이라 10초, 마우스 · 초점이 있는 동안은 멈춥니다. [앱 비밀번호 만들기] → 설정 › 앱 비밀번호.

+

Outlook · 휴대폰 메일 앱을 웹메일 비밀번호로 연결해 둔 직원은 다음에 다시 연결할 때 막힙니다 — 그래서 켠 바로 뒤에 알립니다.

+
+
+
+

흐름 · 규칙

+
+
    +
  • 누가 오나: 회사가 2단계 인증을 필수로 정한 날(시작일)부터 아직 켜지 않은 직원 — 예외 주소는 빼고. 비밀번호가 맞으면 위 화면으로 옵니다. 켜기 전에는 메일 · 주소록 · 설정을 열 수 없습니다(AUTH_010).
  • +
  • 새 비밀번호 정하기가 함께 걸린 직원(입사)은 그것 먼저 — 새 비밀번호 → 이 단계.
  • +
  • 지금 비밀번호는 로그인 때 넣은 것(방금 새 비밀번호를 정했으면 그것)을 화면 메모리에서 씁니다 — 12단계 켜기 창의 ③ 을 묻지 않습니다.
  • +
  • 키는 이 화면을 열 때마다 새로 만들고 10분 안에 켭니다 — 지나면 위쪽 상자와 새 키. 켜지 않고 떠나면 그 키는 버립니다.
  • +
  • 코드 틀림 · 한도는 12단계 그대로입니다(같은 주소 15분 5회 · 하루 20회 — 넘으면 「요청이 너무 많습니다」 위쪽 상자).
  • +
  • [다른 계정으로 로그인] → 로그아웃하고 처음 로그인 화면으로.
  • +
+
+
+
+ +
+

유예 기간 — 시작일 전

+ 로그인은 그대로 · 메일 화면 위 노란 줄 · 설정 › 2단계 인증도 같은 안내 +
+ +
+ + + +
+
+ 메일 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+
+ + 11월 30일까지 2단계 인증을 켜 주세요 — 그 뒤에는 켜야 로그인할 수 있습니다. + 지금 켜기 +
+
+ +
+
+
+

받은편지함

+ 전체 128 · 안 읽음 2 +
+ + + + 목록 + +
+ +
+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+ +

메일을 선택하세요

+

목록에서 메일을 고르면 내용이 여기에 표시됩니다.

+
+
+
+
+
+ +
+ +
+
+

설정 › 2단계 인증 — 유예 기간

+ 필수인데 아직 꺼진 직원 · 머리 아래 같은 안내 · 콘텐츠 열 800 +
+
+
+

2단계 인증

+

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

+
+ +
+ +
+
+ 11월 30일까지 2단계 인증을 켜 주세요 + 회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다. +
+
+
+ +
+
+ 꺼짐 + 지금은 로그인할 때 비밀번호만 확인합니다. +
+ +
+ +

그 아래 경고(메일 프로그램은 앱 비밀번호로) · 켜는 차례 · 켜기 창은 12단계 그대로입니다.

+
+
+ +
+
+
+

필수인 직원 — 켠 뒤

+

끄기 단추 대신 글 · 휴대폰 바꾸기 안내가 바뀐다

+
+
+
+ 켜짐 — 2026년 11월 12일부터 + 로그인할 때마다 비밀번호와 인증 앱의 코드를 확인합니다. +
+ 회사 정책에 따라 끌 수 없습니다 +
+
+

휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다.

+

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

+

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

+
+

필수인 직원은 끌 수 없습니다(서버도 VAL_001 로 거절). 휴대폰을 바꾸면 관리자가 운영 도구의 「2단계 해제」를 하고, 직원은 다음 로그인 때 위 켜기 단계로 새 휴대폰을 등록합니다. 직원이 직접 하는 휴대폰 바꾸기는 처음 판에서 미뤘습니다.

+
+
+
+

유예 · 필수 규칙

+
+
    +
  • 시작일 전(유예)에는 로그인이 그대로이고 메일 화면 위에 노란 줄이 보입니다. 날짜는 시작일 전날입니다 — 시작일이 12월 1일이면 「11월 30일까지」.
  • +
  • [지금 켜기] → 설정 › 2단계 인증. 닫기 단추는 두지 않습니다 — 줄은 켜야 사라집니다.
  • +
  • 줄의 높이 40 · 단추 36 은 기존 밀도 예외(안내 줄 · 안내 줄 단추)를 그대로 씁니다.
  • +
  • 설정 › 2단계 인증도 머리 아래에 같은 안내를 보입니다(GET /api/two-factor 의 isRequired · deadline).
  • +
  • 정책(켜기 · 시작일 · 예외 주소)은 운영 도구로 정합니다 — 처음 값은 꺼짐.
  • +
+
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/MailNoticesCutover.dc.html b/docs/design/webmail/MailNoticesCutover.dc.html new file mode 100644 index 0000000..ca9dcda --- /dev/null +++ b/docs/design/webmail/MailNoticesCutover.dc.html @@ -0,0 +1,881 @@ + + + + +알림 · 안내 모음 + + + + + + + +
+
+ + + +
+
+ 메일 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+
+ +
+
+
+

받은편지함

+ 전체 132 · 안 읽음 2 +
+ + + + 목록 + +
+ +
+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+
+
+
+ + + + + + + +
+
+
+

관리자가 비밀번호를 재설정했습니다

+
+ +
+
+ ARA SYSTEM + postmaster@arasystem.it.kr + 2026. 10. 30. (금) 오후 3:20 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+
+
+

김아라 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.

+ + + + + + + + + + + + + + + + + + + +
시각2026년 10월 30일 (금) 오후 3:20
바뀐 것비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다
로그아웃모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다
앱 비밀번호모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다
+

할 일

+

아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.

+

직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+

이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.

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

알림 · 안내 모음 (13단계)

+

위 화면은 받은편지함에 들어온 시스템 알림 메일입니다 — 보낸 사람은 「ARA SYSTEM」이고 목록 · 읽기 화면은 다른 메일과 같습니다. 아래는 나머지 알림 메일 둘, 휴지통 · 스팸메일함의 30일 안내 줄, 로그인 화면의 문의 줄입니다. 2026-10-31 확정.

+
+ +
+

받은편지함 알림 메일

+ 운영 도구 · 악성 검사가 보낸다 — 문구가 그대로 메일 본문이 된다 +
+ +
+ +
+
+

관리자가 2단계 인증을 껐습니다

+

운영 도구의 「2단계 해제」 — 휴대폰 분실 처리

+
+
+
+
+

관리자가 2단계 인증을 껐습니다

+
+ +
+
+ ARA SYSTEM + postmaster@arasystem.it.kr + 2026. 10. 27. (화) 오전 10:05 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+
+
+

김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.

+ + + + + + + + + + + + + + + +
시각2026년 10월 27일 (화) 오전 10:05
바뀐 것2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다
그대로인 것비밀번호 · 앱 비밀번호 · 로그인된 브라우저
+

할 일

+

새 휴대폰에 인증 앱을 설치한 뒤 웹메일 › 설정 › 계정 › 2단계 인증에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다.

+

직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+

이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.

+
+
+
+
+
+

그대로인 것을 함께 적어 「무엇이 안 바뀌었나」를 묻지 않게 합니다. 2단계 인증이 필수인 직원은 다음 로그인 때 켜기 단계로 갑니다(보드 「로그인 · 2단계 인증 켜기」).

+
+ +
+
+

올린 대용량 파일에서 위험한 내용이 발견되었습니다

+

악성 검사가 위험으로 판정 — 그 파일을 올린 직원에게

+
+
+
+
+

올린 대용량 파일에서 위험한 내용이 발견되었습니다

+
+ +
+
+ ARA SYSTEM + postmaster@arasystem.it.kr + 2026. 10. 30. (금) 오후 4:02 +
+ 받는 사람 김아라 <ara.kim@arasystem.it.kr> +
+
+
+
+

김아라 님, 올린 대용량 파일을 보안 검사했더니 위험한 내용이 발견되어 보내거나 내려받을 수 없게 막았습니다.

+ + + + + + + + + + + + + + + + + + + +
파일견적_요청서.zip
크기48.2 MB
올린 때2026년 10월 30일 (금) 오후 3:58
조치이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다
+

할 일

+

직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 웹메일 › 설정 › 메일 › 대용량 첨부에서 볼 수 있습니다.

+

판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+

이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.

+
+
+
+
+
+

웹메일을 열어 둔 동안 뜨는 빨간 알림(11단계)은 그대로 둡니다 — 이 메일은 그때 열려 있지 않았던 직원도 알게 합니다.

+
+
+ +
+ +
+
+

목록 보기(표) — 한 줄

+

보낸 사람 「ARA SYSTEM」 · 제목 — 다른 메일과 같은 줄, 따로 표시 없음

+
+
+ + +
+

분할 보기(위 화면)와 같습니다 — 안 읽음은 굵은 글자뿐이고, 뱃지 · 아이콘으로 시스템 메일을 따로 가르지 않습니다. 검색에서 「ARA SYSTEM」 이나 postmaster 로 찾을 수 있습니다.

+
+ +
+
+

알림 메일 — 규칙

+
+
    +
  • 보낸 사람 ARA SYSTEM <postmaster@arasystem.it.kr>. 서버가 그 직원의 받은편지함에 바로 넣습니다 — 안 읽음 · 받은 시각은 지금 · 실시간 알림과 바탕화면 알림이 갑니다. 필터 · 스팸 판정은 거치지 않습니다.
  • +
  • 보내는 때: 관리자가 비밀번호를 재설정하거나 2단계 인증을 껐을 때, 올린 대용량 파일이 위험으로 판정됐을 때(11단계에서 미룬 것). 넣지 못해도 원래 일은 그대로 끝납니다.
  • +
  • 본문은 「인사 한 줄 → 표 → 할 일(메뉴 경로 글) → 문의 줄」 차례입니다. 보내는 메일 정화의 허용 목록 안 HTML(표 · 칸 색 · 테두리 · 굵기)이고, 같은 글의 텍스트 본문도 함께 넣습니다.
  • +
  • 알림 메일에는 링크를 넣지 않습니다 — 직원에게 「회사 보안 알림에는 링크가 없다」는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 합니다. 할 일은 메뉴 경로(웹메일 › 설정 › …)를 굵은 글로 적고, 맨 아래 한 줄로도 알립니다.
  • +
  • 문의 줄의 연락처는 문의 연락처 설정값입니다 — 정하지 않았으면 연락처 줄이 빠지고 「…IT 담당에게 알려 주세요.」로 끝납니다.
  • +
  • 웹메일 안의 알림 단추(2단계 인증을 켠 뒤의 [앱 비밀번호 만들기] · 11단계 위험 파일 알림의 [대용량 첨부 보기])는 메일이 아니라 그대로 둡니다.
  • +
+
+
+ +
+

휴지통 · 스팸메일함 — 30일 안내 줄

+ 목록 머리 바로 아래 · 기존 [비우기] 단추는 그대로 +
+ +
+ +
+
+

휴지통

+

목록 머리 아래 회색 줄 — 늘 보임

+
+
+
+

휴지통

+ 전체 23 + +
+

+ + 휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다. +

+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+ +
+
+

스팸메일함

+

같은 꼴 · 같은 줄

+
+
+
+

스팸메일함

+ 전체 9 + +
+

+ + 스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다. +

+
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+
+
+ +
+
+

휴지통이 비었을 때

+

줄 대신 빈 상태의 안내 글에

+
+
+
+

휴지통

+ 전체 0 +
+
+ +

휴지통이 비어 있습니다

+

삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.

+
+
+
+
+ +
+

로그인 화면 — 문의 줄

+ 연락처는 설정값 · 보드의 이름 · 번호는 자리 표시 +
+ +
+ +
+
+

연락처를 정했을 때

+

「비밀번호를 잊으셨나요?」 + 연락처

+
+
+
+
+

로그인

+

회사 메일 계정(@arasystem.it.kr)으로 로그인하세요.

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

비밀번호를 잊으셨나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+ +

Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)

+
+
+
+ +
+
+

로그인에 실패했을 때

+

잠긴 계정도 같은 글 — 문의 줄은 그대로

+
+
+
+
+

로그인

+

회사 메일 계정(@arasystem.it.kr)으로 로그인하세요.

+
+ +

+ + 이메일 또는 비밀번호가 올바르지 않습니다. +

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

비밀번호를 잊으셨나요?
IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it

+ +

Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)

+
+
+
+ +
+
+

연락처를 정하지 않았을 때

+

지금 글 그대로

+
+
+
+
+

로그인

+

회사 메일 계정(@arasystem.it.kr)으로 로그인하세요.

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

비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.

+ +

Outlook이나 휴대폰 기본 메일 앱에서도 같은 계정으로 메일을 쓸 수 있습니다. (IMAP · SMTP 설정)

+
+
+
+
+ +
+ +
+
+

휴지통 · 스팸메일함 — 30일

+
+
    +
  • 메일 서버가 매일 새벽 3시에 지웁니다 — 그 메일함에 들어간 날부터 30일(휴지통 · 스팸메일함 따로). API 는 없고 화면에 줄만 둡니다.
  • +
  • 줄은 두 메일함 목록의 머리 바로 아래에 늘 있습니다(닫기 없음). 목록 보기(표)에서는 표 머리 위 — 같은 줄.
  • +
  • 메일이 없으면 줄을 지우고 빈 상태의 안내 글에 같은 뜻을 붙입니다. 검색 결과 · 다른 메일함에는 보이지 않습니다.
  • +
  • [휴지통 비우기] · [스팸메일함 비우기]는 지금 그대로입니다(확인 창 — 처음 초점 「취소」).
  • +
+
+ +
+
+

문의 연락처

+
+
    +
  • 연락처는 운영 도구로 넣는 설정값(200자 · 한 줄 — GET /api/support-contact, 로그인 없음)입니다. 보드의 「IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it」은 자리 표시이고, 넣지 않으면 지금 글 그대로입니다.
  • +
  • 글자로만 보입니다(링크 · HTML 없음). 물음 다음 줄에 연락처 — 물음은 회색, 연락처는 한 단계 진하게(길어도 중간에서 끊기지 않게).
  • +
  • 들어가는 곳: 로그인(「비밀번호를 잊으셨나요?」) · 2단계 코드 단계와 2단계 켜기(「인증 앱을 쓸 수 없나요?」) · 시스템 알림 메일의 마지막 줄.
  • +
  • 정하지 않았으면 지금 글 그대로 — 「비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요.」 · 「인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요.」
  • +
  • 잠긴 계정(퇴사 · 털림)도 비밀번호 틀림과 같은 글입니다 — 원인을 가리지 않는 규칙. 그 아래 문의 줄로 안내합니다.
  • +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/README.md b/docs/design/webmail/README.md index 8bad400..fcc7c3b 100644 --- a/docs/design/webmail/README.md +++ b/docs/design/webmail/README.md @@ -761,6 +761,230 @@ API 와 서버 규칙은 계약 2절 "실시간 알림". 복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키 · 패스키, 관리자가 2단계 인증을 강제하기(13단계), 켜진 채 휴대폰 바꾸기, 앱 비밀번호 이름 바꾸기 · 만료, 새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 로그인 기록의 나라 · 지역. +## 13단계 — 전환 준비 (2026-10-31 확정 — `LoginPasswordChange` · `LoginTwoFactorSetup` · `SettingsMailClients` · `MailNoticesCutover`) + +전 직원 전환 전에 필요한 화면이다 — 관리자가 준 임시 비밀번호를 바꾸는 로그인 단계, 회사가 2단계 인증을 필수로 정했을 때의 켜기 단계와 유예 안내, +메일 프로그램 연결 안내, 받은편지함 알림 메일 · 휴지통 30일 안내 · 로그인 문의 줄. +데이터 · 규칙은 계약 6절(`docs/mail/api-contract.md` "전환 준비 — 13단계")이 정한다 — 제한된 세션(`restriction`) · `twoFactorDeadline` · `isRequired` · `GET /api/support-contact` · `AUTH_010`. +알림 메일과 메일 프로그램 연결의 글은 이 절이 원본이다(화면 상수 · 메일 본문으로 그대로 옮긴다). +설정 메뉴의 계정 묶음: 비밀번호 · 2단계 인증 · 앱 비밀번호 · **메일 프로그램 연결**(새 — 아이콘 PC · 휴대폰, lucide `monitor-smartphone`) · 로그인 기록. +새 토큰은 없다 — 유예 노란 줄 40 · 줄 안 단추 36 은 안내 줄 밀도 예외(`--size-notice-bar` · `--size-notice-action`)를 다시 쓴다. 메일 프로그램 연결의 펼침 줄은 52(이름 · 설명 두 줄)라 예외가 아니다. +로그인 틀(브랜드 패널 + 양식 칸 360)과 칸 · 단추 · 위쪽 오류 상자는 `Login` · `LoginTwoFactor` 와 같다. 각 보드 위쪽은 실제 크기 화면, 아래는 상태 카드다. + +### 로그인 — 새 비밀번호 정하기 (`LoginPasswordChange`) + +- **누가 오나**: 운영 도구로 입사 · 비밀번호 재설정을 한 직원이 받은 임시 비밀번호로 로그인했을 때(`restriction: passwordChange`). + 마칠 때까지 메일 · 주소록 · 설정을 열 수 없다(`AUTH_010`) — 새로고침 · 다른 탭 · 주소를 바꿔 들어와도 이 단계다(화면은 `me` 의 `restriction` 을 본다). + 2단계 인증을 켠 직원은 비밀번호 → 코드 → 이 단계다(비밀번호만 재설정하면 2단계는 그대로). 2단계 인증이 필수인데 아직 켜지 않았으면 이 단계 다음이 「2단계 인증 켜기」다. +- **틀**(위에서부터): + - 제목 "새 비밀번호를 정해 주세요" / "관리자가 정한 임시 비밀번호로 로그인했습니다. 메일을 쓰려면 먼저 새 비밀번호를 정해 주세요." + - 주소 + [다른 계정으로 로그인] — 로그아웃하고 처음 화면으로(주소 칸은 비움). + - (임시 비밀번호가 화면 메모리에 없을 때만) 「임시 비밀번호」 + "관리자에게 받은 임시 비밀번호를 다시 넣어 주세요." + - 「새 비밀번호」 + "8자 이상 · 임시 비밀번호와 달라야 합니다. 영문 · 숫자 · 기호를 섞으면 더 안전합니다." + - 「새 비밀번호 확인」 — 보기 단추 없이 새 비밀번호의 보기를 따른다(설정 › 비밀번호와 같다). + - (2단계 인증을 켠 직원만) 맨 아래 「인증 앱 코드」 + "로그인할 때 넣은 코드는 다시 쓸 수 없습니다 — 앱에 다음 코드가 뜨면 넣어 주세요." — 로그인 코드 칸 모양(18px · 자간), 숫자 6자리 · `one-time-code`. + - [비밀번호 바꾸고 계속] — 보내는 동안 「바꾸는 중…」(`aria-busy`, 칸은 읽기만). 요청 한도 30초(`PASSWORD_CHANGE_TIMEOUT_MS` — 설정 › 비밀번호와 같다). + - 맨 아래(위쪽 선 · 12px 회색): "메일 프로그램(아웃룩 등)을 연결해 두었다면 바꾼 뒤 다시 연결해야 합니다." + - 칸은 로그인 칸 모양(테두리 #8A94A3 · 글자 14px · 이름 12.5px 굵게)이고, 임시 비밀번호 · 새 비밀번호 칸 끝에 설정 화면의 [보기](44)를 둔다 — 로그인 첫 화면의 비밀번호 칸에는 없다. +- **임시 비밀번호**: 로그인 때 넣은 것을 화면 메모리에서 지금 비밀번호로 쓴다 — 묻지 않는다. 10분 뒤 메모리에서 지운다(12단계 코드 단계와 같은 10분). + 메모리에 없으면(새로고침 · 다른 탭 · 10분 지남) 맨 위에 「임시 비밀번호」 칸을 보인다. +- **코드를 다시 받는 까닭**: 엔진이 비밀번호를 바꿀 때 코드를 꼭 요구하고, 로그인 때 쓴 코드를 서버가 기억해 다시 써도 새 비밀번호를 치는 사이 코드의 유효 시간(앞뒤 30초)을 지날 수 있다. + 그래서 앱에 다음 코드가 뜨면 넣는다(성공에 쓴 코드는 2분 동안 다시 받지 않는다 — 12단계). +- **상태**(칸 아래 오류는 알림 없이 · 처음 걸린 칸에 초점): + - 규칙에 맞지 않음(8자 미만 · 임시 비밀번호와 같음) — 보내지 않고 새 비밀번호의 안내 글이 그대로 빨간 오류가 된다. 엔진이 규칙으로 거절하면 같은 자리에 `VAL_001` 사전 문구. + - 확인 칸이 다름 — 확인 칸 아래 "새 비밀번호가 서로 다릅니다." + - 임시 비밀번호 틀림 — 그 칸 아래 `AUTH_005` 사전 문구("지금 비밀번호가 올바르지 않습니다."). + - 코드 틀림 — 코드 칸 아래 `AUTH_006` 사전 문구, 칸을 비운다. + - 시도 과다 — 위쪽 빨간 상자(로그인 오류와 같은 자리 · 같은 모양)에 `REQ_001` 사전 문구. 임시 비밀번호 틀림은 로그인 실패 한도, 코드 틀림은 코드 실패 한도로 센다. 칸 · 단추는 그대로. +- **끝나면**: 서버가 제한을 지우고 화면은 `/auth/refresh` 로 새 토큰을 받아 받은편지함으로 간다(로그인 전에 가려던 주소가 있으면 그곳) — 알림 "새 비밀번호를 정했습니다." + 2단계 인증이 필수인데 켜지 않았으면 받은편지함 대신 「2단계 인증 켜기」 단계로. 다른 곳의 웹메일 로그인 끊기 · 앱 비밀번호 지우기는 설정 › 비밀번호와 같다(계약 5절) — 맨 아래 안내 줄이 그 까닭이다. + +### 로그인 — 2단계 인증 켜기(필수) (`LoginTwoFactorSetup`) + +- **누가 오나**: 회사가 2단계 인증을 필수로 정한 날(시작일)부터, 아직 켜지 않은 직원(예외 주소 빼고)이 비밀번호를 맞혔을 때(`restriction: twoFactorSetup`). + 켜기 전에는 메일 · 주소록 · 설정을 열 수 없다(`AUTH_010`). 새 비밀번호 정하기가 함께 걸린 직원(입사)은 그것 먼저. +- **틀**(위에서부터): + - 제목 "2단계 인증을 켜야 합니다" / "회사 정책에 따라 로그인할 때 휴대폰 인증 앱의 코드도 확인합니다. 지금 켜면 바로 메일로 이어집니다." + - 주소 + [다른 계정으로 로그인] + - 차례(청록 굵은 숫자 — 동그라미 없음, 숫자 칸 22 · 사이 18 — 12단계 켜기 창과 같다): + - ① "인증 앱으로 QR 코드를 찍어 주세요" / "인증 앱에서 [+] → 「QR 코드 스캔」을 고릅니다. 앱이 없으면 Google Authenticator · Microsoft Authenticator 를 먼저 설치해 주세요." + QR(148 — 양식 칸 360 안에 키를 옆에 두려고 12단계 켜기 창의 160 보다 작게) 오른쪽에 "찍을 수 없으면 이 키를 입력하세요"(12px 굵게) + 32자 키(4자씩 · 두 줄 · 고정폭 12px) + + [복사](36 — "키를 복사했습니다." / 못 하면 "키를 복사하지 못했습니다. 키를 직접 골라 복사해 주세요."), 그 아래 "앱에는 「ARA SYSTEM (주소)」로 저장됩니다." + - ② 「앱에 표시된 6자리 코드」 — 로그인 코드 칸 모양. + - (지금 비밀번호가 화면 메모리에 없을 때만) ③ 「지금 비밀번호」 + "새로고침해서 다시 넣어야 합니다." — [보기] 있음. + - [켜고 계속] — 보내는 동안 「켜는 중…」(`aria-busy`, 칸은 읽기만). 요청 한도 30초(`TWO_FACTOR_CHANGE_TIMEOUT_MS`). + - 문의 줄 "인증 앱을 쓸 수 없나요?" + 연락처(아래 "문의 줄"). + - 맨 아래(위쪽 선 · 12px): "Outlook · 휴대폰 메일 앱은 이 코드를 묻지 않습니다. 켠 뒤에는 설정 › 앱 비밀번호에서 만든 비밀번호로 연결합니다." +- **지금 비밀번호**: 로그인 때 넣은 것(방금 새 비밀번호를 정했으면 그것)을 화면 메모리에서 쓴다 — 12단계 켜기 창의 ③ 을 묻지 않는다. 10분 뒤 지우고, 메모리에 없으면 ③ 칸을 보인다. +- **키**: 이 화면을 열 때마다 새로 만든다(`POST /api/two-factor/enrollments` — 10분 안에 켠다). 켜지 않고 떠나면(다른 계정 · 새로고침) 그 키는 버린다 — + 새로고침 뒤에는 QR · 키가 바뀌므로 앱에 먼저 저장한 항목은 지우고 다시 찍는다. QR · 키는 화면 메모리에만 두고 로그에 남기지 않는다(12단계 그대로). +- **상태**: + - 코드 틀림 — 칸 아래 `AUTH_006` 사전 문구, 칸을 비우고 초점. QR · 키는 그대로라 다시 찍지 않는다. + - 시간이 지남 · 그사이 바뀜(`AUTH_008`) — 위쪽 빨간 상자에 사전 문구("2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.") + 새 QR · 키로 다시 그린다(코드 칸은 비움). + 다른 곳에서 이미 켰으면 이 단계를 건너뛰고 받은편지함으로. + - 지금 비밀번호 틀림(③ 이 있을 때) — 칸 아래 `AUTH_005`. 둘 다 틀렸으면 코드부터(서버가 코드를 먼저 본다 — 12단계). + - 시도 과다 — 위쪽 상자에 `REQ_001`(코드 실패는 같은 주소 15분 5회 · 하루 20회 — 12단계 그대로). +- **켠 뒤**: 서버가 제한을 지우고 받은편지함으로 간다(가려던 주소가 있으면 그곳) — 알림 "2단계 인증을 켰습니다. 메일 프로그램은 앱 비밀번호로 연결합니다." + [앱 비밀번호 만들기] + (단추가 있는 알림 — 10초, 마우스 · 초점이 있으면 멈춤) → 설정 › 앱 비밀번호. 웹메일 비밀번호로 메일 프로그램을 연결해 둔 직원이 다음 연결 때 막히기 전에 알린다. +- **유예 기간**(시작일 전 — 로그인은 그대로, `me` 의 `twoFactorDeadline`): + - 메일 모듈 화면(목록 · 읽기 · 쓰기 · 검색)의 상단 바 바로 아래, 모듈 폭 전체에 노란 줄 하나(높이 40 · 바탕 #FFF7E6 · 아래 선 #F0D9A8 · 글 #7A5200 · 방패 아이콘 15): + **"11월 30일까지 2단계 인증을 켜 주세요"**(굵게) " — 그 뒤에는 켜야 로그인할 수 있습니다." + [지금 켜기](36 — 읽기 화면 안내 줄의 단추와 같은 `.notice-btn`) → 설정 › 2단계 인증. + - 날짜는 시작일의 전날(KST)이다 — 시작일이 12월 1일이면 「11월 30일까지」. 올해가 아니면 해를 붙인다("2027년 1월 3일까지"). + - 닫기 단추는 없다 — 켜야 사라진다. 주소록 · 설정에는 없다(설정 › 2단계 인증은 아래 안내). +- **설정 › 2단계 인증 — 필수인 직원**(`GET /api/two-factor` 의 `isRequired` · `deadline`): + - 유예 중 · 꺼짐 — 머리 아래 갈색 상자(방패): **"11월 30일까지 2단계 인증을 켜 주세요"** / "회사 정책에 따라 그 뒤에는 2단계 인증을 켜야 로그인할 수 있습니다." + 그 아래 상태 상자 · 경고 · 켜는 차례 · 켜기 창은 12단계 그대로. + - 켜짐 — 상태 상자의 [2단계 인증 끄기] 대신 회색 글 "회사 정책에 따라 끌 수 없습니다"(서버도 `VAL_001` 로 거절). + 아래 안내의 첫 줄은 "휴대폰을 바꿀 때는 관리자에게 2단계 인증 해제를 요청해 주세요 — 다음 로그인 때 새 휴대폰으로 다시 켭니다."(나머지 두 줄은 12단계 그대로). +- **휴대폰을 바꿀 때**(필수인 직원): 관리자에게 「2단계 해제」를 요청한다(운영 도구 — 받은편지함 알림 메일이 간다) → 다음 로그인 때 이 켜기 단계로 새 휴대폰을 등록한다. + 직원이 직접 하는 휴대폰 바꾸기는 미뤘다(아래). + +### 설정 › 계정 › 메일 프로그램 연결 (`SettingsMailClients`) + +- **메뉴**: 계정 묶음의 앱 비밀번호 아래 「메일 프로그램 연결」. API 는 없다 — 화면 상수로만 그린다(설정값은 `MAIL_CLIENT_SETTINGS` — 앱 비밀번호 화면과 같은 값, 차례 글도 상수). + 2단계 인증 상태만 이미 있는 `GET /api/two-factor`(스토어)로 보고, 받지 못하면 켠 직원의 안내로 둔다(더 안전한 쪽). +- 머리 "메일 프로그램 연결" + "아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다." +- **설정값 상자**: 「메일 프로그램 설정값」 — 앱 비밀번호 화면과 같은 이름 · 값 표(이름 칸 136). + + | 이름 | 값 | + | --- | --- | + | 받는 메일 (IMAP) | **mx1.arasystem.it.kr** · 포트 993 · SSL/TLS | + | 보내는 메일 (SMTP) | **mx1.arasystem.it.kr** · 포트 465 · SSL/TLS | + | 사용자 이름 | **(직원 주소)** — 메일 주소 전체 | + | 비밀번호 | 켠 직원 "앱 비밀번호 — 웹메일 비밀번호가 아닙니다" / 켜지 않은 직원 "앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다" | + +- **상자 바로 아래** — 2단계 인증 상태로 가른다. 둘 다 [앱 비밀번호 만들기](36) → 설정 › 앱 비밀번호. + - 켠 직원 — 갈색 상자(열쇠): **"비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요"** / "2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다." — 단추 `.notice-btn`. + - 켜지 않은 직원 — 청록 상자(열쇠): **"앱 비밀번호로 연결하기를 권합니다"** / "지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다." — 단추 `.line-btn`. +- **프로그램별 연결 차례**: 「프로그램별 연결 차례」 + "쓰는 프로그램을 누르면 차례가 펼쳐집니다." + - 그 아래 갈색 주의 줄(경고 삼각형): "새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다." + 권하지 않음으로 정했다(2026-10-31) — 회사가 허용하면 줄을 지운다. + - 펼침 목록(테두리 상자 하나 · 줄 사이 선): 줄 52 — 아이콘 18(PC · 휴대폰) · 이름(굵게) / 설명(12px 회색) · 오른쪽 ⌄(펼치면 ⌃). 줄 전체가 버튼(`aria-expanded` · 펼친 것만 `aria-controls`)이고, + 펼친 차례는 region(이름 = 그 버튼)으로 이름 글과 같은 선(왼쪽 44)에서 번호 줄(청록 굵은 숫자 — 동그라미 없음)로 보인다. 처음에는 모두 접힘, 하나를 펼치면 다른 것은 접힌다. + - 표기: 누르는 것 [ ], 고르는 것 「 」, 호스트 이름은 굵게 — 12단계 「켜는 차례」와 같다. 화면 그림은 넣지 않는다. 맥 메일 · 휴대폰 Outlook 앱은 넣지 않는다. + - 차례 글(아래 그대로 — 구현 때 실제 앱(한국어판)의 메뉴 이름과 한 번 더 맞춘다): + - **아웃룩 (Windows)** — 설명 "Microsoft 365 · Outlook 2016 이후의 데스크톱 Outlook" + 1. Outlook 에서 [파일] → [계정 추가]를 누릅니다. + 2. 메일 주소를 넣고 [고급 옵션] → 「내 계정을 수동으로 설정」을 고른 뒤 [연결]을 누릅니다. + 3. 계정 유형에서 「IMAP」을 고릅니다. + 4. 받는 메일 · 보내는 메일 모두 서버 **mx1.arasystem.it.kr**, 포트는 993 · 465, 암호화 방법은 「SSL/TLS」를 넣고 [다음]을 누릅니다. + 5. 비밀번호 칸에 앱 비밀번호를 넣고 [연결]을 누릅니다. + - **새 Outlook** — 설명 "Windows 의 새 Outlook — 메일이 Microsoft 클라우드를 거칩니다" + 1. [설정](톱니) → [계정] → [메일 계정] → [계정 추가]를 누릅니다. + 2. 메일 주소를 넣고 [계속] → 공급자에서 「IMAP」을 고릅니다. + 3. [고급 설정 표시]를 누르고 받는 · 보내는 서버에 위 설정값을, 비밀번호 칸 둘 다에 앱 비밀번호를 넣습니다. + 4. [계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다 — 위 주의를 읽고 정합니다. + - **썬더버드** — 설명 "Windows · Mac — 주소와 앱 비밀번호만 넣으면 됩니다" + 1. 썬더버드에서 [≡] → [새로 만들기] → [기존 메일 계정]을 누릅니다. + 2. 이름 · 메일 주소 · 비밀번호(앱 비밀번호)를 넣고 [계속]을 누릅니다. + 3. 메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다 — 「IMAP」이 보이면 [완료]를 누릅니다. + 4. 찾지 못하면 [수동 설정]에서 위 설정값을 넣습니다. + - **iPhone · iPad** — 설명 "기본 메일 앱" + 1. [설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일]) + 2. 이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다. + 3. 「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 **mx1.arasystem.it.kr**, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다. + 4. [다음] → [저장]을 누릅니다. 포트는 스스로 993 · 465 로 맞춥니다. + - **안드로이드** — 설명 "Gmail 앱 — 다른 계정(IMAP) · 삼성 이메일 앱도 같은 값" + 1. Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다. + 2. 메일 주소를 넣고 [수동 설정] → 「개인(IMAP)」을 고릅니다. + 3. 비밀번호에 앱 비밀번호를 넣습니다. + 4. 받는 서버 · 보내는 서버에 **mx1.arasystem.it.kr**, 포트는 993 · 465, 보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다. +- **아래 안내 상자**(#F3F8F8 · 가운뎃점 줄): "연결이 안 되면 사용자 이름에 메일 주소 전체를, 비밀번호 칸에 앱 비밀번호(띄어쓰기 없이 40자)를 넣었는지 확인해 주세요." · + "POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요." · "웹메일 비밀번호를 바꾸면 앱 비밀번호도 모두 지워집니다 — 프로그램마다 새로 만들어 넣어 주세요." · "그래도 안 되면 관리자에게 문의해 주세요." +- **좁은 폭**: 설정 메뉴가 콘텐츠 위로 올라간다(지금 설정 셸의 줄바꿈). 설정값 상자는 이름(12px 회색) 위 · 값 아래 한 칸으로. 펼침 줄의 이름 · 설명이 줄을 바꿔도 줄 전체가 누르는 곳이다. +- **앱 비밀번호 화면(12단계)에 더하는 것**: 설정값 상자 바로 아래 한 줄(12.5px · #4A5363) "프로그램별 연결 방법은 메일 프로그램 연결에서 볼 수 있습니다." — 「메일 프로그램 연결」이 이 화면 링크다. 12단계 보드는 고치지 않았다. +- **전 직원 전환 때**: 썬더버드의 "설정을 스스로 찾습니다"와 아웃룩 자동 설정은 autoconfig · autodiscover 주소와 SRV 레코드를 낸 뒤부터 된다(인프라 — `mail/README.md`) — + 그 전에는 썬더버드도 [수동 설정](4번 차례)이다. 호스트 `mx1.arasystem.it.kr` 는 전환 때 다시 본다(같은 상수). + +### 받은편지함 알림 메일 · 휴지통 30일 · 문의 줄 (`MailNoticesCutover`) + +- **알림 메일 — 공통** + - 서버가 그 직원의 받은편지함에 바로 넣는다(계약 6절 — 안 읽음 · 받은 시각은 지금 · 실시간 알림과 바탕화면 알림이 간다, 필터 · 스팸 판정은 거치지 않는다). 넣지 못해도 원래 일은 그대로 끝난다. + - 보낸 사람 `ARA SYSTEM >`, 받는 사람 그 직원(`이름 <주소>`). 목록 · 읽기 화면은 다른 메일과 같다 — 보낸 사람 「ARA SYSTEM」, 아바타 "A", 뱃지 · 아이콘으로 따로 가르지 않는다. + - **링크를 넣지 않는다** — 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. 할 일은 메뉴 경로(웹메일 › 설정 › …)를 굵은 글로 적고, 맨 아래 한 줄로도 알린다. + 웹메일 안의 알림 단추(2단계 인증을 켠 뒤의 [앱 비밀번호 만들기] · 11단계 위험 파일 알림의 [대용량 첨부 보기])는 메일이 아니라 그대로 둔다. + - 본문 차례: 인사 한 줄 → 표 → 할 일(메뉴 경로 글) → 문의 줄 → 맨 아래 한 줄. HTML 과 같은 글의 텍스트 본문을 함께 넣는다. + - 자리 표시: `{이름}` 직원의 표시 이름(없으면 주소의 @ 앞) · `{시각}` · `{올린 때}` KST "2026년 10월 30일 (금) 오후 3:20" · `{파일 이름}` 올린 이름 그대로 · `{크기}` 11단계 크기 표기("48.2 MB") · + `{연락처}` 문의 연락처 설정값. 이름 · 파일 이름 · 연락처는 HTML 본문에 넣을 때 이스케이프한다. + - 문의 줄: `{확인 문장}` 다음 줄에 `{연락처}`. 연락처를 정하지 않았으면 그 줄을(HTML 은 앞의 `
` 와 함께) 뺀다. + - 맨 아래 한 줄(세 통 같음): "이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요." + - HTML 꼴(세 통 같음 — 보내는 메일 정화 `sanitize-outgoing-html.ts` 의 허용 목록 안이고 감싸는 div 는 보내는 메일과 같은 기본 글꼴 · 14px · 1.75, 표의 줄은 칸마다 같은 꼴을 되풀이한다): + + ```html +
+

{인사 한 줄}

+ + + + + + + +
{칸 이름}{값}
+

할 일

+

{할 일 — 메뉴 경로는 웹메일 › 설정 › …}

+

{확인 문장}
{연락처}

+

이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.

+
+ ``` + + - 텍스트 본문 꼴(표는 줄마다 "칸 이름: 값", 메뉴 경로는 굵게 없이 그대로): + + ```text + {인사 한 줄} + + {칸 이름}: {값} + + 할 일 + {할 일} + + {확인 문장} + {연락처} + + 이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요. + ``` + +- **① 관리자가 비밀번호를 재설정했습니다** — 운영 도구의 「비밀번호 재설정」 뒤. + - 제목 "관리자가 비밀번호를 재설정했습니다" + - 인사 "{이름} 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다." + - 표: 시각 — `{시각}` / 바뀐 것 — "비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다" / 로그아웃 — "모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다" / + 앱 비밀번호 — "모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다" + - 할 일 "아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. **웹메일 › 설정 › 계정 › 앱 비밀번호**에서 새로 만들어 주세요." + - 확인 문장 "직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요." +- **② 관리자가 2단계 인증을 껐습니다** — 운영 도구의 「2단계 해제」(휴대폰 분실) 뒤. + - 제목 "관리자가 2단계 인증을 껐습니다" + - 인사 "{이름} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다." + - 표: 시각 — `{시각}` / 바뀐 것 — "2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다" / 그대로인 것 — "비밀번호 · 앱 비밀번호 · 로그인된 브라우저" + - 할 일 "새 휴대폰에 인증 앱을 설치한 뒤 **웹메일 › 설정 › 계정 › 2단계 인증**에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다." + - 확인 문장 "직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요." +- **③ 올린 대용량 파일에서 위험한 내용이 발견되었습니다** — 악성 검사가 위험으로 판정했을 때, 그 파일을 올린 직원에게(파일마다 한 통). + - 제목 "올린 대용량 파일에서 위험한 내용이 발견되었습니다" + - 인사 "{이름} 님, 올린 대용량 파일을 보안 검사했더니 위험한 내용이 발견되어 보내거나 내려받을 수 없게 막았습니다." + - 표: 파일 — `{파일 이름}` / 크기 — `{크기}` / 올린 때 — `{올린 때}` / 조치 — "이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다" + - 할 일 "직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 **웹메일 › 설정 › 메일 › 대용량 첨부**에서 볼 수 있습니다." + - 확인 문장 "판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요." + - 웹메일을 열어 둔 동안의 빨간 알림(11단계)은 그대로 둔다 — 이 메일은 그때 열려 있지 않았던 직원도 알게 한다. +- **휴지통 · 스팸메일함 — 30일 줄**: + - 목록 머리(52) 바로 아래 회색 줄(바탕 #F8F9FB · 아래 선 #EEF0F3 · 위아래 8 · 12.5px #4A5363 · 시계 아이콘 14 #667085): + 휴지통 "휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다." / 스팸메일함 "스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다." + - 늘 보인다(닫기 없음). 목록 보기(표)에서는 표 머리 위 — 같은 줄. 검색 결과 · 다른 메일함에는 없다. 머리의 [휴지통 비우기] · [스팸메일함 비우기]는 지금 그대로다. + - 메일이 없으면 줄을 빼고 빈 상태 안내 글에 붙인다: "휴지통이 비어 있습니다" / "삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다." · + "스팸메일함이 비어 있습니다" / "스팸으로 분류된 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다." + - 지우는 것은 메일 서버다 — 그 메일함에 들어간 날부터 30일, 매일 새벽 3시(KST). API 는 없다(계약 6절). +- **문의 줄**(로그인 · 2단계 코드 단계 · 2단계 켜기): + - 연락처는 `GET /api/support-contact` 의 `text`(운영 도구로 넣는 설정값 · 200자 · 한 줄 — 로그인 없이 받는다). 보드의 "IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it" 는 자리 표시다. 글자로만 보인다(링크 · HTML 없음). + - 꼴: 물음(12.5px · #667085) 다음 줄에 연락처(#344054 · 500) — 연락처가 길어도 중간에서 끊기지 않게 줄을 나눈다. + - 로그인 — 있을 때 "비밀번호를 잊으셨나요?" + 연락처 / 없을 때 지금 글 "비밀번호를 잊으셨다면 시스템 관리자에게 문의해 주세요." + - 코드 단계(`LoginTwoFactor`) · 2단계 켜기 — 있을 때 "인증 앱을 쓸 수 없나요?" + 연락처 / 없을 때 지금 글 "인증 앱을 쓸 수 없나요? 관리자에게 문의해 주세요." + - 받지 못하면(오류 · `null`) 없을 때와 같다. + - 잠긴 계정(퇴사 · 털림)도 비밀번호 틀림과 같은 글 "이메일 또는 비밀번호가 올바르지 않습니다."다(원인을 가리지 않는다) — 그 아래 문의 줄로 안내한다. + +### 처음 판에서 미룬 것 + +직원이 직접 하는 휴대폰 바꾸기(지금 휴대폰의 코드로 확인 → 새 휴대폰 등록 — 그때까지는 관리자의 「2단계 해제」), 웹메일 관리자 화면(공통 기반 때 — 그때까지 운영 도구 · 엔진 관리 화면), +비밀번호 스스로 재설정(복구 연락처가 필요하다), 새 기기 로그인 알림 메일, 잠긴 계정만의 안내 글, 공용 주소로 보내기 · 공유 메일함. +시안에서 뺀 것: 알림 메일의 링크 단추(넣지 않는다 — 위 규칙), 유예 노란 줄의 닫기, 맥 메일 · 휴대폰 Outlook 앱의 연결 차례, 프로그램별 화면 그림. + ## 구현하며 정한 보완 (2026-10-26 — 캔버스에는 아직 없다) - 찾지 못한 첨부(보관 시간이 지난 업로드·지워진 원본)는 실패로 표시하고, 전달 원본에는 "빼기"만 둔다(다시 올릴 수 없다). diff --git a/docs/design/webmail/SettingsMailClients.dc.html b/docs/design/webmail/SettingsMailClients.dc.html new file mode 100644 index 0000000..5c7e4c8 --- /dev/null +++ b/docs/design/webmail/SettingsMailClients.dc.html @@ -0,0 +1,725 @@ + + + + +설정 · 메일 프로그램 연결 + + + + + + + +
+
+ + + +
+ +
+ 설정 +
+ + +
+
+ +
+ 김아라 + ara.kim@arasystem.it.kr +
+ + + 로그아웃 + +
+
+ +
+ + + +
+
+ +
+

메일 프로그램 연결

+

아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.

+
+ +
+

메일 프로그램 설정값

+
+
받는 메일 (IMAP)
+
mx1.arasystem.it.kr · 포트 993 · SSL/TLS
+
보내는 메일 (SMTP)
+
mx1.arasystem.it.kr · 포트 465 · SSL/TLS
+
사용자 이름
+
ara.kim@arasystem.it.kr — 메일 주소 전체
+
비밀번호
+
앱 비밀번호 — 웹메일 비밀번호가 아닙니다
+
+
+
+ +
+
+ 비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요 + 2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다. +
+ 앱 비밀번호 만들기 +
+
+ +
+
+

프로그램별 연결 차례

+

쓰는 프로그램을 누르면 차례가 펼쳐집니다.

+
+
+ +
+
+ 새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다. +
+
+
+
    +
  • +

    + +

    +
    +
      +
    1. + + Outlook 에서 [파일] → [계정 추가]를 누릅니다. +
    2. +
    3. + + 메일 주소를 넣고 [고급 옵션] → 「내 계정을 수동으로 설정」을 고른 뒤 [연결]을 누릅니다. +
    4. +
    5. + + 계정 유형에서 「IMAP」을 고릅니다. +
    6. +
    7. + + 받는 메일 · 보내는 메일 모두 서버 mx1.arasystem.it.kr, 포트는 993 · 465, 암호화 방법은 「SSL/TLS」를 넣고 [다음]을 누릅니다. +
    8. +
    9. + + 비밀번호 칸에 앱 비밀번호를 넣고 [연결]을 누릅니다. +
    10. +
    +
    +
  • +
  • +

    + +

    +
  • +
  • +

    + +

    +
  • +
  • +

    + +

    +
  • +
  • +

    + +

    +
  • +
+
+ +
+

연결이 안 되면 사용자 이름에 메일 주소 전체를, 비밀번호 칸에 앱 비밀번호(띄어쓰기 없이 40자)를 넣었는지 확인해 주세요.

+

POP3 로는 연결할 수 없습니다 — 계정 유형은 IMAP 을 골라 주세요.

+

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

+

그래도 안 되면 관리자에게 문의해 주세요.

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

설정 · 메일 프로그램 연결

+

위 화면은 2단계 인증을 켠 직원입니다 — 비밀번호 칸에는 꼭 앱 비밀번호를 넣어야 한다는 상자가 설정값 바로 아래에 있습니다. 프로그램별 차례는 펼침 줄로 하나씩 보입니다. 메뉴는 계정 묶음의 「메일 프로그램 연결」(앱 비밀번호 아래). 2026-10-31 확정.

+
+ +
+ +
+
+
+

2단계 인증을 켜지 않은 직원

+ 설정값의 비밀번호 줄 · 아래 상자만 다르다 · 콘텐츠 열 800 +
+
+
+

메일 프로그램 연결

+

아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.

+
+ +
+

메일 프로그램 설정값

+
+
받는 메일 (IMAP)
+
mx1.arasystem.it.kr · 포트 993 · SSL/TLS
+
보내는 메일 (SMTP)
+
mx1.arasystem.it.kr · 포트 465 · SSL/TLS
+
사용자 이름
+
ara.kim@arasystem.it.kr — 메일 주소 전체
+
비밀번호
+
앱 비밀번호(권장) — 2단계 인증을 켜지 않았으면 웹메일 비밀번호도 됩니다
+
+
+
+ +
+
+ 앱 비밀번호로 연결하기를 권합니다 + 지금은 웹메일 비밀번호로도 연결되지만, 2단계 인증을 켜면 다시 연결할 때 막힙니다. 앱 비밀번호는 기기마다 따로 지울 수 있어 잃어버렸을 때도 안전합니다. +
+ 앱 비밀번호 만들기 +
+
+
+
+
+
+

새 Outlook 을 펼쳤을 때

+

주의 줄은 목록 위에 늘 있다 — 여기서는 함께 보이려고 붙였다

+
+
+ +
+
+ 새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다. +
+
+
+
    +
  • +

    + +

    +
    +
      +
    1. + + [설정](톱니) → [계정] → [메일 계정] → [계정 추가]를 누릅니다. +
    2. +
    3. + + 메일 주소를 넣고 [계속] → 공급자에서 「IMAP」을 고릅니다. +
    4. +
    5. + + [고급 설정 표시]를 누르고 받는 · 보내는 서버에 위 설정값을, 비밀번호 칸 둘 다에 앱 비밀번호를 넣습니다. +
    6. +
    7. + + [계속]을 누르면 메일을 Microsoft 클라우드에 동기화한다는 안내가 나옵니다 — 위 주의를 읽고 정합니다. +
    8. +
    +
    +
  • +
+

마지막 차례의 「Microsoft 클라우드에 동기화」 안내는 새 Outlook 이 띄우는 것입니다 — 웹메일은 막지 못합니다.

+
+
+
+

썬더버드를 펼쳤을 때

+

전환 뒤 — 주소와 앱 비밀번호만으로 스스로 설정

+
+
    +
  • +

    + +

    +
    +
      +
    1. + + 썬더버드에서 [≡] → [새로 만들기] → [기존 메일 계정]을 누릅니다. +
    2. +
    3. + + 이름 · 메일 주소 · 비밀번호(앱 비밀번호)를 넣고 [계속]을 누릅니다. +
    4. +
    5. + + 메일 주소와 앱 비밀번호만 넣으면 설정을 스스로 찾습니다 — 「IMAP」이 보이면 [완료]를 누릅니다. +
    6. +
    7. + + 찾지 못하면 [수동 설정]에서 위 설정값을 넣습니다. +
    8. +
    +
    +
  • +
+

자동 설정 전(지금)에는 찾지 못해 [수동 설정]으로 갑니다 — 4번 차례.

+
+
+ +
+
+

좁은 폭

+

휴대폰 · 좁은 창 — 콘텐츠 열 360 · 설정값은 이름 위 · 값 아래

+
+
+
+
+

메일 프로그램 연결

+

아웃룩 · 썬더버드 · 휴대폰 메일 앱에서 회사 메일을 보는 방법입니다.

+
+
+

메일 프로그램 설정값

+
+
+
받는 메일 (IMAP)
+
mx1.arasystem.it.kr · 포트 993 · SSL/TLS
+
+
+
보내는 메일 (SMTP)
+
mx1.arasystem.it.kr · 포트 465 · SSL/TLS
+
+
+
사용자 이름
+
ara.kim@arasystem.it.kr — 메일 주소 전체
+
+
+
비밀번호
+
앱 비밀번호 — 웹메일 비밀번호가 아닙니다
+
+
+
+
+ +
+
+ 비밀번호 칸에는 꼭 앱 비밀번호를 넣어 주세요 + 2단계 인증을 켜 두어 웹메일 비밀번호로는 연결되지 않습니다. 프로그램마다 앱 비밀번호를 하나씩 만들어 넣습니다. +
+ 앱 비밀번호 만들기 +
+
+
+
+

프로그램별 연결 차례

+

쓰는 프로그램을 누르면 차례가 펼쳐집니다.

+
+
+ +
+
+ 새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받습니다 — 회사 정책에 따라 쓰지 않는 것이 좋습니다. +
+
+
+
    +
  • +

    + +

    +
  • +
  • +

    + +

    +
  • +
  • +

    + +

    +
  • +
  • +

    + +

    +
    +
      +
    1. + + [설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일]) +
    2. +
    3. + + 이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다. +
    4. +
    5. + + 「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 mx1.arasystem.it.kr, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다. +
    6. +
    7. + + [다음] → [저장]을 누릅니다. 포트는 스스로 993 · 465 로 맞춥니다. +
    8. +
    +
    +
  • +
  • +

    + +

    +
  • +
+
+
+
+

설정 메뉴는 콘텐츠 위로 올라갑니다(지금 설정 셸과 같은 줄바꿈). 펼침 줄의 이름 · 설명이 줄을 바꿔도 누르는 곳은 줄 전체입니다.

+
+
+ +
+ +
+
+

iPhone · iPad 를 펼쳤을 때

+

설정 › 메일 › 계정 추가 › 기타

+
+
    +
  • +

    + +

    +
    +
      +
    1. + + [설정] → [메일] → [계정] → [계정 추가] → [기타] → [메일 계정 추가]를 누릅니다. (iOS 18 은 [설정] → [앱] → [메일]) +
    2. +
    3. + + 이름 · 메일 주소 · 암호(앱 비밀번호) · 설명을 넣고 [다음]을 누릅니다. +
    4. +
    5. + + 「IMAP」을 고르고, 받는 · 보내는 메일 서버의 호스트 이름에 mx1.arasystem.it.kr, 사용자 이름에 메일 주소 전체, 암호에 앱 비밀번호를 넣습니다 — 보내는 메일 서버도 「선택 사항」이라 보여도 비우지 않습니다. +
    6. +
    7. + + [다음] → [저장]을 누릅니다. 포트는 스스로 993 · 465 로 맞춥니다. +
    8. +
    +
    +
  • +
+

보내는 메일 서버의 사용자 이름 · 암호를 비우면 받기만 되고 보내기가 막힙니다 — 3번 차례에 적었습니다.

+
+ +
+
+

안드로이드를 펼쳤을 때

+

Gmail 앱 › 다른 계정(IMAP)

+
+
    +
  • +

    + +

    +
    +
      +
    1. + + Gmail 앱에서 프로필 사진 → [다른 계정 추가] → [기타]를 누릅니다. +
    2. +
    3. + + 메일 주소를 넣고 [수동 설정] → 「개인(IMAP)」을 고릅니다. +
    4. +
    5. + + 비밀번호에 앱 비밀번호를 넣습니다. +
    6. +
    7. + + 받는 서버 · 보내는 서버에 mx1.arasystem.it.kr, 포트는 993 · 465, 보안 유형은 「SSL/TLS」를 넣고 [다음]을 누릅니다. +
    8. +
    +
    +
  • +
+

휴대폰 Outlook 앱은 주의 줄과 같은 까닭으로 넣지 않았습니다.

+
+
+ +
+ +
+
+

정한 것 · 규칙

+
+
    +
  • 메뉴: 계정 › 비밀번호 · 2단계 인증 · 앱 비밀번호 · 메일 프로그램 연결 · 로그인 기록. 새 항목 아이콘은 PC · 휴대폰.
  • +
  • 화면 상수(MAIL_CLIENT_SETTINGS)로만 그립니다 — 앱 비밀번호 화면의 설정값 상자와 같은 값. 2단계 인증 상태는 이미 있는 GET /api/two-factor 로 보고, 받지 못하면 켠 직원의 안내(앱 비밀번호 꼭)로 둡니다.
  • +
  • 펼침 줄은 52(이름 · 설명 두 줄) · 처음에는 모두 접힘 · 하나를 펼치면 다른 것은 접힙니다. 위 화면은 아웃룩을 펼친 모습입니다.
  • +
  • 차례는 글만 — 화면 그림은 넣지 않습니다. 맥 메일 · 휴대폰 Outlook 앱은 차례에 넣지 않습니다. 메뉴 이름은 구현 때 실제 앱(한국어판)과 한 번 더 맞춥니다.
  • +
  • 버튼 이름은 [ ], 고를 것은 「 」 — 12단계 「켜는 차례」와 같은 표기.
  • +
  • 앱 비밀번호 화면(12단계)의 설정값 상자 아래에 바로가기 한 줄 「프로그램별 연결 방법은 메일 프로그램 연결에서 볼 수 있습니다.」를 더합니다 — 12단계 보드는 그대로 두고 README 에 적었습니다.
  • +
+
+ +
+
+

전 직원 전환 때 함께

+
+
    +
  • 썬더버드의 「설정을 스스로 찾습니다」와 아웃룩의 자동 설정은 전환 때 autoconfig · autodiscover 주소와 SRV 레코드를 낸 뒤부터 됩니다 — 그 전에는 썬더버드도 [수동 설정]입니다.
  • +
  • 새 Outlook · 휴대폰 Outlook 앱은 권하지 않음으로 정했습니다(2026-10-31) — 회사가 허용하면 주의 줄을 지웁니다.
  • +
  • 호스트 mx1.arasystem.it.kr 은 전환 때 다시 봅니다(앱 비밀번호 화면과 같은 상수).
  • +
+
+
+
+
+
+ + + diff --git a/docs/design/webmail/canvas.json b/docs/design/webmail/canvas.json index dff128e..666046b 100644 --- a/docs/design/webmail/canvas.json +++ b/docs/design/webmail/canvas.json @@ -42,9 +42,13 @@ "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": "설정 · 로그인 기록" } + "SettingsLoginHistory.dc.html": { "x": 4560, "y": 21880, "w": 1440, "h": 2300, "title": "설정 · 로그인 기록" }, + "LoginPasswordChange.dc.html": { "x": 0, "y": 25610, "w": 1440, "h": 2900, "title": "로그인 · 새 비밀번호 정하기" }, + "LoginTwoFactorSetup.dc.html": { "x": 1520, "y": 25610, "w": 1440, "h": 4290, "title": "로그인 · 2단계 인증 켜기(필수)" }, + "SettingsMailClients.dc.html": { "x": 3040, "y": 25610, "w": 1440, "h": 3600, "title": "설정 · 메일 프로그램 연결" }, + "MailNoticesCutover.dc.html": { "x": 4560, "y": 25610, "w": 1440, "h": 3780, "title": "알림 · 안내 모음(13단계)" } }, - "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"], + "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", "LoginPasswordChange.dc.html", "LoginTwoFactorSetup.dc.html", "SettingsMailClients.dc.html", "MailNoticesCutover.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 }, @@ -56,7 +60,8 @@ "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단계 화면 — 대용량 첨부 (쓰기 · 본문 덩이 · 읽기 · 내려받기 화면 · 설정) — 2026-10-30 확정", "kind": "title1", "maxW": 7520 }, - "phase12": { "x": 0, "y": 21580, "text": "12단계 화면 — 계정 보안(2단계 인증 · 앱 비밀번호 · 로그인 기록) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 } + "phase12": { "x": 0, "y": 21580, "text": "12단계 화면 — 계정 보안(2단계 인증 · 앱 비밀번호 · 로그인 기록) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 }, + "phase13": { "x": 0, "y": 25310, "text": "13단계 화면 — 전환 준비(새 비밀번호 · 2단계 필수 · 메일 프로그램 연결 · 알림) — 2026-10-31 확정", "kind": "title1", "maxW": 6000 } }, "designSystems": [] } diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 9a078ae..6f0d2f3 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` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를 더한다) + (11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를, 13단계 — 6절 "전환 준비"가 `AUTH_010` 을 더한다) - **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, @@ -2040,7 +2040,7 @@ interface FileUploadCreate { 기간 고르기 · 연장, 다시 보내기(다시 올리지 않고 새 링크), 받는 사람 확인 코드 · 비밀번호, 사내만(로그인해야 받기), 받는 사람별 링크 · 기록, 처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기, 파일 하나만 끊기(끊기는 링크 단위), 설정 목록의 [링크 복사](토큰을 되살릴 수 없다 — 보낸 메일의 덩이에서), 대용량 첨부만 있는 메일의 목록 클립, 링크 → 일반 첨부로 되돌리기, 전달 원본 · 임시 저장본의 엔진 첨부를 링크로 바꾸기, 인용 속 원래 링크를 다시 싣기, -관리자 화면 · 퇴사자 처리(신고를 받은 링크는 그때까지 운영 스크립트로 끊는다 — 구현하며 정함), 보낸 메일을 지울 때 파일도 지우기, 모바일 앱, 파일 저장소 연동, +관리자 화면 · 퇴사자 처리(신고를 받은 링크 끊기 · 퇴사자 잠금은 13단계의 운영 도구 — 6절), 보낸 메일을 지울 때 파일도 지우기, 모바일 앱, 파일 저장소 연동, 다른 PC · 다른 브라우저에서 이어 올리기(같은 브라우저에서 같은 파일을 다시 고르면 이어진다). ### 정한 것 (열린 질문이었던 것 — 2026-10-30 추천대로) @@ -2121,7 +2121,7 @@ interface FileUploadCreate { 있으면: 다시 쓴 코드인지 보고 → 코드 실패 한 번을 차지하고 → 엔진 `POST /api/auth`(`accountSecret` + `mfaToken`)에 묻는다. `authenticated` 면 로그인한다(차지한 것을 돌려주고 코드를 2분 기억). `failure` 면 `AUTH_006`. 5. 401 → `AUTH_004`. -- **지금은 402 를 엔진 장애(503 `MAIL_001`)로 다룬다 — 고친다.** 운영자가 엔진 관리 화면에서 OTP 를 켠 직원은 지금 웹메일에 들어오지 못한다. 4단계 비밀번호 바꾸기도 같다. +- **12단계 전에는 402 를 엔진 장애(503 `MAIL_001`)로 다뤘다 — 12단계에서 고쳤다.** 그때는 운영자가 엔진 관리 화면에서 OTP 를 켠 직원이 웹메일에 들어오지 못했고, 4단계 비밀번호 바꾸기도 같았다. - 코드 단계에서도 비밀번호를 엔진 Basic 으로 먼저 보는 까닭: `/api/auth` 의 `failure` 는 비밀번호 틀림과 코드 틀림을 가르지 않는다. 비밀번호를 추측하는 요청이 코드 실패로 세지지 않게 한다. - `twoFactorRequired` 는 비밀번호가 맞았다는 뜻이다(2단계 로그인의 성질 — 코드 없이는 들어오지 못한다). 비밀번호 추측은 1절의 실패 한도가 그대로 막는다. - 엔진 `/api/auth` 가 성공하면 엔진 메모리에 1회용 OAuth 코드가 남는다(쓰지 않으면 10분 뒤 사라진다) — 쓰지 않는다. @@ -2375,3 +2375,160 @@ interface FileUploadCreate { 3. 2단계를 켜거나 꺼도 다른 세션을 끊지 않는다 — 로그인 기록 화면에서 따로 끊는다. 4. 비밀번호를 바꾸면 앱 비밀번호도 모두 지운다(위 "비밀번호 바꾸기"). 5. 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 브라우저"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다. + +--- + +## 6. 전환 준비 — 13단계 (2026-10-31 확정) + +> 운영 도구 · 계정 잠금 · 비밀번호 바꾸기 강제 · 2단계 인증 필수 · 받은편지함 알림 · 문의 연락처. +> 백업 · 감시 · 휴지통 비우기 시각 · 메일 프로그램 자동 설정 · 관리자 계정 단단히는 인프라 몫이다(mail/README). +> 근거: 개발 엔진 실험(2026-10-31) — 계정 막기 변형 표, 비밀번호 만료 동작, 관리자 대리 로그인, 엔진 변경과 웹메일 세션. + +### 공유 계약을 바꾸는 곳 (한데 모음) + +- `POST /api/auth/login`(`signedIn`) · `GET /api/auth/me` · `POST /api/auth/refresh` 응답에 `restriction`(`null` · `passwordChange` · `twoFactorSetup`)과 + `twoFactorDeadline`(2단계 필수의 유예 중이면 시작일 0시(KST)의 시각 — ISO 8601 UTC, 아니면 `null`)을 더한다. +- 제한된 세션으로 다른 API 를 부르면 403 `AUTH_010` 이다. +- `GET /api/two-factor` 응답에 `isRequired`(이 직원에게 필수인가) · `deadline`. +- 새 공개 API `GET /api/support-contact`(로그인 없음). +- 운영 도구(서버 안 명령)는 API 가 아니다 — 아래 "운영 도구". +- 에러 코드 `AUTH_010`. + +### 엔진 사실 (이 절이 기대는 것 — 0.16.27) + +- 엔진 계정에는 "끄기" 칸이 없다. 로그인 권한(`authenticate`)을 빼면 비밀번호 · 앱 비밀번호 · 대리 접근(웹메일)이 모두 막히고 메일은 계속 받는다. 되돌리기 쉽다. +- 비밀번호 자격의 만료(`expiresAt` 을 지난 날로)는 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다: 본인 Basic 은 200 이지만 권한이 다섯 + (`authenticate` · `authenticateWithAlias` · `sysAccountPasswordGet` · `sysAccountPasswordUpdate` · `emailReceive`)뿐이고, 본인이 바꾸면 엔진이 만료를 스스로 푼다. 앱 비밀번호는 영향이 없다(자체 만료). + - 웹메일(대리 접근)도 세션은 열리지만 메일 메서드가 `forbidden` 이다 — 그대로면 로그인 뒤 메일 오류만 보인다. 그래서 서버가 로그인 때 알아채 새 비밀번호 단계로 보낸다(아래 "비밀번호 바꾸기 강제"). + - 만료된 동안은 대리 접근으로 메일을 넣거나 앱 비밀번호를 지우는 일도 막힐 것이다(구현 때 확인) — 그래서 운영 도구는 그 일을 만료를 걸기 전에 한다(아래 "운영 도구"). +- 엔진에서 비밀번호를 바꾸거나 막아도 웹메일 세션은 끊기지 않는다. 갱신이 게이트웨이로 계정이 있는지만 보고, 엔진에는 바뀐 것을 알리는 칸이 없다. + - 그래서 운영 도구가 세션을 직접 끊는다. +- 엔진에는 2단계 인증을 강제하는 설정이 없다 — 서버 정책으로 둔다. +- 대리 접근으로 직원 받은편지함에 메일을 넣을 수 있다(`Email/import`). 안 읽음 · 실시간 알림이 가고, 필터 · 스팸 판정은 거치지 않는다. + +### 제한된 세션 + +- 로그인은 성공해 쿠키를 받지만 세션에 제한이 붙는다. 제한은 서버(세션 표)에 있고, 액세스 토큰에도 싣는다. +- 제한된 세션으로 부를 수 있는 것 + - `/api/auth/*`(me · refresh · logout · password · sessions) + - `/api/two-factor` · `/api/two-factor/enrollments` · `/api/two-factor/activation` + - `/api/support-contact` + - 그 밖은 403 `AUTH_010` 이다. +- 차례: `passwordChange` 가 먼저, 그다음 `twoFactorSetup`. +- 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 성공하면 서버가 세션의 제한을 지운다. 클라이언트는 곧바로 `/auth/refresh` 로 새 토큰을 받는다. + - 다음 제한이 남아 있으면 그 제한이 붙어 온다. +- 새로고침 · 다른 탭에서도 같은 제한이다(서버가 막는다). 화면은 `me` 의 `restriction` 을 보고 로그인 틀의 그 단계로 간다. +- 클라이언트는 403 `AUTH_010` 을 받으면 알림 없이 `me` 를 다시 받아 그 제한의 단계로 간다. 제한된 동안은 실시간 알림(`/api/mail-events`)도 열지 않는다 — 제한이 풀린 뒤 연다. + +```jsonc +// POST /api/auth/login → 200 + Set-Cookie — 제한된 세션도 쿠키를 받는다 +{ "status": "signedIn", "email": "ara.kim@arasystem.it.kr", "name": "김아라", "restriction": "passwordChange", "twoFactorDeadline": null } +// GET /api/auth/me → 200 — 2단계 필수의 유예 중(시작일 2026-12-01) +{ "email": "ara.kim@arasystem.it.kr", "name": "김아라", "restriction": null, "twoFactorDeadline": "2026-11-30T15:00:00.000Z" } +``` + +### 비밀번호 바꾸기 강제 (`restriction: passwordChange`) + +- 언제: 엔진의 비밀번호가 만료됐을 때다. 운영 도구의 입사 · 비밀번호 재설정이 건다. +- 알아채는 법: 로그인 때 본인 자격으로 엔진에 묻는다(권한이 비밀번호 바꾸기뿐인가 — 방법은 구현 때 정한다). + - 알아챈 제한은 세션에 남는다 — 갱신은 게이트웨이라 다시 알아채지 못하므로. +- 푸는 법: `PUT /api/auth/password` — 지금 비밀번호는 임시 비밀번호, 새 비밀번호, 2단계를 켰으면 `otpCode`. + - 성공하면 엔진의 만료가 풀리고 세션 제한을 지운다. + - 다른 세션 끊기 · 앱 비밀번호 지우기는 5절 규칙 그대로다. +- **클라이언트**: 로그인 직후면 방금 넣은 비밀번호를 화면 메모리(반응형이 아닌 클로저 — 5절 코드 단계와 같다)에서 지금 비밀번호로 쓴다. + 10분이 지났거나 새로고침 · 다른 탭이면 「임시 비밀번호」 칸을 보인다. +- 2단계를 켠 직원은 비밀번호 → 코드 → 이 단계이고, 이 단계에서 코드를 한 번 더 받는다(`otpCode` — 엔진이 비밀번호를 바꿀 때 코드를 요구한다). + 로그인 때 쓴 코드는 2분 동안 다시 받지 않으므로(5절) 앱의 다음 코드를 넣는다. 서버가 로그인 코드를 기억해 다시 쓰지 않는다 — 새 비밀번호를 치는 사이 코드의 유효 시간(앞뒤 30초)을 지날 수 있다. +- 엔진이 만료와 2단계 중 무엇을 먼저 보는지는 구현 때 확인한다. + +### 2단계 인증 필수 (`restriction: twoFactorSetup`) + +- 정책은 서버 설정이고 운영 도구로 바꾼다: 꺼짐 / 켜짐 + 시작일 + 예외 주소. 처음 값은 꺼짐이다. +- 시작일 전(유예): 로그인은 그대로다. `twoFactorDeadline` 에 시작일이 오고, 화면이 메일함 위에 안내 줄을 보인다(날짜는 그 1ms 앞 — 시작일 전날, 부재중의 끝나는 날과 같은 규칙). +- 시작일부터: 2단계를 켜지 않은 직원(예외 주소 빼고)의 로그인에 `restriction: twoFactorSetup` 이 붙는다. 화면은 로그인 틀의 켜기 단계로 간다. + - 로그인은 엔진의 답(402 면 켜짐)으로, 갱신은 정책과 우리 기록(`two_factor_settings`)으로 다시 본다 — 시작일이 지나면 이미 로그인한 세션도 다음 갱신(최대 1시간) 때 제한이 붙는다. + - 켜기는 12단계 API(`enrollments` · `activation`) 그대로다. 지금 비밀번호는 위 "클라이언트"와 같은 규칙이다. + - 켜면 세션 제한을 지운다. 화면은 받은편지함으로 가며 [앱 비밀번호 만들기] 알림을 띄운다(디자인 README 13단계). +- `GET /api/two-factor` 의 `isRequired` · `deadline` 으로 설정 화면도 같은 안내를 보인다. 필수인 직원은 끄기 단추가 없다 — 서버도 400 `VAL_001` 로 거절한다. +- 필수인 직원이 휴대폰을 바꿀 때는 관리자가 운영 도구의 「2단계 해제」를 하고, 다음 로그인 때 새 휴대폰으로 켠다(직원이 켜진 채 새 휴대폰을 등록하는 길은 이 단계에 없다). +- 메일 프로그램은 앱 비밀번호로 연결한다(12단계 그대로). + +### 문의 연락처 (`GET /api/support-contact`) + +```jsonc +// GET /api/support-contact → 200 (로그인 없음) +{ "text": "IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it" } // 정하지 않았으면 null +``` + +- 운영 도구로 바꾼다(서버 설정). 200자 이하 · 한 줄이다. +- 화면은 글자로만 보인다(링크 · HTML 없음): 로그인 화면(「비밀번호를 잊으셨나요?」 아래), 코드 단계 · 2단계 켜기(「인증 앱을 쓸 수 없나요?」 아래), 받은편지함 알림 메일의 마지막 줄. + 정하지 않았으면 지금 글 그대로다(디자인 README 13단계). 잠긴 계정도 비밀번호 틀림과 같은 `AUTH_004` 라 같은 줄로 안내한다. + +### 받은편지함 알림 메일 (시스템 메일) + +- 서버가 대리 접근으로 그 직원의 받은편지함에 넣는다(`Email/import`). + - 안 읽음으로, 받은 시각은 지금이다. + - 필터 · 스팸 판정을 거치지 않고, 실시간 알림이 간다. +- 보낸 사람은 `ARA SYSTEM >` 이다. 엔진 안이라 SPF · DKIM 과 상관없다. +- 보내는 때 + - 운영 도구의 비밀번호 재설정 · 2단계 해제 + - 위험 파일 판정 — 11단계에서 미룬 것. 그 파일을 올린 직원에게 보낸다. +- 넣기에 실패해도 원래 일은 성공으로 둔다(warn 로그). +- 본문은 짧은 HTML(보내는 정화 허용 목록 안) + 같은 글의 텍스트다. 제목 · 문구는 디자인 README 13단계를 그대로 쓴다. +- **링크를 넣지 않는다** — 할 일은 메뉴 경로를 글로 적는다(「웹메일 › 설정 › 계정 › 앱 비밀번호」 등). 직원에게 "회사 보안 알림에는 링크가 없다"는 습관을 주어 링크 달린 피싱 메일이 눈에 띄게 한다. +- 위험 파일 알림은 파일마다 한 번 — 위험 판정을 처음 기록할 때만 보낸다. + +### 운영 도구 (서버 안 명령) + +- 실행: `sudo docker exec -it -backend node dist/cli/ops.js`. 운영 이미지에 들어 있다. + - 한국어 메뉴로 묻고, 바뀔 것을 보여 주고, y/N 으로 확인받은 뒤 실행한다. + - 엔진 관리 API(관리자 Basic — `MAIL_JMAP_URL`)와 우리 DB · 서비스를 함께 쓴다. +- 처음에 묻는 것 + - 운영자 이름 — 실행 기록에 남긴다. + - 관리자 비밀번호 — 저장하지 않는다. 화면에 보이지 않게 넣는다. +- 실행 기록 표(`ops_audit_log`): 언제 · 누가 · 명령 · 대상 · 결과. 비밀은 남기지 않는다. +- 명령 + +| 명령 | 하는 일 | +| --- | --- | +| 입사 | 주소 · 이름 · 메일함 용량 → 엔진 계정(직원 역할) + 임시 비밀번호(무작위 — 한 번만 보여 줌) + 만료(첫 로그인 때 바꾸기) | +| 비밀번호 재설정 | 임시 비밀번호 + 만료, 웹메일 세션 모두 끊기, 앱 비밀번호 지우기, 받은편지함 알림. 2단계는 그대로다(따로 해제) | +| 2단계 해제 | 휴대폰 분실 — 엔진 OTP 지우기 + `enabledAt` 정리 + 받은편지함 알림 | +| 잠금 | 퇴사 · 털림 — 엔진 `authenticate` 빼기, 웹메일 세션 모두 끊기. 묻고: 그 직원의 공개 링크 끊기, 받는 메일 넘기기(Sieve `redirect :copy`), 자동 응답. 메일은 계속 받는다 | +| 잠금 풀기 | `authenticate` 되돌리기. 비밀번호 재설정을 함께 권한다 | +| 퇴사 마무리 | 잠긴 계정만. 엔진 계정 지우기(메일 영구 삭제 — 주소를 다시 쳐서 확인) + 우리 DB 의 그 주소 행 정리 — 같은 주소를 다시 쓸 때 섞이지 않게 | +| 링크 끊기 | 신고된 공개 링크 주소로 → `revoked_by = operator` | +| 공용 주소 | MailingList 만들기 · 구성원 넣기 / 빼기 · 목록(구성원 받은편지함으로 복사) | +| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 | +| 정책 · 설정 | 2단계 필수(켜기 · 시작일 · 예외), 문의 연락처 | + +- 차례가 있는 명령 + - 비밀번호 재설정: 앱 비밀번호 지우기 · 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 웹메일 세션 끊기. 만료 뒤에는 대리 접근 메서드가 막히기 때문이다. 엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. + - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근으로 Sieve)을 먼저 걸고 → `authenticate` 빼기 → 세션 끊기. 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). + - 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 세션 · 사내 목록 기록 · 대용량 파일 · 링크. 실행 기록(`ops_audit_log`)은 남긴다. +- 관리자 계정에서 대리 접근 권한(`impersonate`)을 빼므로(인프라 — mail/README) 도구가 직원의 메일함 · Sieve · 앱 비밀번호를 다룰 때는 웹메일과 같은 게이트웨이 대리 접근을 쓴다. 관리자 Basic 은 계정 관리 API(`x:Account` · `x:MailingList`)에만 쓴다. +- **도구로 끊은 세션도 15초 안에 막힌다.** 세션 끊기는 DB 로 하고, 서버는 끊은 세션 기억을 15초마다 DB 에서 채운다(5절의 기억에 더한다). +- 잠금은 도구로 한다. 엔진 관리 화면에서 `authenticate` 만 빼면 웹메일 세션이 남는다 — 갱신이 503 `MAIL_001` 을 내며 세션을 그대로 둔다(엔진의 대리 접근 403 을 설정 문제로 본다). + +### 에러 코드 (13단계에 더함) + +| 코드 | HTTP | 상황 | 문구 | +| --- | --- | --- | --- | +| `AUTH_010` | 403 | 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)으로 다른 API | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." | + +### 휴지통 · 스팸 30일 · 메일 프로그램 연결 + +- 휴지통 · 스팸메일함의 메일은 엔진이 지운다: 그 메일함에 들어간 날부터 30일, 매일 03:00 KST(setup). API 는 없고 화면 안내 줄만 둔다. +- 메일 프로그램 연결 화면은 화면 상수(`MAIL_CLIENT_SETTINGS`)로만 그린다. API 는 없다. 2단계 인증 상태는 `GET /api/two-factor` 로 보고, 받지 못하면 켠 직원의 안내(앱 비밀번호 꼭)로 둔다. + - 앱 비밀번호 화면(5절)의 설정값 상자 아래에 이 화면으로 가는 바로가기를 둔다. + - 새 Outlook · 휴대폰 Outlook 앱은 메일을 Microsoft 클라우드를 거쳐 받으므로 권하지 않는다고 안내한다(회사가 허용하면 그 줄을 지운다). + +### 확장 때 옮길 것 + +- 2단계 필수 정책 · 문의 연락처는 DB 라 그대로 맞다. +- 끊은 세션 기억은 15초 DB 동기화라 인스턴스를 늘려도 15초 안에 맞는다. + +### 이 단계에서 하지 않는 것 + +웹메일 관리자 화면(공통 기반 때), 비밀번호 스스로 재설정(복구 연락처 필요), 직원이 켜진 채 새 휴대폰으로 바꾸기(관리자의 「2단계 해제」로 한다), +공용 주소로 보내기 · 공유 메일함(Group), 새 기기 로그인 알림 메일, 관리자 열람 기능, 주소 바꾸기, 모바일 앱. -- 2.52.0 From 73babeaa2c7e5b82d70bbdec423b05e30c1d4e6d Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 13:44:44 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(docker):=2013=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EA=B8=B0=EB=B0=98=20=E2=80=94=20=EB=B0=B1?= =?UTF-8?q?=EC=97=85=20=C2=B7=20=EA=B0=90=EC=8B=9C=20=C2=B7=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=8B=A8=EB=8B=A8=ED=9E=88=20=C2=B7=20?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5=2030=EC=9D=BC=20=C2=B7=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전 직원 전환 전에 서버가 스스로 지키고 되살릴 수 있게 했다. - 관리자 단단히(setup): 관리자 역할에서 impersonate 를 빼고 관리자 허용 IP 를 앱 네트워크로 — 관리자 비밀번호로 바깥 993 · JMAP 에서 직원 메일함에 대리 로그인되던 길을 막았다. 운영에서 처음 걸 때는 되돌릴 복구 관리자가 있을 때만 건다(관리자 잠김 방지). - 휴지통 · 스팸 30일 정리를 KST 03:00 으로 명시. imapsync 로 옮긴 메일도 옮긴 때부터 센다(임시 엔진 실측). - restic 백업(호스트 systemd 03:30 — 메일 볼륨 warm → 엔진 멈춤 9초 안팎 → final, pg_dump, 업로드, env) · 복원(새 서버 차례를 빈 클론에서 실측) · 15분 점검 · 부팅 되살리기, Healthchecks 하트비트. - 메일 프로그램 자동 설정: frontend nginx 의 autoconfig · autodiscover 고정 응답(엔진 것은 POP3 를 알리고 입력을 되돌려 쓴다). - 모든 서비스 docker 로그 보존, DB 이미지 판 · digest 고정. Co-Authored-By: Claude Opus 5.5 --- .env.development.example | 6 +- .env.production.example | 6 +- .github/workflows/ci.yml | 104 +++++- .gitignore | 3 + docker-compose.dev.yml | 9 + docker-compose.remote-proxy.yml | 7 + docker-compose.yml | 45 ++- frontend/nginx-env.envsh | 59 ++- frontend/nginx.conf | 81 +++++ mail/README.md | 395 ++++++++++++++++++++- mail/scripts/setup.mjs | 324 ++++++++++++++++- ops/backup/arasystem-backup.sh | 376 ++++++++++++++++++++ ops/backup/arasystem-restore.sh | 321 +++++++++++++++++ ops/lib/common.sh | 141 ++++++++ ops/monitor/arasystem-check.sh | 246 +++++++++++++ ops/ops.env.example | 65 ++++ ops/systemd/arasystem-backup.service | 28 ++ ops/systemd/arasystem-backup.timer | 13 + ops/systemd/arasystem-check.service | 16 + ops/systemd/arasystem-check.timer | 10 + ops/systemd/arasystem-mail-recover.service | 28 ++ 21 files changed, 2249 insertions(+), 34 deletions(-) create mode 100644 ops/backup/arasystem-backup.sh create mode 100644 ops/backup/arasystem-restore.sh create mode 100644 ops/lib/common.sh create mode 100644 ops/monitor/arasystem-check.sh create mode 100644 ops/ops.env.example create mode 100644 ops/systemd/arasystem-backup.service create mode 100644 ops/systemd/arasystem-backup.timer create mode 100644 ops/systemd/arasystem-check.service create mode 100644 ops/systemd/arasystem-check.timer create mode 100644 ops/systemd/arasystem-mail-recover.service diff --git a/.env.development.example b/.env.development.example index 110a8af..48d0208 100644 --- a/.env.development.example +++ b/.env.development.example @@ -40,7 +40,11 @@ MAIL_RESOLVER_IP=172.16.73.254 # ========================================== # Database 설정 # ========================================== -DB_IMAGE=postgres:15-alpine +# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 — +# 바꾸면 다음 up 에서 db 가 다시 만들어진다(데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다) +# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 이미지로는 2주가 2026-11-04 에 찬다 +# (개발은 지금 써도 된다 — 운영에 올리는 것은 11-04 뒤, .env.production.example 의 같은 줄) +DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984 DB_USER=app_user DB_PASSWORD=change-me-in-local-env DB_NAME=project_db diff --git a/.env.production.example b/.env.production.example index be63457..6801f32 100644 --- a/.env.production.example +++ b/.env.production.example @@ -38,7 +38,11 @@ MAIL_RESOLVER_IP=172.16.73.254 # ========================================== # Database 설정 # ========================================== -DB_IMAGE=postgres:15-alpine +# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 — +# 바꾸면 다음 up 에서 db 가 다시 만들어진다(몇 초 끊김 · 데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다). +# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 2주 규칙으로 운영에 올리는 것은 2026-11-04 뒤. +# 올리기 전에 백업이 그날 돌았는지 본다 (mail/README.md "백업" · "13단계 전부터 운영 중인 서버") +DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984 DB_USER=app_user DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY DB_NAME=project_db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c957839..dc9455c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: run: npm run build-only infra: - name: Infra (compose · Dockerfile · nginx) + name: Infra (compose · Dockerfile · nginx · 운영 스크립트) runs-on: ubuntu-latest # 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다 timeout-minutes: 10 @@ -150,6 +150,22 @@ jobs: if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1 fi + # 로그 보존(13단계) — 모든 서비스에 logging 이 있어야 한다(없으면 도커 기본 — 회전 없이 디스크를 채운다). + # 서비스는 두 칸, 그 키는 네 칸 들여 쓴다(compose config 의 출력 꼴) + # 리버스 프록시가 다른 서버일 때의 조합(remote-proxy — frontend-bind-check 가 더해진다)도 센다. 게시 주소는 이 확인에서만 준다 + for args in "-f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile mail --profile redis --profile frontend --profile clamav" \ + "-f docker-compose.yml --env-file .env.production.example --profile mail --profile redis" \ + "-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example --profile mail --profile redis"; do + # shellcheck disable=SC2086 + services=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config --services | wc -l) + # shellcheck disable=SC2086 + logged=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config | awk '/^ logging:/ { c++ } END { print c + 0 }') + [ "$services" -eq "$logged" ] || { echo "::error::logging 이 없는 서비스가 있습니다 ($args — 서비스 $services · logging $logged)"; exit 1; } + done + # DB 이미지는 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정") + for envf in .env.development.example .env.production.example; do + grep -Eq '^DB_IMAGE=postgres:[0-9]+\.[0-9]+-alpine[0-9.]+@sha256:[0-9a-f]{64}$' "$envf" || { echo "::error::$envf 의 DB_IMAGE 가 판 · digest 까지 고정되지 않았습니다"; exit 1; } + done # docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다 # (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다 @@ -182,13 +198,35 @@ jobs: docker build --target nginx-config -t "$tag" frontend # 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf) docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t - # 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다 - docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -t - rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -T 2>/dev/null) + # 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다. + # 이 꼴은 메일 프로그램 자동 설정(13단계 — autoconfig. · autodiscover.)도 켠다 + docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t + rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -T 2>/dev/null) for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \ - 'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do + 'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;' \ + 'server_name autoconfig.ci.test autodiscover.ci.test;' 'mx1.ci.test' 'mx1.ci.test993'; do printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; } done + # 메일 값이 없으면 자동 설정은 꺼진다 — 어느 요청에도 맞지 않는 이름 + docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null | grep -F 'server_name autoconfig.invalid;' >/dev/null \ + || { echo "::error::MAIL_DOMAIN 없이 자동 설정 server 가 꺼지지 않았습니다"; exit 1; } + # 자동 설정의 값은 server_name · 응답 XML 에 그대로 들어간다 — 호스트 이름 꼴이 아니면 · 포털 · 공개 호스트와 겹치면 기동을 멈춰야 한다 + for value in 'ci.test;return 200' 'ci..test' '-ci.test' 'ci.test.' 'citest' 'ci test' 'ci.test/x'; do + if docker run --rm -e PROJECT_NAME=ci -e "MAIL_DOMAIN=$value" -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t >/dev/null 2>&1; then + echo "::error::MAIL_DOMAIN=$value 를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done + for value in 'mx1.ci.test' 'mx1.ci.test:993' 'mx1'; do + if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e "MAIL_HOSTNAME=$value" "$tag" nginx -t >/dev/null 2>&1; then + echo "::error::MAIL_HOSTNAME=$value 를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done + # 끝의 / · 빈칸 · 여러 출처도 같게 본다 (공개 호스트 판정과 같은 읽기) + for value in 'https://autoconfig.ci.test' 'https://autoconfig.ci.test/' 'https://portal.ci.test, https://AUTODISCOVER.ci.test/'; do + if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test -e "CORS_ORIGIN=$value" "$tag" nginx -t >/dev/null 2>&1; then + echo "::error::포털(CORS_ORIGIN=$value)과 겹치는 자동 설정 호스트를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done # 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다 # (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if) plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null) @@ -285,7 +323,7 @@ jobs: EOF docker build -t "$tag" "$ctx" docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \ - -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" + -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; } @@ -355,6 +393,59 @@ jobs: [ "$fails" -eq 0 ] EOF + # 메일 프로그램 자동 설정(13단계 — frontend/nginx.conf 의 셋째 server)을 실제 요청으로 본다 — 위 단계의 컨테이너(MAIL_DOMAIN=ci.test · MAIL_HOSTNAME=mx1.ci.test). + # 고정 응답이어야 한다 — 질의 · POST 본문에 꾸민 주소를 넣어도 응답에 되돌아오지 않고, 백엔드로 넘기지 않는다(/api 도 404) + - name: 메일 프로그램 자동 설정 시험 + run: | + name="ci-nginx-paths-${{ github.run_id }}" + docker exec -i "$name" sh -s <<'EOF' + fails=0 + # host method path [본문] → 응답 전체(머리글 + 본문, CR 없이). 본문이 있으면 XML 로 보낸다 + raw() { + { printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf 'Content-Type: text/xml\r\nContent-Length: %s\r\n' "${#4}"; printf 'Connection: close\r\n\r\n'; [ -n "$4" ] && printf '%s' "$4"; sleep 1; } \ + | timeout 10 nc 127.0.0.1 8080 | tr -d '\r' + } + ok() { echo "ok $1"; } + bad() { echo "FAIL $1"; fails=$((fails + 1)); } + has() { if printf '%s\n' "$2" | grep -F -- "$3" >/dev/null; then ok "$1 — '$3' 있음"; else bad "$1 — '$3' 없음"; fi; } + hasnt() { if printf '%s\n' "$2" | grep -F -i -- "$3" >/dev/null; then bad "$1 — '$3' 가 되돌아왔다"; else ok "$1 — '$3' 없음"; fi; } + INJ='%3Cx%3Ascript%20xmlns%3Ax%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3Ealert(1)%3C%2Fx%3Ascript%3E%40ci.test' + r=$(raw autoconfig.ci.test GET "/mail/config-v1.1.xml?emailaddress=$INJ") + has "Thunderbird" "$r" "HTTP/1.1 200"; has "Thunderbird" "$r" "Content-Type: text/xml; charset=utf-8" + has "Thunderbird" "$r" "mx1.ci.test"; has "Thunderbird" "$r" "993"; has "Thunderbird" "$r" "465" + has "Thunderbird" "$r" "%EMAILADDRESS%"; hasnt "Thunderbird" "$r" "script"; hasnt "Thunderbird" "$r" "pop3" + r=$(raw autoconfig.ci.test GET /.well-known/autoconfig/mail/config-v1.1.xml) + has ".well-known" "$r" "mx1.ci.test" + BODY='<script>evil@ci.testhttp://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' + for p in /autodiscover/autodiscover.xml /AutoDiscover/AutoDiscover.xml /Autodiscover/Autodiscover.xml; do + r=$(raw autodiscover.ci.test POST "$p" "$BODY") + has "Outlook $p" "$r" "HTTP/1.1 200"; has "Outlook $p" "$r" "mx1.ci.test993"; has "Outlook $p" "$r" "SMTP" + hasnt "Outlook $p" "$r" "evil"; hasnt "Outlook $p" "$r" "script" + done + for p in / /api/auth/me /index.html /s/x /autodiscover/autodiscover.json; do + r=$(raw autoconfig.ci.test GET "$p"); has "그 밖 $p" "$r" "HTTP/1.1 404" + done + # 포털 · 공개 호스트는 이 경로를 자동 설정으로 답하지 않는다 + r=$(raw portal.ci.test GET /mail/config-v1.1.xml); hasnt "포털" "$r" "/dev/null + docker run --rm "$tag" -x -P SCRIPTDIR -S style backup/arasystem-backup.sh backup/arasystem-restore.sh monitor/arasystem-check.sh lib/common.sh + for key in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do + grep -Eq "^${key}=change-me$" ops/ops.env.example || { echo "::error::ops/ops.env.example 의 $key 는 change-me 여야 합니다"; exit 1; } + done + if grep -Eq '^HC_(BACKUP|CHECK)_URL=.+' ops/ops.env.example; then + echo "::error::ops/ops.env.example 에 Healthchecks 주소가 들어 있습니다"; exit 1 + fi + # 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf. # 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만 # 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 — @@ -401,6 +492,7 @@ jobs: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true docker image rm -f "ci-clamav-config:${{ github.run_id }}" || true + docker image rm -f "ci-shellcheck:${{ github.run_id }}" || true # 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다) docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true diff --git a/.gitignore b/.gitignore index 602e317..0e9fd23 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ /redis-data/ # CI 러너 등록 정보 ci/runner-data/ +# 운영 서버의 백업 · 감시 설정(13단계 — S3 키 · Healthchecks 주소 · restic 비밀번호). 서버의 /etc/arasystem/ 에 두지만 저장소 안에 잘못 만들어도 올라가지 않게 +/ops/*.env +/ops/restic-password # 업로드 파일 (로컬 저장) — 코드가 아니다 backend/uploads/ # 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ce04651..a8b49fb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,3 +1,11 @@ +# 로그 보존 — docker-compose.yml 의 logging-default 와 같은 값 (앵커는 파일을 건너가지 않아 이 파일에만 있는 서비스 — mailpit — 에 다시 적는다) +x-logging: &logging-default + driver: json-file + options: + max-size: "10m" + max-file: "5" + compress: "true" + services: # ---------------------------------------- # frontend — dev 에서는 기본 기동하지 않는다 (profile 로 숨김) @@ -155,6 +163,7 @@ services: # 메일은 /tmp 의 임시 DB 에 저장되므로 쓰기 권한 문제가 없다 (컨테이너를 재시작하면 비워진다) user: "65534:65534" restart: "no" + logging: *logging-default # SMTP 는 내부망(mail 컨테이너)에서만 받는다 expose: - "1025" diff --git a/docker-compose.remote-proxy.yml b/docker-compose.remote-proxy.yml index c11f66e..ba32be2 100644 --- a/docker-compose.remote-proxy.yml +++ b/docker-compose.remote-proxy.yml @@ -49,6 +49,13 @@ services: cap_drop: - ALL restart: "no" + # 로그 보존 — docker-compose.yml 의 logging-default 와 같은 값 (한 번 돌고 끝나는 컨테이너라 몇 줄뿐이다) + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + compress: "true" environment: - FRONTEND_PRIVATE_BIND=${FRONTEND_PRIVATE_BIND:-} entrypoint: diff --git a/docker-compose.yml b/docker-compose.yml index 9e7ba68..209a2ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,30 @@ +# ---------------------------------------- +# 컨테이너 로그 보존 (13단계 — mail/README.md "로그 보존") — 모든 서비스에 명시한다 +# +# 도커 기본(json-file · 회전 없음)이면 로그 파일이 디스크를 끝없이 채운다. 크기로 회전하고(max-size), 정한 개수(max-file)만 남기며, +# 회전한 파일은 gzip 으로 줄인다(compress — docker logs 는 그대로 읽는다). json-file 은 줄마다 JSON 껍데기(시각 · stream)가 붙어 글의 1.4배 안팎이다. +# 기본(logging-default) 10m × 5 (컨테이너마다 글 35MB 안팎) — db · redis · mail-resolver · clamav · clamav-updater · (개발) mailpit +# 길게(logging-long) 20m × 10 (글 140MB 안팎) — mail · frontend · backend +# 메일 엔진 인증 · 거절 · 배달 기록. 직원 50명 · 하루 받는 메일 3,000통 · 보내는 메일 750통이면 하루 10MB 안팎 → 2주쯤 +# frontend nginx 접근 로그(쿼리 · 토큰 없음). 한 사람이 하루 1,500 요청이면 50명에 하루 20MB 안팎 → 1주쯤 +# backend 경고 · 오류 · 주요 일만(요청마다 남기지 않는다) — 몇 주 +# (줄 길이는 개발 엔진 151바이트 · backend 176바이트 — 2026-10-31. 직원 수 · 메일 양은 가정이다 — 운영에서 docker logs --since 24h <컨테이너> | wc -c 로 다시 잰다) +# 디스크는 모두 차도 0.75GB 안팎(회전한 파일은 압축돼 실제로는 그 몇 분의 1). 더 오래 둬야 하는 기록은 로그 수집기로 — 지금은 두지 않았다 +# 바꾼 값은 컨테이너를 다시 만들어야 걸린다(up -d 가 다시 만든다) +# ---------------------------------------- +x-logging: &logging-default + driver: json-file + options: + max-size: "10m" + max-file: "5" + compress: "true" +x-logging-long: &logging-long + driver: json-file + options: + max-size: "20m" + max-file: "10" + compress: "true" + services: # ---------------------------------------- # 1. Frontend (Nginx — 화면 정적 호스팅 + /api 프록시) — 운영의 유일한 입구 @@ -40,7 +67,12 @@ services: # 그 이름의 server 가 공개 화면(/s/)· 공개 API 만 열고 포털은 그 둘을 404 로 닫는다. 비었거나 같으면 포털이 함께 연다 (nginx-env.envsh) - SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-} - CORS_ORIGIN=${CORS_ORIGIN:-} + # 메일 프로그램 자동 설정(13단계) — autoconfig. · autodiscover. 에 IMAP · SMTP 호스트 MAIL_HOSTNAME 을 고정 XML 로 낸다. + # 둘 중 하나라도 비면 끈다 (nginx-env.envsh · mail/README.md "메일 프로그램 자동 설정") + - MAIL_DOMAIN=${MAIL_DOMAIN:-} + - MAIL_HOSTNAME=${MAIL_HOSTNAME:-} restart: unless-stopped + logging: *logging-long # 열린 파일 수 — nginx 의 연결마다 하나(워커마다 worker_connections 4096 — frontend/Dockerfile). 데몬 기본값(배포판 · 판마다 1024 일 수 있다)에 기대지 않는다 ulimits: nofile: @@ -163,6 +195,7 @@ services: - uploads:/app/uploads - files:/app/files restart: unless-stopped + logging: *logging-long # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다. # clamav 는 악성 검사기와만 잇는 internal 네트워크다 (7번) networks: @@ -198,6 +231,8 @@ services: # ---------------------------------------- db: container_name: ${PROJECT_NAME}-db + # 판 · digest 까지 고정한다(루트 env 의 DB_IMAGE — docker 스킬 "이미지 버전 고정"). 메이저(15)는 데이터 형식이라 그대로 둔다 — + # 올리려면 pg_dump → 새 메이저에 복원(mail/README.md "백업"의 복원 차례). 아래 watchtower 표시는 13단계에 뺐다(판을 사람이 올린다) image: ${DB_IMAGE} expose: - "5432" @@ -208,6 +243,7 @@ services: volumes: - ${DB_DATA_DIR}:/var/lib/postgresql/data restart: always + logging: *logging-default networks: - app-network healthcheck: @@ -221,8 +257,6 @@ services: limits: cpus: '0.5' memory: 512m - labels: - - "com.centurylinklabs.watchtower.enable=true" # ---------------------------------------- # 4. Redis — 내부 네트워크 전용 · 기본 비활성 @@ -241,6 +275,7 @@ services: volumes: - ${REDIS_DATA_DIR}:/data restart: always + logging: *logging-default networks: - app-network healthcheck: @@ -254,8 +289,6 @@ services: limits: cpus: '0.25' memory: 256m - labels: - - "com.centurylinklabs.watchtower.enable=true" # ---------------------------------------- # 5. Mail (Stalwart) — 사내 메일 엔진 · profile `mail` @@ -297,6 +330,7 @@ services: - mail-config:/etc/stalwart - mail-data:/var/lib/stalwart restart: unless-stopped + logging: *logging-long # 컨테이너의 DNS — 도커 DNS(127.0.0.11)가 도커 내부 이름(mailpit 등)은 직접 풀고 바깥 이름은 메일 전용 리졸버에 묻는다. # 엔진 자신의 DNS(x:DnsResolver)는 mail setup 이 맞춘다 — 운영은 이 리졸버에 바로(도커 DNS 를 거치지 않는다), 개발은 이 길(System) dns: @@ -348,6 +382,7 @@ services: # 이미지의 기본 설정을 통째로 바꾼다. DNSSEC 신뢰 앵커(/var/unbound/root.key)는 이미지의 것을 쓰고 컨테이너 안에서 고쳐 쓴다 - ./mail/resolver/unbound.conf:/etc/unbound/unbound.conf:ro restart: unless-stopped + logging: *logging-default networks: mail-dns: ipv4_address: ${MAIL_RESOLVER_IP:-172.16.73.254} @@ -400,6 +435,7 @@ services: - clamav-db:/var/lib/clamav:ro - ./mail/clamav/clamd.conf:/etc/clamav/clamd.conf:ro restart: unless-stopped + logging: *logging-default networks: - clamav # 서명이 볼륨에 생긴 뒤에 뜬다 — 처음 기동은 clamav-updater 가 서명을 받는 몇 분을 기다린다(없으면 clamd 가 바로 끝난다). @@ -441,6 +477,7 @@ services: - clamav-db:/var/lib/clamav - ./mail/clamav/freshclam.conf:/etc/clamav/freshclam.conf:ro restart: unless-stopped + logging: *logging-default networks: - clamav-egress healthcheck: diff --git a/frontend/nginx-env.envsh b/frontend/nginx-env.envsh index 3154c7b..e180442 100644 --- a/frontend/nginx-env.envsh +++ b/frontend/nginx-env.envsh @@ -133,5 +133,62 @@ else echo "nginx-env.envsh: 공개 내려받기 호스트 ${nginx_env_share_host} — 포털에서는 /s/ · /api/public-links/ 를 닫는다" fi +# ------------------------------------------ +# 메일 프로그램 자동 설정 (13단계 — mail/README.md "메일 프로그램 자동 설정") +# +# server 이름 autoconfig. · autodiscover., 알리는 호스트는 MAIL_HOSTNAME(엔진 인증서의 이름 — 전환 기간 mx1.). 둘 다 compose 가 루트 env 에서 준다. +# 둘 중 하나라도 비면 끈다 — server 이름이 어느 요청에도 맞지 않는 autoconfig.invalid 다. 모양이 틀리면 기동을 멈춘다(server_name · 응답 XML 에 그대로 들어간다 — +# 지시어 · XML 을 끼워 넣지 못하게 영문 소문자 · 숫자 · - · . 의 호스트 이름만). 포털 · 공개 내려받기 호스트와 겹치면(그쪽 요청을 가로챈다) 멈춘다 +# ------------------------------------------ + +# 호스트 이름 — 소문자로 바꾼 값이 이름 꼴이면 그 값, 아니면 아무것도 내지 않고 1 (칸마다 63자 · 모두 253자 · 점 하나 이상) +nginx_env_hostname() { + nginx_env_name="$(printf '%s' "$1" | tr 'A-Z' 'a-z')" + # 빈 값 · 그 밖의 글자 · 점이나 하이픈으로 시작 · 끝 · 연이은 점 · 칸의 앞뒤 하이픈 · 점 없는 이름 + case "$nginx_env_name" in + "" | *[!a-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*) return 1 ;; + *.*) ;; + *) return 1 ;; + esac + [ "${#nginx_env_name}" -le 253 ] || return 1 + nginx_env_rest="$nginx_env_name" + while [ -n "$nginx_env_rest" ]; do + nginx_env_label="${nginx_env_rest%%.*}" + [ "${#nginx_env_label}" -le 63 ] || return 1 + case "$nginx_env_rest" in *.*) nginx_env_rest="${nginx_env_rest#*.}" ;; *) nginx_env_rest="" ;; esac + done + printf '%s' "$nginx_env_name" +} + +if [ -z "${MAIL_DOMAIN:-}" ] || [ -z "${MAIL_HOSTNAME:-}" ]; then + NGINX_AUTOCONFIG_SERVER_NAMES="autoconfig.invalid" + NGINX_MAIL_DOMAIN="invalid" + NGINX_MAIL_CLIENT_HOST="invalid" + echo "nginx-env.envsh: 메일 프로그램 자동 설정을 끈다 (MAIL_DOMAIN · MAIL_HOSTNAME 중 비어 있는 값이 있다)" +else + NGINX_MAIL_DOMAIN="$(nginx_env_hostname "$MAIL_DOMAIN")" \ + || nginx_env_fail "MAIL_DOMAIN 은 호스트 이름(영문 · 숫자 · - · . — 예: arasystem.it.kr)이어야 합니다 (현재: \"$MAIL_DOMAIN\")." + NGINX_MAIL_CLIENT_HOST="$(nginx_env_hostname "$MAIL_HOSTNAME")" \ + || nginx_env_fail "MAIL_HOSTNAME 은 호스트 이름(예: mx1.arasystem.it.kr)이어야 합니다 (현재: \"$MAIL_HOSTNAME\")." + # 견줄 호스트 — 공개 내려받기 호스트와 CORS_ORIGIN 의 호스트들(공유 호스트 판정과 같게 빈칸 · 끝의 / 를 뗀다 — 백엔드도 끝 / 를 받는다) + nginx_env_taken_hosts="$nginx_env_share_host" + while IFS= read -r nginx_env_origin; do + nginx_env_origin="$(printf '%s' "$nginx_env_origin" | tr -d ' ')" + nginx_env_origin="${nginx_env_origin%/}" + nginx_env_portal_host="$(nginx_env_origin_host "$nginx_env_origin")" || continue + nginx_env_taken_hosts="$nginx_env_taken_hosts $nginx_env_portal_host" + done <(화면) · 화면 자산 · /api/public-links/ 만 연다. 그 밖은 404 # 공개 호스트를 따로 두지 않으면(SHARE_PUBLIC_URL 이 비었거나 그 호스트 이름이 CORS_ORIGIN 에 있다) 포털이 둘 다 연다 — nginx-env.envsh +# 메일 프로그램 자동 설정(13단계) autoconfig. · autodiscover. — 고정 XML 만 낸다(백엔드로 넘기지 않는다). 파일 끝의 셋째 server # # ⚠️ 백엔드(Express)는 경로의 대소문자를 가리지 않는다 — /api/Public-Links/… · /api/messages/x/LARGE-FILES/… 도 처리기에 닿는다. # 그래서 /api 아래에서 경로로 무엇을 정하는 location(받기의 동시 연결 수 · 버퍼링 끔, 포털의 공개 API 닫기, 조각 본문 한도)은 @@ -525,3 +526,83 @@ server { # API 의 nginx 오류 응답 (포털과 같은 파일) include /etc/nginx/snippets/api-errors.conf; } + +# ========================================== +# 메일 프로그램 자동 설정 (13단계 — mail/README.md "메일 프로그램 자동 설정") — autoconfig. · autodiscover. +# +# 주소만 치면 메일 프로그램이 서버 · 포트를 찾아 채운다. 고정 응답이다 — 사용자가 보낸 값(질의의 emailaddress · Outlook 이 POST 한 주소)을 되돌려 쓰지 않는다 +# (엔진의 같은 경로는 그 값을 이스케이프 없이 XML 에 넣고, 열지 않은 POP3 · DAV 까지 알려서 쓰지 않는다 — 엔진 443 은 그대로 닫아 둔다). +# 알리는 것은 IMAP 993(SSL) · SMTP 465(SSL) 둘뿐 — 호스트는 MAIL_HOSTNAME(엔진 인증서의 이름), 사용자 이름은 메일 주소(%EMAILADDRESS% 는 프로그램이 채운다). +# GET /mail/config-v1.1.xml Thunderbird · Thunderbird for Android(K-9) · Evolution 등 (IETF draft-ietf-mailmaint-autoconfig 의 첫 자리) +# GET /.well-known/autoconfig/mail/config-v1.1.xml 같은 내용 (그 초안의 둘째 자리 — 원래는 도메인 자체의 주소지만 이 호스트에서도 같게) +# POST /autodiscover/autodiscover.xml 클래식 Outlook(POX) — 대소문자를 가리지 않는다. LoginName 을 주지 않는다(되돌려 쓰지 않으려고) — +# 그러면 Outlook 은 @ 앞부분으로 로그인하는데 엔진은 그것도 받는다(IMAP · SMTP · 앱 비밀번호 — 2026-10-31 임시 엔진) +# 값은 nginx-env.envsh 가 루트 env 의 MAIL_DOMAIN · MAIL_HOSTNAME 에서 만든다 — 비었으면 이 server 의 이름은 어느 요청에도 맞지 않는 autoconfig.invalid 다. +# 백엔드로 넘기는 것이 없다(/api 도 404). 외부 리버스 프록시는 이 두 호스트를 포털과 같은 8080 으로 넘기고 Host 를 그대로 둔다 — DNS · Proxy Host 는 전환 때 +# (전환 전 DNS 는 옛 서버를 가리킨다 — mail/README.md "메일 프로그램 자동 설정") +# ========================================== +server { + listen 8080; + server_name ${NGINX_AUTOCONFIG_SERVER_NAMES}; + + # 접근 로그 — 포털과 같은 형식(쿼리 없음 — 질의에 메일 주소가 실린다) + access_log /var/log/nginx/access.log main_rid; + # Outlook 이 POST 하는 요청은 1KB 안팎의 XML 이다 + client_max_body_size 64k; + + default_type text/xml; + charset utf-8; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Robots-Tag "noindex, nofollow" always; + add_header Cache-Control "no-cache" always; + + location = /mail/config-v1.1.xml { + return 200 ' + + + ${NGINX_MAIL_DOMAIN} + ${NGINX_MAIL_DOMAIN} + ${NGINX_MAIL_DOMAIN} + + ${NGINX_MAIL_CLIENT_HOST} + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + ${NGINX_MAIL_CLIENT_HOST} + 465 + SSL + password-cleartext + %EMAILADDRESS% + + + +'; + } + + location = /.well-known/autoconfig/mail/config-v1.1.xml { + rewrite ^ /mail/config-v1.1.xml last; + } + + location ~* ^/autodiscover/autodiscover\.xml$ { + return 200 ' + + + + email + settings + IMAP${NGINX_MAIL_CLIENT_HOST}993offoffonon + SMTP${NGINX_MAIL_CLIENT_HOST}465offoffonon + + + +'; + } + + location / { + return 404; + } +} diff --git a/mail/README.md b/mail/README.md index dd77532..2ccceda 100644 --- a/mail/README.md +++ b/mail/README.md @@ -49,6 +49,7 @@ app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악 | dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 | | `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` | | setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" | +| 백업 · 15분 점검 (13단계) | `ops/` — 스크립트 `backup/` · `monitor/` · `lib/`, 호스트의 systemd 유닛 `systemd/`, 설정 예시 `ops.env.example` | 운영 서버의 호스트에서 돈다(컨테이너가 아니다) — [백업](#백업) · [감시](#감시-13단계) | | 포트 | 운영 | 개발 | | --- | --- | --- | @@ -58,8 +59,8 @@ app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악 | 계정 | 용도 | 비밀번호 | | --- | --- | --- | -| `@` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` | -| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` | +| `@` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다. 13단계부터 **대리 접근 권한이 없고 앱 네트워크 안에서만 로그인된다** ([관리자 계정 단단히](#관리자-계정-단단히)) | `MAIL_ADMIN_PASSWORD` | +| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다. 역할 · 허용 IP 를 보지 않아 관리자가 막혔을 때 되살리는 길이다 | `MAIL_RECOVERY_ADMIN` | | `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` | | `@` (기본 `mail-directory@`) | 사내 목록 계정 — 백엔드가 엔진의 계정 목록(Principal)에서 직원 이름·주소만 읽는다. 역할 권한은 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐, 게이트웨이 대리 접근으로만 쓰고 메일을 받지 않는다. **복구 관리자로만 만들어진다** ([사내 목록 계정](#사내-목록-계정)) | 없음 | | `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` | @@ -124,6 +125,9 @@ node --env-file=.env.development mail/scripts/setup.mjs **서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다.** 이 스크립트가 만든 예전 항목은 지운다. 5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만 6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`). + **관리자 단단히**(13단계) — 관리자 역할(System Administrator)의 빼는 권한에 `impersonate` 를 두고(→ `InvalidateCaches`), 영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 묶는다 + (이 실행의 관리 API 주소가 앱 네트워크 안으로 보일 때만 — 건 뒤 다시 로그인해 보고, 막혔으면 복구 관리자로 되돌린다). 리로드 뒤 관리자의 실제 권한(`/api/account`)에 `impersonate` 가 없는지 본다 + ([관리자 계정 단단히](#관리자-계정-단단히)) 공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안) 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게 (`x:MtaStageRcpt` 의 `script` + 시스템 Sieve 스크립트 — [보안](#보안) "메일을 받지 않는 시스템 계정") @@ -138,6 +142,8 @@ node --env-file=.env.development mail/scripts/setup.mjs 스팸 필터의 Spamhaus 두 항목은 키(`MAIL_SPAMHAUS_DQS_KEY`)가 있고 시험을 통과하면 DQS 로 켜고 아니면 끈다(공개 미러는 쓰지 않는다 — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)). **엔진 웹훅**(12단계) — 인증 이벤트 셋을 백엔드의 받는 곳(`http://backend:/api/internal/mail-engine-events`)으로 `MAIL_WEBHOOK_SECRET` 로 서명해 보내는 `x:WebHook` 하나를 맞춘다(setup 표시 머리글로 찾는다 — 키가 비면 운영은 중단, 개발은 `[주의]` 뒤 있던 것을 끈다 — [엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록)). + **휴지통 · 스팸메일함 30일**(13단계) — `x:DataRetention` 의 `expungeTrashAfter` 30일과 정리 시각(계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30 — 컨테이너 시각으로 바꿔 넣는다) + ([휴지통 · 스팸메일함 자동 비우기](#휴지통--스팸메일함-자동-비우기)). 모두 9 의 리로드로 반영된다 9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — @@ -282,6 +288,10 @@ node --env-file=.env.development mail/scripts/setup.mjs - **계정 보안(12단계)** — `.env.production` 의 `MAIL_WEBHOOK_SECRET`(엔진 웹훅의 서명 키 — `openssl rand -hex 24` 로 만든 값, 사람이 지은 값은 쓰지 않는다). 운영 필수다 — 아래 2)의 `up` **전에** 넣는다(없으면 백엔드가 env 검사에서 멈춰 웹메일 전체가 서고, setup 도 멈춘다). 직원에게 미리 알린다 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다 ([계정 보안](#계정-보안-12단계--인프라)) +- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 버킷 · 그 버킷만 쓰는 하위 계정 키 · restic 비밀번호(비밀번호 관리자에 따로 보관), + Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 ([백업](#백업) · [감시](#감시-13단계)). + `.env.production` 의 `DB_IMAGE` 를 예시와 같은 값(판 · digest 고정 — 옛 파일은 `postgres:15-alpine`)으로 바꾼다 — 다음 `up -d` 에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다). + frontend 는 `MAIL_DOMAIN` · `MAIL_HOSTNAME` 으로 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정) 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다. @@ -295,6 +305,7 @@ sudo -E docker compose --env-file .env.production up -d --build # 3) setup --recovery — 호스트에서, docker 를 쓸 수 있는 사용자로 실행한다 (운영은 docker 가 없으면 아무것도 바꾸지 않고 실패한다). # 8080 을 공개하지 않으므로 mail 컨테이너의 내부 주소로 붙는다 (리눅스 호스트는 컨테이너 IP 로 바로 닿는다). +# 앱 네트워크의 주소여야 한다 — 13단계부터 관리자는 그 대역에서만 로그인된다(다른 네트워크 주소면 403 — "관리자 계정 단단히") export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" # (A) 호스트 Node (24 LTS 권장, 최소 20.6 — --env-file) — docker 그룹이면 그대로: node --env-file=.env.production mail/scripts/setup.mjs --recovery @@ -335,13 +346,16 @@ node --env-file=.env.production mail/scripts/setup.mjs 3. backend 가 healthy 가 된 뒤(`sudo docker compose --env-file .env.production ps backend`) 3)처럼 `MAIL_ADMIN_URL` 을 구해 setup(복구 관리자 없이 — 5 와 같은 형태) → `[생성] 엔진 웹훅` · `[확인] 엔진 웹훅 — … 받았다` 4. [운영 확인 (12단계)](#운영-확인-12단계) — 메일 프로그램으로 한 번 로그인해 로그인 기록에 IMAP 줄이 생기는지 본다 - 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다. + `autoconfig` · `autodiscover` 의 권장값(엔진 — mx1)은 쓰지 않는다 — 13단계부터 메일 프로그램 자동 설정은 엔진이 아니라 포털 쪽 nginx 가 답한다 ([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)). - **TLS 인증서 이름을 `MAIL_HOSTNAME` 하나로 정한다** (2026-10-31 운영에서 겪음). bootstrap 이 켠 도메인의 자동 인증서(ACME)는 이름을 정하지 않으면 기본 묶음 — `mta-sts` · `autoconfig` · `autodiscover` · `ua-auto-config` · MX 이름 · 호스트 이름 — 을 **한 주문**으로 요청하고, ACME 는 하나라도 실패하면 전체가 실패한다. 전환 전에는 그 이름들이 옛 서버(mailcow)를 가리키거나 DNS 에 없으므로 mx1 까지 인증서를 받지 못하고, 엔진은 993 · 465 · 25(STARTTLS)에 자체 서명 인증서(`CN=rcgen self signed cert`)를 낸다. 관리 화면 Domains › `arasystem.it.kr` › Certificate Management 의 **Additional Hostnames** 에 `mx1`(도메인은 엔진이 붙인다 — Enter 로 항목을 만든 뒤 저장)만 넣고, Management › Tasks › Scheduled 에서 **AcmeRenewal** 을 **한 번** 건다(재시작만으로는 바로 요청하지 않는다. 걸 때마다 새로 발급하니 여러 번 걸지 않는다 — Let's Encrypt 같은 인증서 주에 5번). 확인: `docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep -i acme` 에 `hostname = ["mx1.arasystem.it.kr"]` · `acme.order-completed`, 바깥에서 `openssl s_client -connect mx1.arasystem.it.kr:993` 의 발급자가 Let's Encrypt. - **전면 전환** 때 `autoconfig` · `autodiscover` · `mta-sts` 의 DNS 를 새 서버로 옮긴 뒤 그 이름들을 이 목록에 더하고 AcmeRenewal 을 한 번 더 건다. + **전면 전환** 때 `mta-sts` 의 DNS 를 새 서버로 옮긴 뒤(MTA-STS 를 게시할 때 — 아래 전환 절차 "주의할 레코드") 그 이름을 이 목록에 더하고 AcmeRenewal 을 한 번 더 건다. + `autoconfig` · `autodiscover` 는 **넣지 않는다** — 그 이름은 서버 A 의 외부 프록시(NPM)가 인증서를 받고 이 서버의 frontend nginx 가 답한다 ([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)). + 엔진 쪽에 넣으면 그 이름이 엔진을 가리키지 않아 ACME 주문 전체가 실패하고 mx1 인증서까지 갱신되지 않는다. - 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면 경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https:///jmap/session` → `403`. @@ -373,8 +387,8 @@ openssl s_client -quiet -connect mx1.arasystem.it.kr:993 # 메일 호스트에서 sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3 ``` -- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 내부 8080 에 붙는다: - `ssh -L 8180::8080 <메일 호스트>` → . setup 은 운영에서 직원 계정을 만들지 않는다. +- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 mail 컨테이너의 **앱 네트워크 주소**:8080 에 붙는다 + (13단계부터 관리자는 앱 네트워크 안에서만 로그인된다 — 명령은 [관리 화면 — SSH 터널](#관리-화면--ssh-터널)). setup 은 운영에서 직원 계정을 만들지 않는다. ⚠️ **공용 주소(Group 계정)는 일반 관리자로 만들 수 없다** — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어 `You are not authorized to grant permissions: jmapPrincipalCreate, …` 로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다). 공용 주소를 만들 때는 복구 관리자를 쓴다 ("운영 배포" 1)·`up -d mail` 뒤 그 아이디로 관리 화면 · 관리 API → 4)·5) 로 끈다). @@ -776,6 +790,7 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne | backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 | | db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | | | **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 | +| restic (백업 — 13단계, compose 밖) | 1g | 돌 때만 — 매일 03:30 몇 분 | 0.63GB (데이터 1.2GB — 2026-10-31 MinIO). 그때는 합이 7.15GB — 사용이 적은 새벽이라 둔다. 복원 연습은 엔진 1g · DB 512m 를 더 띄운다(한 달에 한 번 — 낮에 하지 않는다) | - **ClamAV 의 `ConcurrentDatabaseReload` 를 켜지 않는다**(기본은 켬) — 켜면 서명을 다시 읽는 동안(하루 몇 번) 두 벌(2GB 남짓)을 들어 2g 한도에서 죽는다. 한도를 올려 켜려면 합이 8GB 를 넘지 않는지 다시 본다. @@ -914,7 +929,7 @@ unset OTP_ADMIN_PW OTP_TARGET 켤 때의 주소는 계약대로 이름 `ARA SYSTEM`(띄어쓰기)이었고 엔진이 그 주소로 코드를 확인했다(직원 경로로 끄기도 됨). 끝나고 처음 상태(OTP 꺼짐 · 앱 비밀번호 0개 · 비밀번호 그대로)로 되돌렸다. - 웹메일의 "켠 때"(`enabledAt`)는 서버가 엔진에서 꺼진 것을 보면 지운다 (계약 5절 "2단계 인증"). -**2) 관리 화면** (운영은 SSH 터널 — `ssh -L 8180::8080 <메일 호스트>` → , [운영 배포](#운영-배포) 아래 "실제 직원 계정" 줄) — **직접 눌러 보지 못했다**: +**2) 관리 화면** (운영은 SSH 터널 — [관리 화면 — SSH 터널](#관리-화면--ssh-터널)) — **직접 눌러 보지 못했다**: 1. Management › Directory › **Accounts** 에서 그 직원을 연다. 2. Authentication 의 **Credentials** 에서 비밀번호 자격 증명(Password)을 연다. @@ -936,6 +951,161 @@ unset OTP_ADMIN_PW OTP_TARGET --- +## 메일 운영 설정 (13단계 — 인프라) + +전환 준비(13단계)에서 setup · compose · nginx 가 더 맡은 것이다. 백업 · 감시 · 로그는 따로 — [백업](#백업) · [감시](#감시-13단계) · [로그 보존](#로그-보존-13단계). + +### 13단계 전부터 운영 중인 서버 + +이미 떠 있는 운영 서버(서버 B)에 13단계를 거는 차례다. **2026-11-04 뒤에** 한다 — 고정한 DB 이미지(digest)가 그날 2주가 찬다(`.env.production.example` 의 `DB_IMAGE` 주석). +모든 컨테이너가 다시 만들어진다(로그 한도 · DB 이미지) — 메일 · 웹메일이 1~2분 끊기므로 사용이 적은 시간에 한다. 그 전에 손으로 백업을 받아 두기를 권한다([손으로 받을 때](#손으로-받을-때-타이머-없이--예전-방법)). + +```bash +cd ~/arasystem && git pull # 13단계 코드 (저장소 사용자로) +export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)" +# 1) 로그 남기기 — 컨테이너를 다시 만들면 그때까지의 로그가 지워진다 +mkdir -p ~/logs-before-13 && for c in mail backend frontend db; do sudo docker logs "${PROJECT_NAME}-$c" > ~/logs-before-13/$c.log 2>&1; done +# 2) .env.production 의 DB_IMAGE 를 예시와 같은 고정값으로 (옛 값 postgres:15-alpine) — 편집기로 그 한 줄만 +grep -n '^DB_IMAGE=' .env.production .env.production.example +# 3) 복구 관리자를 일회성으로 준 채 전부 다시 만든다 — 관리자 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 setup 이 건다(막히면 되돌릴 길) +read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P" +sudo -E docker compose --env-file .env.production up -d --build +# 4) mail 의 앱 네트워크 주소가 바뀌었다 — 다시 구한다 ("운영 배포" 3 과 같다) +export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" +# 5) 첫 setup 은 --recovery — 끝에 "복구 관리자 끄기"를 안내하고 종료 3 +node --env-file=.env.production mail/scripts/setup.mjs --recovery +# 6) 복구 관리자 끄기 → mail 다시 만들기 → 주소 다시 → 보통 setup (모두 [유지] 와 [확인] 관리자 줄) +unset MAIL_RECOVERY_ADMIN P; sudo docker compose --env-file .env.production up -d mail +export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" +node --env-file=.env.production mail/scripts/setup.mjs +``` + +- 5)의 출력에 `[변경] 관리자 역할("System Administrator") — 대리 접근(impersonate) 뺌`, `[변경] 관리자 … — 허용 IP `, 휴지통 · 정리 시각 줄, + 그리고 `[확인] 관리자 … — 대리 접근 권한 없음 (권한 N개에 impersonate 없음) · 허용 IP … 안에서 로그인됨` 이 있어야 한다. 6)은 모두 `[유지]` 에 같은 `[확인]` 줄. +- 복구 관리자 없이 처음 돌리면 setup 이 허용 IP 를 걸지 않고 종료 1 로 이 절을 가리킨다(아무것도 막히지 않는다 — 다시 3)부터). +- 7) **백업 · 감시 설치** — [백업](#백업) › 설치 1~4(부팅 때 엔진 되살리기 유닛 포함), 첫 백업 뒤 `restore.sh drill` 한 번, [감시](#감시-13단계)의 Healthchecks · UptimeRobot. +- 8) 바깥에서 관리자 비밀번호가 막혔는지 — 다른 네트워크의 PC 에서 `openssl s_client -quiet -connect mx1.arasystem.it.kr:993` 뒤 `a LOGIN <관리자 주소> <비밀번호>` → `NO`(또는 끊김). + 관리 화면은 이제 [SSH 터널](#관리-화면--ssh-터널)로만 들어간다. + +### 관리자 계정 단단히 + +엔진의 기본 관리자 역할(System Administrator)에는 대리 접근(`impersonate`)이 있어, 관리자 비밀번호 하나로 `<직원 주소>%<관리자 주소>` 로그인 — **모든 직원의 메일함** — 이 됐다. +setup 이 두 가지를 걸고 매번 확인한다(되돌려 두었으면 다시 건다). + +| 무엇 | setup 이 하는 일 | 효과 | +| --- | --- | --- | +| 대리 접근 빼기 | 관리자 역할의 빼는 권한(`disabledPermissions`)에 `impersonate` → `InvalidateCaches` | 관리자 비밀번호로는 어디서든 대리 접근이 403(`security.unauthorized … details = "impersonate"`). 관리 화면 · 관리 API 는 그대로 | +| 허용 IP | 영구 관리자 비밀번호 자격의 `allowedIps` = `APP_NETWORK_SUBNET` | 바깥(993 · 465 · 공개 443 — 게시 포트의 접속은 원래 주소로 보인다)에서 관리자 비밀번호 로그인이 403(`security.ip-unauthorized` "IP address not allowed."). 관리자 비밀번호가 통하는 곳은 서버 안(호스트에서 도는 setup · SSH 터널 · 앱 네트워크의 컨테이너)뿐이다 | + +- 확인 줄 — `[확인] 관리자 … — 대리 접근 권한 없음 (권한 N개에 impersonate 없음) · 허용 IP <대역> 안에서 로그인됨`(관리자 세션의 실제 권한 `GET /api/account` 로 본다). +- 역할의 빼기가 계정 쪽 권한보다 앞선다 — 관리자가 자기 계정에 `impersonate` 를 더해도(Merge — 저장은 된다) 대리 접근은 403 이고, 관리자는 스스로 되돌리지 못한다 + (역할의 빼기 지우기 · 게이트웨이 역할 받기 · `impersonate` 를 주는 새 역할 만들기가 모두 `You are not authorized to grant permissions: impersonate`). 되돌리는 길은 복구 관리자뿐이다. +- 허용 IP 는 이 실행의 관리 API 주소가 앱 네트워크 안으로 보일 때만 건다 — 루프백(개발 포트 매핑) · `mail` · 앱 네트워크 안의 IPv4(운영의 `MAIL_ADMIN_URL` — "운영 배포" 3). + **운영에서 처음 걸 때는 복구 관리자가 있어야 건다**(`--recovery` — 걸자마자 막히면 되돌릴 길이 그것뿐이다). 걸지 못하면 운영은 종료 1, 개발은 `[주의]` 다. + 건 뒤 관리자로 다시 로그인해 보고, 막혔으면 복구 관리자로 되돌린다. +- 운영자가 대역 안으로 **더 좁힌 값**(예: 관리 화면에서 `172.16.71.1` 하나)은 넓히지 않고 `[유지]` 한다. 대역 밖 항목이 하나라도 있으면(옛 대역 · `0.0.0.0/0`) 대역 하나로 바꾼다. +- **`APP_NETWORK_SUBNET` 을 바꾸면** 관리자 허용 IP 가 옛 대역에 남아 setup 이 `관리자(…) 로그인이 HTTP 403 입니다` 로 멈춘다 → + 복구 관리자를 일회성으로 주고 `--recovery` 로 돌리면 지금 대역으로 고친다(`[변경] 관리자 … — 허용 IP <옛 대역> → <새 대역> …` — "운영 배포" 1 · 3 · 4 · 5 차례). 개발은 env 에 복구 관리자가 있어 보통 실행이 고친다. + 허용 IP 에 지금 대역 안의 항목이 있는데(좁힌 값) 403 이면 고치지 않고 멈춘다 — 이 실행의 주소가 좁힌 값 밖이다(`MAIL_ADMIN_URL` · 관리 화면에서 넓히기). +- 관리자의 **다른 자격**(앱 비밀번호 · API 키 — 엔진은 비밀번호 자격을 계정마다 하나만 받는다)의 허용 IP 가 대역 밖이거나 없으면 `[주의]` — 그 자격으로 바깥에서 관리자가 된다. + 쓰지 않으면 관리 화면 › 계정 › 관리자 › Credentials 에서 지운다. +- 다른 관리자 역할 계정 — setup 은 비밀번호를 몰라 걸지 않고 `[주의]` 를 낸다. 관리 화면 › 계정 › 그 계정 › Credentials 의 Allowed IPs 를 같은 대역으로 두거나, 쓰지 않으면 지운다. +- **그대로인 것** — 게이트웨이(`MAIL_GATEWAY_*`)는 여전히 모든 직원과 관리자까지 대리 접근한다(백엔드가 막는다 — [보안](#보안)). 관리자는 직원의 비밀번호를 바꿀 수 있다(그러면 직원이 로그인하지 못해 드러난다). + 복구 관리자는 역할 · 허용 IP 를 보지 않는다 — 운영은 그래서 파일에 두지 않는다. + +시험 (0.16.27 — 2026-10-31 임시 엔진 · 2026-11-02 다시, 개발 엔진): + +| 어디서 | 무엇 | 걸기 전 | 건 뒤 | +| --- | --- | --- | --- | +| 서버 안(앱 네트워크) | 관리자 세션 · 관리 API(`x:Account/query`) | 200 | 200 | +| 서버 안 | `<직원>%<관리자>` (JMAP · IMAP) | 200 · 로그인 | 403 · 실패 | +| 서버 안 | `<직원>%<게이트웨이>` (웹메일이 쓰는 길) | 200 | 200 | +| 바깥(다른 네트워크) | 관리자 본인 JMAP · IMAP | 200 · 로그인 | 403 · 실패 | +| 바깥 | 직원 본인 JMAP (대조) | 200 | 200 | +| 호스트 → 앱 네트워크 주소 (SSH 터널) | 관리자 세션 · 관리 화면 `/admin/` | 200 | 200 · 200 (엔진이 본 주소 `172.16.71.1` — 앱 네트워크의 게이트웨이) | +| 호스트 → 리졸버 네트워크(mail-dns) 주소 | 관리자 세션 | 200 | 403 (`remoteIp = 172.16.73.1` · IP address not allowed) | + +#### 관리 화면 — SSH 터널 + +관리 화면 · 관리 API(8080)는 바깥에 열지 않는다 — SSH 로 서버 B 에 붙어 mail 컨테이너의 **앱 네트워크 주소**로 터널을 연다. +호스트에서 컨테이너 주소로 가는 연결은 그 네트워크의 게이트웨이 주소(`APP_NETWORK_SUBNET` 의 `.1`)로 나가 관리자 허용 IP 안에 든다. +mail 컨테이너의 다른 주소(리졸버 네트워크 `172.16.73.x`)로 열면 403 이다. + +```bash +# 1) 서버 B 에서 — mail 컨테이너의 앱 네트워크 주소 (mail 을 다시 만들면 바뀐다 — 열 때마다 본다) +sudo docker inspect -f '{{index .NetworkSettings.Networks "arasystem_app-network" "IPAddress"}}' arasystem-mail +# 2) 노트북에서 — 서버 A 를 거쳐(-J) 서버 B 로. 로컬 포트는 127.0.0.1 에만 +ssh -N -J <서버 A> -L 127.0.0.1:8180:<1의 주소>:8080 <서버 B> +# 3) 브라우저 http://localhost:8180/admin/ — 관리자 주소 · MAIL_ADMIN_PASSWORD +``` + +- 서버 B(와 `-J` 로 거치는 서버 A)의 sshd 가 TCP 전달을 허락해야 한다(`AllowTcpForwarding` — Ubuntu 기본 yes. 꺼 두었으면 `administratively prohibited`). Windows 의 OpenSSH 도 같은 명령이다. +- 개발은 터널 없이 (포트 매핑 — 앱 네트워크의 게이트웨이 주소로 보인다). + +### 휴지통 · 스팸메일함 자동 비우기 + +setup 이 엔진의 `x:DataRetention` 을 맞춘다 — 휴지통 · 스팸메일함에 **들어온 때부터 30일**(`expungeTrashAfter`)이 지난 메일을 엔진의 계정 정리가 영구 삭제한다. + +- 기준은 받은 시각이 아니라 그 메일함에 들어온 시각이다(엔진 안의 삭제 시각 칸 — 0.16.27 `email/src/message/delete.rs`). 받은편지함에도 함께 든 메일은 통째로 지워진다. + - **옮겨 온 메일(imapsync)도 옮긴 때부터 센다** — 엔진은 휴지통 · 스팸메일함에 든 순간의 시각(`now()`)을 그 칸에 쓴다(0.16.27 `email/src/message/index/mod.rs` — 내부 날짜가 아니다). + 시험(2026-11-02 임시 엔진, 기간 70초): 내부 날짜 2025-01-01 로 휴지통 · 스팸메일함에 IMAP APPEND → 바로 정리해도 남고, 70초 뒤 정리에 지워졌다(받은편지함의 같은 날짜 메일은 그대로). + 그래서 이전 다음 날 30일 넘은 옛 휴지통 메일이 한꺼번에 지워지지 않는다 — 이전한 날부터 30일 뒤에 지워진다(직원에게 알릴 거리). + - 자동 비우기는 **엔진의 휴지통 · 스팸메일함(특수 메일함 — "Deleted Items" · "Junk Mail") 둘에만** 걸린다. 이름이 같은 다른 폴더(imapsync 가 "Trash" 를 따로 만든 경우)는 지워지지 않는다 — + [imapsync](#imapsync) 의 메일함 맞추기를 본다. +- 엔진 기본값도 30일이지만 기대지 않고 맞추고, 관리 화면에서 바꾼 값은 되돌린다(`[변경]`). 휴지통에서 지운 메일도 [백업](#백업)의 보존 기간(최대 6개월)까지는 백업에 남는다. +- **정리 시각을 새벽으로 옮긴다** — 엔진의 정리 작업 셋은 메일 컨테이너의 현지 시각(UTC — compose 가 TZ 를 주지 않는다)으로 돌아 기본값(00:00 · 02:00 · 04:00)이 한국 시각 09:00 · 11:00 · 13:00 이었다. + +| 작업 | 엔진 칸 | 한국 시각 | 컨테이너(UTC)에 넣는 값 | +| --- | --- | --- | --- | +| 계정 정리 — 휴지통 · 스팸 30일 | `expungeSchedule` | 03:00 | 18:00 | +| (백업 — 메일을 몇 초 멈춘다) | — | 03:30 | — | +| 데이터 정리 | `dataCleanupSchedule` | 04:00 | 19:00 | +| 원문(blob) 정리 | `blobCleanupSchedule` | 04:30 | 19:30 | + +- setup 은 컨테이너의 UTC 차이(`date +%z`)로 바꿔 넣는다. 바뀐 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 다시 뜬 뒤)부터다 — 엔진이 다음 시각을 기동 때와 실행 직후에만 정한다. +- 확인 — setup 요약의 `휴지통 들어온 지 30일 · 정리 KST 03:00 · 04:00 · 04:30`, 다음 날 엔진 로그의 그 시각(UTC) 작업 줄. + +### 메일 프로그램 자동 설정 + +메일 프로그램(Thunderbird · 클래식 Outlook 등)에 주소만 넣으면 서버 · 포트가 채워지게, **포털의 frontend nginx 가** `autoconfig.` · `autodiscover.` 에 고정 응답을 낸다(B안). +엔진의 같은 경로는 쓰지 않는다 — 요청에 실린 주소를 이스케이프 없이 XML 에 되돌려 넣고, 열지 않은 POP3 · DAV 까지 알린다. 엔진의 공개 443 은 그대로 닫아 둔다. + +| 경로 (그 두 호스트에서만) | 누가 묻나 | 응답 | +| --- | --- | --- | +| `GET /mail/config-v1.1.xml` · `GET /.well-known/autoconfig/mail/config-v1.1.xml` | Thunderbird · Thunderbird for Android(K-9) · Evolution 등 | IMAP `MAIL_HOSTNAME`:993 SSL · SMTP :465 SSL, 사용자 이름 `%EMAILADDRESS%`(프로그램이 채운다) | +| `POST /autodiscover/autodiscover.xml` (대소문자 무관) | 클래식 Outlook (POX) | 같은 IMAP · SMTP. `LoginName` 은 주지 않는다(요청 값을 되돌려 쓰지 않으려고) — 그러면 Outlook 은 `@` 앞부분으로 로그인하는데 엔진은 그것도 받는다(IMAP · SMTP · 앱 비밀번호 — 2026-10-31 임시 엔진) | +| 그 밖 | — | 404 (`/api` 도 — 백엔드로 넘기지 않는다) | + +- 요청 값(질의의 `emailaddress` · POST 본문)은 읽지 않는다 — 응답은 늘 같다. 접근 로그는 쿼리 없이(주소가 질의에 실린다), 본문 한도 64k. +- 값은 루트 env 의 `MAIL_DOMAIN` · `MAIL_HOSTNAME`(compose 가 frontend 에 넘긴다 — `frontend/nginx-env.envsh` 가 검사). 하나라도 비면 이 server 의 이름은 어느 요청에도 맞지 않는 `autoconfig.invalid` 다. + 모양이 틀리거나(영문 · 숫자 · `-` · `.` 의 점 있는 이름만 — 소문자로 바꿔 쓴다), `autoconfig.` · `autodiscover.` 이 공개 내려받기 호스트나 `CORS_ORIGIN` 의 호스트와 같으면 frontend 가 뜨지 않는다(fail-fast — `docker logs arasystem-frontend` 의 `nginx-env.envsh:` 줄). +- CI 의 infra 잡이 렌더링(server 이름 · 호스트 이름)과 실제 요청(주소를 꾸민 질의 · POST 가 응답에 들어가지 않는지, 그 밖 경로 404, 포털 · 공개 호스트에서는 열리지 않는지)을 본다. + +**전면 전환 때 할 일** — ⚠️ **지금은 DNS 를 바꾸지 않는다.** 두 이름은 아직 mailcow 를 가리키고 mailcow 직원이 쓰고 있다(전환 체크리스트의 일): + +1. **미리 확인** (DNS 를 바꾸기 전 — 서버 A 에서 서버 B 의 사설 주소로): `curl -s -H 'Host: autoconfig.arasystem.it.kr' http://<서버 B 사설 IP>:8080/mail/config-v1.1.xml | grep -E 'hostname|port'` → `mx1.arasystem.it.kr` · 993 · 465. +2. **NPM** (서버 A): Proxy Host 둘 — `autoconfig.arasystem.it.kr` · `autodiscover.arasystem.it.kr` → `http://<서버 B 사설 IP>:8080`(포털과 같은 곳, Host 는 그대로 — NPM 기본). + 접근 로그는 끈다(Thunderbird 는 질의에 메일 주소를 싣는다). 인증서는 3 다음에. +3. **DNS** (가비아): `autoconfig` · `autodiscover` A → 서버 A 의 공인 IP(지금 mailcow 를 가리키는 레코드를 바꾼다). TTL 은 다른 전환 레코드와 함께 미리 낮춘다. + 전파된 뒤 NPM 의 두 Proxy Host 에서 SSL 탭 › Let's Encrypt 인증서(HTTP 검증 — 그래서 DNS 가 먼저다) · Force SSL. + 이 이름들의 인증서는 NPM 이 갖는다 — 엔진의 인증서 이름(Additional Hostnames)에는 넣지 않는다("운영 배포"의 TLS 인증서 줄). +4. **SRV** (RFC 6186 · RFC 8314 — 지원하는 프로그램이 쓴다): + + | 이름 | 값 | 뜻 | + | --- | --- | --- | + | `_imaps._tcp.arasystem.it.kr` | `SRV 0 1 993 mx1.arasystem.it.kr.` | IMAP — 처음부터 TLS | + | `_submissions._tcp.arasystem.it.kr` | `SRV 0 1 465 mx1.arasystem.it.kr.` | 보내기 — 처음부터 TLS (465) | + | `_submission._tcp` · `_imap._tcp` · `_pop3._tcp` · `_pop3s._tcp` | `SRV 0 0 0 .` | 열지 않음 — `_submission` 은 587(STARTTLS)용이라 465 를 적으면 프로그램이 465 에 STARTTLS 를 시도해 실패한다 | + + 가비아가 대상 `.` 을 받지 않으면 "열지 않음" 줄은 빼도 된다(없으면 프로그램이 다음 방법으로 넘어간다). +5. **확인** — 바깥에서 `curl -s https://autoconfig.arasystem.it.kr/mail/config-v1.1.xml | grep hostname`, + `curl -s -X POST -H 'Content-Type: text/xml' --data '' https://autodiscover.arasystem.it.kr/autodiscover/autodiscover.xml | grep Server`, + `dig +short SRV _imaps._tcp.arasystem.it.kr`. Thunderbird · 클래식 Outlook 으로 새 계정을 한 번씩 만들어 본다(2단계 인증을 켠 직원은 앱 비밀번호). + 클래식 Outlook 은 도메인 자체(`https://arasystem.it.kr/autodiscover/autodiscover.xml` — 회사 홈페이지)를 먼저 묻는다 — 거기서 오래 붙잡히거나 인증서 경고가 나지 않는지도 본다. + 새 Outlook · 휴대폰 기본 메일 앱은 자기 방식(마이크로소프트 · 제조사의 자동 감지)을 써서 이 응답을 쓰지 않을 수 있다 — 그때는 직원이 서버를 손으로 넣는다(값은 웹메일 설정 › 앱 비밀번호의 메일 프로그램 설정 표). + +--- + ## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저) 전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다. @@ -1293,7 +1463,8 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul - **DMARC** — setup 이 출력하는 권장값은 `p=reject; rua=mailto:postmaster@…` 다. 전환 중에는 **지금의 `p=none`·`rua=dmarc@` 를 유지**하고, 리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다. - **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다. -- **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다. +- **SRV·autoconfig·autodiscover** — 메일 프로그램(IMAP) 자동 설정용. setup 이 출력하는 권장값(엔진 · mx1)이 아니라 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정)의 "전면 전환 때 할 일"대로 — autoconfig · autodiscover 는 서버 A(NPM) 로, SRV 는 `_imaps` · `_submissions` → mx1. + 전환 전에는 그대로 둔다(mailcow 직원이 쓰고 있다). MX 를 바꾸는 날 함께 바꾼다. - **DKIM 자동 교체** — 도메인 설정은 `dkimManagement: Automatic`(`rotateAfter` 90일 · `retireAfter` 7일 · `deleteAfter` 30일)이지만, **0.16.27 은 엔진이 DNS 를 직접 고칠 때(`dnsManagement: Automatic` + DKIM 게시)만 교체한다.** DNS 를 손으로(가비아) 관리하는 지금 설정(`dnsManagement: Manual`)에서는 활성 키에 교체 일정이 생기지 않아(`x:DkimSignature` 의 `nextTransitionAt` 가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다 @@ -1315,6 +1486,9 @@ imapsync \ - 원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽는다. mailcow 의 마스터 계정 설정 방법과 로그인 형식(Dovecot 기본 구분자는 `*`)은 **mailcow 문서를 확인해야 한다** (여기서는 검증하지 않았다). +- **휴지통 · 스팸메일함은 엔진의 특수 메일함으로 맞춘다** — 엔진의 이름은 "Deleted Items" · "Junk Mail"(IMAP SPECIAL-USE `\Trash` · `\Junk`)이고 mailcow 는 "Trash" · "Junk" 다. + `--automap` 이 특수 용도로 짝짓지만, 한 사람으로 먼저 돌려 새 엔진에 "Trash" · "Junk" 폴더가 따로 생기지 않았는지 본다 — 생겼으면 `--f1f2 'Trash=Deleted Items' --f1f2 'Junk=Junk Mail'` 로 다시. + 따로 생긴 폴더는 자동 비우기(30일 — [휴지통 · 스팸메일함 자동 비우기](#휴지통--스팸메일함-자동-비우기))가 걸리지 않는다. 옮긴 휴지통 메일의 30일은 옮긴 날부터 센다(같은 절 — 시험함) - 대상(Stalwart)은 계정을 만들 때 정한 초기 비밀번호로 로그인한다. 게이트웨이 대리 접근(`%`)은 HTTP 에서만 검증했고 IMAP 에서는 확인하지 않았다. 게이트웨이는 신뢰 대역에서만 로그인되므로 외부에서 쓸 수도 없다. - 직원 비밀번호는 정책 결정이 필요하다 — 새 초기 비밀번호를 배포하고 첫 로그인 때 바꾸게 하거나, @@ -1324,15 +1498,119 @@ imapsync \ ## 백업 -| 볼륨 | 내용 | -| --- | --- | -| `_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** | -| `_mail-data` | RocksDB — 메일·계정·설정 전체 | -| `_files` | 대용량 첨부 파일(11단계) — **백업하지 않는다**(보낸 날부터 30일짜리 전송물 · 계약 4절). 기록(이름 · 크기 · 링크)은 DB 백업에 든다 — 복원한 뒤 바이트가 없는 파일은 받는 쪽에 "찾을 수 없음"이다. ⚠️ `docker compose down -v` 가 지운다 | -| `_clamav-db` | 악성 검사 서명 — 백업하지 않는다. 지우면 `clamav-updater` 가 다시 받는다(1분 안팎) | +13단계부터 서버 B 의 호스트가 **매일 03:30(KST)** 혼자 백업한다 — `ops/backup/arasystem-backup.sh` 를 systemd 타이머가 돌리고, +암호화한 restic 저장소를 **NCP Object Storage**(서버 밖)에 둔다. 결과는 Healthchecks.io 로 알린다([감시](#감시-13단계)). -RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다. -멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다. +| 무엇 | 어디서 | 비고 | +| --- | --- | --- | +| 메일 엔진 `_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 | +| DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 | +| 업로드 `_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` | +| env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 | +| 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 | + +**차례** — RocksDB 는 켜 둔 채 파일을 복사하면 일관되지 않아 잠시 멈춘다. 멈추는 시간을 줄이려고 두 번 올린다: + +0. 사전 확인 — 메일 엔진 · DB 가 healthy 인지. 부팅 직후 타이머가 놓친 백업을 바로 할 때(`Persistent=true`)는 엔진이 아직 starting 이라 + healthy 를 `BACKUP_HEALTHY_WAIT`(기본 300초)까지 기다린 뒤 판단한다(멈췄거나 없으면 기다리지 않는다) +1. DB 덤프 · env 파일 복사(작업 폴더 `/var/lib/arasystem-ops` — 0700. 어떻게 끝나도 지운다) +2. **warm** — 엔진을 켜 둔 채 올린다(바뀐 것 대부분이 여기서 올라간다. 이 스냅숏은 복원에 쓰지 않는다 — 복원 명령은 id 를 줘도 final 만 받는다). + 도는 엔진이 그사이 지운 파일을 읽지 못하면 restic 이 종료 3(일부를 읽지 못함)으로 끝나는데, warm 에서만 받아들이고 한 줄 남긴다 +3. 엔진을 멈추고(`docker stop`) **final** — 같은 경로를 다시. RocksDB 의 파일은 한 번 쓰면 바뀌지 않아 그사이 바뀐 것만 올라간다. + final 은 종료 3 도 실패다. **시간 상한**(`BACKUP_FINAL_TIMEOUT`, 기본 15분)을 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다 — 메일이 오래 멈춰 있지 않게. + 상한은 `timeout --foreground` 로 건다 — 손으로 돌릴 때 Ctrl-C 가 restic 에 바로 닿아 정리로 간다(그룹이 다르면 상한까지 기다렸다 — 시험함) +4. 엔진을 띄우고 `/healthz/ready` 까지 기다린다(3분). 스크립트가 어떻게 끝나도(오류 · 시간 초과 · 중지) 멈춘 엔진을 다시 띄운다 — 멈추는 중에 끊겼으면 멈춤이 끝난 뒤에 띄운다 + (docker 가 stop 을 처리하는 동안 부른 start 는 아무 일도 하지 않고, 그 뒤 stop 이 끝나 엔진이 멈춘 채 남는다 — 시험함). 그래도 남으면 systemd `ExecStopPost` 와 + 부팅 때의 `arasystem-mail-recover.service`(백업보다 먼저 — 도커가 늦으면 30초마다 10번까지 다시)가 표시 파일(`mail-stopped-by-backup`)을 보고 띄운다 — + 엔진이 떠 있는 것을 본 뒤에만 표시를 지운다. 이것(`ensure-mail-running`)도 백업 잠금을 잡는다 — 백업이 도는 동안 손으로 부르면 아무것도 하지 않고 그만둔다 + (final 도중에 엔진을 띄우면 엔진이 쓰는 RocksDB 를 읽은 스냅숏이 final 로 남아 복원의 latest 가 된다) + 끊겼을 때의 정리 차례: 비밀 사본 지우기 → 엔진 띄우기 → 상태 파일 · Healthchecks `/fail` → 그 뒤 엔진 준비 기다리기(systemd 가 시간 상한으로 끊어도 `/fail` 이 빠지지 않게) +5. 보존 — final 은 **매일 7 · 매주 4 · 매달 6**, warm 은 마지막 1. 일요일(KST)에 `prune` 과 `check`(데이터 5% 를 내려받아 본다) +6. 상태 파일(15분 점검이 읽는다) · Healthchecks(시작 · 성공 + 요약 한 줄 · 실패 + 까닭 — ping 주소는 curl 의 설정 글로 표준 입력에 준다, 명령줄에 남지 않게) +7. 같은 잠금(`backup.lock`)을 복원 연습 · `ensure-mail-running` 도 잡는다 — 함께 돌지 않는다(백업 쪽은 건너뛰고 Healthchecks 에 `/fail "건너뜀 …"` 한 줄, 연습 쪽은 멈추고, ensure 는 그만둔다) + +영향 (임시 엔진 + 로컬 MinIO 로 잰 값 — 2026-10-31): + +| 항목 | 값 | +| --- | --- | +| 메일 엔진 멈춤 | 8~11초 (데이터 0.1~1.3GB). 그동안 웹메일은 잠깐 `MAIL_001`, IMAP 은 다시 붙고, 들어오는 메일은 보낸 서버가 다시 보낸다 | +| 시간 | warm 28초(1.2GB, 처음) — NCP 로는 서버의 바깥 대역폭에 달렸다. 다음 날부터는 바뀐 만큼만 | +| 메모리 · CPU | restic 컨테이너 — 메모리 한도 1g(잰 최대 0.63GB — [메모리 예산](#메모리-예산)) · CPU 1 · CPU 몫 512(다른 컨테이너의 절반). systemd 의 `Nice=10` · IO 낮은 우선은 스크립트 · docker CLI 에만 걸린다(컨테이너는 dockerd 아래라 물려받지 않는다) | +| 디스크 | 복사본을 만들지 않는다(볼륨을 직접 읽는다) — DB 덤프(압축) · env 만 작업 폴더에. restic 캐시 볼륨 `-backup-cache`(잰 값 144KB — 저장소가 크면 는다) | +| 되살릴 수 있는 시점 | 마지막 03:30 (그 뒤 하루치는 잃을 수 있다) | + +### 설치 (서버 B — 한 번) + +```bash +# 1) 스크립트 · 유닛 — 저장소의 ops/ 를 /opt 로 복사한다 (저장소를 고쳐도 복사본은 그대로 — ops/ 가 바뀌면 이 단계를 다시) +sudo install -d -m 0755 /opt/arasystem-ops +sudo cp -r ops/lib ops/backup ops/monitor /opt/arasystem-ops/ && sudo chown -R root:root /opt/arasystem-ops && sudo chmod -R go-w /opt/arasystem-ops +sudo install -m 0644 ops/systemd/arasystem-*.service ops/systemd/arasystem-*.timer /etc/systemd/system/ && sudo systemctl daemon-reload + +# 2) 설정 · 비밀 — 저장소 밖, root 만 (값은 ops/ops.env.example 의 설명대로) +sudo install -d -m 0700 -o root -g root /etc/arasystem +sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env && sudoedit /etc/arasystem/ops.env +sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' +# ⚠️ restic 비밀번호와 NCP 키를 회사의 비밀번호 관리자에도 둔다 — 서버를 잃으면 이것 없이는 백업을 풀 수 없다 + +# 3) 저장소를 만들고 첫 백업을 손으로 (메일 엔진이 몇 초 멈춘다 — 사용이 적은 시간에) +sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh init +sudo systemctl start arasystem-backup.service; journalctl -u arasystem-backup -n 30 --no-pager + +# 4) 타이머 켜기 — 백업 03:30 · 15분 점검, 그리고 부팅 때 백업이 멈춘 엔진 되살리기(표시 파일이 있을 때만 — 평소에는 아무 일도 하지 않는다) +sudo systemctl enable --now arasystem-backup.timer arasystem-check.timer +sudo systemctl enable arasystem-mail-recover.service +systemctl list-timers 'arasystem-*' +``` + +- **NCP Object Storage** — 콘솔에서 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). API 키는 이 버킷만 쓰는 서브 계정으로. + `RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/<버킷>/arasystem`, `RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'` — + 지역 `kr-standard`(서명 V4) · 경로 방식(NCP 는 경로 · 가상 호스트 방식 둘 다 받는다 — [NCP API 가이드 Object Storage](https://api.ncloud-docs.com/docs/storage-objectstorage)). + ⚠️ **NCP 로는 아직 돌려 보지 않았다**(MinIO 로만) — 3)의 `init` · 첫 백업 · 아래 `drill` 로 처음 확인한다. +- 설정 파일 `/etc/arasystem/ops.env` 는 셸로 실행하지 않고 줄마다 읽는다. `ARASYSTEM_DIR`(저장소 폴더)의 `.env.production` 에서 `PROJECT_NAME` 등을 읽는다 — 값을 따로 복사해 두지 않는다. + `PROJECT_NAME` 은 ops.env 에도 적어 두기를 권한다(새 서버 복원의 첫 단계에는 `.env.production` 이 없다 — 아래 "서버를 통째로 잃었을 때"). 둘 다 있고 다르면 스크립트가 멈춘다. +- 백업은 root 의 systemd 가 돈다(`ProtectSystem=full` · `ProtectHome=read-only` · `PrivateTmp` · 최대 6시간 · 멈출 때 엔진을 되살릴 시간 5분). 로그는 `journalctl -u arasystem-backup`. + docker 는 `Wants` 로만 묶는다 — `Requires` 면 docker 를 다시 시작할 때 백업도 함께 멈춘다. +- 바깥 연결 — 서버 B 에서 NCP Object Storage(443)로 나갈 수 있어야 한다. restic 이미지는 처음 한 번 받는다(`docker pull` — Docker Hub). + +### 복원 + +`ops/backup/arasystem-restore.sh` (root — 설정은 백업과 같다): + +| 명령 | 하는 일 | +| --- | --- | +| `snapshots` | final 스냅숏 목록 (`.env.production` 없이 돈다) | +| `check [5%]` | 저장소 검사 — 데이터를 그만큼 내려받아 본다 (`.env.production` 없이 돈다) | +| `drill [스냅숏\|latest] [--keep]` | **복원 연습** — 운영을 건드리지 않는다. 임시 볼륨(`-drill_*`)에 풀고 같은 판의 엔진 · DB 를 **네트워크 없이** 띄워 계정 수 · 계정마다 메일 수 · DB 표마다 행 수 · 업로드 수 · env 지문을 지금 운영과 견준 뒤 지운다. 백업과 같은 잠금을 잡는다 | +| `drill-clean` | 남은 연습 자원을 지운다 | +| `files <스냅숏> <폴더>` | DB 덤프 · env 파일만 폴더로 꺼낸다 (`.env.production` 없이 돈다 — 새 서버의 첫 단계) | +| `volumes <스냅숏>` | 메일 엔진 · 업로드 볼륨을 되살린다 — 볼륨이 없거나 비었고 쓰는 컨테이너가 멈춰 있을 때만(compose 의 이름표를 붙여 만든다). 볼륨을 들여다보지 못하면 비었다고 보지 않고 멈춘다 | +| `db <덤프>` | 빈 DB 에 덤프를 넣는다(표준 입력으로) — 표가 있으면 멈춘다 | + +- 스냅숏은 **final 만** 받는다 — id 를 직접 줘도 태그를 보고 warm 이면 멈춘다(켜 둔 엔진에서 받은 것이라 RocksDB 가 일관되지 않을 수 있다). +- 보조 컨테이너는 모두 권한을 버리고(`--cap-drop ALL` · `no-new-privileges`) 꼭 필요한 것만 더한다 — 볼륨 들여다보기는 파일 읽기, 복원(restic)은 쓰기 · 소유자 되살리기, + 연습 DB 는 postgres 시작 스크립트가 쓰는 것, 연습 엔진은 실행 파일의 낮은 포트 권한(`NET_BIND_SERVICE` — 없으면 exec 가 거절된다), 연습의 복구 관리자 비밀번호는 `-e 이름` 으로만. +- **연습은 한 달에 한 번**(그리고 백업 설정을 바꾼 뒤) — `sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh drill`. 출력의 계정 · 메일 수가 운영과 같고 DB 표의 행 수가 백업 시각 이후 바뀐 만큼만 다른지 본다. + 연습 엔진은 운영의 호스트 이름 · 인증서 · 발송 대기열 · 웹훅을 안고 뜨므로 네트워크 없이 띄운다(대기열의 메일이 다시 나가지 않게). 디스크는 데이터만큼 잠시 더 쓴다. +- 시험 (2026-10-31 · 11-02, MinIO — 11-02 는 서버와 같은 Ubuntu 24.04 · bash · flock · timeout 컨테이너에서): 백업 → 연습 · `files` · `volumes` → 되살린 엔진의 계정 · 메일함 · 메일 수 지문이 원본과 같았다. + `volumes` 는 1.2GB 를 14초(로컬). 멈추는 도중 · final 도중에 SIGTERM · 시간 상한이 와도 엔진이 다시 떴다. 새 서버 차례(아래 1~5)를 빈 클론에서 끝까지 돌렸다(DB 행 수 · 엔진 지문 같음). + +**서버를 통째로 잃었을 때** (새 서버 — 차례가 중요하다: backend 가 먼저 뜨면 빈 DB 에 마이그레이션을 돌려 덤프를 넣을 수 없다): + +1. 새 서버를 [운영 배포](#운영-배포)의 사전 조건까지 준비하고 저장소를 같은 경로에 클론한다. 위 설치 1) · 2)(restic 비밀번호 · 키는 비밀번호 관리자에서 — 새로 만들지 않는다). + **`/etc/arasystem/ops.env` 에 `PROJECT_NAME` 을 옛 서버의 값으로 적는다** — 이 단계에는 `.env.production` 이 없고, 백업 스냅숏의 host 이름이 그 값이다(비우면 "PROJECT_NAME 을 알 수 없습니다"로 멈춘다) +2. `sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh files latest /root/restore`(없거나 빈 폴더) → `/root/restore/backup/env/.env.production` · `…/backup/env/backend__.env.production` 을 + 저장소의 `.env.production` · `backend/.env.production` 으로(0600 — 저장소 사용자 소유) +3. `… restore.sh volumes latest` — 메일 엔진 · 업로드 볼륨 (여기부터는 `.env.production` 의 `PROJECT_NAME` 과 ops.env 의 값이 같아야 한다 — 다르면 멈춘다) +4. `sudo docker compose --env-file .env.production up -d db` → healthy 뒤 `… restore.sh db /root/restore/backup/db/db.dump`. 다 끝나면 `/root/restore` 를 지운다(비밀이 들어 있다) +5. `sudo docker compose --env-file .env.production up -d --build` — 메일 엔진은 되살린 설정으로 정상 모드로 뜬다(bootstrap 아님) +6. setup — "운영 배포" 5)와 같은 형태(`--recovery` 없이). 서버 IP 가 바뀌었으면 PTR · SPF · `mx1` A · NPM 의 전달 주소 · `TRUST_PROXY` · `FRONTEND_PRIVATE_BIND` 를 함께 고친다. + `APP_NETWORK_SUBNET` 을 바꿨다면 관리자 허용 IP 가 옛 대역이라 403 으로 멈춘다 — 복구 관리자 + `--recovery` 가 고친다([관리자 계정 단단히](#관리자-계정-단단히)) +7. 웹메일 로그인 · 메일 수 · `sudo systemctl start arasystem-check.service` · 백업 타이머 · 부팅 유닛(설치 4) + +- 멈춘 동안 들어오던 메일은 보낸 서버가 며칠 다시 보낸다 — 되살리는 데 걸린 시간만큼 늦게 도착하고 잃지 않는다. 백업 뒤 ~ 사고 사이에 받은 메일 · 바꾼 설정은 없다. + +### 손으로 받을 때 (타이머 없이 — 예전 방법) ```bash sudo docker compose --env-file .env.production stop mail @@ -1341,8 +1619,80 @@ sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_N sudo docker compose --env-file .env.production start mail ``` -- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /` , 소유자 UID 2000 유지) 컨테이너를 띄운다. 복원 연습을 주기적으로 한다. -- 백업 파일은 다른 장소에도 둔다. 메일 전체가 들어 있으므로 접근을 제한한다. +- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /`, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체가 들어 있다 — 접근을 제한하고 서버 밖에도 둔다. + +--- + +## 감시 (13단계) + +두 겹이다 — **바깥**(UptimeRobot: 서버 · 포트가 바깥에서 보이는지)과 **안**(15분마다 도는 점검 스크립트 → Healthchecks.io). Healthchecks 는 "점검이 아예 돌지 않음"도 알린다(신호가 끊기면). + +**알림 받는 곳은 우리 메일 서버가 아닌 주소로** 둔다(운영자 둘 이상의 Gmail 등) — 메일 서버가 서면 회사 주소로는 알림이 오지 않는다. + +### Healthchecks.io (무료) — 체크 둘 + +| 체크 | 일정 | 유예 | 넣는 곳 | +| --- | --- | --- | --- | +| `arasystem-backup` | Cron `30 3 * * *` · 시간대 `Asia/Seoul` | 3시간 | `/etc/arasystem/ops.env` 의 `HC_BACKUP_URL` — 시작 · 성공(요약) · 실패(까닭) | +| `arasystem-check` | 15분마다 (Simple — Period 15분) | 20분 | `HC_CHECK_URL` — 매번 정상(요약 한 줄) 또는 실패(문제 목록) | + +- ping 주소를 아는 사람은 거짓 "정상"을 보낼 수 있다 — `ops.env`(0600) 밖에 적어 두지 않는다. 비워 두면 알리지 않고 로그 · 상태 파일만 남긴다. + +### UptimeRobot (무료 — 5분 간격) — 감시 셋 + +| 종류 | 대상 | 보는 것 | +| --- | --- | --- | +| HTTP(s) | `https://portal.arasystem.it.kr/` | 포털(서버 A 의 NPM → 서버 B frontend)이 200 | +| Port | `mx1.arasystem.it.kr` : 993 | IMAP — 서버 B 가 바깥에서 보이는지 | +| Port | `mx1.arasystem.it.kr` : 25 | 메일 받기 | + +### 15분 점검 (`ops/monitor/arasystem-check.sh`) + +| 항목 | 문제로 보는 것 (값 — `ops.env`) | 어떻게 | +| --- | --- | --- | +| 디스크 | `/` 와 도커 데이터 폴더 사용률 85% 이상 (`CHECK_DISKS` · `CHECK_DISK_MAX_PERCENT`) | `df` | +| 컨테이너 | frontend · backend · db · mail · mail-resolver · clamav · clamav-updater 가 healthy 가 아님 | `docker inspect` (clamav-updater 가 unhealthy 면 "서명 갱신이 이틀 넘게 멈춤") | +| 백엔드 | `/api/health` 가 200 이 아님 — 상태 코드와 실패한 구성 요소 이름만 | backend 컨테이너 안에서 | +| 메일 대기열 | 50통 이상 또는 가장 오래된 것이 24시간 이상 (`CHECK_QUEUE_MAX` · `CHECK_QUEUE_MAX_AGE_HOURS`) | backend 컨테이너의 node → 엔진 `x:QueuedMessage` (관리자로) | +| 인증서 | 믿을 수 없는 체인 · 이름 불일치 · 남은 날 14일 미만 (`CHECK_CERTS` · `CHECK_CERT_MIN_DAYS`) — 기본은 `MAIL_HOSTNAME`:993 · 포털 · 공개 내려받기 호스트 :443 | `openssl s_client -verify_hostname` | +| 백업 | 마지막 성공에서 26시간 이상 · 백업 타이머가 꺼짐 (`CHECK_BACKUP_MAX_AGE_HOURS`) | 백업의 상태 파일 · `systemctl` | +| 백업 표시 | 표시 파일(`mail-stopped-by-backup`)이 남았는데 백업 서비스가 돌지 않고 15분보다 오래됨 — 끊긴 백업이 엔진을 멈춘 채 남겼을 수 있다 | 표시 파일 · `systemctl show arasystem-backup.service` | +| Spamhaus 키 | 키가 있는데 시험 항목(`127.0.0.2` · `127.0.1.2`)이 오지 않음 — 체험 기간 끝 등 | 메일 컨테이너의 perl 로 리졸버에 묻는다(키는 표준 입력으로) | + +- 백업이 메일 엔진을 멈춘 동안은 메일 쪽 항목을 건너뛴다 — 표시 파일만 믿지 않고, 백업 서비스가 지금 돌거나 표시가 15분 안에 생긴 것일 때만이다(남은 표시가 점검을 끝없이 조용하게 하지 않게). 문제가 있으면 종료 1 — `journalctl -u arasystem-check -n 20 --no-pager`. +- **대기열 확인의 비밀** — 관리자 비밀번호는 따로 두지 않는다: 점검이 돌 때마다 `.env.production` 의 `MAIL_ADMIN_PASSWORD` 를 읽어 `docker exec -e`(환경 변수 — 명령줄에 남지 않는다)로 backend 컨테이너의 node 에 넘긴다. + 그 node 는 앱 네트워크에서 엔진에 붙으므로 관리자 허용 IP 안이다. 읽기만 한다(대기열 조회). +- 결과 예 (개발에서): `정상 — 디스크 / 72% · 컨테이너 5개 정상 · 백엔드 health 정상 · 대기열 0통 · 인증서 … 88일 · 백업 0시간 전 · Spamhaus 키 없음`. + +### 알림이 오면 + +| 알림 | 먼저 볼 것 | +| --- | --- | +| 백업 실패 · 26시간 넘음 | `journalctl -u arasystem-backup -n 50` — NCP 키 · 버킷 · 바깥 연결, 메일 엔진이 healthy 였는지. 다시: `sudo systemctl start arasystem-backup.service` | +| 백업 표시가 남아 있음 | `sudo docker compose --env-file .env.production ps mail` — 멈춰 있으면 `sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running`(띄우고, 떠 있는 것을 본 뒤 표시를 지운다). 백업이 돌고 있으면 `[건너뜀]` 으로 그만둔다 — 그 백업이 끝나며 스스로 띄우니 끝난 뒤 다시 본다(`systemctl status arasystem-backup`). 그 뒤 `journalctl -u arasystem-backup` 로 왜 끊겼는지 | +| 디스크 | `sudo docker system df -v` · 도커 데이터 폴더(`docker info -f '{{.DockerRootDir}}'` — 서버 B 는 `/data/docker`). 대용량 첨부(`_files`)는 백엔드가 보관량 · 여유를 본다([대용량 첨부](#대용량-첨부-11단계--인프라)) · 로그는 [로그 보존](#로그-보존-13단계) | +| 컨테이너 unhealthy | `sudo docker compose --env-file .env.production ps` · 그 컨테이너 로그. 리졸버면 [리졸버 장애 때](#리졸버-장애-때) | +| 대기열 쌓임 | 엔진 로그의 배달 실패(바깥 25 막힘 · 받는 쪽 거절 · DNS) — 관리 화면 › Queue (SSH 터널) | +| 인증서 | mx1 은 엔진의 ACME("운영 배포"의 TLS 인증서 줄), 포털 · 공개 호스트는 NPM 의 인증서 | +| Spamhaus 키 | 체험 기간 · 구독 — 끝났으면 키를 비우고 setup ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)) | +| UptimeRobot 만 | 서버 A(NPM) · 서버 B 가 살아 있는지, NCP ACG · 공인 IP | + +--- + +## 로그 보존 (13단계) + +모든 compose 서비스에 도커 로그 한도를 적었다(`json-file` · 넘으면 돌려 쓰고 지난 파일은 압축) — 전에는 도커 기본값(한도 없음)이라 로그가 디스크를 채울 수 있었다. + +| 서비스 | 한도 | 대략 남는 기간 (직원 50명 추정) | +| --- | --- | --- | +| frontend · backend · mail | 20MB × 10개 (`x-logging-long` — 글로 140MB 안팎) | mail(인증 · 거절 · 배달) 하루 10MB 안팎 → 2주쯤, frontend(접근 로그) 하루 20MB 안팎 → 1주쯤, backend(경고 · 오류 · 주요 일만) 몇 주 | +| db · redis · mail-resolver · clamav · clamav-updater (개발 mailpit · remote-proxy 의 frontend-bind-check) | 10MB × 5개 (`x-logging-default`) | 평소 거의 쓰지 않는다 | + +- 추정의 가정은 직원 50명 · 하루 받는 메일 3,000통 · 보내는 메일 750통 · 한 사람 하루 1,500 요청(줄 길이는 개발에서 잰 값 — `docker-compose.yml` 머리말). 운영에서 `sudo docker logs --since 24h arasystem-mail 2>&1 | wc -c` 로 다시 잰다. +- 한도는 압축 전 크기다(json-file 은 줄마다 시각 · stream 껍데기가 붙어 글의 1.4배 안팎) — 디스크에는 모두 차도 0.75GB 안팎이고, 지난 파일은 gzip 이라 실제로는 그 몇 분의 1이다. `docker logs --since …` 는 지난 파일까지 읽는다. +- **처음 적용할 때 컨테이너가 다시 만들어진다**(다음 `up -d`) — 그때까지의 로그는 컨테이너와 함께 지워진다. 남길 로그가 있으면 먼저 `sudo docker logs arasystem-mail > mail-$(date +%F).log 2>&1`. +- 더 오래 남기려면 숫자를 늘리지 말고 바깥 수집기(Loki · CloudWatch 등 — 로깅 규칙 "운영 확장")로 보낸다. 같은 호스트의 다른 프로젝트(도커 데몬 설정)에는 영향이 없다. +- CI 의 infra 잡이 모든 서비스에 `logging` 이 있는지 센다 — 새 서비스를 더하면 앵커를 붙인다. --- @@ -1374,8 +1724,8 @@ sudo docker compose --env-file .env.production start mail - ⚠️ **루프백(127.0.0.1)에서 온 요청은 정책을 건너뛴다** — 메일 컨테이너 안에서 `https://127.0.0.1` 로 시험하면 200 이 나온다. 확인은 컨테이너의 네트워크 주소로 한다 (setup 이 그렇게 확인한다). 이미지 healthcheck 는 https 실패 시 http 8080 으로 대체되어 영향이 없다. - ACME(TLS-ALPN-01)는 TLS 단계에서 끝나 HTTP 경로 제한과 무관하다 (운영 인증서 발급으로는 아직 확인하지 않았다). - - 관리 화면은 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180::8080 <메일 호스트>` → . - 직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 필요해지면 백엔드 API 로 만든다. + - 관리 화면은 SSH 터널로 mail 컨테이너의 앱 네트워크 주소:8080 에 붙는다 — [관리 화면 — SSH 터널](#관리-화면--ssh-터널). + 직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 웹메일 설정 화면(비밀번호 · 2단계 · 앱 비밀번호)이 그 일을 한다. - **직원은 계정 목록을 볼 수 없다.** 기본 `User` 역할에는 `Principal/get` 으로 관리자·게이트웨이를 포함한 전체 계정을 보는 권한 (`jmapPrincipal*`·`davPrincipal*` 13개)이 있어서, setup 이 뺀다 (역할 권한은 캐시되므로 `InvalidateCaches` 로 반영). - 2026-10-26 확인: 테스트 직원의 `Principal/get` → forbidden. 백엔드를 거친 웹메일(폴더·목록·읽기·첨부 올리기/내려받기·발송·중복 발송 방지)은 그대로 동작. @@ -1397,6 +1747,7 @@ sudo docker compose --env-file .env.production start mail 그때는 그 식에 이 계정들일 때 `arasystem-system-recipients` 를 고르는 갈래를 합친다. - 확인 (2026-10-29, 0.16.27): 바꾸기 전 세 주소 모두 RCPT 250 → 배달 때 거절 → 보낸 사람(개발은 Mailpit)에게 반송. 바꾼 뒤 대소문자 · +태그 꼴까지 550, 직원 · 직원 +태그 · 이름이 `admin` 으로 시작하는 직원 · 공용 주소(Group, +태그 포함)는 250 이고 저장됐다. setup 을 이어서 돌리면 `[유지]`, 관리자를 더 만들거나 지우면 `[변경]`. +- **관리자 비밀번호는 대리 접근을 못 하고 서버 안에서만 통한다**(13단계 — setup 이 건다) — [관리자 계정 단단히](#관리자-계정-단단히). 관리 화면은 SSH 터널로만 ([관리 화면 — SSH 터널](#관리-화면--ssh-터널)). - **복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다** (검증함). 운영은 `.env.production` 에 두지 않고 일회성 override + `--recovery` 로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5). - **포트는 IPv4(`0.0.0.0`)로만 공개한다.** 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는 @@ -1714,6 +2065,12 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend" | clamav 의 디스크(쓰기 계층)가 줄지 않는다 · `docker system df` 의 clamav 컨테이너가 크다 | 검사 중에 clamd 가 죽으며 남긴 임시 파일이다(`/tmp/clamav-.tmp` — 받던 스트림 · 푼 내용). compose 는 clamd 를 띄울 때마다 지우므로 다시 뜬 뒤에도 크면 지금 검사 중인 것이다(한 번에 2개 × 최대 6GB). `sudo docker exec "${PROJECT_NAME}-clamav" du -sh /tmp` · `ls -la /tmp`. 자주 죽는다면 까닭을 본다 — `docker inspect -f '{{.State.OOMKilled}} {{.RestartCount}}' "${PROJECT_NAME}-clamav"`(메모리 한도 — [메모리 예산](#메모리-예산)) · `docker logs` | | frontend 가 뜨지 않는다 — `nginx-env.envsh: SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처…` | `.env.production` 의 `SHARE_PUBLIC_URL` 을 `https://호스트[:포트]` 로(경로 · 끝의 `/` · 끝 점 없이) 고친 뒤 `up -d frontend` | | `up` 이 `all predefined address pools have been fully subnetted` 로 멈춘다 | 도커 기본 주소 풀(172.17~31.x · 192.168.x — 네트워크 31개)이 다 찼다 — 서브넷을 정하지 않은 네트워크(`clamav` · `clamav-egress` · `mail-dns-egress`)를 만들 자리가 없다. 쓰지 않는 네트워크를 지운다(`docker network prune` — 개발 PC 는 다른 프로젝트 · CI 의 것이 쌓인다) | +| setup 이 `관리자(…) 로그인이 HTTP 403 입니다` 로 멈춘다 (13단계부터) | 관리자 허용 IP(앱 네트워크) 밖에서 붙었다 — 운영은 `MAIL_ADMIN_URL` 을 mail 컨테이너의 **앱 네트워크** 주소로("운영 배포" 3 — 다른 네트워크 주소면 403). `APP_NETWORK_SUBNET` 을 바꾼 뒤라면 복구 관리자 + `--recovery` 가 고친다([관리자 계정 단단히](#관리자-계정-단단히)). 엔진이 본 주소는 `docker logs` 의 `security.ip-unauthorized` 줄 | +| 관리 화면 로그인이 안 된다 — SSH 터널로 (13단계부터) | 터널 대상이 mail 컨테이너의 앱 네트워크 주소인지([관리 화면 — SSH 터널](#관리-화면--ssh-터널)) — mail 을 다시 만들면 주소가 바뀐다. 바깥(993 · 공개 443)에서는 관리자 비밀번호가 통하지 않는 것이 정상이다 | +| 백업이 `메일 엔진(…)이 healthy 가 아니라 백업하지 않았습니다` · `restic 저장소에 닿지 못했습니다` | 앞은 엔진부터 본다(`ps` · 로그) — 백업은 멈춘 엔진을 건드리지 않는다. 뒤는 NCP 키 · 버킷 · `RESTIC_OPTIONS` · 바깥 443, 처음이면 `arasystem-backup.sh init`([백업](#백업)) | +| 백업이 `final 이 시간 상한(…)을 넘어 멈췄습니다` 로 실패 | 바깥 대역폭 · NCP 상태를 본다 — 엔진은 다시 떠 있다. 끊긴 restic 의 저장소 잠금은 30분 뒤 오래된 잠금이 된다 — 30분 안에 손으로 다시 돌리면 보존 정리(forget)가 잠금에 걸릴 수 있다(30분 뒤 다시, 다음 날 백업은 그대로). 평소보다 바뀐 양이 크면 `BACKUP_FINAL_TIMEOUT` 을 늘린다 | +| 백업 뒤 메일 엔진이 멈춰 있다 | 정상이면 생기지 않는다(스크립트의 EXIT 처리 — 멈추는 중이었으면 멈춤이 끝난 뒤 띄운다 — 와 systemd `ExecStopPost` · 부팅 유닛이 다시 띄운다). 먼저 백업이 아직 도는지 본다(`systemctl status arasystem-backup` — 돌고 있으면 끝나며 띄운다). 끝났으면 `sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running`(백업 잠금을 잡는다 — 백업이 돌면 `[건너뜀]`) → 그래도면 `sudo docker compose --env-file .env.production up -d mail` 과 `journalctl -u arasystem-backup` | +| frontend 가 뜨지 않는다 — `nginx-env.envsh: MAIL_DOMAIN …` · `… autoconfig …` | `MAIL_DOMAIN` · `MAIL_HOSTNAME` 의 모양(영문 · 숫자 · `-` · `.` 의 점 있는 이름 — 끝 점 · 밑줄 없이), `autoconfig.` · `autodiscover.` 이 `CORS_ORIGIN` · `SHARE_PUBLIC_URL` 의 호스트와 겹치지 않는지([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)) | ### 리졸버 장애 때 diff --git a/mail/scripts/setup.mjs b/mail/scripts/setup.mjs index cbebeaa..22f4239 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -36,6 +36,8 @@ * (기본 보안 설정은 IP 당 인증 실패가 하루 100회면 그 IP 를 차단한다) * 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능) * 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게 + * 관리자 역할에서 대리 접근(impersonate)을 빼고 관리자 비밀번호의 허용 IP 를 앱 네트워크로 묶는다(13단계 — 관리자 비밀번호 하나로 + * 바깥 993 에서 <직원>%<관리자> 로 아무 메일함에나 들어가지 못하게 · ADMIN_DISABLED_PERMISSION 머리말). 리로드 뒤 관리자의 실제 권한으로 확인한다 * 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다) * 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — * 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다) @@ -49,6 +51,8 @@ * — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도") * 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영) * — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 + * 휴지통 · 스팸메일함 자동 비우기 30일(들어온 때부터)과 엔진 정리 시각 — 계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30 + * (13단계 — 백업 03:30 의 앞뒤, 설정 리로드 뒤 반영 · TRASH_RETENTION_MS 머리말) * 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문). * Spamhaus 두 항목은 키가 있고 시험을 통과하면 DQS 로 켜고, 아니면 끈다 (설정 리로드 뒤 반영 · mail/README.md "DNS 리졸버와 스팸 차단 목록") * 엔진 웹훅(x:WebHook 하나 — 12단계): 인증 이벤트 셋을 백엔드의 받는 곳으로, MAIL_WEBHOOK_SECRET 로 서명해 보낸다 (설정 리로드 뒤 반영). @@ -128,6 +132,47 @@ const HTTP_ENDPOINT_POLICY = { const USER_ROLE_DESCRIPTION = 'User'; /** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */ const ADMIN_ROLE_DESCRIPTION = 'System Administrator'; +/** + * 관리자 계정 단단히 (13단계 — mail/README.md "관리자 계정 단단히"). 둘을 맞춘다: + * 1. 관리자 역할(ADMIN_ROLE_DESCRIPTION)의 빼는 권한(disabledPermissions)에 impersonate — 그대로면 관리자 비밀번호 하나로 바깥 993 IMAP · JMAP 에서 + * <직원>%<관리자> 로 아무 직원 메일함에나 들어가고(엔진 로그에는 관리자 로그인만 남는다), 관리 API 로 accountId=<직원> 메일함도 바로 연다. + * 빼면 둘 다 막히고(대리 접근 403 · IMAP 끊김 · 남의 메일함 forbidden) 관리 API(계정 · 설정)는 그대로다. + * 엔진은 역할을 바꿀 때 "바꾼 뒤의 권한 전체"를 바꾸는 쪽이 가졌는지만 본다(0.16.27 jmap/src/registry/mapping/principal.rs 의 validate_role) — + * 빼는 것은 관리자로 되고, 되돌리기(빼는 권한 지우기 · impersonate 를 주는 역할 · 계정에 게이트웨이 역할)는 관리자로 안 된다(복구 관리자만). + * 켜는 권한(enabledPermissions)에서 지우지 않고 빼는 권한에 두는 까닭 — 엔진은 역할 · 계정 권한의 켜는 것끼리 · 빼는 것끼리 합친 뒤 뺀다 + * (common/src/auth/permissions.rs 의 union · finalize). 계정에 Merge 로 impersonate 를 켜도 이 역할을 가진 계정은 여전히 못 한다(2026-10-31 임시 엔진). + * 게이트웨이(자기 역할의 impersonate — 백엔드)와 복구 관리자(역할을 쓰지 않는 슈퍼유저)의 대리 접근은 그대로다 + * 2. 영구 관리자(@도메인) 비밀번호 자격의 허용 IP = 앱 네트워크(APP_NETWORK_SUBNET) — 바깥(게시 포트 993 · 465 · 443 은 원래 주소 그대로 보인다)에서 + * 관리자 비밀번호로 로그인하지 못하게. 이 스크립트(호스트 → 컨테이너 주소 · 개발 포트 매핑)와 SSH 터널(호스트의 sshd → 컨테이너 주소)은 + * 앱 네트워크의 게이트웨이 주소(<서브넷>.1)로 보이고, backend 컨테이너의 node 로 하는 관리 API 는 그 컨테이너 주소다 — 둘 다 대역 안이다. + * 밖이면 엔진은 401 이 아니라 403(IMAP 은 끊김) — security.ip-unauthorized "IP address not allowed." 로 남는다 (2026-10-31 임시 엔진 · 개발 엔진). + * MAIL_TRUSTED_NETWORKS(게이트웨이 대역 — 더 좁힐 수 있다)가 아니라 앱 네트워크 전체인 까닭 — 좁히면 호스트(<서브넷>.1)가 빠져 이 스크립트가 막힐 수 있다. + * 운영자가 대역 안으로 더 좁힌 값(/32 등)은 그대로 둔다. 대역 밖 항목이 있으면(없음 · 옛 대역 · 0.0.0.0/0) 대역 하나로 바꾼다. + * 둘 다 관리자로 된다. 다만 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 건다(--recovery) — 걸자마자 막히면 되돌리는 길이 + * 복구 관리자(허용 IP · 역할을 보지 않는다 — mail/README.md "운영 배포" 1 · 4 · 5)뿐이다. 걸지 못하면 운영은 종료 1, 개발은 [주의]. + * APP_NETWORK_SUBNET 을 바꿔 관리자 로그인이 403 이면 복구 관리자로 지금 대역으로 고친다(ensureAdmin · resetAdminAllowedIps) + */ +const ADMIN_DISABLED_PERMISSION = 'impersonate'; +/** 관리자 허용 IP 를 걸어도 되는 이 실행의 관리 API 주소 — 루프백(개발 포트 매핑) · compose 이름 mail(앱 네트워크 안의 컨테이너) · 앱 네트워크 안의 IPv4 */ +const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; +/** + * 휴지통 · 스팸메일함 자동 비우기 (13단계 — mail/README.md "휴지통 · 스팸메일함 자동 비우기") — 엔진의 x:DataRetention 싱글톤. + * expungeTrashAfter — 휴지통 · 스팸메일함에 **들어온 때부터** 이만큼 지나면 다음 계정 정리에서 메일째 영구 삭제(받은 시각이 아니다 — + * 0.16.27 email/src/message/delete.rs 가 내부 칸 DeletedAt 으로 고른다. 받은편지함에도 함께 든 메일은 통째로 지워진다). 엔진 기본값이 30일이지만 기대지 않고 맞춘다. + * 정리 시각 셋 — 엔진 기본(00:00 · 02:00 · 04:00)은 메일 컨테이너의 현지 시각(chrono Local — utils/src/cron.rs)이고 컨테이너에 TZ 가 없어 UTC 다 → KST 09:00 · 11:00 · 13:00(일하는 시간). + * 새벽으로 옮긴다: 계정 정리(휴지통 · 스팸 30일) 03:00 → 백업 03:30(ops/backup — 엔진을 몇 초 멈춘다) → 데이터 정리 04:00 → 원문 정리 04:30 (KST). + * setup 은 컨테이너의 UTC 차이(date +%z)로 바꿔 넣는다(UTC 컨테이너면 18:00 · 19:00 · 19:30). + * 바뀐 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 다시 뜬 뒤)부터다 — 스케줄러가 다음 시각을 기동 때와 실행 직후에만 정한다 + * (services/src/task_manager/scheduler.rs — 2026-10-31 임시 엔진: 저장 · 리로드 · 재시작 뒤 task-scheduled due 17:59:59Z · 18:59:59Z · 19:29:59Z) + */ +const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const KST_OFFSET_MINUTES = 9 * 60; +const RETENTION_SCHEDULES_KST = [ + { field: 'expungeSchedule', label: '계정 정리(휴지통 · 스팸 30일)', hour: 3, minute: 0 }, + { field: 'dataCleanupSchedule', label: '데이터 정리', hour: 4, minute: 0 }, + { field: 'blobCleanupSchedule', label: '원문(blob) 정리', hour: 4, minute: 30 }, +]; +const MINUTES_PER_DAY = 24 * 60; /** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */ const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/; /** @@ -1380,6 +1425,19 @@ function statusInsideMail(cfg, url) { return result.status === 0 && Number.isInteger(code) ? code : null; } +/** + * 메일 컨테이너의 현지 시각이 UTC 와 몇 분 다른가 (이미지의 date +%z — 엔진의 정리 시각이 이 시각으로 돈다 · RETENTION_SCHEDULES_KST). + * docker 를 쓸 수 없거나 읽지 못하면 null + */ +function mailUtcOffsetMinutes(cfg) { + if (!cfg.docker) return null; + const result = runDocker(['exec', cfg.container, 'date', '+%z']); + const match = /^([+-])(\d{2})(\d{2})$/.exec(result.stdout?.trim() ?? ''); + if (result.status !== 0 || !match) return null; + const minutes = Number(match[2]) * 60 + Number(match[3]); + return match[1] === '-' ? -minutes : minutes; +} + /** * 메일 컨테이너 안에서 이름을 IPv4 로 풀어 본다 (이미지의 getent — 엔진이 쓰는 것과 같은 리졸버 설정). * IPv4 주소면 그대로. 돌려주는 값: 주소 배열(못 풀면 빈 배열), docker 로 물을 수 없으면 null @@ -1444,7 +1502,7 @@ async function waitForHttp(cfg, timeoutMs) { throw new SetupError(`메일 엔진이 응답하지 않습니다: ${cfg.adminUrl} (${lastError})`, { hint: unreachableHint(cfg) }); } -/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치(또는 허용 IP 밖) */ +/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치 · 402 비밀번호는 맞고 2단계 인증이 켜짐 · 403 허용 IP 밖(security.ip-unauthorized) */ async function loginStatus(cfg, user, password) { const res = await request(cfg, '/jmap/session', { headers: { authorization: basicAuth(user, password) } }); await res.body?.cancel(); @@ -1790,6 +1848,8 @@ async function detectRenamedAdmin(cfg) { async function ensureAdmin(cfg) { /** 이번 실행에서 bootstrap 뒤 재시작을 했다 — 엔진의 관리자를 아직 아무도 쓰지 않았다 */ let restarted = false; + /** 이번 실행에서 복구 관리자로 관리자 허용 IP 를 지금 대역으로 고쳤다 — 한 번만 (고친 뒤에도 403 이면 다른 원인이다) */ + let allowedIpsReset = false; for (let step = 0; step < MAX_ADMIN_STEPS; step += 1) { const admin = await openClient(cfg, cfg.adminAddress, cfg.adminPassword); if (admin?.accountId) { @@ -1831,6 +1891,23 @@ async function ensureAdmin(cfg) { continue; } + // 관리자 로그인이 403 — 비밀번호가 틀린 것(401)이 아니라 허용 IP(13단계 — 앱 네트워크) 밖이거나 로그인 권한이 없다. + // 비밀번호를 다시 맞춰도 풀리지 않는다 (ADMIN_DISABLED_PERMISSION 머리말 2). 복구 관리자가 있고 이 실행의 주소가 지금 대역 안이면 + // 허용 IP 가 옛 대역(APP_NETWORK_SUBNET 을 바꾼 뒤 등)인 경우라 지금 대역으로 고쳐 잇는다 — 한 번만, 그래도 403 이면 멈춘다 + if (!restarted && (await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword)) === 403) { + if (recovery?.accountId && !allowedIpsReset && adminUrlInsideAppNetwork(cfg) && (await resetAdminAllowedIps(recovery, cfg))) { + allowedIpsReset = true; + continue; + } + throw new SetupError(`관리자(${cfg.adminAddress}) 로그인이 HTTP 403 입니다 — 비밀번호가 아니라 접속 주소가 관리자 허용 IP(앱 네트워크 ${cfg.appNetworkSubnet}) 밖이다.`, { + hint: + `이 실행의 관리 API 주소는 ${cfg.adminUrl} — 운영은 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080)로 둔다 (mail/README.md "운영 배포" 3). ` + + `엔진이 본 주소는 docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)에 있다. ` + + 'APP_NETWORK_SUBNET 을 바꿨다면 복구 관리자를 일회성으로 주고 --recovery 로 실행하면 허용 IP 를 지금 대역으로 고친다(mail/README.md "관리자 계정 단단히"). ' + + '관리자의 로그인 권한(authenticate)을 뺐다면 복구 관리자로 되돌린다.', + }); + } + // 정상 모드인데 MAIL_ADMIN_PASSWORD 로는 로그인되지 않는다 → env 값으로 맞춘다 const saved = readState(); const temporary = saved ? await openClient(cfg, saved.username, saved.secret) : null; @@ -2345,6 +2422,193 @@ async function ensureUserRoleWithoutPrincipals(client) { log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`); } +/** + * 관리자 역할에서 대리 접근(impersonate)을 뺀다 — 빼는 권한(disabledPermissions)에 둔다 (ADMIN_DISABLED_PERMISSION 머리말). + * 관리자로 된다. 역할 권한은 캐시되므로 InvalidateCaches 로 반영한다(직원 역할과 같다). 돌려주는 값: 맞췄으면(이미 맞았으면) true + */ +async function ensureAdminRoleWithoutImpersonate(client) { + const roles = await listAll(client, 'Role', ['description', 'enabledPermissions', 'disabledPermissions']); + const role = roles.find((item) => item.description === ADMIN_ROLE_DESCRIPTION); + const label = `관리자 역할("${ADMIN_ROLE_DESCRIPTION}") — 대리 접근(${ADMIN_DISABLED_PERMISSION}) 뺌 (<직원>%<관리자> 로그인 · 남의 메일함 직접 열기 막힘, 관리 API 는 그대로)`; + if (!role) { + problems.push(`관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못해 대리 접근(${ADMIN_DISABLED_PERMISSION})을 빼지 못했습니다 — 관리 화면 › 역할을 확인하세요 (mail/README.md "관리자 계정 단단히").`); + return false; + } + if (role.disabledPermissions?.[ADMIN_DISABLED_PERMISSION] === true) { + log(`[유지] ${label}`); + return true; + } + await setObjects(client, 'Role', { update: { [role.id]: { [`disabledPermissions/${ADMIN_DISABLED_PERMISSION}`]: true } } }); + await setObjects(client, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } }); + log(`[변경] ${label}`); + return true; +} + +/** + * 이 실행이 관리 API 에 붙는 주소가 앱 네트워크 안으로 보이는가 — 관리자 허용 IP 를 걸기 전에 본다(밖이면 걸자마자 이 실행이 막힌다). + * 루프백(개발 — 포트 매핑은 앱 네트워크의 게이트웨이 주소로 보인다) · compose 이름 mail(앱 네트워크에 붙은 컨테이너) · 앱 네트워크 안의 IPv4(운영 — 호스트에서 + * 컨테이너 주소로 붙으면 그 네트워크의 게이트웨이 주소로 나간다)면 true. 그 밖(다른 네트워크의 주소 · 바깥 이름)은 false + */ +function adminUrlInsideAppNetwork(cfg) { + let host; + try { + host = new URL(cfg.adminUrl).hostname.toLowerCase(); + } catch { + return false; + } + if (LOOPBACK_HOSTS.includes(host) || host === new URL(INTERNAL_ADMIN_URL).hostname) return true; + return parseIpv4(host) !== null && parseCidr(cfg.appNetworkSubnet) !== null && cidrContains(cfg.appNetworkSubnet, host); +} + +/** 허용 IP 가 모두 그 대역 안인가 — 비었으면 false(어디서나 된다), IPv6 · 읽지 못하는 항목은 밖으로 본다 */ +function allowedIpsWithin(allowedIps, subnet) { + const entries = trueKeys(allowedIps); + return entries.length > 0 && entries.every((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, subnet)); +} + +/** + * 영구 관리자 비밀번호 자격의 허용 IP 를 앱 네트워크로 (ADMIN_DISABLED_PERMISSION 머리말 2). + * 이미 모두 대역 안이면 그대로 둔다 — 운영자가 더 좁힌 값(/32 등)을 넓히지 않는다. + * 걸기 전에 이 실행의 주소가 대역 안으로 보이는지 보고(adminUrlInsideAppNetwork), 운영에서 처음 걸 때는 복구 관리자가 있어야 건다 — + * 걸자마자 이 실행이 막히면 되돌릴 길이 복구 관리자뿐이다. 걸지 못하면 운영은 실패(problems — 종료 1), 개발은 [주의]. + * 건 뒤에는 관리자로 다시 로그인해 보고, 막혔으면(403) 복구 관리자로 되돌린다. + * 돌려주는 값: { ready — 관리자가 허용 IP 안에서 로그인된다, ips — 지금의 허용 IP(좁힌 값이면 그 값) } + */ +/** ensureAdminAllowedIps 가 허용 IP 를 걸지 못했을 때 */ +const NOT_READY = Object.freeze({ ready: false, ips: '' }); + +async function ensureAdminAllowedIps(client, cfg) { + const accounts = await listAll(client, 'Account', ['name', 'emailAddress', 'credentials', 'roles']); + const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress); + if (!admin) { + problems.push(`관리자 ${cfg.adminAddress} 를 찾지 못해 허용 IP 를 걸지 못했습니다.`); + return NOT_READY; + } + const key = passwordKey(admin); + const current = admin.credentials[key].allowedIps ?? {}; + const label = `관리자 ${cfg.adminAddress} — 허용 IP ${cfg.appNetworkSubnet} (앱 네트워크 — 바깥 993 · 465 · 443 에서는 관리자 비밀번호로 로그인되지 않는다)`; + if (allowedIpsWithin(current, cfg.appNetworkSubnet)) { + if (sameTrueSet(current, [cfg.appNetworkSubnet])) log(`[유지] ${label}`); + else log(`[유지] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(current).join(', ')} (앱 네트워크 ${cfg.appNetworkSubnet} 안으로 좁힌 값 — 넓히지 않는다)`); + return { ready: true, ips: trueKeys(current).join(', ') }; + } + const notApplied = (message) => { + if (cfg.isProduction) problems.push(message); + else warn(message); + return NOT_READY; + }; + if (!adminUrlInsideAppNetwork(cfg)) { + return notApplied( + `관리자 허용 IP 를 걸지 않았습니다 — 이 실행의 관리 API 주소(${cfg.adminUrl})가 앱 네트워크(${cfg.appNetworkSubnet}) 안으로 보이는지 알 수 없어, 걸면 이 실행이 막힐 수 있다. ` + + `MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080 — mail/README.md "운영 배포" 3)로 두고 다시 실행하세요.`, + ); + } + const recovery = await openRecoveryClient(cfg); + if (cfg.isProduction && !recovery) { + return notApplied( + `관리자 허용 IP 를 걸지 않았습니다 — 운영에서 처음 걸 때는 복구 관리자가 있어야 한다(걸자마자 이 실행이 막히면 되돌릴 길이 그것뿐이다). ` + + '복구 관리자를 일회성으로 주고 --recovery 로 다시 실행하세요 (mail/README.md "13단계 전부터 운영 중인 서버").', + ); + } + await setObjects(client, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } }); + log(`[변경] ${label}${trueKeys(current).length > 0 ? ` (전 ${trueKeys(current).join(', ')})` : ''}`); + const status = await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword); + if (status >= 200 && status < 300) return { ready: true, ips: cfg.appNetworkSubnet }; + // 걸자마자 이 실행이 막혔다 — 엔진이 이 실행의 주소를 대역 밖으로 본다 (403 · security.ip-unauthorized) + const previous = current; + if (recovery) { + await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: previous } } }); + problems.push( + `관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 라 복구 관리자로 되돌렸습니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다. ` + + `docker logs ${cfg.container} 의 security.ip-unauthorized 줄의 remoteIp 를 보고 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두세요.`, + ); + return NOT_READY; + } + throw new SetupError(`관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 입니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다.`, { + hint: + `docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)을 본다. 되돌리기 — 복구 관리자를 일회성으로 주고(운영: mail/README.md "운영 배포" 1 · 2) ` + + '복구 관리자로 관리 화면 › 계정 › 관리자 › Credentials 의 Allowed IPs 를 비운 뒤, MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두고 다시 실행한다.', + }); +} + +/** + * 관리자 로그인이 403 일 때(ensureAdmin) 복구 관리자로 관리자 비밀번호의 허용 IP 를 지금 대역으로 고친다 — APP_NETWORK_SUBNET 을 바꾸면 + * 허용 IP 가 옛 대역에 남아 관리자가 막힌다. 지금 대역 안의 항목이 하나라도 있으면(그대로이거나 운영자가 좁힌 값) 고치지 않고 false — + * 403 은 다른 원인이다(이 실행의 주소가 좁힌 값 밖 등 — 부른 쪽이 멈춘다) + */ +async function resetAdminAllowedIps(recovery, cfg) { + const accounts = await listAll(recovery, 'Account', ['emailAddress', 'credentials']); + const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress); + if (!admin) return false; + const key = passwordKey(admin); + const previous = admin.credentials?.[key]?.allowedIps ?? {}; + if (trueKeys(previous).some((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, cfg.appNetworkSubnet))) return false; + await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } }); + log( + `[변경] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(previous).join(', ') || '없음'} → ${cfg.appNetworkSubnet} ` + + '(관리자 로그인이 403 이었다 — 앱 네트워크 대역이 바뀐 것으로 보고 복구 관리자로 고쳤다)', + ); + return true; +} + +/** + * 관리자 단단히가 걸렸는지 — 관리자 세션의 실제 권한(GET /api/account 의 permissions — 역할 · 계정 권한을 합친 결과)에 impersonate 가 없고, + * 관리자 로그인이 이 실행(앱 네트워크 안)에서 된다. 그 밖에 [주의] 둘 (setup 은 그 비밀을 모른다 — 걸지 않는다): + * - 관리자의 다른 자격(두 번째 비밀번호 · 앱 비밀번호 · API 키)의 허용 IP 가 앱 네트워크 밖이거나 없다 — 그 자격으로 바깥에서 관리자가 된다 + * - 다른 관리자 역할 계정의 비밀번호에 앱 네트워크 안의 허용 IP 가 없다 + */ +async function checkAdminHardening(admin, cfg, ipState) { + let permissions = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + const res = await request(cfg, '/api/account', { headers: { authorization: admin.auth } }); + let body = null; + if (res.ok) body = await res.json().catch(() => null); + else await res.body?.cancel(); + permissions = Array.isArray(body?.permissions) ? body.permissions : null; + if (permissions && !permissions.includes(ADMIN_DISABLED_PERMISSION)) break; + await sleep(POLL_INTERVAL_MS / 2); + } + if (!permissions) { + problems.push(`관리자 ${cfg.adminAddress} 의 권한(GET /api/account)을 읽지 못해 대리 접근이 빠졌는지 확인하지 못했습니다.`); + } else if (permissions.includes(ADMIN_DISABLED_PERMISSION)) { + problems.push( + `관리자 ${cfg.adminAddress} 에 아직 대리 접근(${ADMIN_DISABLED_PERMISSION}) 권한이 있습니다 — 관리자 비밀번호로 <직원>%<관리자> 로그인이 된다. ` + + '관리 화면 › 역할에서 System Administrator 의 Disabled permissions 를 확인하세요.', + ); + } else { + const ips = ipState?.ready ? ` · 허용 IP ${ipState.ips} 안에서 로그인됨` : ''; + log(`[확인] 관리자 ${cfg.adminAddress} — 대리 접근 권한 없음 (권한 ${permissions.length}개에 ${ADMIN_DISABLED_PERMISSION} 없음)${ips}`); + } + const accounts = await listAll(admin, 'Account', ['emailAddress', 'roles', 'credentials']); + const self = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress); + if (self) { + const primary = passwordKey(self); + const loose = Object.entries(self.credentials ?? {}) + .filter(([key, credential]) => key !== primary && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet)) + .map(([key, credential]) => `${credential['@type'] ?? '?'} ${key}`); + if (loose.length > 0) { + warn( + `관리자 ${cfg.adminAddress} 의 다른 자격(${loose.join(', ')})은 허용 IP 가 앱 네트워크(${cfg.appNetworkSubnet}) 밖이거나 없어 바깥(993 · 465)에서도 관리자로 로그인된다. ` + + `쓰지 않으면 관리 화면 › 계정 › 관리자 › Credentials 에서 지우고, 쓴다면 Allowed IPs 를 ${cfg.appNetworkSubnet} 안으로 두세요 (mail/README.md "관리자 계정 단단히").`, + ); + } + } + const others = accounts + .filter((account) => isAdminAccount(account) && account.emailAddress?.toLowerCase() !== cfg.adminAddress) + .filter((account) => + Object.values(account.credentials ?? {}).some( + (credential) => credential['@type'] === 'Password' && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet), + ), + ) + .map((account) => account.emailAddress?.toLowerCase()); + if (others.length > 0) { + warn( + `관리자 역할 계정 ${others.join(', ')} 의 비밀번호에 허용 IP 가 없어 바깥(993 · 465)에서도 로그인된다 — 대리 접근은 역할에서 빠졌지만 관리 권한은 그대로다. ` + + `관리 화면 › 계정 › 그 계정 › Credentials 의 Allowed IPs 를 ${cfg.appNetworkSubnet} 로 두거나 쓰지 않는 계정이면 지우세요 (mail/README.md "관리자 계정 단단히").`, + ); + } +} + // ============================================================ // 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만) // ============================================================ @@ -2879,6 +3143,54 @@ async function ensureUploadQuota(client, cfg) { log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`); } +/** 시 · 분 → "HH:MM" */ +function formatClock(hour, minute) { + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +/** + * 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각 (TRASH_RETENTION_MS · RETENTION_SCHEDULES_KST 머리말) — 저장만 한다(main 의 리로드로 반영). + * 정리 시각은 메일 컨테이너의 현지 시각으로 넣는다 — 컨테이너의 UTC 차이를 읽지 못하면 UTC 로 본다(compose 는 mail 에 TZ 를 주지 않는다). + * 관리 화면에서 바꾼 값은 되돌린다. 돌려주는 값: 요약 글 + */ +async function ensureDataRetention(client, cfg) { + const fields = ['expungeTrashAfter', ...RETENTION_SCHEDULES_KST.map((item) => item.field)]; + const current = (await call(client, 'x:DataRetention/get', { ids: ['singleton'], properties: fields })).list[0] ?? {}; + const read = mailUtcOffsetMinutes(cfg); + const offset = read ?? 0; + const patch = {}; + const lines = []; + const days = TRASH_RETENTION_MS / 86_400_000; + const trashSame = current.expungeTrashAfter === TRASH_RETENTION_MS; + if (!trashSame) patch.expungeTrashAfter = TRASH_RETENTION_MS; + lines.push( + `${trashSame ? '[유지]' : '[변경]'} 휴지통 · 스팸메일함 자동 비우기 — 들어온 지 ${days}일이 지나면 영구 삭제 (x:DataRetention 의 expungeTrashAfter` + + `${trashSame ? '' : ` · 전 ${current.expungeTrashAfter === null ? '꺼짐(null)' : formatDuration(current.expungeTrashAfter)}`})`, + ); + for (const item of RETENTION_SCHEDULES_KST) { + const local = (((item.hour * 60 + item.minute - KST_OFFSET_MINUTES + offset) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY; + const desired = { '@type': 'Daily', hour: Math.floor(local / 60), minute: local % 60 }; + const same = stableJson(current[item.field]) === stableJson(desired); + if (!same) patch[item.field] = desired; + const was = current[item.field]?.['@type'] === 'Daily' ? formatClock(current[item.field].hour, current[item.field].minute) : stableJson(current[item.field]); + lines.push( + `${same ? '[유지]' : '[변경]'} 엔진 정리 시각 — ${item.label} 매일 KST ${formatClock(item.hour, item.minute)} ` + + `(컨테이너 시각 ${formatClock(desired.hour, desired.minute)}${same ? '' : ` · 전 ${was}`})`, + ); + } + if (Object.keys(patch).length > 0) await setObjects(client, 'DataRetention', { update: { singleton: patch } }); + for (const line of lines) log(line); + if (read === null) { + log(`[참고] 메일 컨테이너의 시간대를 읽지 못해 UTC 로 보고 정리 시각을 넣었습니다 (docker 를 쓸 수 없거나 date 가 없음).`); + } else if (read !== 0) { + log(`[참고] 메일 컨테이너의 현지 시각이 UTC${read > 0 ? '+' : '-'}${formatClock(Math.floor(Math.abs(read) / 60), Math.abs(read) % 60)} 라 그 시각으로 바꿔 넣었습니다.`); + } + if (Object.keys(patch).some((field) => field !== 'expungeTrashAfter')) { + log('[참고] 바뀐 정리 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 mail 컨테이너가 다시 뜬 뒤 — 밤 백업이 다시 띄운다)부터 걸린다.'); + } + return `들어온 지 ${days}일 · 정리 KST ${RETENTION_SCHEDULES_KST.map((item) => formatClock(item.hour, item.minute)).join(' · ')}`; +} + /** 엔진 DNS(x:DnsResolver)를 사람이 읽는 글로 — @type 과 Custom 의 서버 */ function describeEngineDns(value) { const servers = Object.values(value?.servers ?? {}).map((server) => `${server.address}:${server.port}/${server.protocol}`); @@ -4939,6 +5251,9 @@ async function main() { // 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API await ensureUserRoleWithoutPrincipals(admin); + // 관리자 단단히 (13단계) — 관리자 역할의 대리 접근(impersonate) 빼기 · 관리자 비밀번호의 허용 IP = 앱 네트워크 (관리자로 된다) + await ensureAdminRoleWithoutImpersonate(admin); + const adminIps = await ensureAdminAllowedIps(admin, cfg); await ensureHttpEndpointPolicy(admin); // 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의]) const directory = await ensureDirectoryAccount(admin, cfg, domain); @@ -4959,6 +5274,8 @@ async function main() { // 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다) await ensurePilotInboundTrust(admin, pilot); await ensureUploadQuota(admin, cfg); + // 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각(새벽 KST — 13단계, 아래 리로드로 반영) + const retention = await ensureDataRetention(admin, cfg); // 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영) const engineDnsChanged = await ensureEngineDns(admin, cfg); const spamhausState = await ensureSpamhausDnsbl(admin, cfg, dnsCheck.spamhaus); @@ -4983,6 +5300,7 @@ async function main() { if (pilot) await checkPilotSenderGuard(cfg); checkEngineDnssec(cfg, engineDnsChanged); await checkEngineWebhook(admin, cfg, webhook); + await checkAdminHardening(admin, cfg, adminIps); const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg); const devUsers = []; @@ -5020,7 +5338,9 @@ async function main() { const adminScreen = cfg.isProduction ? '공개 443 에서는 막혀 있다 — SSH 터널로 내부 8080 (mail/README.md "운영 배포")' : `${cfg.publicUrl ?? cfg.adminUrl}/admin/`; - log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`); + const adminIpsSummary = adminIps.ready ? `허용 IP ${adminIps.ips}` : '허용 IP 걸지 않음(위 [주의] · [실패])'; + log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 대리 접근 없음 · ${adminIpsSummary} — 관리 화면 ${adminScreen}`); + log(` 휴지통 ${retention} (휴지통 · 스팸메일함 — 엔진이 지운다)`); log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`); log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`); log( diff --git a/ops/backup/arasystem-backup.sh b/ops/backup/arasystem-backup.sh new file mode 100644 index 0000000..c904ef9 --- /dev/null +++ b/ops/backup/arasystem-backup.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# ========================================== +# arasystem 백업 — 메일 엔진 볼륨 · DB · 업로드 · env 를 restic 저장소(NCP Object Storage)로 (13단계 — mail/README.md "백업") +# +# arasystem-backup.sh 매일 백업 (systemd 타이머 arasystem-backup.timer — 03:30 KST) +# arasystem-backup.sh init 저장소를 처음 만든다 (한 번) +# arasystem-backup.sh ensure-mail-running +# 백업이 멈춘 메일 엔진이 남아 있으면 다시 띄운다 — systemd 의 ExecStopPost(스크립트가 죽어도)와 +# 부팅 때(arasystem-mail-recover.service — 멈춘 채 서버가 다시 켜져도). 떠 있는 것을 본 뒤에만 표시를 지운다 +# +# 흐름 — 메일 엔진(Stalwart · RocksDB)은 켜 둔 채 파일을 복사하면 일관되지 않는다(공식 문서 — 실행 중 백업 없음): +# 1. DB 를 pg_dump(-Fc, 멈추지 않는다) → pg_restore -l 로 읽히는지 본다. env 파일 둘을 작업 폴더에 복사한다 +# 2. warm — 엔진을 켜 둔 채 restic 으로 한 번 올린다(크게 올릴 것은 여기서 다 올린다 — 이 스냅숏은 복원에 쓰지 않는다). +# 엔진이 도는 동안 RocksDB 가 지운 파일은 읽지 못한다 — restic 의 종료 3(일부를 읽지 못함)은 warm 에서만 받아들인다 +# 3. 엔진을 멈추고(docker stop) final — 같은 경로를 다시 올린다. RocksDB 의 SST · blob 은 한 번 쓰면 바뀌지 않아 바뀐 것만 올라간다. +# 시간 상한(BACKUP_FINAL_TIMEOUT, 기본 15분)을 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다 — 메일이 오래 멈춰 있지 않게 +# 4. 엔진을 다시 띄우고 /healthz/ready 까지 기다린다 → 멈춘 시간을 남긴다 +# 5. 보존(forget) — final 은 매일 7 · 매주 4 · 매달 6, warm 은 마지막 1. 일요일(KST)에 prune 과 check(데이터 5%) +# 6. 상태 파일(15분 점검이 읽는다)과 Healthchecks 에 알린다(성공 · /fail + 까닭 한 줄) +# 받지 않는 것: 대용량 첨부 볼륨(_files — 30일짜리 전송물), 악성 검사 서명(_clamav-db), 리졸버 · frontend(상태 없음), 로그 +# +# 설정 /etc/arasystem/ops.env (예시 ops/ops.env.example) · restic 비밀번호 /etc/arasystem/restic-password. root 로 돈다(docker · 볼륨을 읽는다). +# restic 은 고정한 이미지로 띄운다 — 판을 올릴 때는 docker 스킬 "이미지 버전 고정"의 표와 함께 +# ========================================== +set -Eeuo pipefail +umask 077 + +OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$OPS_ROOT/lib/common.sh" + +# restic 0.19.1(2026-07-05) — manifest list digest 까지 고정한다 (docker 스킬 "이미지 버전 고정") +RESTIC_IMAGE=${RESTIC_IMAGE:-restic/restic:0.19.1@sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510} + +STEP="시작" +# 메일 엔진을 어디까지 다뤘나 — 비었음(건드리지 않음) · stopping(docker stop 을 불렀다) · starting(docker start 를 불렀다). on_exit 가 이것으로 되살린다 +MAIL_PHASE="" +# restic 한 번의 시간 상한 (timeout 의 길이 — 비면 없음). final 에서만 건다 +RESTIC_TIMEOUT="" +SEQ=0 + +# restic 을 컨테이너로 — restic_run [docker 인자 …] -- [restic 인자 …] +# root 로 돌되 권한(capability)은 파일 읽기(DAC_READ_SEARCH)만 남긴다 — 메일(UID 2000) · 업로드(UID 1000) 파일을 읽어야 한다. 볼륨은 읽기 전용으로 붙인다. +# 비밀은 -e 이름만 넘긴다(값이 명령줄 · docker 이벤트에 남지 않게). 캐시는 이름 있는 볼륨(-backup-cache)에 둔다. +# CPU 1 · 메모리 1g · CPU 몫 512(다른 컨테이너의 절반 — 켜 둔 메일 엔진을 앞세운다). 스크립트의 Nice · IO 우선(systemd)은 dockerd 아래의 컨테이너가 물려받지 않는다. +# RESTIC_TIMEOUT 이 있으면 timeout 으로 감싼다 — 넘으면 docker 가 restic 에 SIGTERM 을 넘기고, 그래도 남은 컨테이너는 지운다(종료 124). +# --foreground — timeout 이 따로 프로세스 그룹을 만들지 않게. 손으로 돌릴 때 Ctrl-C 가 docker CLI 에 닿아(restic 에 넘긴다) 바로 멈추고 정리로 간다 +# (그룹이 다르면 Ctrl-C 가 상한까지 기다린다). systemd 는 그룹과 상관없이 cgroup 전체에 보낸다 +restic_run() { + local dargs=() + while [ "$1" != "--" ]; do dargs+=("$1"); shift; done + shift + local net=() limit=() rc=0 name="${PROJECT_NAME}-backup-restic-$$-$((SEQ += 1))" + [ -n "${BACKUP_DOCKER_NETWORK:-}" ] && net=(--network "$BACKUP_DOCKER_NETWORK") + [ -n "$RESTIC_TIMEOUT" ] && limit=(timeout --foreground -k 30 "$RESTIC_TIMEOUT") + # shellcheck disable=SC2086 # RESTIC_OPTIONS 는 낱말로 나눠 넘긴다 (-o s3.region=… -o s3.bucket-lookup=…) + "${limit[@]}" docker run --rm --name "$name" \ + --cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges \ + --memory 1g --cpus 1 --cpu-shares 512 "${net[@]}" \ + -e RESTIC_REPOSITORY -e RESTIC_PASSWORD_FILE=/run/secrets/restic-password -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ + -v "$RESTIC_PASSWORD_FILE:/run/secrets/restic-password:ro" -v "${PROJECT_NAME}-backup-cache:/root/.cache/restic" \ + "${dargs[@]}" "$RESTIC_IMAGE" ${RESTIC_OPTIONS:-} "$@" || rc=$? + if [ "$rc" = 124 ] || [ "$rc" = 137 ]; then + # docker CLI 가 끊겨도 데몬이 만들던 컨테이너는 뒤늦게 뜰 수 있다 — 몇 초 동안 보이는 대로 지운다 + local _ + for _ in 1 2 3 4 5; do + docker rm -f "$name" >/dev/null 2>&1 || true + sleep 1 + done + return 124 + fi + return "$rc" +} + +write_status() { # write_status <글> + local now + now=$(date +%s) + { + if [ "$1" = ok ]; then + echo "last_success_epoch=$now" + elif [ -f "$WORK_DIR/status" ]; then + grep '^last_success_epoch=' "$WORK_DIR/status" || true + fi + echo "last_run_epoch=$now" + echo "last_result=$1" + echo "last_message=$2" + } > "$WORK_DIR/status.tmp" + mv -f "$WORK_DIR/status.tmp" "$WORK_DIR/status" + chmod 0644 "$WORK_DIR/status" +} + +mail_ready() { # 엔진의 /healthz/ready (이미지의 curl) + docker exec "$MAIL_CONTAINER" curl -fsS -m 3 -o /dev/null http://127.0.0.1:8080/healthz/ready >/dev/null 2>&1 +} + +wait_mail_ready() { # MAIL_READY_TIMEOUT 초까지 — 준비되면 0 + local waited=0 + until mail_ready; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt "$MAIL_READY_TIMEOUT" ] || return 1 + done +} + +# 엔진을 다시 띄운다 — 떠 있는 것을 본 뒤에만 표시를 지운다(15분 점검 · ensure-mail-running 이 표시를 본다) +start_mail() { + STEP="메일 엔진 다시 띄우기" + MAIL_PHASE=starting + docker start "$MAIL_CONTAINER" >/dev/null + container_running "$MAIL_CONTAINER" || die "docker start 뒤에도 메일 엔진($MAIL_CONTAINER)이 떠 있지 않습니다" + rm -f "$MARKER" + MAIL_PHASE="" + wait_mail_ready || die "메일 엔진이 ${MAIL_READY_TIMEOUT}초 안에 준비되지 않았습니다 (docker logs $MAIL_CONTAINER)" +} + +# 끊긴 백업이 멈춘 엔진을 다시 띄운다 (on_exit) — 띄웠으면 0. 준비(/healthz/ready)는 여기서 기다리지 않는다(on_exit 가 알린 뒤에 기다린다). +# 멈추는 중이었으면 멈춤이 끝나기를 기다린 뒤에 띄운다 — docker 가 stop 을 처리하는 동안 start 를 부르면 아무 일도 하지 않고(아직 떠 있다), +# 그 뒤 stop 이 끝나 엔진이 멈춘 채 남는다 +restart_stopped_mail() { + log "[복구] 메일 엔진을 다시 띄운다 (끊긴 단계: $MAIL_PHASE)" + if [ "$MAIL_PHASE" = stopping ]; then + docker stop -t "$MAIL_STOP_TIMEOUT" "$MAIL_CONTAINER" >/dev/null 2>&1 || true + fi + if ! docker start "$MAIL_CONTAINER" >/dev/null 2>&1 || ! container_running "$MAIL_CONTAINER"; then + log "[실패] 메일 엔진을 다시 띄우지 못했습니다 — 표시($MARKER)를 둔다. docker start $MAIL_CONTAINER" + return 1 + fi + rm -f "$MARKER" + MAIL_PHASE="" +} + +# 정리 차례 — systemd 가 시간 상한(TimeoutStopSec)으로 끊어도 중요한 것부터 끝나게: +# 1. 비밀이 든 사본(env · DB 덤프)을 지운다 2. 멈춘 엔진을 띄운다(준비는 기다리지 않는다) 3. 실패를 알린다(상태 파일 · Healthchecks /fail) +# 4. 그 뒤에 엔진 준비를 기다린다 — 오래 걸려도 /fail 은 이미 나갔다 +on_exit() { + local rc=$? + # 정리 중에는 다시 끊기지 않게 (systemd 는 SIGTERM 한 번 뒤 TimeoutStopSec 까지 기다린다) + trap '' TERM INT + rm -rf "${WORK_DIR:?}/env" "${WORK_DIR:?}/db" + local restarted=0 mail_note="" + if [ -n "$MAIL_PHASE" ]; then + if restart_stopped_mail; then + restarted=1 + mail_note=" · 메일 엔진은 다시 띄웠다" + else + mail_note=" · ⚠️ 메일 엔진을 다시 띄우지 못했다(ensure-mail-running)" + fi + fi + if [ "$rc" -ne 0 ] && [ "${MODE:-run}" = run ]; then + write_status fail "$STEP" || true + hc_ping "${HC_BACKUP_URL:-}" /fail "arasystem-backup 실패 — ${STEP} (종료 코드 ${rc}${mail_note} · journalctl -u arasystem-backup 로 본다)" + fi + if [ "$restarted" = 1 ]; then + if wait_mail_ready; then log "[복구] 메일 엔진 준비됨"; else log "[주의] 메일 엔진이 ${MAIL_READY_TIMEOUT}초 안에 준비되지 않았습니다 (docker logs $MAIL_CONTAINER)"; fi + fi +} + +# 부팅 직후(타이머의 Persistent — 꺼져 있던 동안 놓친 백업을 켜지자마자 한다)에는 엔진 · DB 가 아직 starting 이다 — +# healthy 를 BACKUP_HEALTHY_WAIT 초(기본 300)까지 기다린 뒤 판단한다. 멈췄거나 없으면(exited · missing) 기다리지 않는다 +wait_healthy() { # wait_healthy <컨테이너> — healthy 면 0 + local waited=0 state + while :; do + state=$(container_state "$1") + case $state in + healthy) return 0 ;; + starting | unhealthy | running) [ "$waited" -lt "$BACKUP_HEALTHY_WAIT" ] || return 1 ;; + *) return 1 ;; + esac + [ "$waited" -gt 0 ] || log "[대기] $1 이(가) $state — healthy 를 ${BACKUP_HEALTHY_WAIT}초까지 기다린다" + sleep 5 + waited=$((waited + 5)) + done +} + +# restic backup --json 의 요약 줄에서 값 하나 +summary_field() { sed -n "s/.*\"message_type\":\"summary\".*\"$1\":\"\{0,1\}\([^\",}]*\).*/\1/p" <<< "$2" | tail -n 1; } + +human_bytes() { + awk -v b="${1:-0}" 'BEGIN { split("B KB MB GB TB", u); i = 1; while (b >= 1024 && i < 5) { b /= 1024; i++ } printf (i == 1 ? "%d%s" : "%.1f%s"), b, u[i] }' +} + +prepare() { + load_config + WORK_DIR=$(ops_work_dir) + MARKER="$WORK_DIR/mail-stopped-by-backup" + # 메일 엔진이 SIGTERM 뒤 RocksDB 를 닫고 끝낼 시간 — 넘으면 docker 가 죽인다(그래도 WAL 로 일관되게 다시 뜬다) + MAIL_STOP_TIMEOUT=${MAIL_STOP_TIMEOUT:-60} + # 다시 띄운 엔진이 /healthz/ready 를 줄 때까지 기다리는 시간(초) + MAIL_READY_TIMEOUT=${MAIL_READY_TIMEOUT:-180} + # final(엔진 멈춤) 한 번의 시간 상한 — timeout 의 길이(숫자 + s · m · h) + BACKUP_FINAL_TIMEOUT=${BACKUP_FINAL_TIMEOUT:-15m} + [[ $BACKUP_FINAL_TIMEOUT =~ ^[1-9][0-9]*[smh]?$ ]] || die "$OPS_CONFIG 의 BACKUP_FINAL_TIMEOUT 은 15m 처럼 적는다 (숫자 + s · m · h)" + # 사전 확인에서 엔진 · DB 가 healthy 가 되기를 기다리는 시간(초) — 부팅 직후 따라잡는 백업 + BACKUP_HEALTHY_WAIT=${BACKUP_HEALTHY_WAIT:-300} + [[ $BACKUP_HEALTHY_WAIT =~ ^[0-9]+$ ]] || die "$OPS_CONFIG 의 BACKUP_HEALTHY_WAIT 은 초(숫자)로 적는다" + require_set RESTIC_REPOSITORY + case $RESTIC_REPOSITORY in s3:*) require_set AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY ;; esac + RESTIC_PASSWORD_FILE=${RESTIC_PASSWORD_FILE:-/etc/arasystem/restic-password} + [ -s "$RESTIC_PASSWORD_FILE" ] || die "restic 비밀번호 파일 $RESTIC_PASSWORD_FILE 이 없거나 비어 있습니다 (ops/ops.env.example 머리말)" + export RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + MAIL_CONTAINER=${PROJECT_NAME}-mail + DB_CONTAINER=${PROJECT_NAME}-db + BACKUP_HOST=${BACKUP_HOST:-$PROJECT_NAME} + BACKUP_ENV_FILES=${BACKUP_ENV_FILES:-"$APP_ENV_FILE $ARASYSTEM_DIR/backend/.env.production"} + mkdir -p "$WORK_DIR" + chmod 0700 "$WORK_DIR" + command -v docker >/dev/null || die "docker 가 없습니다" +} + +cmd_init() { + prepare + log "restic 저장소를 만든다 — ${RESTIC_REPOSITORY%%\?*}" + restic_run -- init + log "[완료] 저장소를 만들었습니다. restic 비밀번호($RESTIC_PASSWORD_FILE)를 서버 밖(비밀번호 관리자)에도 둔다 — 잃으면 백업을 풀 수 없다" +} + +# 표시 파일이 남아 있으면 그 메일 엔진이 떠 있게 한다 — 설정을 다 읽지 않는다(설정이 깨졌어도 돈다). +# 백업과 같은 잠금을 잡는다 — 백업이 돌고 있으면(손으로 불렀을 때) 아무것도 하지 않고 그만둔다: 그 백업이 끝나며 엔진을 띄운다. +# final 도중에 엔진을 띄우면 엔진이 쓰는 RocksDB 를 읽은 스냅숏이 final 로 남아 복원의 latest 가 된다. +# (ExecStopPost 때는 백업 본체가 끝나 잠금이 풀려 있다 · 부팅 유닛은 백업보다 먼저 돈다 — Before=) +# 떠 있는 것을 본 뒤에만 표시를 지운다. 도커에 닿지 못하면 표시를 둔 채 실패한다(15분 점검이 남은 표시를 알린다 · 부팅 유닛은 다시 한다). +# 방금 멈추기 시작한 엔진(표시가 MAIL_STOP_TIMEOUT 보다 새것)은 멈춤이 끝날 때까지 기다린 뒤에 본다 — 아직 떠 보여도 곧 멈춘다 +cmd_ensure_mail_running() { + WORK_DIR=$(ops_work_dir) + local marker="$WORK_DIR/mail-stopped-by-backup" + [ -f "$marker" ] || exit 0 + if ! take_backup_lock "$WORK_DIR"; then + log "[건너뜀] 백업이나 복원 연습이 돌고 있다(잠금) — 백업이면 끝나며 엔진을 스스로 다시 띄운다. 끝난 뒤에도 표시가 남아 있으면 다시 부른다(journalctl -u arasystem-backup)" + exit 0 + fi + # 잠금을 잡는 사이 백업이 끝나 표시를 지웠을 수 있다 + [ -f "$marker" ] || exit 0 + local container stop_timeout age + container=$(cat "$marker") + [[ $container =~ ^[a-z0-9][a-z0-9_-]*-mail$ ]] || die "표시 파일($marker)의 내용이 메일 컨테이너 이름이 아닙니다 — 확인하고 지운다" + stop_timeout=${MAIL_STOP_TIMEOUT:-$(env_get "$OPS_CONFIG" MAIL_STOP_TIMEOUT || true)} + stop_timeout=${stop_timeout:-60} + [[ $stop_timeout =~ ^[0-9]+$ ]] || stop_timeout=60 + age=$(( $(date +%s) - $(stat -c %Y "$marker") )) + [ "$age" -ge 0 ] || age=0 + if [ "$age" -lt $((stop_timeout + 15)) ]; then + log "[대기] 표시가 ${age}초 전 것이다 — 멈춤이 끝날 때까지 $((stop_timeout + 15 - age))초 기다린 뒤 본다" + sleep $((stop_timeout + 15 - age)) + fi + local state=0 + container_running "$container" || state=$? + case $state in + 0) ;; + 1) + log "[복구] 백업이 멈춘 메일 엔진($container)이 남아 있어 다시 띄운다" + docker start "$container" >/dev/null || die "docker start $container 에 실패했습니다 — 표시($marker)를 둔다" + container_running "$container" || die "docker start 뒤에도 $container 가 떠 있지 않습니다 — 표시($marker)를 둔다" + ;; + *) die "도커에 닿지 못했거나 $container 가 없습니다 — 표시($marker)를 둔다" ;; + esac + rm -f "$marker" + log "[확인] $container 가 떠 있다 — 표시를 지웠다" +} + +cmd_run() { + prepare + if ! take_backup_lock "$WORK_DIR"; then + log "[건너뜀] 다른 백업 · 복원 연습이 돌고 있습니다" + hc_ping "${HC_BACKUP_URL:-}" /fail "arasystem-backup 건너뜀 — 다른 백업 · 복원 연습이 돌고 있다(잠금 $WORK_DIR/backup.lock)" + exit 0 + fi + trap on_exit EXIT + # systemd 가 멈추거나(시간 초과 · stop) Ctrl-C 여도 EXIT 정리(멈춘 메일 엔진 다시 띄우기)를 탄다 + trap 'exit 143' TERM + trap 'exit 130' INT + local started rc + started=$(date +%s) + hc_ping "${HC_BACKUP_URL:-}" /start + + STEP="사전 확인" + local volume + for volume in "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" "${PROJECT_NAME}_uploads"; do + docker volume inspect "$volume" >/dev/null 2>&1 || die "볼륨 $volume 이 없습니다 (PROJECT_NAME 확인)" + done + wait_healthy "$MAIL_CONTAINER" || die "메일 엔진($MAIL_CONTAINER)이 healthy 가 아니라 백업하지 않았습니다 ($(container_state "$MAIL_CONTAINER"))" + wait_healthy "$DB_CONTAINER" || die "DB($DB_CONTAINER)가 healthy 가 아닙니다 ($(container_state "$DB_CONTAINER"))" + STEP="restic 저장소 확인" + restic_run -- cat config >/dev/null || die "restic 저장소에 닿지 못했습니다 — 주소 · 키 · 비밀번호, 처음이면 arasystem-backup init" + # 죽은 restic 이 남긴 오래된 잠금만 지운다 (살아 있는 잠금은 두고) + restic_run -- unlock >/dev/null 2>&1 || true + + STEP="DB 덤프" + rm -rf "${WORK_DIR:?}/db" "${WORK_DIR:?}/env" + mkdir -p "$WORK_DIR/db" "$WORK_DIR/env" + # shellcheck disable=SC2016 # $POSTGRES_* 는 컨테이너 안에서 푼다(비밀번호 없이 소켓으로) + docker exec "$DB_CONTAINER" sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$WORK_DIR/db/db.dump.tmp" + docker exec -i "$DB_CONTAINER" pg_restore -l < "$WORK_DIR/db/db.dump.tmp" > /dev/null || die "DB 덤프를 pg_restore 가 읽지 못했습니다" + mv -f "$WORK_DIR/db/db.dump.tmp" "$WORK_DIR/db/db.dump" + local dump_bytes + dump_bytes=$(wc -c < "$WORK_DIR/db/db.dump") + STEP="env 파일 복사" + # 저장소 기준 경로의 / 를 __ 로 — .env.production · backend__.env.production (복원 때 제자리를 안다) + local file name + for file in $BACKUP_ENV_FILES; do + [ -r "$file" ] || die "env 파일 $file 을 읽을 수 없습니다" + name=${file#"$ARASYSTEM_DIR"/} + cp -f "$file" "$WORK_DIR/env/${name//\//__}" + done + + local mounts=( + -v "${PROJECT_NAME}_mail-config:/backup/mail-config:ro" -v "${PROJECT_NAME}_mail-data:/backup/mail-data:ro" + -v "${PROJECT_NAME}_uploads:/backup/uploads:ro" -v "$WORK_DIR/db:/backup/db:ro" -v "$WORK_DIR/env:/backup/env:ro" + ) + local paths=(/backup/mail-config /backup/mail-data /backup/uploads /backup/db /backup/env) + + STEP="warm(켜 둔 채 미리 올리기)" + log "[1/4] warm — 메일 엔진을 켜 둔 채 올린다" + rc=0 + restic_run "${mounts[@]}" -- backup --host "$BACKUP_HOST" --tag warm --quiet "${paths[@]}" || rc=$? + case $rc in + 0) ;; + # 켜 둔 RocksDB 가 그사이 지운 파일(압축으로 사라진 SST 등)은 읽지 못한다 — warm 은 미리 올리기라 괜찮다. final 은 멈춘 엔진에서 다시 다 읽는다 + 3) log "[참고] warm — 도는 엔진이 그사이 지운 파일을 읽지 못했다(restic 종료 3). final 이 멈춘 엔진에서 다시 받는다" ;; + *) die "warm 의 restic 이 종료 $rc 로 끝났습니다" ;; + esac + + STEP="final(메일 엔진 멈춤)" + log "[2/4] final — 메일 엔진을 멈추고 바뀐 것만 올린다 (상한 $BACKUP_FINAL_TIMEOUT)" + local stopped_at down out="" + stopped_at=$(date +%s) + printf '%s\n' "$MAIL_CONTAINER" > "$MARKER" + MAIL_PHASE=stopping + docker stop -t "$MAIL_STOP_TIMEOUT" "$MAIL_CONTAINER" >/dev/null + rc=0 + RESTIC_TIMEOUT=$BACKUP_FINAL_TIMEOUT + out=$(restic_run "${mounts[@]}" -- backup --host "$BACKUP_HOST" --tag final --json "${paths[@]}") || rc=$? + RESTIC_TIMEOUT="" + case $rc in + 0) ;; + 124) die "final 이 시간 상한($BACKUP_FINAL_TIMEOUT)을 넘어 멈췄습니다 — 메일 엔진을 다시 띄운다 (바깥 대역폭 · NCP · BACKUP_FINAL_TIMEOUT)" ;; + *) die "final 의 restic 이 종료 $rc 로 끝났습니다 (3 이면 멈춘 엔진의 파일을 읽지 못한 것 — 받지 않는다)" ;; + esac + start_mail + down=$(( $(date +%s) - stopped_at )) + local snapshot added + snapshot=$(summary_field snapshot_id "$out") + added=$(summary_field data_added "$out") + [ -n "$snapshot" ] || die "final 스냅숏 id 를 읽지 못했습니다" + log "[3/4] 메일 엔진 다시 준비됨 — 멈춘 시간 ${down}초 · final 스냅숏 ${snapshot:0:8} · 새로 올린 양 $(human_bytes "$added")" + + STEP="보존 정리(forget)" + log "[4/4] 보존 — 매일 ${BACKUP_KEEP_DAILY:-7} · 매주 ${BACKUP_KEEP_WEEKLY:-4} · 매달 ${BACKUP_KEEP_MONTHLY:-6}" + restic_run -- forget --host "$BACKUP_HOST" --tag final --keep-daily "${BACKUP_KEEP_DAILY:-7}" --keep-weekly "${BACKUP_KEEP_WEEKLY:-4}" --keep-monthly "${BACKUP_KEEP_MONTHLY:-6}" --quiet + restic_run -- forget --host "$BACKUP_HOST" --tag warm --keep-last 1 --quiet + # 요일은 한국 시각으로 — 서버의 시간대가 UTC 여도 03:30 KST(전날 18:30 UTC)가 토요일로 읽히지 않게 + if [ "$(TZ=Asia/Seoul date +%u)" = "${BACKUP_PRUNE_WEEKDAY:-7}" ] || [ "${BACKUP_FORCE_PRUNE:-0}" = 1 ]; then + STEP="prune · check" + log "[주간] prune · check(데이터 ${BACKUP_CHECK_SUBSET:-5%})" + restic_run -- prune --quiet + restic_run -- check --read-data-subset="${BACKUP_CHECK_SUBSET:-5%}" --quiet + fi + + local summary + summary="arasystem-backup 성공 — 스냅숏 ${snapshot:0:8} · 메일 엔진 멈춤 ${down}초 · 새로 올림 $(human_bytes "$added") · DB 덤프 $(human_bytes "$dump_bytes") · 전체 $(( $(date +%s) - started ))초" + write_status ok "$summary" + log "[완료] $summary" + hc_ping "${HC_BACKUP_URL:-}" "" "$summary" +} + +main() { + MODE=${1:-run} + case $MODE in + run) cmd_run ;; + init) cmd_init ;; + ensure-mail-running) cmd_ensure_mail_running ;; + *) echo "사용법: $0 [run|init|ensure-mail-running]" >&2; exit 2 ;; + esac +} + +# source 하면(시험) 함수만 읽는다 +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi diff --git a/ops/backup/arasystem-restore.sh b/ops/backup/arasystem-restore.sh new file mode 100644 index 0000000..24ac836 --- /dev/null +++ b/ops/backup/arasystem-restore.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# ========================================== +# arasystem 복원 · 복원 연습 (13단계 — mail/README.md "백업" · "복원") +# +# arasystem-restore.sh snapshots final 스냅숏 목록 (복원에 쓰는 것 — warm 은 쓰지 않는다) +# arasystem-restore.sh check [5%|100%] 저장소 검사 — 데이터를 그만큼 내려받아 본다 (기본 5%) +# arasystem-restore.sh drill [스냅숏|latest] [--keep] +# 복원 연습 — 운영을 건드리지 않는다. 임시 볼륨에 풀고, 네트워크 없이(--network none) +# 같은 판의 메일 엔진 · DB 를 띄워 계정 · 메일 수 · DB 행 수 · 업로드 · env 를 지금 운영과 견준 뒤 지운다(--keep 이면 남긴다). +# 백업과 같은 잠금을 잡는다 — 함께 돌지 않는다 +# arasystem-restore.sh drill-clean 남은 연습 자원(-drill*)을 지운다 +# arasystem-restore.sh files <스냅숏> <폴더> DB 덤프 · env 파일만 폴더로 꺼낸다 (새 서버 — env 를 제자리에 둘 때) +# arasystem-restore.sh volumes <스냅숏> 메일 엔진 · 업로드 볼륨을 되살린다 (새 서버 — 볼륨이 없거나 비었고, 쓰는 컨테이너가 멈춰 있을 때만) +# arasystem-restore.sh db <덤프 파일> 빈 DB 에 덤프를 넣는다 (db 만 띄우고 backend 를 띄우기 전에 — 표가 있으면 멈춘다) +# +# 설정은 백업과 같다 (/etc/arasystem/ops.env · restic-password). root 로 돈다. +# snapshots · check · files 는 저장소의 .env.production 없이 돈다 — 새 서버 복원의 첫 단계가 그 파일을 꺼낸다. 그때 PROJECT_NAME(스냅숏의 host 이름)은 +# ops.env 의 값이다(옛 서버의 값을 적는다). volumes · db · drill 은 .env.production 이 있어야 한다(볼륨 · 컨테이너 이름이 그 PROJECT_NAME 이다) +# 스냅숏은 final 만 받는다 — id 를 직접 줘도 태그를 본다(warm 은 켜 둔 엔진에서 받아 RocksDB 가 일관되지 않을 수 있다) +# ⚠️ 연습의 메일 엔진은 운영 설정(호스트 이름 · 인증서 · 발송 대기열 · 웹훅)을 그대로 안고 뜬다 — 그래서 네트워크 없이(루프백뿐) 띄운다 +# (대기열의 메일을 다시 보내거나 인증서를 새로 받으러 나가지 않게). 포트도 열지 않는다. 세는 쪽(백엔드 이미지의 node)은 그 엔진의 네트워크 이름공간을 함께 쓴다 +# (--network container:…). 도커의 주소 풀이 바닥나도(all predefined address pools have been fully subnetted) 연습이 멈추지 않는다 +# 보조 컨테이너는 모두 권한을 버리고(--cap-drop ALL · no-new-privileges) 꼭 필요한 것만 더한다 — 볼륨 들여다보기는 파일 읽기(DAC_READ_SEARCH)만, +# 복원(restic)은 쓰기 · 소유자 되살리기, 연습 DB 는 postgres 의 시작 스크립트가 쓰는 것(소유자 바꾸기 · 사용자 바꾸기) +# ========================================== +set -Eeuo pipefail +umask 077 + +OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$OPS_ROOT/lib/common.sh" + +RESTIC_IMAGE=${RESTIC_IMAGE:-restic/restic:0.19.1@sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510} +SEQ=0 +# 볼륨을 읽기만 하는 보조 컨테이너 (restic 이미지의 sh) +PROBE=(--rm --network none --cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges) + +prepare() { # prepare [optional] — optional 이면 .env.production 이 없어도 된다 (snapshots · check · files) + load_config "${1:-required}" + require_set RESTIC_REPOSITORY + case $RESTIC_REPOSITORY in s3:*) require_set AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY ;; esac + RESTIC_PASSWORD_FILE=${RESTIC_PASSWORD_FILE:-/etc/arasystem/restic-password} + [ -s "$RESTIC_PASSWORD_FILE" ] || die "restic 비밀번호 파일 $RESTIC_PASSWORD_FILE 이 없거나 비어 있습니다" + export RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + BACKUP_HOST=${BACKUP_HOST:-$PROJECT_NAME} + DRILL=${PROJECT_NAME}-drill + WORK_DIR=$(ops_work_dir) +} + +# restic 을 컨테이너로 — restic_run [docker 인자 …] -- [restic 인자 …]. 복원은 소유자(메일 UID 2000 · 업로드 UID 1000)를 되살려야 해 +# 파일 쓰기 · 소유자 바꾸기 권한을 더한다(백업은 읽기만) +restic_run() { + local dargs=() + while [ "$1" != "--" ]; do dargs+=("$1"); shift; done + shift + local net=() + [ -n "${BACKUP_DOCKER_NETWORK:-}" ] && net=(--network "$BACKUP_DOCKER_NETWORK") + # shellcheck disable=SC2086 # RESTIC_OPTIONS 는 낱말로 나눠 넘긴다 + docker run --rm --name "${PROJECT_NAME}-restore-restic-$$-$((SEQ += 1))" \ + --cap-drop ALL --cap-add DAC_READ_SEARCH --cap-add DAC_OVERRIDE --cap-add CHOWN --cap-add FOWNER --security-opt no-new-privileges \ + --memory 1g "${net[@]}" \ + -e RESTIC_REPOSITORY -e RESTIC_PASSWORD_FILE=/run/secrets/restic-password -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ + -v "$RESTIC_PASSWORD_FILE:/run/secrets/restic-password:ro" -v "${PROJECT_NAME}-backup-cache:/root/.cache/restic" \ + "${dargs[@]}" "$RESTIC_IMAGE" ${RESTIC_OPTIONS:-} "$@" +} + +# 스냅숏 이름 → 64자 id (latest 는 이 서버의 마지막 final). id 를 직접 주면 final 인지 본다 +resolve_snapshot() { + local want=${1:-latest} json id + if [ "$want" = latest ]; then + json=$(restic_run -- snapshots --json --host "$BACKUP_HOST" --tag final --latest 1) + else + [[ $want =~ ^[0-9a-f]{8,64}$ ]] || die "스냅숏은 id(8자 이상) 또는 latest 입니다" + json=$(restic_run -- snapshots --json "$want") + fi + id=$(grep -o '"id":"[0-9a-f]\{64\}"' <<< "$json" | tail -n 1 | cut -d'"' -f4) + [ -n "$id" ] || die "스냅숏을 찾지 못했습니다 ($want · host $BACKUP_HOST · 태그 final — 새 서버면 ops.env 의 PROJECT_NAME 이 옛 서버의 값인지 본다)" + grep -q '"tags":\[[^]]*"final"' <<< "$json" || + die "스냅숏 ${id:0:8} 은 final 이 아닙니다(warm 등 — 켜 둔 엔진에서 받은 것) — 복원에는 final 만 쓴다 ($0 snapshots)" + printf '%s' "$id" +} + +# 볼륨이 없거나 비었으면 0 · 무엇이 있으면 1. 들여다보지 못하면(도커 · 이미지 오류) 멈춘다 — 실패를 "비었다"로 보고 덮어쓰지 않게 +volume_empty() { + docker volume inspect "$1" >/dev/null 2>&1 || return 0 + local out rc=0 + out=$(docker run "${PROBE[@]}" -v "$1:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'ls -A /v') || rc=$? + [ "$rc" = 0 ] || die "볼륨 $1 을 들여다보지 못했습니다 (docker 종료 $rc) — 비었는지 모르므로 멈춘다" + [ -z "$out" ] +} + +volume_in_use() { # 그 볼륨을 붙인 컨테이너가 떠 있으면 이름 + docker ps --filter "volume=$1" --format '{{.Names}}' | head -n 1 +} + +cmd_snapshots() { + prepare optional + restic_run -- snapshots --host "$BACKUP_HOST" --tag final +} + +cmd_check() { + prepare optional + local subset=${1:-5%} + [[ $subset =~ ^[0-9]{1,3}%$ ]] || die "check 의 범위는 5% 처럼 적는다" + log "저장소 검사 — 데이터 ${subset}" + restic_run -- check --read-data-subset="$subset" +} + +cmd_files() { + prepare optional + [ $# -eq 2 ] || die "사용법: $0 files <스냅숏> <폴더>" + local id dir + id=$(resolve_snapshot "$1") + dir=$2 + if [ -e "$dir" ] && [ -n "$(ls -A "$dir")" ]; then die "$dir 가 비어 있지 않습니다"; fi + mkdir -p "$dir" + chmod 0700 "$dir" + # docker -v 는 절대 경로만 받는다 (C:/… 는 Git Bash 시험용) + case $dir in /* | [A-Za-z]:/*) ;; *) dir="$PWD/$dir" ;; esac + log "스냅숏 ${id:0:8} 의 DB 덤프 · env 파일을 $dir 로" + restic_run -v "$dir:/restore" -- restore "$id" --target /restore --include /backup/db --include /backup/env + log "[완료] $dir/backup/db/db.dump · $dir/backup/env/ (backend__.env.production 은 backend/.env.production 자리) — 비밀이 들어 있다, 쓴 뒤 지운다" +} + +cmd_volumes() { + prepare + [ $# -eq 1 ] || die "사용법: $0 volumes <스냅숏>" + local id volume + id=$(resolve_snapshot "$1") + for volume in "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" "${PROJECT_NAME}_uploads"; do + [ -z "$(volume_in_use "$volume")" ] || die "$volume 을 쓰는 컨테이너($(volume_in_use "$volume"))가 떠 있습니다 — 멈춘 뒤에" + volume_empty "$volume" || die "$volume 이 비어 있지 않습니다 — 되살리려는 서버가 맞는지 확인하고, 맞으면 그 볼륨을 직접 지운 뒤(docker volume rm $volume) 다시 실행한다" + done + # 없는 볼륨은 compose 의 이름표(프로젝트 · 볼륨 이름)를 붙여 만든다 — 그냥 만들어도 compose 가 그 볼륨을 쓰지만 + # up 마다 "already exists but was not created by Docker Compose" 경고를 낸다 (compose 2.40 — 2026-11-02 확인) + for volume in mail-config mail-data uploads; do + docker volume inspect "${PROJECT_NAME}_${volume}" >/dev/null 2>&1 || + docker volume create --label "com.docker.compose.project=${PROJECT_NAME}" --label "com.docker.compose.volume=${volume}" "${PROJECT_NAME}_${volume}" >/dev/null + done + log "스냅숏 ${id:0:8} → 볼륨 ${PROJECT_NAME}_mail-config · _mail-data · _uploads" + local started + started=$(date +%s) + restic_run -v "${PROJECT_NAME}_mail-config:/backup/mail-config" -v "${PROJECT_NAME}_mail-data:/backup/mail-data" -v "${PROJECT_NAME}_uploads:/backup/uploads" \ + -- restore "$id" --target / --include /backup/mail-config --include /backup/mail-data --include /backup/uploads + log "[완료] $(( $(date +%s) - started ))초 — 다음은 compose up (mail/README.md \"복원\")" +} + +cmd_db() { + load_config + [ $# -eq 1 ] && [ -r "$1" ] || die "사용법: $0 db <덤프 파일>" + local db=${PROJECT_NAME}-db tables + [ "$(container_state "$db")" = healthy ] || die "DB($db)가 healthy 가 아닙니다 — db 만 먼저 띄운다 (compose up -d db)" + # shellcheck disable=SC2016 # $POSTGRES_* 는 컨테이너 안에서 푼다 + tables=$(docker exec "$db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select count(*) from information_schema.tables where table_schema = '\''public'\''"') + [ "$tables" = 0 ] || die "DB 에 표가 이미 $tables 개 있습니다 — 덤프는 빈 DB 에만 넣는다 (backend 가 먼저 떠 마이그레이션을 돌렸다면 db 볼륨을 새로 만들고 db 만 띄운 뒤)" + log "덤프를 $db 에 넣는다 (표준 입력으로)" + # shellcheck disable=SC2016 + docker exec -i "$db" sh -c 'exec pg_restore --no-owner --exit-on-error -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$1" + # shellcheck disable=SC2016 + tables=$(docker exec "$db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select count(*) from information_schema.tables where table_schema = '\''public'\''"') + log "[완료] 표 ${tables}개 — 다음은 compose up -d (backend 는 마이그레이션이 이미 적용된 것을 보고 그대로 뜬다)" +} + +drill_resources() { + docker ps -aq --filter "name=^${DRILL}-" || true +} + +cmd_drill_clean() { + load_config + DRILL=${PROJECT_NAME}-drill + local ids + ids=$(drill_resources) + # shellcheck disable=SC2086 + [ -z "$ids" ] || docker rm -f $ids >/dev/null + local volume + for volume in mail-config mail-data uploads db env; do + docker volume rm "${DRILL}_${volume}" >/dev/null 2>&1 || true + done + log "[정리] 연습 자원(${DRILL}*)을 지웠습니다" +} + +# 표마다 행 수 — "표|행" 줄들. 표마다 count(*) 문을 만들어(첫 psql) 그대로 다시 넣는다(둘째 psql — 표준 입력) +table_counts() { # table_counts <컨테이너> <사용자> + local sql="select format('select %L || ''|'' || count(*) from %I.%I;', table_name, table_schema, table_name) from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' order by table_name" + docker exec "$1" psql -U "$2" -d "$3" -At -c "$sql" | docker exec -i "$1" psql -U "$2" -d "$3" -At +} + +cmd_drill() { + prepare + local want=latest keep=0 arg + for arg in "$@"; do + case $arg in --keep) keep=1 ;; *) want=$arg ;; esac + done + mkdir -p "$WORK_DIR" + chmod 0700 "$WORK_DIR" + take_backup_lock "$WORK_DIR" || die "백업이나 다른 복원 연습이 돌고 있습니다 — 끝난 뒤에 (journalctl -u arasystem-backup)" + [ -z "$(drill_resources)" ] || die "지난 연습이 남아 있습니다 — $0 drill-clean" + local mail_image db_image backend_image + mail_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-mail" 2>/dev/null || env_get "$APP_ENV_FILE" MAIL_IMAGE || true) + db_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-db" 2>/dev/null || env_get "$APP_ENV_FILE" DB_IMAGE || true) + backend_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-backend" 2>/dev/null || true) + [ -n "$mail_image" ] && [ -n "$db_image" ] || die "메일 · DB 이미지를 알 수 없습니다" + local id t0 t_restore t_ready + id=$(resolve_snapshot "$want") + [ "$keep" = 1 ] || trap cmd_drill_clean EXIT + log "복원 연습 — 스냅숏 ${id:0:8} · 메일 $mail_image · DB $db_image" + + # 네트워크를 만들지 않는다 — 연습 엔진은 --network none(루프백뿐 — 바깥으로 한 걸음도 나가지 않는다), 세는 쪽은 그 네트워크 이름공간을 함께 쓴다 + t0=$(date +%s) + restic_run -v "${DRILL}_mail-config:/backup/mail-config" -v "${DRILL}_mail-data:/backup/mail-data" -v "${DRILL}_uploads:/backup/uploads" \ + -v "${DRILL}_db:/backup/db" -v "${DRILL}_env:/backup/env" -- restore "$id" --target / >/dev/null + t_restore=$(( $(date +%s) - t0 )) + log "[1] 풀기 ${t_restore}초" + + # 메일 엔진 — 복구 관리자(연습마다 무작위 — 출력하지 않고, 명령줄에도 싣지 않는다 — -e 이름만)로 계정 · 메일을 센다. + # 운영 관리자 · 게이트웨이는 허용 IP(앱 네트워크)라 여기서는 쓰지 못한다. 엔진 이미지는 stalwart 사용자로 돈다 — 다만 실행 파일에 + # 낮은 포트용 파일 권한(cap_net_bind_service=ep)이 붙어 있어 그 권한이 없으면 exec 가 거절된다(operation not permitted — 2026-11-02 확인). 그것만 둔다 + local recovery + recovery=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n') + STALWART_RECOVERY_ADMIN="drill:$recovery" docker run -d --name "${DRILL}-mail" --network none --memory 1g \ + --cap-drop ALL --cap-add NET_BIND_SERVICE --security-opt no-new-privileges \ + -e STALWART_RECOVERY_ADMIN -v "${DRILL}_mail-config:/etc/stalwart" -v "${DRILL}_mail-data:/var/lib/stalwart" "$mail_image" >/dev/null + local waited=0 + until docker exec "${DRILL}-mail" curl -fsS -m 3 -o /dev/null http://127.0.0.1:8080/healthz/ready >/dev/null 2>&1; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt 180 ] || die "연습 메일 엔진이 180초 안에 준비되지 않았습니다 (docker logs ${DRILL}-mail)" + done + t_ready=$waited + log "[2] 메일 엔진 준비 ${t_ready}초 (RocksDB 를 열었다)" + if [ -n "$backend_image" ]; then + # 백엔드 이미지의 node(node 사용자)로 센다 (fetch) — 연습 네트워크 안에서. 직원마다 메일 수를 대리 접근(<주소>%drill)으로 + R="$recovery" docker run --rm --network "container:${DRILL}-mail" --memory 512m --cap-drop ALL --security-opt no-new-privileges \ + -e R -e H=127.0.0.1 -e CHECK="${RESTORE_CHECK_ACCOUNT:-}" --entrypoint node "$backend_image" -e ' + const base = `http://${process.env.H}:8080`, b = (u) => "Basic " + Buffer.from(u + ":" + process.env.R).toString("base64") + const call = async (auth, using, calls) => (await (await fetch(base + "/jmap/", { method: "POST", headers: { authorization: auth, "content-type": "application/json" }, body: JSON.stringify({ using, methodCalls: calls }) })).json()).methodResponses + ;(async () => { + const s = await (await fetch(base + "/jmap/session", { headers: { authorization: b("drill") } })).json() + const x = s.primaryAccounts["urn:stalwart:jmap"], X = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"] + const [[, q]] = await call(b("drill"), X, [["x:Account/query", { accountId: x, limit: 1000 }, "q"]]) + const [[, g]] = await call(b("drill"), X, [["x:Account/get", { accountId: x, ids: q.ids, properties: ["name", "emailAddress", "@type"] }, "g"]]) + const [[, d]] = await call(b("drill"), X, [["x:Domain/get", { accountId: x, ids: null, properties: ["name"] }, "d"]]) + let total = 0, withMail = 0; const rows = [] + for (const a of g.list.filter((a) => a["@type"] === "User" && a.emailAddress)) { + const r = await fetch(base + "/jmap/session", { headers: { authorization: b(a.emailAddress + "%drill") } }) + if (r.status !== 200) continue + const m = (await r.json()).primaryAccounts["urn:ietf:params:jmap:mail"]; if (!m) continue + const res = await call(b(a.emailAddress + "%drill"), ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], [["Email/query", { accountId: m, calculateTotal: true, limit: 1 }, "e"], ["Mailbox/get", { accountId: m, ids: null, properties: ["name", "totalEmails"] }, "b"]]) + const n = res[0][1].total ?? 0; total += n; if (n > 0) withMail += 1 + rows.push(a.emailAddress.split("@")[0] + " " + n) + if (process.env.CHECK && a.emailAddress.toLowerCase() === process.env.CHECK.toLowerCase()) console.log(" 확인 계정 " + a.emailAddress + " — " + res[1][1].list.map((x) => x.name + " " + x.totalEmails).join(" · ")) + } + console.log(" 도메인 " + d.list.map((x) => x.name).join(", ") + " · 계정 " + g.list.length + "개(직원 꼴 " + rows.length + ") · 메일 " + total + "통(메일이 있는 계정 " + withMail + ")") + console.log(" 계정마다 메일 수: " + rows.join(" · ")) + })().catch((e) => { console.log(" [실패] " + e.message); process.exitCode = 1 })' \ + || log "[주의] 연습 메일 엔진의 계정 · 메일 수를 세지 못했습니다" + else + log "[건너뜀] 백엔드 이미지가 없어 계정 · 메일 수는 세지 않았다 (엔진이 RocksDB 를 연 것까지 확인)" + fi + + # DB — 같은 이미지의 임시 postgres(네트워크 없음)에 덤프를 넣고 표마다 행 수를 지금 운영과 견준다. + # postgres 의 시작 스크립트는 root 로 데이터 폴더의 소유자를 바꾼 뒤 postgres 사용자로 내려간다 — 그 권한만 더한다 + local db_pass + db_pass=$(head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n') + POSTGRES_PASSWORD=$db_pass docker run -d --name "${DRILL}-db" --network none --memory 512m \ + --cap-drop ALL --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER --cap-add SETUID --cap-add SETGID --security-opt no-new-privileges \ + -e POSTGRES_PASSWORD -e POSTGRES_USER=drill -e POSTGRES_DB=drill -v "${DRILL}_db:/restore:ro" "$db_image" >/dev/null + waited=0 + until docker exec "${DRILL}-db" pg_isready -U drill -d drill >/dev/null 2>&1 && docker exec "${DRILL}-db" psql -U drill -d drill -Atc 'select 1' >/dev/null 2>&1; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt 120 ] || die "연습 DB 가 뜨지 않았습니다 (docker logs ${DRILL}-db)" + done + docker exec "${DRILL}-db" pg_restore --no-owner --no-privileges --exit-on-error -U drill -d drill /restore/db.dump + local restored live="" + restored=$(table_counts "${DRILL}-db" drill drill) + if [ "$(container_state "${PROJECT_NAME}-db")" = healthy ]; then + live=$(table_counts "${PROJECT_NAME}-db" "$(docker exec "${PROJECT_NAME}-db" printenv POSTGRES_USER)" "$(docker exec "${PROJECT_NAME}-db" printenv POSTGRES_DB)" || true) + fi + log "[3] DB — 표 $(grep -c . <<< "$restored")개 (표 · 백업 · 지금)" + local table count now + while IFS='|' read -r table count; do + [ -n "$table" ] || continue + now=$(grep "^${table}|" <<< "$live" | cut -d'|' -f2 || true) + printf ' %-34s %8s %8s\n' "$table" "$count" "${now:--}" + done <<< "$restored" + + # 업로드 · env — 파일 수 · 크기, env 는 지금 파일과 같은지(sha256 — 값은 보지 않는다) + local up_restored up_live + up_restored=$(docker run "${PROBE[@]}" -v "${DRILL}_uploads:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'echo "$(find /v -type f | wc -l)개 $(du -sh /v | cut -f1)"') + up_live=$(docker run "${PROBE[@]}" -v "${PROJECT_NAME}_uploads:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'echo "$(find /v -type f | wc -l)개 $(du -sh /v | cut -f1)"' 2>/dev/null || echo -) + log "[4] 업로드 — 백업 $up_restored · 지금 $up_live" + local env_report file name + env_report="" + for file in $(docker run "${PROBE[@]}" -v "${DRILL}_env:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'ls -A /v'); do + name=${file//__//} + if [ -r "$ARASYSTEM_DIR/$name" ] && [ "$(docker run "${PROBE[@]}" -v "${DRILL}_env:/v:ro" --entrypoint sha256sum "$RESTIC_IMAGE" "/v/$file" | cut -d' ' -f1)" = "$(sha256sum "$ARASYSTEM_DIR/$name" | cut -d' ' -f1)" ]; then + env_report+=" $name(지금과 같음)" + else + env_report+=" $name(지금과 다름 · 또는 지금 없음)" + fi + done + log "[5] env —${env_report:- 없음}" + log "[완료] 복원 연습 — 스냅숏 ${id:0:8} · 풀기 ${t_restore}초 · 메일 엔진 준비 ${t_ready}초$( [ "$keep" = 1 ] && printf ' · 남겨 둠(%s drill-clean 으로 지운다)' "$0")" +} + +main() { + case ${1:-} in + snapshots) shift; cmd_snapshots "$@" ;; + check) shift; cmd_check "$@" ;; + drill) shift; cmd_drill "$@" ;; + drill-clean) shift; cmd_drill_clean ;; + files) shift; cmd_files "$@" ;; + volumes) shift; cmd_volumes "$@" ;; + db) shift; cmd_db "$@" ;; + *) sed -n '3,14p' "$0" >&2; exit 2 ;; + esac +} + +# source 하면(시험) 함수만 읽는다 +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi diff --git a/ops/lib/common.sh b/ops/lib/common.sh new file mode 100644 index 0000000..2c027c1 --- /dev/null +++ b/ops/lib/common.sh @@ -0,0 +1,141 @@ +# shellcheck shell=bash +# ========================================== +# 운영 스크립트 공용 함수 (백업 · 복원 · 15분 점검이 source 한다 — 이 파일만 따로 실행하지 않는다) +# +# 설정은 /etc/arasystem/ops.env(예시 ops/ops.env.example)와 저장소의 .env.production 에서 읽는다 — 셸로 실행(source)하지 않고 줄마다 읽는다 +# (값에 $ · 따옴표 · 공백이 있어도 명령이 되지 않게). 비밀(비밀번호 · 키)은 출력 · 로그 · 알림 어디에도 싣지 않는다. +# ========================================== + +# Git Bash(Windows)에서 시험할 때만 뜻이 있다 — docker 인자의 컨테이너 경로(/backup/…)를 Windows 경로로 바꾸지 않게. 리눅스에서는 아무 일도 하지 않는다 +export MSYS_NO_PATHCONV=1 + +OPS_CONFIG=${ARASYSTEM_OPS_CONFIG:-/etc/arasystem/ops.env} + +log() { printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"; } +die() { log "[실패] $*" >&2; exit 1; } + +trim() { + local value=$1 + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + printf '%s' "$value" +} + +# KEY=값 한 줄의 값 — Node 의 --env-file 과 같게 읽는다(따옴표로 감싸지 않은 값은 # 부터 주석) +parse_env_value() { + local value + value=$(trim "$1") + case $value in + \"*\") value=${value#\"}; value=${value%\"} ;; + \'*\') value=${value#\'}; value=${value%\'} ;; + *) value=$(trim "${value%%#*}") ;; + esac + printf '%s' "$value" +} + +# env_get <파일> — 마지막 정의의 값(없으면 빈 글, 종료 코드 1). KEY 는 이 스크립트들이 정한 이름뿐이다(영문 대문자 · 숫자 · _) +env_get() { + local line + line=$(grep -E "^[[:space:]]*(export[[:space:]]+)?$2[[:space:]]*=" "$1" 2>/dev/null | tail -n 1) || true + [ -n "$line" ] || return 1 + parse_env_value "${line#*=}" +} + +# /etc/arasystem/ops.env 를 읽어 KEY 마다 환경 변수로 — 이미 셸에 있는 값은 덮어쓰지 않는다(시험에서 하나만 바꿔 돌릴 수 있게) +# load_config 저장소의 .env.production 이 있어야 한다 (백업 · 점검 · 볼륨 · DB 복원) +# load_config optional 없어도 된다 — 새 서버 복원의 첫 단계(restore.sh files · snapshots · check)는 바로 그 파일을 백업에서 꺼낸다. +# 그때 PROJECT_NAME(백업 스냅숏의 host 이름 · 볼륨 이름)은 ops.env 의 값이다 +# PROJECT_NAME 이 ops.env 와 .env.production 에 둘 다 있고 다르면 멈춘다 — 다른 이름의 볼륨을 받거나 되살리지 않게 +load_config() { + local app_env=${1:-required} + [ -r "$OPS_CONFIG" ] || die "설정 파일 $OPS_CONFIG 를 읽을 수 없습니다 — ops/ops.env.example 을 복사해 채운다 (mail/README.md \"백업\")" + local line key + while IFS= read -r line || [ -n "$line" ]; do + case $line in '' | '#'*) continue ;; esac + line=$(trim "$line") + [[ $line =~ ^(export[[:space:]]+)?([A-Z_][A-Z0-9_]*)[[:space:]]*= ]] || continue + key=${BASH_REMATCH[2]} + [ -n "${!key+x}" ] && continue + printf -v "$key" '%s' "$(parse_env_value "${line#*=}")" + export "${key?}" + done < "$OPS_CONFIG" + [ -n "${ARASYSTEM_DIR:-}" ] || die "$OPS_CONFIG 에 ARASYSTEM_DIR(저장소 폴더)이 없습니다" + APP_ENV_FILE=${APP_ENV_FILE:-$ARASYSTEM_DIR/.env.production} + local from_file="" + if [ -r "$APP_ENV_FILE" ]; then + from_file=$(env_get "$APP_ENV_FILE" PROJECT_NAME || true) + elif [ "$app_env" = optional ]; then + APP_ENV_FILE="" + else + die "$APP_ENV_FILE 을 읽을 수 없습니다 (ARASYSTEM_DIR 확인 — 새 서버 복원이면 restore.sh files 로 먼저 꺼낸다, mail/README.md \"복원\")" + fi + if [ -n "${PROJECT_NAME:-}" ] && [ -n "$from_file" ] && [ "$PROJECT_NAME" != "$from_file" ]; then + die "PROJECT_NAME 이 $OPS_CONFIG(${PROJECT_NAME})와 $APP_ENV_FILE(${from_file})에서 다릅니다 — 같게 맞춘다(볼륨 · 컨테이너 · 백업 스냅숏의 이름이 이 값이다)" + fi + PROJECT_NAME=${PROJECT_NAME:-$from_file} + [[ ${PROJECT_NAME:-} =~ ^[a-z0-9][a-z0-9_-]*$ ]] || + die "PROJECT_NAME 을 알 수 없습니다 — $OPS_CONFIG 에 적거나(새 서버 복원 — 옛 서버의 값) 저장소에 .env.production 을 둔다" + export PROJECT_NAME APP_ENV_FILE +} + +# 작업 폴더(상태 파일 · 잠금 · 표시 파일 · DB 덤프) — systemd 의 StateDirectory(/var/lib/arasystem-ops, root 만) +ops_work_dir() { + local dir=${BACKUP_WORK_DIR:-$(env_get "$OPS_CONFIG" BACKUP_WORK_DIR || true)} + printf '%s' "${dir:-/var/lib/arasystem-ops}" +} + +# 백업과 복원 연습이 함께 돌지 않게 — 작업 폴더의 같은 잠금 파일. 잡지 못하면 1 (flock 이 없는 곳 — 시험용 Git Bash — 은 잠그지 않는다) +take_backup_lock() { + command -v flock >/dev/null 2>&1 || return 0 + exec 9> "$1/backup.lock" + flock -n 9 +} + +# 예시 값(change-me)이 남은 설정을 거절한다 — 이름만 알린다(값은 출력하지 않는다) +require_set() { + local key + for key in "$@"; do + [ -n "${!key:-}" ] || die "$OPS_CONFIG 에 $key 가 비어 있습니다" + case ${!key} in *[Cc][Hh][Aa][Nn][Gg][Ee]-[Mm][Ee]* | *[Cc][Hh][Aa][Nn][Gg][Ee][Mm][Ee]*) die "$OPS_CONFIG 의 $key 가 예시 값 그대로입니다" ;; esac + done +} + +# curl 설정 글(-K)의 따옴표 값 — \ · " 는 이스케이프, 줄바꿈 · 탭은 \n · \t (curl 이 따옴표 안에서 푼다) +curl_config_quote() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\r'/} + value=${value//$'\n'/\\n} + value=${value//$'\t'/\\t} + printf '%s' "$value" +} + +# hc_ping <주소> [/fail|/start] [본문] — Healthchecks.io 에 알린다. 주소가 비면 아무것도 하지 않는다. 실패해도 스크립트를 멈추지 않는다. +# 주소 · 본문은 curl 의 설정 글로 표준 입력에 준다(-K -) — 명령줄(ps · /proc)에 ping 주소가 남지 않게 (주소를 아는 사람은 거짓 신호를 보낼 수 있다) +hc_ping() { + local url=$1 suffix=${2:-} body=${3:-} + [ -n "$url" ] || return 0 + if command -v curl >/dev/null 2>&1; then + # 응답은 셸이 버린다(-o /dev/null 은 쓰지 않는다 — 위 MSYS_NO_PATHCONV 때문에 Windows 의 curl 이 /dev/null 을 열지 못한다) + printf 'url = "%s"\ndata-raw = "%s"\n' "$(curl_config_quote "${url%/}${suffix}")" "$(curl_config_quote "${body:0:9000}")" \ + | curl -fsS -m 15 --retry 4 --retry-delay 5 -K - >/dev/null 2>&1 \ + || log "[주의] Healthchecks 에 알리지 못했습니다 (${suffix:-성공})" + else + log "[주의] curl 이 없어 Healthchecks 에 알리지 못했습니다" + fi +} + +# 컨테이너가 떠 있으면 0 · 멈춰 있으면 1 · 도커에 닿지 못하거나 컨테이너가 없으면 2 +container_running() { + local running + running=$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null) || return 2 + [ "$running" = true ] +} + +# 컨테이너 상태 한 낱말 — healthy · unhealthy · starting · running(healthcheck 없음) · exited · … · missing +container_state() { + local state + state=$(docker inspect -f '{{if .State.Running}}{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}{{else}}{{.State.Status}}{{end}}' "$1" 2>/dev/null) || state="" + printf '%s' "${state:-missing}" +} diff --git a/ops/monitor/arasystem-check.sh b/ops/monitor/arasystem-check.sh new file mode 100644 index 0000000..b1ded7e --- /dev/null +++ b/ops/monitor/arasystem-check.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# ========================================== +# arasystem 15분 점검 (13단계 — mail/README.md "감시") — systemd 타이머 arasystem-check.timer +# +# 서버 안에서만 볼 수 있는 것을 보고 Healthchecks.io 에 알린다 — 정상이면 요약 한 줄, 아니면 /fail + 까닭 한 줄. +# 바깥에서 보는 것(웹 · 메일 포트가 열리나)은 UptimeRobot 이 맡는다 — 서버가 통째로 죽으면 이 스크립트도 못 돈다(Healthchecks 가 늦은 신호로 알린다). +# 1. 디스크 — / 와 도커 데이터 폴더(또는 CHECK_DISKS)의 사용률 +# 2. 컨테이너 — frontend · backend · db · mail · mail-resolver · clamav · clamav-updater 가 떠 있고 healthy +# (clamav-updater 의 unhealthy = 악성 검사 서명이 이틀 넘게 그대로 — compose 의 healthcheck) +# 3. 백엔드 /api/health — DB 까지 닿는지 (백엔드 컨테이너 안에서 — 바깥에서는 404 다) +# 4. 메일 대기열 — 쌓인 수 · 가장 오래 머문 시간 (관리 API x:QueuedMessage — 관리자 비밀번호는 실행할 때 .env.production 에서 읽어 +# 백엔드 컨테이너의 node 로 http://mail:8080 에 묻는다(관리자 허용 IP = 앱 네트워크 안). 값은 docker exec 의 -e 이름으로만 넘기고 출력하지 않는다) +# 5. 인증서 남은 날 — mx1:993(이 서버의 127.0.0.1 로 — 공인 IP 로 되돌아오는 길이 막힌 곳이 있다) · 포털 · files:443, 믿을 수 있는 인증서인지도 +# 6. 마지막 백업 성공에서 지난 시간 (arasystem-backup 의 상태 파일) · 백업 타이머가 켜져 있는지 +# 7. Spamhaus 키(MAIL_SPAMHAUS_DQS_KEY 가 있을 때) — setup 과 같은 시험 항목이 메일 전용 리졸버로 나오는지(키가 만료되면 답이 멈춘다). +# 키는 표준 입력으로만 넘긴다(docker exec 의 명령줄 · 도커 이벤트에 남지 않게) +# 상주하지 않는다(메모리 0) — 15분마다 몇 초 돈다. 종료 코드 0 정상 · 1 문제 있음 +# ========================================== +set -Euo pipefail +umask 077 + +OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$OPS_ROOT/lib/common.sh" +load_config + +WORK_DIR=$(ops_work_dir) +PROBLEMS=() +NOTES=() +problem() { PROBLEMS+=("$*"); } +note() { NOTES+=("$*"); } +P=$PROJECT_NAME + +# 백업이 메일 엔진을 잠깐 멈춘 동안(몇 초 — arasystem-backup 의 표시 파일)은 메일 쪽 확인을 건너뛴다. +# 표시만 믿지 않는다 — 백업 서비스가 지금 돌고 있거나(systemd) 표시가 15분 안에 생긴 것일 때만 "백업 중"이다. +# 그 밖의 남은 표시는 문제다(끊긴 백업이 엔진을 멈춘 채 남겼을 수 있다 — 표시만 남아 점검이 메일 쪽을 끝없이 건너뛰지 않게) +MARKER="$WORK_DIR/mail-stopped-by-backup" +MARKER_FRESH_SECONDS=900 +backup_service_running() { + command -v systemctl >/dev/null && [ -d /run/systemd/system ] || return 1 + case $(systemctl show --property=ActiveState --value arasystem-backup.service 2>/dev/null) in + activating | active | deactivating | reloading) return 0 ;; + *) return 1 ;; + esac +} +BACKUP_HOLDS_MAIL=0 +if [ -f "$MARKER" ]; then + marker_age=$(( $(date +%s) - $(stat -c %Y "$MARKER" 2>/dev/null || echo 0) )) + if backup_service_running || [ "$marker_age" -lt "$MARKER_FRESH_SECONDS" ]; then + BACKUP_HOLDS_MAIL=1 + else + problem "백업 표시가 $((marker_age / 60))분째 남아 있음(백업은 돌지 않는다 — 메일 엔진이 멈춘 채일 수 있다: sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running)" + fi +fi + +# 1. 디스크 +check_disks() { + local paths=${CHECK_DISKS:-} path used seen=" " + [ -n "$paths" ] || paths="/ $(docker info -f '{{.DockerRootDir}}' 2>/dev/null || true)" + for path in $paths; do + [ -e "$path" ] || { problem "디스크 $path 없음"; continue; } + local fs + # --output 칸을 쓴다 — 마운트 경로에 빈칸이 있어도 칸이 밀리지 않게 (GNU df) + fs=$(df --output=source "$path" | tail -n 1) + case $seen in *" $fs "*) continue ;; esac + seen+="$fs " + used=$(df --output=pcent "$path" | tail -n 1 | tr -dc '0-9') + if [ "${used:-0}" -ge "${CHECK_DISK_MAX_PERCENT:-85}" ]; then problem "디스크 $path ${used}%"; else note "디스크 $path ${used}%"; fi + done +} + +# 2. 컨테이너 +check_containers() { + local service state healthy=0 + for service in ${CHECK_CONTAINERS:-frontend backend db mail mail-resolver clamav clamav-updater}; do + state=$(container_state "$P-$service") + if [ "$service" = mail ] && [ "$BACKUP_HOLDS_MAIL" = 1 ]; then note "mail 은 백업 중(잠깐 멈춤)"; continue; fi + case $state in + healthy | running) healthy=$((healthy + 1)) ;; + unhealthy) + if [ "$service" = clamav-updater ]; then problem "컨테이너 $service unhealthy(악성 검사 서명이 이틀 넘게 그대로)"; else problem "컨테이너 $service unhealthy"; fi + ;; + *) problem "컨테이너 $service $state" ;; + esac + done + note "컨테이너 ${healthy}개 정상" +} + +# 3. 백엔드 /api/health — 무엇이 죽었는지(error 의 키)만 옮긴다 (본문의 오류 글은 싣지 않는다) +check_backend_health() { + local out + # shellcheck disable=SC2016 # process.env.PORT 는 컨테이너 안에서 푼다 + out=$(docker exec "$P-backend" node -e 'fetch("http://127.0.0.1:" + process.env.PORT + "/api/health", { signal: AbortSignal.timeout(10000) }).then(async (r) => { const j = await r.json().catch(() => ({})); console.log(r.status + " " + (j.status ?? "-") + " " + Object.keys(j.error ?? {}).join(",")) }).catch((e) => console.log("000 " + e.name))' 2>/dev/null || echo "000 exec") + case $out in + "200 ok"*) note "백엔드 health 정상" ;; + *) problem "백엔드 /api/health ${out}" ;; + esac +} + +# 4. 메일 대기열 — "수 가장오래(시간)" (관리자 비밀번호는 -e 이름으로만) +check_queue() { + local pw out total oldest + pw=$(env_get "$APP_ENV_FILE" MAIL_ADMIN_PASSWORD || true) + [ -n "$pw" ] || { problem "대기열 — .env.production 의 MAIL_ADMIN_PASSWORD 를 읽지 못함"; return; } + out=$(QUEUE_ADMIN_PASSWORD=$pw docker exec -i -e QUEUE_ADMIN_PASSWORD "$P-backend" node - 2>/dev/null <<'EOF' || echo "error exec" +const base = process.env.MAIL_JMAP_URL || 'http://mail:8080' +const user = `${(process.env.MAIL_ADMIN_LOCAL_PART || 'admin').trim().toLowerCase()}@${(process.env.MAIL_DOMAIN || '').trim().toLowerCase()}` +const auth = 'Basic ' + Buffer.from(`${user}:${process.env.QUEUE_ADMIN_PASSWORD}`).toString('base64') +const using = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'] +;(async () => { + const s = await fetch(`${base}/jmap/session`, { headers: { authorization: auth }, signal: AbortSignal.timeout(10000) }) + if (s.status !== 200) return console.log(`error session-${s.status}`) + const accountId = (await s.json()).primaryAccounts['urn:stalwart:jmap'] + const call = async (method, args) => { + const r = await fetch(`${base}/jmap/`, { method: 'POST', headers: { authorization: auth, 'content-type': 'application/json' }, body: JSON.stringify({ using, methodCalls: [[method, { accountId, ...args }, 'c']] }), signal: AbortSignal.timeout(15000) }) + const [[name, result]] = (await r.json()).methodResponses + if (name === 'error') throw new Error(result.type) + return result + } + const q = await call('x:QueuedMessage/query', { calculateTotal: true, limit: 200 }) + let oldest = 0 + if (q.ids.length > 0) { + const g = await call('x:QueuedMessage/get', { ids: q.ids, properties: ['createdAt'] }) + oldest = Math.max(0, ...g.list.map((m) => (Date.now() - Date.parse(m.createdAt)) / 3600000)) + } + console.log(`${q.total ?? q.ids.length} ${oldest.toFixed(1)}`) +})().catch((e) => console.log(`error ${e.message}`)) +EOF +) + read -r total oldest <<< "$out" + if ! [[ ${total:-} =~ ^[0-9]+$ ]]; then problem "대기열 — 읽지 못함(${oldest:-?})"; return; fi + if [ "$total" -gt "${CHECK_QUEUE_MAX:-50}" ]; then problem "메일 대기열 ${total}통"; fi + if awk -v a="$oldest" -v m="${CHECK_QUEUE_MAX_AGE_HOURS:-24}" 'BEGIN { exit !(a > m) }'; then problem "메일 대기열에 ${oldest}시간 머문 메일"; fi + note "대기열 ${total}통" +} + +host_of() { # 출처(https://호스트[:포트]) → 호스트 + local value=${1#*://} + value=${value%%/*} + printf '%s' "${value%%:*}" +} + +# 5. 인증서 — 대상 꼴: 이름:포트 또는 이름:포트@붙을주소 +check_certs() { + local targets=${CHECK_CERTS:-} target name port addr out end days verify + if [ -z "$targets" ]; then + local mail cors share + mail=$(env_get "$APP_ENV_FILE" MAIL_HOSTNAME || true) + cors=$(env_get "$APP_ENV_FILE" CORS_ORIGIN || true) + share=$(env_get "$APP_ENV_FILE" SHARE_PUBLIC_URL || true) + [ -n "$mail" ] && targets+=" $mail:993@127.0.0.1" + [ -n "$cors" ] && targets+=" $(host_of "${cors%%,*}"):443" + [ -n "$share" ] && targets+=" $(host_of "$share"):443" + fi + command -v openssl >/dev/null || { problem "인증서 — openssl 이 없음"; return; } + for target in $targets; do + name=${target%%:*} + port=${target#*:} + addr=$name + case $port in *@*) addr=${port#*@}; port=${port%@*} ;; esac + if [ "$addr" = 127.0.0.1 ] && [ "$BACKUP_HOLDS_MAIL" = 1 ]; then continue; fi + out=$(timeout 20 openssl s_client -connect "$addr:$port" -servername "$name" -verify_hostname "$name" /dev/null || true) + end=$(openssl x509 -noout -enddate <<< "$out" 2>/dev/null | cut -d= -f2) + verify=$(sed -n 's/^ *Verify return code: \([0-9]*\).*/\1/p' <<< "$out" | tail -n 1) + if [ -z "$end" ]; then problem "인증서 $name:$port — 연결 · 읽기 실패"; continue; fi + days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) + if [ "${verify:-1}" != 0 ]; then + problem "인증서 $name — 믿을 수 없음(검증 코드 ${verify:-?} · 자체 서명이면 ACME 실패)" + elif [ "$days" -lt "${CHECK_CERT_MIN_DAYS:-14}" ]; then + problem "인증서 $name ${days}일 남음" + else + note "인증서 ${name%%.*} ${days}일" + fi + done +} + +# 6. 백업 — 마지막 성공에서 지난 시간 · 타이머 +check_backup() { + local last hours + last=$(sed -n 's/^last_success_epoch=//p' "$WORK_DIR/status" 2>/dev/null || true) + if [ -z "$last" ]; then + problem "백업 — 성공한 기록 없음($WORK_DIR/status)" + else + hours=$(( ( $(date +%s) - last ) / 3600 )) + if [ "$hours" -ge "${CHECK_BACKUP_MAX_AGE_HOURS:-26}" ]; then problem "백업 — 마지막 성공 ${hours}시간 전"; else note "백업 ${hours}시간 전"; fi + fi + if command -v systemctl >/dev/null && [ -d /run/systemd/system ]; then + systemctl is-active --quiet arasystem-backup.timer || problem "백업 타이머(arasystem-backup.timer)가 꺼져 있음" + fi +} + +# 7. Spamhaus 키 — 메일 컨테이너의 perl 로 리졸버에 A 를 묻는다(이름은 표준 입력으로). 줄마다 "이름의 시험 결과 주소들" +SPAMHAUS_PROBE=$(cat <<'EOF' +use strict; use IO::Socket::INET; use IO::Select; +my @in = ; chomp @in; my $server = shift @in; +for my $name (@in) { + my $id = int(rand(65536)); my $q = pack('n6', $id, 0x0100, 1, 0, 0, 0); + $q .= pack('C', length $_) . $_ for grep { length } split /\./, $name; + $q .= pack('Cnn', 0, 1, 1); + my $s = IO::Socket::INET->new(PeerAddr => $server, PeerPort => 53, Proto => 'udp', Timeout => 10) or do { print "error\n"; next }; + send($s, $q, 0); my $r = ''; + unless (IO::Select->new($s)->can_read(10) && defined recv($s, $r, 4096, 0) && length($r) >= 12) { print "timeout\n"; next } + my ($rid, $flags, $qd, $an) = unpack('n4', $r); my $o = 12; + my $skip = sub { while ($o < length $r) { my $l = unpack('C', substr($r, $o, 1)); if (($l & 0xC0) == 0xC0) { $o += 2; return } $o += 1 + $l; return if $l == 0 } }; + for (1 .. $qd) { $skip->(); $o += 4 } + my @a; for (1 .. $an) { $skip->(); my ($t, $c, $ttl, $len) = unpack('nnNn', substr($r, $o, 10)); $o += 10; push @a, join('.', unpack('C4', substr($r, $o, 4))) if $t == 1 && $len == 4; $o += $len } + print 'rcode' . ($flags & 0x0F) . ' ' . join(',', @a) . "\n"; +} +EOF +) +check_spamhaus() { + local key resolver out zen dbl + key=$(env_get "$APP_ENV_FILE" MAIL_SPAMHAUS_DQS_KEY || true) + [ -n "$key" ] || { note "Spamhaus 키 없음(꺼 둠)"; return; } + resolver=$(env_get "$APP_ENV_FILE" MAIL_RESOLVER_IP || true) + key=$(tr '[:upper:]' '[:lower:]' <<< "$key") + # 프로그램은 명령줄로(비밀 없음), 키가 든 조회 이름은 표준 입력으로 + out=$(printf '%s\n' "${resolver:-172.16.73.254}" "2.0.0.127.$key.zen.dq.spamhaus.net" "dbltest.com.$key.dbl.dq.spamhaus.net" \ + | docker exec -i "$P-mail" perl -e "$SPAMHAUS_PROBE" 2>/dev/null || echo error) + zen=$(sed -n 1p <<< "$out") + dbl=$(sed -n 2p <<< "$out") + case "$zen|$dbl" in + *127.0.0.2*"|"*127.0.1.2*) note "Spamhaus 키 정상" ;; + *) problem "Spamhaus 키 — 시험 항목이 안 나옴(ZEN ${zen%% *} · DBL ${dbl%% *} — 키 만료면 setup 이 끄고 종료 1, mail/README.md \"DNS 리졸버와 스팸 차단 목록\")" ;; + esac +} + +check_disks +check_containers +check_backend_health +if [ "$BACKUP_HOLDS_MAIL" = 0 ]; then check_queue; fi +check_certs +check_backup +if [ "$BACKUP_HOLDS_MAIL" = 0 ]; then check_spamhaus; fi + +if [ "${#PROBLEMS[@]}" -gt 0 ]; then + message="문제 ${#PROBLEMS[@]} — $(printf '%s · ' "${PROBLEMS[@]}")" + message=${message% · } + log "[문제] $message" + hc_ping "${HC_CHECK_URL:-}" /fail "$message" + exit 1 +fi +message="정상 — $(printf '%s · ' "${NOTES[@]}")" +message=${message% · } +log "[정상] $message" +hc_ping "${HC_CHECK_URL:-}" "" "$message" diff --git a/ops/ops.env.example b/ops/ops.env.example new file mode 100644 index 0000000..2c08b43 --- /dev/null +++ b/ops/ops.env.example @@ -0,0 +1,65 @@ +# ========================================== +# 운영 서버의 백업 · 감시 설정 (13단계 — mail/README.md "백업" · "감시") +# +# 이 파일은 예시다. 서버에서는 저장소 밖에 둔다 — root 만 읽게: +# sudo install -d -m 0700 -o root -g root /etc/arasystem +# sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env # 그다음 값을 채운다 (sudoedit /etc/arasystem/ops.env) +# restic 저장소 비밀번호는 따로 /etc/arasystem/restic-password (0600) — 한 줄, 무작위로: +# sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' +# ⚠️ restic 비밀번호 · S3 키는 서버 밖(회사의 비밀번호 관리자)에도 둔다 — 서버를 잃었는데 비밀번호가 서버에만 있으면 백업을 풀 수 없다. +# +# 형식: KEY=값 한 줄씩. 스크립트는 이 파일을 셸로 실행(source)하지 않고 줄마다 읽는다 — 값에 공백이 있으면 작은따옴표로 감싼다. +# ========================================== + +# 저장소 폴더 (git clone 한 곳) — 스크립트가 여기의 .env.production · backend/.env.production 을 읽는다 +# (PROJECT_NAME · 메일 도메인 · 관리자 비밀번호(대기열 확인) · Spamhaus 키 — 따로 복사해 두지 않는다) +ARASYSTEM_DIR=/home/ubuntu/arasystem +# 프로젝트 이름 — 볼륨 · 컨테이너 이름(_mail-data · -mail)과 백업 스냅숏의 host 이름이 이 값이다. +# 비우면 위 저장소의 .env.production 에서 읽는다. ⚠️ 새 서버를 되살릴 때는 적는다(옛 서버의 값 — 예: arasystem) — 그 단계에는 .env.production 이 +# 아직 없다(restore.sh files 가 백업에서 꺼낸다). 둘 다 있고 다르면 스크립트가 멈춘다 +PROJECT_NAME= +# 백업 스냅숏의 host 이름 — 비우면 PROJECT_NAME. 다른 이름으로 받던 백업을 이어 쓸 때만 적는다 +BACKUP_HOST= + +# ------------------------------------------ +# 백업 (ops/backup/arasystem-backup.sh — systemd 타이머 매일 03:30 KST) +# ------------------------------------------ +# restic 저장소 — NCP Object Storage(S3 호환). 버킷은 콘솔에서 먼저 만든다(공개 안 함). 뒤의 /arasystem 은 버킷 안의 접두어 +RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/change-me-bucket/arasystem +# NCP 의 S3 옵션 — 지역 kr-standard(서명 V4 에 쓴다) · 경로 방식(https://<엔드포인트>/<버킷> — NCP 는 경로 · 가상 호스트 방식 둘 다 받는다) +# (근거: NCP API 가이드 Object Storage — https://api.ncloud-docs.com/docs/storage-objectstorage · …-listobjects) +RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path' +# NCP 서브 계정의 API 인증키 — 이 버킷만 쓰게 좁힌 계정으로 (콘솔 › Sub Account) +AWS_ACCESS_KEY_ID=change-me +AWS_SECRET_ACCESS_KEY=change-me +# 보존 — 매일 7 · 매주 4 · 매달 6 (그보다 오래된 백업은 지운다 — 지운 메일도 이 기간까지만 백업에 남는다) +BACKUP_KEEP_DAILY=7 +BACKUP_KEEP_WEEKLY=4 +BACKUP_KEEP_MONTHLY=6 +# 메일 엔진을 멈추고 올리는 final 한 번의 시간 상한(숫자 + s · m · h) — 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다. +# 평소 final 은 몇 초다(바뀐 것만 올린다). 바깥 대역폭이 아주 느리면 늘린다 +BACKUP_FINAL_TIMEOUT=15m +# 사전 확인에서 메일 엔진 · DB 가 healthy 가 되기를 기다리는 시간(초) — 부팅 직후 타이머가 놓친 백업을 바로 할 때 엔진이 아직 starting 이다 +BACKUP_HEALTHY_WAIT=300 + +# ------------------------------------------ +# 알림 — Healthchecks.io (무료). 체크 둘을 만들고 각자의 ping 주소를 넣는다 (mail/README.md "감시" — 체크 만드는 차례) +# 비우면 알리지 않는다(로그 · 상태 파일은 그대로). 주소를 아는 사람은 거짓 신호를 보낼 수 있다 — 이 파일 밖에 적어 두지 않는다 +# ------------------------------------------ +HC_BACKUP_URL= +HC_CHECK_URL= + +# ------------------------------------------ +# 15분 점검 (ops/monitor/arasystem-check.sh) — 비운 값은 기본값 +# ------------------------------------------ +# 디스크 — 볼 마운트 지점(공백으로) · 넘으면 알리는 사용률(%). 비우면 / 와 도커 데이터 폴더(docker info 의 DockerRootDir) +CHECK_DISKS= +CHECK_DISK_MAX_PERCENT=85 +# 인증서 — host:port (공백으로). 비우면 .env.production 의 MAIL_HOSTNAME:993 · CORS_ORIGIN 의 호스트:443 · SHARE_PUBLIC_URL 의 호스트:443 +CHECK_CERTS= +CHECK_CERT_MIN_DAYS=14 +# 메일 대기열 — 쌓인 수 · 가장 오래 머문 시간(시간) +CHECK_QUEUE_MAX=50 +CHECK_QUEUE_MAX_AGE_HOURS=24 +# 마지막 백업 성공에서 지난 시간(시간) +CHECK_BACKUP_MAX_AGE_HOURS=26 diff --git a/ops/systemd/arasystem-backup.service b/ops/systemd/arasystem-backup.service new file mode 100644 index 0000000..45455fe --- /dev/null +++ b/ops/systemd/arasystem-backup.service @@ -0,0 +1,28 @@ +# arasystem 백업 (13단계 — mail/README.md "백업") — arasystem-backup.timer 가 매일 03:30 KST 에 부른다 +# 설치: mail/README.md "백업" › "설치 (서버 B — 한 번)" (스크립트는 /opt/arasystem-ops 에 root 소유로 둔다 — 저장소 폴더를 바로 부르지 않는다) +[Unit] +Description=arasystem 백업 — 메일 엔진 · DB · 업로드 · env 를 restic 저장소로 +# docker 가 없으면 스크립트가 스스로 실패해 알린다 — Requires 로 묶으면 docker 를 다시 시작할 때 이 서비스도 함께 멈춘다(엔진을 멈춘 채로) +Wants=docker.service network-online.target +After=docker.service network-online.target + +[Service] +Type=oneshot +ExecStart=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh +# 스크립트가 어떻게 끝나든(죽어도) 백업이 멈춘 메일 엔진이 남아 있으면 다시 띄운다 +ExecStopPost=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running +# 상태 파일 · DB 덤프 자리 (/var/lib/arasystem-ops — root 만) +StateDirectory=arasystem-ops +StateDirectoryMode=0700 +# 처음 백업은 메일 전체를 올려 오래 걸릴 수 있다 (그 뒤로는 바뀐 것만 — 분 단위). 메일 엔진이 멈추는 final 은 스크립트가 따로 묶는다(BACKUP_FINAL_TIMEOUT) +TimeoutStartSec=6h +# 멈출 때(stop · 시간 초과) 스크립트가 정리할 시간 — 사본 지우기 → 엔진 띄우기(멈춤 끝내기 60초) → /fail 알림(재시도 포함 최대 1분 반) → 준비 180초 +TimeoutStopSec=8min +# 스크립트 · docker CLI 에만 걸린다 — restic 컨테이너는 dockerd 아래라 물려받지 않는다(스크립트가 docker run --cpus 1 --cpu-shares 512 --memory 1g 로 묶는다) +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=read-only diff --git a/ops/systemd/arasystem-backup.timer b/ops/systemd/arasystem-backup.timer new file mode 100644 index 0000000..854b4c8 --- /dev/null +++ b/ops/systemd/arasystem-backup.timer @@ -0,0 +1,13 @@ +# 매일 03:30 KST — 엔진의 휴지통 · 스팸 정리(03:00)와 데이터 · 원문 정리(04:00 · 04:30 — setup 이 맞춘다) 사이 +[Unit] +Description=arasystem 백업 — 매일 03:30 KST + +[Timer] +OnCalendar=*-*-* 03:30:00 Asia/Seoul +# 서버가 꺼져 있어 놓쳤으면 켜진 뒤 바로 한 번 (메일 엔진이 몇 초 멈춘다). 그때 엔진은 아직 starting 이다 — +# 스크립트가 healthy 를 5분(BACKUP_HEALTHY_WAIT)까지 기다리고, 부팅 유닛(arasystem-mail-recover)이 먼저 돈다 +Persistent=true +AccuracySec=1min + +[Install] +WantedBy=timers.target diff --git a/ops/systemd/arasystem-check.service b/ops/systemd/arasystem-check.service new file mode 100644 index 0000000..bb32bd7 --- /dev/null +++ b/ops/systemd/arasystem-check.service @@ -0,0 +1,16 @@ +# arasystem 15분 점검 (13단계 — mail/README.md "감시") — arasystem-check.timer 가 부른다 +[Unit] +Description=arasystem 점검 — 디스크 · 컨테이너 · 대기열 · 인증서 · 백업 · Spamhaus 키 +# docker 가 없으면 점검이 그것을 문제로 알린다 — Requires 로 묶지 않는다 +Wants=docker.service network-online.target +After=docker.service network-online.target + +[Service] +Type=oneshot +ExecStart=/bin/bash /opt/arasystem-ops/monitor/arasystem-check.sh +TimeoutStartSec=5min +Nice=10 +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=read-only diff --git a/ops/systemd/arasystem-check.timer b/ops/systemd/arasystem-check.timer new file mode 100644 index 0000000..1988c94 --- /dev/null +++ b/ops/systemd/arasystem-check.timer @@ -0,0 +1,10 @@ +# 15분마다 — 매시 07 · 22 · 37 · 52분 (백업이 시작하는 03:30 과 겹치지 않게 비켜 둔다) +[Unit] +Description=arasystem 점검 — 15분마다 + +[Timer] +OnCalendar=*-*-* *:07/15:00 +AccuracySec=30s + +[Install] +WantedBy=timers.target diff --git a/ops/systemd/arasystem-mail-recover.service b/ops/systemd/arasystem-mail-recover.service new file mode 100644 index 0000000..7758405 --- /dev/null +++ b/ops/systemd/arasystem-mail-recover.service @@ -0,0 +1,28 @@ +# arasystem — 부팅 때 백업이 멈춘 메일 엔진을 다시 띄운다 (13단계 — mail/README.md "백업") +# 백업이 메일 엔진을 멈춘 몇 초 사이에 서버가 꺼지면 엔진은 멈춘 채 남는다 — docker stop 으로 멈춘 컨테이너는 restart: unless-stopped 여도 +# 도커가 다시 띄우지 않는다. 표시 파일(/var/lib/arasystem-ops/mail-stopped-by-backup)이 있을 때만 띄우고, 떠 있는 것을 본 뒤 표시를 지운다 +[Unit] +Description=arasystem — 백업이 멈춘 메일 엔진 다시 띄우기 (부팅 때) +Wants=docker.service +After=docker.service +# 부팅 뒤 따라잡는 백업(타이머의 Persistent)보다 먼저 — 백업이 이 엔진을 멈춘 채 받지 않게 +Before=arasystem-backup.service +# 도커가 늦게 뜨면 실패한다 — 30초마다 다시, 10분 동안 10번까지 +StartLimitIntervalSec=10min +StartLimitBurst=10 + +[Service] +Type=oneshot +ExecStart=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running +Restart=on-failure +RestartSec=30 +StateDirectory=arasystem-ops +StateDirectoryMode=0700 +TimeoutStartSec=5min +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=read-only + +[Install] +WantedBy=multi-user.target -- 2.52.0 From 452d9fef1dbd73ae7cb741e736e56577d5a9cad5 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 15:37:03 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(backend):=2013=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=A4=80=EB=B9=84=20=E2=80=94=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=EB=90=9C=20=EC=84=B8=EC=85=98=20=C2=B7=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=ED=95=84=EC=88=98=20=C2=B7=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=C2=B7=20=EB=B0=9B=EC=9D=80=ED=8E=B8?= =?UTF-8?q?=EC=A7=80=ED=95=A8=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=20=EC=97=B0=EB=9D=BD=EC=B2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전 직원 전환 때 관리자가 계정을 다루고(입사 · 비밀번호 재설정 · 2단계 해제 · 잠금 · 퇴사 마무리 · 공용 주소) 회사가 2단계 인증을 필수로 할 수 있게 했다. - 제한된 세션(AUTH_010): 임시 비밀번호(엔진 만료) · 2단계 필수인 직원은 로그인은 되지만 새 비밀번호 · 2단계 켜기만 지난다. 푸는 요청은 그 세션만 다음 제한으로 바꾸고, 2단계를 켜면 2단계를 거친 적 없는 다른 세션은 끊는다. 만료는 게이트웨이 대리 접근 세션의 권한(다섯)으로 로그인 · 갱신 때 알아챈다. - 운영 도구 CLI(dist/cli/ops.js): 바뀔 것 → y/N → 실행 → ops_audit_log. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0, 재설정은 앱 비밀번호 → 세션 → 알림 → 만료 차례(만료 뒤 대리 접근이 막힌다). - 받은편지함 알림 메일(Email/import — 링크 없음): 재설정 · 2단계 해제 · 위험 파일 판정. 끊은 세션 기억은 15초마다 DB 에서 채운다. - 2단계를 켠 잠긴 계정도 로그인은 AUTH_004 — 엔진이 잠긴 계정에도 2단계를 먼저 봐, 게이트웨이 자신의 권한으로 잠김과 설정 문제를 가른다(실사용 시험에서 찾음). 검증: lint 0 · 단위 3,138 · e2e 266 · 리뷰 두 번 · 실사용 시험(브라우저 + 운영 도구) 89/89. Co-Authored-By: Claude Opus 5.5 --- backend/src/app.module.ts | 3 + backend/src/cli/ops.ts | 40 + .../allow-restricted-session.decorator.ts | 33 + .../api-standard-response.decorator.ts | 25 + .../decorators/current-user.decorator.ts | 11 + backend/src/common/errors/error-codes.spec.ts | 6 + backend/src/common/errors/error-codes.ts | 6 + .../src/common/guards/jwt-auth.guard.spec.ts | 97 +- backend/src/common/guards/jwt-auth.guard.ts | 37 +- .../1793585725784-AddCutoverPrep.ts | 44 + .../account-security.module.ts | 9 + .../app-passwords.controller.ts | 3 +- .../account-security/dto/two-factor.dto.ts | 17 + .../login-events.controller.ts | 3 +- .../two-factor-policy.service.spec.ts | 59 + .../two-factor-policy.service.ts | 79 ++ .../account-security/two-factor.controller.ts | 66 +- .../two-factor.service.spec.ts | 157 +++ .../account-security/two-factor.service.ts | 80 +- .../src/modules/auth/auth-session.entity.ts | 25 +- .../modules/auth/auth-session.repository.ts | 67 + .../modules/auth/auth-sessions.controller.ts | 3 + .../auth/auth-sessions.service.spec.ts | 1 + backend/src/modules/auth/auth-token.types.ts | 9 + .../src/modules/auth/auth.controller.spec.ts | 81 +- backend/src/modules/auth/auth.controller.ts | 49 +- backend/src/modules/auth/auth.service.spec.ts | 429 +++++- backend/src/modules/auth/auth.service.ts | 293 +++- backend/src/modules/auth/dto/auth-user.dto.ts | 30 + .../src/modules/auth/dto/login-result.dto.ts | 18 + backend/src/modules/auth/jwt.strategy.spec.ts | 148 +- backend/src/modules/auth/jwt.strategy.ts | 37 +- .../src/modules/auth/revoked-sessions.spec.ts | 82 +- backend/src/modules/auth/revoked-sessions.ts | 91 +- .../contacts/contact-groups.controller.ts | 3 +- .../contact-restorations.controller.ts | 3 +- .../modules/contacts/contacts.controller.ts | 3 +- .../contacts/directory-entries.controller.ts | 3 +- .../file-sharing/file-sharing.repository.ts | 21 + .../file-sharing/file-sharing.service.ts | 51 + .../share-link-lookup.controller.ts | 3 +- .../file-storage/file-scan.service.spec.ts | 29 + .../modules/file-storage/file-scan.service.ts | 7 +- .../file-storage/file-storage.repository.ts | 20 + .../file-storage/file-storage.types.ts | 4 + .../file-storage/file-upload.controller.ts | 5 +- .../mail-signature.controller.ts | 3 +- .../mail-user-settings.controller.ts | 3 +- backend/src/modules/mail/draft.controller.ts | 4 +- .../mail/images/mail-image.controller.ts | 7 +- .../mail/jmap/jmap-mail.provider.spec.ts | 13 + .../modules/mail/jmap/jmap-mail.provider.ts | 8 + .../modules/mail/jmap/jmap-ops-engine.spec.ts | 646 +++++++++ .../src/modules/mail/jmap/jmap-ops-engine.ts | 784 +++++++++++ .../mail/jmap/jmap-system-mailbox.spec.ts | 207 +++ .../modules/mail/jmap/jmap-system-mailbox.ts | 206 +++ .../src/modules/mail/jmap/jmap.client.spec.ts | 237 +++- backend/src/modules/mail/jmap/jmap.client.ts | 267 +++- .../mail/mail-attachment.controller.ts | 3 +- .../src/modules/mail/mail-block.controller.ts | 3 +- .../modules/mail/mail-events.controller.ts | 6 +- .../modules/mail/mail-filter.controller.ts | 5 +- .../mail/mail-large-files.service.spec.ts | 38 + .../modules/mail/mail-large-files.service.ts | 11 +- backend/src/modules/mail/mail.module.ts | 3 + backend/src/modules/mail/mail.provider.ts | 28 + backend/src/modules/mail/mail.settings.ts | 15 + .../src/modules/mail/mailbox.controller.ts | 10 +- .../mail/message-deletion.controller.ts | 4 +- .../src/modules/mail/message.controller.ts | 4 +- .../mail/recipient-suggestion.controller.ts | 3 +- .../mail/scheduled-message.controller.ts | 3 +- .../src/modules/mail/share-link.controller.ts | 3 +- backend/src/modules/ops/account-commands.ts | 446 ++++++ backend/src/modules/ops/admin-commands.ts | 557 ++++++++ backend/src/modules/ops/lock-commands.ts | 680 +++++++++ .../src/modules/ops/ops-audit-log.entity.ts | 47 + backend/src/modules/ops/ops-audit.service.ts | 58 + backend/src/modules/ops/ops-cli.ts | 189 +++ backend/src/modules/ops/ops-commands.spec.ts | 1243 +++++++++++++++++ backend/src/modules/ops/ops-data.service.ts | 294 ++++ backend/src/modules/ops/ops-engine.port.ts | 151 ++ backend/src/modules/ops/ops-helpers.spec.ts | 223 +++ backend/src/modules/ops/ops-io.ts | 148 ++ backend/src/modules/ops/ops-prompts.ts | 128 ++ backend/src/modules/ops/ops-text.ts | 132 ++ backend/src/modules/ops/ops.module.ts | 141 ++ backend/src/modules/ops/temp-password.ts | 32 + .../modules/system-mail/system-mail.module.ts | 22 + .../system-mail/system-mail.service.spec.ts | 109 ++ .../system-mail/system-mail.service.ts | 114 ++ .../system-mail/system-mailbox.port.ts | 30 + .../system-mail/system-notices.spec.ts | 182 +++ .../src/modules/system-mail/system-notices.ts | 238 ++++ .../dto/support-contact.dto.ts | 15 + .../support-contact.controller.ts | 28 + .../system-settings/system-settings.entity.ts | 41 + .../system-settings/system-settings.module.ts | 18 + .../system-settings.service.ts | 133 ++ .../system-settings/system-settings.spec.ts | 234 ++++ .../system-settings/system-settings.types.ts | 103 ++ .../src/modules/upload/upload.controller.ts | 3 +- backend/src/shared/utils/kst-date.spec.ts | 53 + backend/src/shared/utils/kst-date.ts | 47 + backend/test/app.e2e-spec.ts | 440 +++++- flutter/lib/core/api/error_lexicon.g.dart | 1 + .../shared/constants/error-codes.generated.ts | 1 + 107 files changed, 10903 insertions(+), 192 deletions(-) create mode 100644 backend/src/cli/ops.ts create mode 100644 backend/src/common/decorators/allow-restricted-session.decorator.ts create mode 100644 backend/src/migrations/1793585725784-AddCutoverPrep.ts create mode 100644 backend/src/modules/account-security/two-factor-policy.service.spec.ts create mode 100644 backend/src/modules/account-security/two-factor-policy.service.ts create mode 100644 backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-ops-engine.ts create mode 100644 backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-system-mailbox.ts create mode 100644 backend/src/modules/ops/account-commands.ts create mode 100644 backend/src/modules/ops/admin-commands.ts create mode 100644 backend/src/modules/ops/lock-commands.ts create mode 100644 backend/src/modules/ops/ops-audit-log.entity.ts create mode 100644 backend/src/modules/ops/ops-audit.service.ts create mode 100644 backend/src/modules/ops/ops-cli.ts create mode 100644 backend/src/modules/ops/ops-commands.spec.ts create mode 100644 backend/src/modules/ops/ops-data.service.ts create mode 100644 backend/src/modules/ops/ops-engine.port.ts create mode 100644 backend/src/modules/ops/ops-helpers.spec.ts create mode 100644 backend/src/modules/ops/ops-io.ts create mode 100644 backend/src/modules/ops/ops-prompts.ts create mode 100644 backend/src/modules/ops/ops-text.ts create mode 100644 backend/src/modules/ops/ops.module.ts create mode 100644 backend/src/modules/ops/temp-password.ts create mode 100644 backend/src/modules/system-mail/system-mail.module.ts create mode 100644 backend/src/modules/system-mail/system-mail.service.spec.ts create mode 100644 backend/src/modules/system-mail/system-mail.service.ts create mode 100644 backend/src/modules/system-mail/system-mailbox.port.ts create mode 100644 backend/src/modules/system-mail/system-notices.spec.ts create mode 100644 backend/src/modules/system-mail/system-notices.ts create mode 100644 backend/src/modules/system-settings/dto/support-contact.dto.ts create mode 100644 backend/src/modules/system-settings/support-contact.controller.ts create mode 100644 backend/src/modules/system-settings/system-settings.entity.ts create mode 100644 backend/src/modules/system-settings/system-settings.module.ts create mode 100644 backend/src/modules/system-settings/system-settings.service.ts create mode 100644 backend/src/modules/system-settings/system-settings.spec.ts create mode 100644 backend/src/modules/system-settings/system-settings.types.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f8d008e..54c0ef6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { FileStorageModule } from '@/modules/file-storage/file-storage.module'; import { HealthModule } from '@/modules/health/health.module'; import { MailModule } from '@/modules/mail/mail.module'; +import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; import { parsePositiveInt } from '@/shared/utils/env'; @@ -77,6 +78,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 // 메일 모듈도 가져온다(메일 → 공유 → 저장 한 방향) — 2부 파일 저장소가 같은 두 모듈을 쓴다 FileStorageModule, FileSharingModule, + // 서버 설정 (13단계) — 2단계 인증 필수 정책 · 문의 연락처(GET /api/support-contact). 운영 도구가 바꾼다 + SystemSettingsModule, UploadModule, UserModule, ], diff --git a/backend/src/cli/ops.ts b/backend/src/cli/ops.ts new file mode 100644 index 0000000..160705a --- /dev/null +++ b/backend/src/cli/ops.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; + +import { OpsCli } from '@/modules/ops/ops-cli'; +import { OPS_ALLOW_NO_TTY_ENV, TerminalIo } from '@/modules/ops/ops-io'; +import { OpsModule } from '@/modules/ops/ops.module'; + +/** + * 운영 도구 (13단계 — 계약 6절 "운영 도구") — 서버 안에서 사람이 돌리는 명령: + * sudo docker exec -it -backend node dist/cli/ops.js + * 한국어 메뉴로 묻고, 바뀔 것을 보여 주고, y/N 으로 확인받은 뒤 실행한다. 한 일은 ops_audit_log 에 남는다. + * Nest 앱 컨텍스트로 띄운다 — HTTP 서버 · cron · 실시간 엔진 연결 · 악성 검사 · 마이그레이션 없이 (modules/ops/ops.module.ts). + * 화면에는 경고 · 오류 로그만 섞인다(info 로그는 끈다) + */ +async function main(): Promise { + const app = await NestFactory.createApplicationContext(OpsModule, { + logger: ['error', 'warn'], + }); + // 표준 입력이 터미널이 아니면 도구가 비밀번호를 묻기 전에 멈춘다(-it 를 빠뜨림). 자동 시험만 OPS_ALLOW_NO_TTY=1 로 파이프를 받는다 + const io = new TerminalIo(process.stdin, process.stdout, { + allowNonInteractive: process.env[OPS_ALLOW_NO_TTY_ENV] === '1', + }); + try { + return await app.get(OpsCli).run(io); + } finally { + io.close(); + await app.close(); + } +} + +main() + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + process.stderr.write( + `운영 도구를 시작하지 못했습니다: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); diff --git a/backend/src/common/decorators/allow-restricted-session.decorator.ts b/backend/src/common/decorators/allow-restricted-session.decorator.ts new file mode 100644 index 0000000..0c42b7a --- /dev/null +++ b/backend/src/common/decorators/allow-restricted-session.decorator.ts @@ -0,0 +1,33 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * 제한된 세션 (계약 6절 "제한된 세션" — 13단계) — 로그인은 했지만 먼저 해야 할 일이 있는 세션. + * - passwordChange: 엔진의 비밀번호가 만료됐다(관리자가 준 임시 비밀번호) — 새 비밀번호를 정할 때까지 + * - twoFactorSetup: 회사가 2단계 인증을 필수로 정했고 시작일이 지났는데 아직 켜지 않았다 — 켤 때까지 + * 차례는 passwordChange 가 먼저다. 제한은 세션 표(auth_sessions.restriction)와 액세스 토큰(`rst`)에 있다 + */ +export const SESSION_RESTRICTIONS = [ + 'passwordChange', + 'twoFactorSetup', +] as const; +export type SessionRestriction = (typeof SESSION_RESTRICTIONS)[number]; + +export function isSessionRestriction( + value: unknown, +): value is SessionRestriction { + return ( + typeof value === 'string' && + (SESSION_RESTRICTIONS as readonly string[]).includes(value) + ); +} + +/** 메타데이터 키 — JwtAuthGuard 가 읽는다 */ +export const ALLOW_RESTRICTED_SESSION = 'allowRestrictedSession'; + +/** + * 제한된 세션으로도 부를 수 있는 핸들러 · 컨트롤러 (계약 6절) — `/api/auth/*`(me · password · sessions) · + * `/api/two-factor` · `enrollments` · `activation` 만 단다. 달지 않은 보호 라우트는 제한된 세션이면 403 AUTH_010 이다(JwtAuthGuard). + * 로그인 없이 부르는 라우트(로그인 · 갱신 · 로그아웃 · 문의 연락처)는 가드가 없어 상관없다 + */ +export const AllowRestrictedSession = (): MethodDecorator & ClassDecorator => + SetMetadata(ALLOW_RESTRICTED_SESSION, true); diff --git a/backend/src/common/decorators/api-standard-response.decorator.ts b/backend/src/common/decorators/api-standard-response.decorator.ts index 994ef86..b8f7a4c 100644 --- a/backend/src/common/decorators/api-standard-response.decorator.ts +++ b/backend/src/common/decorators/api-standard-response.decorator.ts @@ -69,3 +69,28 @@ export function ApiErrorResponse(code: ErrorCode, description?: string) { }, }); } + +/** AUTH_001 의 기본 설명 — 로그인이 필요한 API */ +const SESSION_REQUIRED_DESCRIPTION = + '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) — 클라이언트는 /auth/refresh 를 한 번 시도한다'; + +/** + * 로그인이 필요한 API 의 공통 실패 (13단계 — 계약 6절) — 401 AUTH_001(로그인 필요) + 403 AUTH_010(제한된 세션 — 비밀번호 바꾸기 · 2단계 켜기 전). + * `@UseGuards(JwtAuthGuard)` 를 건 컨트롤러(클래스)에 함께 붙인다 — 가드가 두 실패를 모두 낸다. 핸들러에 같은 상태 코드를 따로 적으면 그것이 이긴다. + * 제한된 세션도 부르는 곳(`@AllowRestrictedSession()` — 인증 · 로그인된 브라우저 · 2단계 상태 · 등록 · 켜기)에는 붙이지 않는다 — AUTH_001 만 적는다 + * + * 사용 예: + * @UseGuards(JwtAuthGuard) + * @ApiSessionErrors() + */ +export function ApiSessionErrors( + description: string = SESSION_REQUIRED_DESCRIPTION, +) { + return applyDecorators( + ApiErrorResponse('AUTH_001', description), + ApiErrorResponse( + 'AUTH_010', + '제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전) — 이 API 는 부를 수 없다. 클라이언트는 알림 없이 me 로 제한을 다시 받는다 (13단계)', + ), + ); +} diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts index 7f7fb6c..bc7b60b 100644 --- a/backend/src/common/decorators/current-user.decorator.ts +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -2,6 +2,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import type { Request } from 'express'; import { BusinessException } from '@/common/errors/business.exception'; +import type { SessionRestriction } from './allow-restricted-session.decorator'; /** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */ export interface AuthenticatedUser { @@ -19,6 +20,16 @@ export interface AuthenticatedUser { * 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저") */ readonly sessionId?: string; + /** + * 제한된 세션이면 그 제한 (계약 6절 "제한된 세션" — 13단계) — 액세스 토큰의 `rst`. 없으면(null · 없음) 제한이 없다. + * JwtAuthGuard 가 허용한 핸들러(@AllowRestrictedSession) 밖이면 403 AUTH_010 으로 막는다 + */ + readonly restriction?: SessionRestriction | null; + /** + * 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 액세스 토큰의 `tfd`. 아니면 null · 없음. + * 로그인 · 갱신 때 정해 토큰에 싣는다 (`me` 가 엔진 · DB 에 묻지 않고 답한다) + */ + readonly twoFactorDeadline?: string | null; } /** diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index b54f344..9651e67 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -50,6 +50,12 @@ describe('에러 코드 레지스트리', () => { expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001'); }); + it('제한된 세션(AUTH_010 — 13단계)은 403 이고, 403 의 상태 역매핑은 그대로 AUTH_003 이다', () => { + expect(ERROR_CODES.AUTH_010.status).toBe(HttpStatus.FORBIDDEN); + // AUTH_010 은 가드가 명시적으로만 던진다 — 프레임워크의 403 은 권한 부족(AUTH_003)으로 둔다 + expect(resolveCodeByStatus(HttpStatus.FORBIDDEN)).toBe('AUTH_003'); + }); + it('같은 이름의 메일함(MAIL_009)은 409 다', () => { expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT); }); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b7c5069..8fc346b 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -75,6 +75,12 @@ const COMMON_ERROR_CODES = { message: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', }, + // 제한된 세션(비밀번호 바꾸기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API 를 불렀다. + // 화면은 알림 없이 me 를 다시 받아 그 제한의 단계로 간다 (계약 6절 "제한된 세션"). 403 의 상태 역매핑은 그대로 AUTH_003 이다 + AUTH_010: { + status: HttpStatus.FORBIDDEN, + message: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', + }, VAL_001: { status: HttpStatus.BAD_REQUEST, message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', diff --git a/backend/src/common/guards/jwt-auth.guard.spec.ts b/backend/src/common/guards/jwt-auth.guard.spec.ts index 048a692..ed43362 100644 --- a/backend/src/common/guards/jwt-auth.guard.spec.ts +++ b/backend/src/common/guards/jwt-auth.guard.spec.ts @@ -1,3 +1,8 @@ +import type { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthModuleOptions } from '@nestjs/passport'; + +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -14,7 +19,7 @@ function codeOf(run: () => unknown): string | undefined { } describe('JwtAuthGuard.handleRequest', () => { - const guard = new JwtAuthGuard(); + const guard = new JwtAuthGuard(new Reflector()); it('검증된 사용자는 그대로 통과시킨다', () => { expect(guard.handleRequest(null, user, undefined)).toBe(user); @@ -69,3 +74,93 @@ describe('JwtAuthGuard.handleRequest', () => { ); }); }); + +/** 제한된 세션 시험용 — 허용한 컨트롤러 · 허용하지 않은 컨트롤러와 핸들러 */ +@AllowRestrictedSession() +class AllowedController {} + +class MixedController {} + +const meHandler = (): void => undefined; +const statusHandler = (): void => undefined; +const deactivateHandler = (): void => undefined; +// 핸들러 하나에만 표시 — 메서드 데코레이터와 같은 메타데이터 +AllowRestrictedSession()(statusHandler); + +function contextOf( + controller: abstract new () => object, + handler: () => void, +): ExecutionContext { + return { + getHandler: () => handler, + getClass: () => controller, + } as unknown as ExecutionContext; +} + +describe('JwtAuthGuard — 제한된 세션 (계약 6절, 13단계)', () => { + const guard = new JwtAuthGuard(new Reflector()); + const restricted = { ...user, restriction: 'passwordChange' as const }; + + it('제한 없는 세션은 허용 표시가 없는 핸들러도 통과한다', () => { + const context = contextOf(MixedController, deactivateHandler); + expect(guard.handleRequest(null, user, undefined, context)).toBe(user); + expect( + guard.handleRequest( + null, + { ...user, restriction: null }, + undefined, + context, + ), + ).toEqual({ ...user, restriction: null }); + }); + + it('제한된 세션은 허용 표시가 없는 핸들러에서 403 AUTH_010 이다 — 제한은 로그 context 에만', () => { + const context = contextOf(MixedController, deactivateHandler); + expect( + codeOf(() => guard.handleRequest(null, restricted, undefined, context)), + ).toBe('AUTH_010'); + try { + guard.handleRequest(null, restricted, undefined, context); + } catch (error) { + expect((error as BusinessException).context).toEqual({ + reason: 'session-restricted', + restriction: 'passwordChange', + }); + } + }); + + it('핸들러나 컨트롤러에 @AllowRestrictedSession() 이 있으면 제한된 세션도 통과한다', () => { + expect( + guard.handleRequest( + null, + restricted, + undefined, + contextOf(MixedController, statusHandler), + ), + ).toBe(restricted); + expect( + guard.handleRequest( + null, + { ...user, restriction: 'twoFactorSetup' }, + undefined, + contextOf(AllowedController, meHandler), + ), + ).toEqual({ ...user, restriction: 'twoFactorSetup' }); + }); + + it('실행 맥락을 모르면 제한된 세션을 막는다 (닫힌 쪽)', () => { + expect(codeOf(() => guard.handleRequest(null, restricted, undefined))).toBe( + 'AUTH_010', + ); + }); +}); + +describe('JwtAuthGuard — PassportModule 설정', () => { + it('PassportModule.register 의 설정(AuthModuleOptions)을 부모 가드에 넘긴다 (리뷰 L13)', () => { + const options = Object.assign(new AuthModuleOptions(), { session: false }); + const guard = new JwtAuthGuard(new Reflector(), options); + expect((guard as unknown as { options: AuthModuleOptions }).options).toBe( + options, + ); + }); +}); diff --git a/backend/src/common/guards/jwt-auth.guard.ts b/backend/src/common/guards/jwt-auth.guard.ts index 6edc954..ccced48 100644 --- a/backend/src/common/guards/jwt-auth.guard.ts +++ b/backend/src/common/guards/jwt-auth.guard.ts @@ -1,6 +1,8 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; +import { type ExecutionContext, Injectable, Optional } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard, AuthModuleOptions } from '@nestjs/passport'; +import { ALLOW_RESTRICTED_SESSION } from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; @@ -12,16 +14,29 @@ export const JWT_STRATEGY = 'jwt'; * 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다. * (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다) * + * 제한된 세션(토큰의 `rst` — 계약 6절 "제한된 세션")은 `@AllowRestrictedSession()` 을 단 핸들러 · 컨트롤러만 통과한다 — + * 그 밖은 403 AUTH_010 (실시간 알림 `/api/mail-events` 포함). 허용 목록은 가드를 거는 쪽이 아니라 핸들러가 정한다 — + * 새 보호 라우트는 달지 않으면 저절로 막힌다(닫힌 쪽). + * * 사용 예: * @UseGuards(JwtAuthGuard) * @ApiCookieAuth() */ @Injectable() export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) { + // PassportModule.register 의 설정(AuthModuleOptions)을 부모에 그대로 넘긴다 — 생성자를 두면 Nest 가 부모의 주입을 대신하지 않는다 + constructor( + private readonly reflector: Reflector, + @Optional() options?: AuthModuleOptions, + ) { + super(options); + } + handleRequest( error: unknown, user: unknown, info: unknown, + context?: ExecutionContext, ): TUser { // 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다 if (error instanceof BusinessException) throw error; @@ -31,8 +46,26 @@ export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) { context: { reason: describeFailure(error ?? info) }, }); } + + const restriction = (user as AuthenticatedUser).restriction ?? null; + if (restriction !== null && !this.allowsRestricted(context)) { + throw new BusinessException('AUTH_010', { + context: { reason: 'session-restricted', restriction }, + }); + } return user as TUser; } + + /** 핸들러 · 컨트롤러에 @AllowRestrictedSession() 이 있는가 — 실행 맥락이 없으면(가드 밖에서 부름) 막는다 */ + private allowsRestricted(context: ExecutionContext | undefined): boolean { + if (!context) return false; + return ( + this.reflector.getAllAndOverride( + ALLOW_RESTRICTED_SESSION, + [context.getHandler(), context.getClass()], + ) === true + ); + } } /** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */ diff --git a/backend/src/migrations/1793585725784-AddCutoverPrep.ts b/backend/src/migrations/1793585725784-AddCutoverPrep.ts new file mode 100644 index 0000000..0b217da --- /dev/null +++ b/backend/src/migrations/1793585725784-AddCutoverPrep.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 전환 준비 표 (docs/mail/api-contract.md 6절 "전환 준비 — 13단계"). + * - auth_sessions.restriction: 제한된 세션 — passwordChange(비밀번호 바꾸기 전) · twoFactorSetup(2단계 인증 켜기 전) · null. 기존 세션은 null(제한 없음) + * - system_settings: 서버 설정 한 줄(id 1) — 2단계 인증 필수 정책(꺼짐 · 시작일 · 예외 주소)과 문의 연락처. 처음 값은 꺼짐 · 연락처 없음이다 + * - ops_audit_log: 운영 도구의 실행 기록 — 언제 · 누가 · 명령 · 대상 · 결과(비밀은 남기지 않는다). 일어난 때 인덱스 + * 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다. + * (엔티티: modules/auth/auth-session.entity.ts · modules/system-settings/system-settings.entity.ts · modules/ops/ops-audit-log.entity.ts — + * 만든 뒤 migration:generate 가 "변경 없음"인 것을 확인했다) + */ +export class AddCutoverPrep1793585725784 implements MigrationInterface { + name = 'AddCutoverPrep1793585725784'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "auth_sessions" ADD "restriction" character varying(32)`, + ); + await queryRunner.query( + `CREATE TABLE "system_settings" ("id" smallint NOT NULL, "two_factor_required" boolean NOT NULL, "two_factor_start_date" date, "two_factor_exempt" jsonb NOT NULL, "support_contact" character varying(200), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_82521f08790d248b2a80cc85d40" PRIMARY KEY ("id"))`, + ); + // 한 줄만 둔다 — 처음 값: 2단계 인증 필수 꺼짐 · 예외 없음 · 문의 연락처 없음 + await queryRunner.query( + `INSERT INTO "system_settings" ("id", "two_factor_required", "two_factor_start_date", "two_factor_exempt", "support_contact", "updated_at") VALUES (1, false, NULL, '[]', NULL, now())`, + ); + await queryRunner.query( + `CREATE TABLE "ops_audit_log" ("id" BIGSERIAL NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "operator" character varying(100) NOT NULL, "command" character varying(32) NOT NULL, "target" character varying(254), "result" character varying(16) NOT NULL, "detail" character varying(500), CONSTRAINT "PK_79f2db0d5b46ba1d43348bbc85c" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ops_audit_log_occurred_at" ON "ops_audit_log" ("occurred_at") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_ops_audit_log_occurred_at"`, + ); + await queryRunner.query(`DROP TABLE "ops_audit_log"`); + await queryRunner.query(`DROP TABLE "system_settings"`); + await queryRunner.query( + `ALTER TABLE "auth_sessions" DROP COLUMN "restriction"`, + ); + } +} diff --git a/backend/src/modules/account-security/account-security.module.ts b/backend/src/modules/account-security/account-security.module.ts index 110b955..abb4a1f 100644 --- a/backend/src/modules/account-security/account-security.module.ts +++ b/backend/src/modules/account-security/account-security.module.ts @@ -2,11 +2,13 @@ import { Logger, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthSessionModule } from '@/modules/auth/auth-session.module'; 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 { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; import { ACCOUNT_SECURITY_PROVIDER } from './account-security.provider'; import { ACCOUNT_SECURITY_SETTINGS, @@ -26,6 +28,7 @@ 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 { TwoFactorPolicyService } from './two-factor-policy.service'; import { TwoFactorSettingEntity } from './two-factor-setting.entity'; import { TwoFactorController } from './two-factor.controller'; import { TwoFactorService } from './two-factor.service'; @@ -64,6 +67,9 @@ function createSettings(config: ConfigService): AccountSecuritySettings { // 로그인 기록의 실패를 사내 목록으로 거른다 ContactsModule, LoginAttemptModule, + // 13단계 — 2단계 인증 필수 정책(서버 설정)과, 켜면 세션의 twoFactorSetup 제한 지우기 + SystemSettingsModule, + AuthSessionModule, ], controllers: [ TwoFactorController, @@ -85,6 +91,7 @@ function createSettings(config: ConfigService): AccountSecuritySettings { TwoFactorEnrollments, TwoFactorCodeGuard, TwoFactorLocks, + TwoFactorPolicyService, AppPasswordsService, LoginEventsService, LoginEventsCleanup, @@ -93,6 +100,8 @@ function createSettings(config: ConfigService): AccountSecuritySettings { ], exports: [ ACCOUNT_SECURITY_SETTINGS, + // 13단계 — 인증 모듈이 로그인 · 갱신에서 2단계 인증 필수 정책을 본다 + TwoFactorPolicyService, TwoFactorCodeGuard, TwoFactorLocks, AppPasswordsService, diff --git a/backend/src/modules/account-security/app-passwords.controller.ts b/backend/src/modules/account-security/app-passwords.controller.ts index e6043a9..3a2da76 100644 --- a/backend/src/modules/account-security/app-passwords.controller.ts +++ b/backend/src/modules/account-security/app-passwords.controller.ts @@ -18,6 +18,7 @@ import { import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -56,7 +57,7 @@ function toDto(view: AppPasswordView): AppPasswordDto { @ApiTags('Account security') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') +@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') @Controller('app-passwords') export class AppPasswordsController { constructor(private readonly appPasswords: AppPasswordsService) {} diff --git a/backend/src/modules/account-security/dto/two-factor.dto.ts b/backend/src/modules/account-security/dto/two-factor.dto.ts index a5ff824..769b229 100644 --- a/backend/src/modules/account-security/dto/two-factor.dto.ts +++ b/backend/src/modules/account-security/dto/two-factor.dto.ts @@ -24,6 +24,23 @@ export class TwoFactorStatusDto { example: '2026-10-31T00:12:00.000Z', }) enabledAt!: string | null; + + @ApiProperty({ + description: + '이 직원에게 2단계 인증이 필수인가 (13단계 — 회사 정책, 시작일 전에도 true). 필수인 직원은 끄지 못한다(끄기는 400 VAL_001)', + example: false, + }) + isRequired!: boolean; + + @ApiProperty({ + description: + '필수의 유예 중이고 아직 켜지 않았으면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 켰거나 시작했거나 필수가 아니면 null(로그인 · me 의 twoFactorDeadline 과 같은 규칙). 화면은 그 1ms 앞 날짜(시작일 전날)까지로 안내한다', + type: String, + format: 'date-time', + nullable: true, + example: '2026-11-30T15:00:00.000Z', + }) + deadline!: string | null; } /** POST /api/two-factor/enrollments → 201 (Cache-Control: private, no-store) */ diff --git a/backend/src/modules/account-security/login-events.controller.ts b/backend/src/modules/account-security/login-events.controller.ts index fbfa014..0c8cb99 100644 --- a/backend/src/modules/account-security/login-events.controller.ts +++ b/backend/src/modules/account-security/login-events.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -41,7 +42,7 @@ function toDto(event: LoginEventEntity): LoginEventDto { @ApiTags('Account security') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') +@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') @Controller('login-events') export class LoginEventsController { constructor(private readonly loginEvents: LoginEventsService) {} diff --git a/backend/src/modules/account-security/two-factor-policy.service.spec.ts b/backend/src/modules/account-security/two-factor-policy.service.spec.ts new file mode 100644 index 0000000..2ab540a --- /dev/null +++ b/backend/src/modules/account-security/two-factor-policy.service.spec.ts @@ -0,0 +1,59 @@ +import type { Repository } from 'typeorm'; + +import type { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { DEFAULT_SYSTEM_SETTINGS } from '@/modules/system-settings/system-settings.types'; +import type { AccountSecurityProvider } from './account-security.provider'; +import { TwoFactorPolicyService } from './two-factor-policy.service'; +import type { TwoFactorSettingEntity } from './two-factor-setting.entity'; + +const ARA = 'ara.kim@arasystem.test'; + +describe('TwoFactorPolicyService — 2단계 인증 켜짐 판정 (13단계)', () => { + let records: { existsBy: jest.Mock; delete: jest.Mock }; + let provider: { isTwoFactorEnabled: jest.Mock }; + let service: TwoFactorPolicyService; + + beforeEach(() => { + records = { + existsBy: jest.fn().mockResolvedValue(true), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + provider = { isTwoFactorEnabled: jest.fn().mockResolvedValue(false) }; + service = new TwoFactorPolicyService( + { + get: jest.fn().mockResolvedValue(DEFAULT_SYSTEM_SETTINGS), + } as unknown as SystemSettingsService, + records as unknown as Repository, + provider as unknown as AccountSecurityProvider, + ); + }); + + it('isEnabledInEngine — 우리 기록이 아니라 엔진에 묻는다: 기록이 남았어도 엔진이 꺼짐이면 false 이고 기록을 지운다 (재리뷰 N1)', async () => { + await expect(service.isEnabledInEngine(ARA)).resolves.toBe(false); + expect(records.existsBy).not.toHaveBeenCalled(); + expect(provider.isTwoFactorEnabled).toHaveBeenCalledWith(ARA); + expect(records.delete).toHaveBeenCalledWith({ userEmail: ARA }); + }); + + it('isEnabledInEngine — 엔진이 켜짐이면 true, 기록은 건드리지 않는다', async () => { + provider.isTwoFactorEnabled.mockResolvedValueOnce(true); + await expect(service.isEnabledInEngine(ARA)).resolves.toBe(true); + expect(records.delete).not.toHaveBeenCalled(); + }); + + it('isEnabledInEngine — 기록 정리가 실패해도 판정은 그대로, 엔진 장애는 던진다', async () => { + records.delete.mockRejectedValueOnce(new Error('db down')); + await expect(service.isEnabledInEngine(ARA)).resolves.toBe(false); + + provider.isTwoFactorEnabled.mockRejectedValueOnce(new Error('engine down')); + await expect(service.isEnabledInEngine(ARA)).rejects.toThrow('engine down'); + }); + + it('isEnabled — 기록이 있으면 켜짐(엔진에 묻지 않는다), 없으면 엔진', async () => { + await expect(service.isEnabled(ARA)).resolves.toBe(true); + expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled(); + records.existsBy.mockResolvedValueOnce(false); + provider.isTwoFactorEnabled.mockResolvedValueOnce(true); + await expect(service.isEnabled(ARA)).resolves.toBe(true); + }); +}); diff --git a/backend/src/modules/account-security/two-factor-policy.service.ts b/backend/src/modules/account-security/two-factor-policy.service.ts new file mode 100644 index 0000000..65a4e02 --- /dev/null +++ b/backend/src/modules/account-security/two-factor-policy.service.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { + sessionTwoFactorState, + twoFactorRequirementFor, + type SessionTwoFactorState, + type TwoFactorRequirement, +} from '@/modules/system-settings/system-settings.types'; +import { + ACCOUNT_SECURITY_PROVIDER, + type AccountSecurityProvider, +} from './account-security.provider'; +import { TwoFactorSettingEntity } from './two-factor-setting.entity'; + +/** + * 2단계 인증 필수 정책의 판정 (13단계 — 계약 6절 "2단계 인증 필수") — 정책은 서버 설정(system_settings), 켜짐은 엔진이 기준이다. + * - 로그인: 엔진의 답(402 면 켜짐)을 그대로 넘겨받는다 (knownEnabled) + * - 갱신: 우리 기록(two_factor_settings — 웹메일에서 켠 때)이 있으면 켜짐, 없으면 엔진에 묻는다 + * (엔진 관리 화면에서 켠 직원은 기록이 없다 — 기록만 보면 켠 직원을 막게 된다) + * 정책이 꺼졌거나 예외인 직원은 엔진에 묻지 않는다 + */ +@Injectable() +export class TwoFactorPolicyService { + constructor( + private readonly settings: SystemSettingsService, + @InjectRepository(TwoFactorSettingEntity) + private readonly records: Repository, + @Inject(ACCOUNT_SECURITY_PROVIDER) + private readonly provider: AccountSecurityProvider, + ) {} + + /** 이 직원에게 정책이 걸리는가 (GET /api/two-factor 의 isRequired · deadline, 끄기 막기) */ + async requirementFor( + email: string, + now: Date = new Date(), + ): Promise { + const { twoFactor } = await this.settings.get(); + return twoFactorRequirementFor(twoFactor, email, now); + } + + /** + * 세션에 붙일 2단계 인증 상태 — 제한(twoFactorSetup) 또는 유예 중의 시작 시각. + * `knownEnabled` 를 모르면(갱신) 우리 기록 → 엔진 차례로 본다 — 엔진 장애는 그대로 던진다(갱신은 세션을 남기고 503) + */ + async sessionState( + email: string, + knownEnabled?: boolean, + now: Date = new Date(), + ): Promise { + const requirement = await this.requirementFor(email, now); + if (!requirement.isRequired) { + return { restriction: null, deadline: null }; + } + const enabled = knownEnabled ?? (await this.isEnabled(email)); + return sessionTwoFactorState(requirement, enabled); + } + + /** + * 엔진에서 2단계 인증이 켜져 있는가 — 우리 기록을 보지 않는다. 엔진이 꺼짐인데 기록이 남았으면(엔진 관리 화면 · 운영 도구에서 껐다) 기록을 지운다. + * 갱신이 2단계를 거치지 않은 세션을 끊을지 정할 때 쓴다 — 오래된 기록 때문에 제한된 세션이 갱신마다 끊기지 않게(재리뷰 N1). 엔진 장애는 그대로 던진다 + */ + async isEnabledInEngine(email: string): Promise { + const enabled = await this.provider.isTwoFactorEnabled(email); + if (!enabled) { + // 기록 정리는 실패해도 판정은 그대로다 — 다음 상태 읽기(GET /api/two-factor)도 맞춘다 + await this.records.delete({ userEmail: email }).catch(() => undefined); + } + return enabled; + } + + /** 2단계 인증이 켜져 있는가 — 우리 기록(웹메일에서 켠 때) → 엔진. 엔진 장애는 그대로 던진다 */ + async isEnabled(email: string): Promise { + if (await this.records.existsBy({ userEmail: email })) return true; + return this.provider.isTwoFactorEnabled(email); + } +} diff --git a/backend/src/modules/account-security/two-factor.controller.ts b/backend/src/modules/account-security/two-factor.controller.ts index aaac70d..231c8d6 100644 --- a/backend/src/modules/account-security/two-factor.controller.ts +++ b/backend/src/modules/account-security/two-factor.controller.ts @@ -10,6 +10,7 @@ import { } from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -26,12 +27,17 @@ import { TwoFactorEnrollmentDto, TwoFactorStatusDto, } from './dto/two-factor.dto'; -import { TwoFactorService, type TwoFactorStatus } from './two-factor.service'; +import { + TwoFactorService, + type TwoFactorStatusView, +} from './two-factor.service'; -function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto { +function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto { return { isEnabled: status.isEnabled, enabledAt: status.enabledAt?.toISOString() ?? null, + isRequired: status.isRequired, + deadline: status.deadline?.toISOString() ?? null, }; } @@ -44,25 +50,34 @@ function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto { 'AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)', ) +// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010 @Controller('two-factor') export class TwoFactorController { constructor(private readonly twoFactor: TwoFactorService) {} @Get() + @AllowRestrictedSession() @ApiOperation({ summary: '2단계 인증 상태', description: - '엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다.', + '엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다. ' + + '13단계: isRequired · deadline — 회사의 2단계 인증 필수 정책. 제한된 세션도 부른다.', }) @ApiStandardResponse(TwoFactorStatusDto, { description: '지금 상태' }) @ApiErrorResponse('MAIL_001', '메일 엔진 장애') async status( @CurrentUser() user: AuthenticatedUser, ): Promise { - return toStatusDto(await this.twoFactor.getStatus(user.email)); + return toStatusDto( + await this.twoFactor.withPolicy( + user.email, + await this.twoFactor.getStatus(user.email), + ), + ); } @Post('enrollments') + @AllowRestrictedSession() @NoStore() @ApiOperation({ summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)', @@ -90,13 +105,15 @@ export class TwoFactorController { } @Post('activation') + @AllowRestrictedSession() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '2단계 인증 켜기 — 첫 코드와 지금 비밀번호', description: '코드를 등록의 비밀로 먼저 확인한다(앞뒤 30초) — 엔진은 등록 때 코드를 보지 않는다. 둘 다 틀렸으면 코드부터 알린다(AUTH_006). ' + '그다음 지금 비밀번호를 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다) 엔진에 넣는다. 켠 코드는 2분 동안 다시 받지 않는다. ' + - '다른 곳의 웹메일 로그인은 끊지 않는다.', + '다른 곳의 웹메일 로그인은 끊지 않는다. 13단계: 켜면 이 요청의 세션의 twoFactorSetup 제한을 지우고(클라이언트는 곧바로 /auth/refresh 로 새 토큰을 받는다), ' + + '그 직원의 다른 세션 가운데 twoFactorSetup 이 붙은 것(2단계를 거친 적이 없는 세션)은 끊는다.', }) @ApiStandardResponse(TwoFactorStatusDto, { description: '켰다' }) // 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다) @@ -121,17 +138,16 @@ export class TwoFactorController { @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 }, - ), + const status = await this.twoFactor.activate( + user.email, + { + enrollmentId: dto.enrollmentId, + otpCode: dto.otpCode, + currentPassword: dto.currentPassword, + }, + { ip, sessionId: user.sessionId ?? null }, ); + return toStatusDto(await this.twoFactor.withPolicy(user.email, status)); } @Post('deactivation') @@ -140,14 +156,19 @@ export class TwoFactorController { summary: '2단계 인증 끄기 — 지금 비밀번호와 지금 코드', description: '비밀번호를 먼저 확인한다(틀리면 AUTH_005 — 로그인 실패 한도로 센다). 이미 꺼져 있으면 200(같은 결과). ' + - '코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다.', + '코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다. ' + + '13단계: 회사 정책으로 필수인 직원은 400 VAL_001(비밀번호를 묻기 전에). 제한된 세션은 403 AUTH_010.', }) @ApiStandardResponse(TwoFactorStatusDto, { description: '껐다 (또는 이미 꺼져 있었다)', }) @ApiErrorResponse( 'AUTH_006', - 'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)', + 'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 · 회사 정책으로 필수인 직원 (모두 400)', + ) + @ApiErrorResponse( + 'AUTH_010', + '제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)', ) @ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중') @ApiErrorResponse( @@ -159,12 +180,11 @@ export class TwoFactorController { @Body() dto: TwoFactorDeactivationDto, @Ip() ip: string, ): Promise { - return toStatusDto( - await this.twoFactor.deactivate( - user.email, - { currentPassword: dto.currentPassword, otpCode: dto.otpCode }, - { ip }, - ), + const status = await this.twoFactor.deactivate( + user.email, + { currentPassword: dto.currentPassword, otpCode: dto.otpCode }, + { ip }, ); + return toStatusDto(await this.twoFactor.withPolicy(user.email, status)); } } diff --git a/backend/src/modules/account-security/two-factor.service.spec.ts b/backend/src/modules/account-security/two-factor.service.spec.ts index a64a578..fba2d95 100644 --- a/backend/src/modules/account-security/two-factor.service.spec.ts +++ b/backend/src/modules/account-security/two-factor.service.spec.ts @@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'; import type { Repository } from 'typeorm'; import { BusinessException } from '@/common/errors/business.exception'; +import type { AuthSessionRepository } from '@/modules/auth/auth-session.repository'; import { LOGIN_ADDRESS_FAILURE_LIMIT, TWO_FACTOR_CODE_ADDRESS_LIMIT, @@ -19,6 +20,7 @@ import type { import { TwoFactorCodeGuard } from './two-factor-code-guard'; import { TwoFactorEnrollments } from './two-factor-enrollments'; import { TwoFactorLocks } from './two-factor-locks'; +import type { TwoFactorPolicyService } from './two-factor-policy.service'; import type { TwoFactorSettingEntity } from './two-factor-setting.entity'; import { TwoFactorService } from './two-factor.service'; @@ -122,9 +124,24 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( let service: TwoFactorService; let logs: string[]; let errors: string[]; + /** 2단계 인증 필수 정책 대역 (13단계) — 처음은 필수 아님 */ + let policy: { requirementFor: jest.Mock }; + /** 세션 표 대역 — 켜면 twoFactorSetup 제한을 지운다 (13단계) */ + let sessions: { clearRestriction: jest.Mock; revokeRestrictedOf: jest.Mock }; beforeEach(() => { engineEnabled = false; + policy = { + requirementFor: jest.fn().mockResolvedValue({ + isRequired: false, + startsAt: null, + isStarted: false, + }), + }; + sessions = { + clearRestriction: jest.fn().mockResolvedValue(true), + revokeRestrictedOf: jest.fn().mockResolvedValue(0), + }; provider = { isTwoFactorEnabled: jest.fn, [string]>(() => Promise.resolve(engineEnabled), @@ -162,6 +179,8 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( codes, locks, attempts, + policy as unknown as TwoFactorPolicyService, + sessions as unknown as AuthSessionRepository, ); logs = []; errors = []; @@ -777,4 +796,142 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( ).toBe('AUTH_001'); expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled(); }); + + describe('2단계 인증 필수 정책 (13단계 — 계약 6절)', () => { + it('켜면 요청 세션의 twoFactorSetup 제한만 지우고, twoFactorSetup 이 붙은 다른 세션은 끊는다 (리뷰 M1)', async () => { + sessions.revokeRestrictedOf.mockResolvedValueOnce(2); + const { enrollment, code } = await enroll(); + await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP, sessionId: 'sid-mine' }, + ); + expect(sessions.clearRestriction).toHaveBeenCalledWith( + 'sid-mine', + 'twoFactorSetup', + ); + expect(sessions.revokeRestrictedOf).toHaveBeenCalledWith( + ARA, + 'twoFactorSetup', + 'two-factor-not-verified', + expect.any(Date), + 'sid-mine', + ); + // 그 직원의 모든 세션 제한을 한꺼번에 지우지 않는다 — 비밀번호만 아는 다른 사람의 세션이 2단계 없이 열리지 않게 + expect(sessions.clearRestriction).toHaveBeenCalledTimes(1); + }); + + it('요청 세션을 모르면(sid 없는 옛 토큰) 지우지 않고, twoFactorSetup 이 붙은 세션은 모두 끊는다', async () => { + const { enrollment, code } = await enroll(); + await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP }, + ); + expect(sessions.clearRestriction).not.toHaveBeenCalled(); + expect(sessions.revokeRestrictedOf).toHaveBeenCalledWith( + ARA, + 'twoFactorSetup', + 'two-factor-not-verified', + expect.any(Date), + null, + ); + }); + + it('제한 정리가 실패해도 켜기는 성공이다 — error 로그만(갱신이 다시 본다)', async () => { + sessions.clearRestriction.mockRejectedValueOnce(new Error('db down')); + const { enrollment, code } = await enroll(); + const status = await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP, sessionId: 'sid-mine' }, + ); + expect(status.isEnabled).toBe(true); + expect(errors.join('\n')).toContain('세션의 제한을 정리하지 못했습니다'); + }); + + it('필수인 직원의 끄기는 400 VAL_001 — 비밀번호를 묻기 전에 막고 엔진에 보내지 않는다', async () => { + engineEnabled = true; + policy.requirementFor.mockResolvedValue({ + isRequired: true, + startsAt: new Date('2026-11-30T15:00:00.000Z'), + isStarted: false, + }); + expect( + ( + await failureOf( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('VAL_001'); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + expect(provider.disableTwoFactor).not.toHaveBeenCalled(); + }); + + it('응답에 정책을 더한다 — 유예 중이고 꺼져 있으면 시작 시각, 켰거나 시작했거나 필수가 아니면 null', async () => { + const startsAt = new Date('2026-11-30T15:00:00.000Z'); + policy.requirementFor.mockResolvedValueOnce({ + isRequired: true, + startsAt, + isStarted: false, + }); + await expect( + service.withPolicy(ARA, { isEnabled: false, enabledAt: null }), + ).resolves.toEqual({ + isEnabled: false, + enabledAt: null, + isRequired: true, + deadline: startsAt, + }); + + policy.requirementFor.mockResolvedValueOnce({ + isRequired: true, + startsAt, + isStarted: true, + }); + await expect( + service.withPolicy(ARA, { isEnabled: true, enabledAt: null }), + ).resolves.toEqual({ + isEnabled: true, + enabledAt: null, + isRequired: true, + deadline: null, + }); + + await expect( + service.withPolicy(ARA, { isEnabled: false, enabledAt: null }), + ).resolves.toMatchObject({ isRequired: false, deadline: null }); + + // 유예 중이어도 이미 켰으면 null — 켠 직원에게는 켤 기한이 없다 + policy.requirementFor.mockResolvedValueOnce({ + isRequired: true, + startsAt, + isStarted: false, + }); + await expect( + service.withPolicy(ARA, { isEnabled: true, enabledAt: null }), + ).resolves.toEqual({ + isEnabled: true, + enabledAt: null, + isRequired: true, + deadline: null, + }); + }); + }); }); diff --git a/backend/src/modules/account-security/two-factor.service.ts b/backend/src/modules/account-security/two-factor.service.ts index 4e970cb..5fdc3bb 100644 --- a/backend/src/modules/account-security/two-factor.service.ts +++ b/backend/src/modules/account-security/two-factor.service.ts @@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from 'crypto'; import type { Repository } from 'typeorm'; import { BusinessException } from '@/common/errors/business.exception'; +import { AuthSessionRepository } from '@/modules/auth/auth-session.repository'; import { isEmployeeAddress, normalizeEmail, @@ -32,6 +33,7 @@ import { enrollmentExpiry, } from './two-factor-enrollments'; import { TwoFactorLocks } from './two-factor-locks'; +import { TwoFactorPolicyService } from './two-factor-policy.service'; import { TwoFactorSettingEntity } from './two-factor-setting.entity'; /** GET /api/two-factor 의 모양 — 켜기 · 끄기의 응답도 같다 */ @@ -41,6 +43,14 @@ export interface TwoFactorStatus { readonly enabledAt: Date | null; } +/** 응답의 모양 — 상태 + 2단계 인증 필수 정책 (13단계 — 계약 6절 "2단계 인증 필수") */ +export interface TwoFactorStatusView extends TwoFactorStatus { + /** 이 직원에게 필수인가 — 정책이 켜져 있고 예외가 아니다(시작일 전에도). 필수인 직원은 끄지 못한다 */ + readonly isRequired: boolean; + /** 필수의 유예 중이면 시작 시각(시작일 0시 KST) — 아니면 null */ + readonly deadline: Date | null; +} + /** 등록 — 비밀 · otpauth 주소는 이 응답에만 (no-store) */ export interface TwoFactorEnrollmentView { readonly enrollmentId: string; @@ -53,6 +63,8 @@ export interface TwoFactorEnrollmentView { /** 요청한 클라이언트 — 실패 한도의 IP (모르면 빈 값 · undefined — 'unknown' 한 묶음으로 센다) */ export interface SecurityClient { readonly ip: string | undefined; + /** 요청의 세션(액세스 토큰의 sid) — 2단계를 켜면 이 세션의 제한만 지운다(13단계). 모르면 null */ + readonly sessionId?: string | null; } function errorMessage(error: unknown): string { @@ -91,8 +103,30 @@ export class TwoFactorService { private readonly codes: TwoFactorCodeGuard, private readonly locks: TwoFactorLocks, private readonly attempts: LoginAttemptService, + /** 13단계 — 2단계 인증 필수 정책(끄기 막기 · 응답의 isRequired · deadline), 켜면 세션의 twoFactorSetup 제한을 지운다 */ + private readonly policy: TwoFactorPolicyService, + private readonly sessions: AuthSessionRepository, ) {} + /** + * 상태에 정책을 더한다 — GET · 켜기 · 끄기의 응답 (계약 6절: isRequired · deadline). + * deadline 은 로그인 · me 의 twoFactorDeadline 과 같은 규칙이다 — 유예 중이고 아직 켜지 않았을 때만 시작 시각 + */ + async withPolicy( + user: string, + status: TwoFactorStatus, + ): Promise { + const requirement = await this.policy.requirementFor(normalizeEmail(user)); + return { + ...status, + isRequired: requirement.isRequired, + deadline: + requirement.isRequired && !requirement.isStarted && !status.isEnabled + ? requirement.startsAt + : null, + }; + } + /** GET /api/two-factor — 엔진에서 꺼졌으면 켠 때 기록을 지운다 */ async getStatus(user: string): Promise { const address = this.requireEmployee(user); @@ -192,6 +226,7 @@ export class TwoFactorService { } const enabledAt = await this.recordEnabledAt(address); + await this.settleSetupRestriction(address, client.sessionId ?? null); this.logger.log(`2단계 인증을 켰습니다: ${maskEmail(address)}`); return { isEnabled: true, enabledAt }; }); @@ -246,7 +281,42 @@ export class TwoFactorService { } } - /** POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과) */ + /** + * 2단계를 켰다 — **요청의 세션**(액세스 토큰의 sid)의 twoFactorSetup 제한만 지우고, 그 직원의 다른 세션 가운데 twoFactorSetup 이 붙은 것은 끊는다 + * (2단계를 거친 적이 없는 세션 — 비밀번호만 아는 다른 사람의 세션일 수 있다, 계약 6절 "제한된 세션"). + * 실패해도 켜기는 성공이다(error 로그) — 갱신이 다시 본다: 세션 표가 twoFactorSetup 인데 계정이 켜짐이면 그 세션을 끊는다(이 세션이면 다시 로그인) + */ + private async settleSetupRestriction( + address: string, + sessionId: string | null, + ): Promise { + try { + if (sessionId !== null) { + await this.sessions.clearRestriction(sessionId, 'twoFactorSetup'); + } + const ended = await this.sessions.revokeRestrictedOf( + address, + 'twoFactorSetup', + 'two-factor-not-verified', + new Date(), + sessionId, + ); + if (ended > 0) { + this.logger.warn( + `[AUTH_001]${traceTag()} 2단계 인증을 켜 2단계를 거치지 않은 다른 세션 ${ended}개를 끊었습니다: user=${maskEmail(address)}`, + ); + } + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 2단계 인증을 켰지만 세션의 제한을 정리하지 못했습니다 — 다음 갱신이 다시 봅니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + } + } + + /** + * POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과). + * 2단계 인증이 필수인 직원(정책 — 시작일 전 포함)은 400 VAL_001 — 비밀번호를 묻기 전에 막는다 (계약 6절) + */ async deactivate( user: string, input: { readonly currentPassword: string; readonly otpCode: string }, @@ -254,6 +324,14 @@ export class TwoFactorService { ): Promise { const address = this.requireEmployee(user); const ip = client.ip || 'unknown'; + if ((await this.policy.requirementFor(address)).isRequired) { + throw new BusinessException('VAL_001', { + context: { + reason: 'two-factor-required-by-policy', + email: maskEmail(address), + }, + }); + } return this.locks.run(address, async () => { if (isAppPasswordShape(input.currentPassword)) { throw this.currentPasswordRejected(address, 'app-password-shape'); diff --git a/backend/src/modules/auth/auth-session.entity.ts b/backend/src/modules/auth/auth-session.entity.ts index 99e7b4b..f23870d 100644 --- a/backend/src/modules/auth/auth-session.entity.ts +++ b/backend/src/modules/auth/auth-session.entity.ts @@ -1,5 +1,7 @@ import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; + /** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ const EMAIL_COLUMN_LENGTH = 254; /** SHA-256 16진 문자열 길이 */ @@ -10,6 +12,8 @@ const REVOKED_REASON_LENGTH = 32; const IP_COLUMN_LENGTH = 64; /** User-Agent 원문 길이 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */ const USER_AGENT_COLUMN_LENGTH = 512; +/** 제한 이름 길이 — 'passwordChange' · 'twoFactorSetup' (여유를 둔다) */ +const RESTRICTION_COLUMN_LENGTH = 32; /** 세션을 끝낸 사유 — 로그·문의 대응용 */ export type SessionRevokedReason = @@ -21,7 +25,14 @@ export type SessionRevokedReason = /** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */ | 'password-change-uncertain' /** 직원이 로그인된 브라우저 목록에서 끊었다 — 하나 · 다른 곳 모두 (계약 5절 "로그인된 브라우저") */ - | 'ended-by-user'; + | 'ended-by-user' + /** 운영 도구가 끊었다 — 비밀번호 재설정 · 잠금 (계약 6절 "운영 도구") */ + | 'ended-by-operator' + /** + * 2단계 인증을 거친 적이 없는 세션(twoFactorSetup 제한)인데 그 직원이 2단계를 켰다 — 다른 세션에서 켰을 때 · 갱신에서 계정이 이미 켜짐일 때 + * (비밀번호만 아는 다른 사람의 세션일 수 있다 — 계약 6절 "제한된 세션") + */ + | 'two-factor-not-verified'; /** * 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다. @@ -30,6 +41,7 @@ export type SessionRevokedReason = * - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증). 12단계부터 액세스 토큰에 세션 id(sid)를 싣고, * 끊은 세션 id 는 서버 메모리가 1시간 기억해 가드가 막는다 (revoked-sessions.ts — 기동 때 이 표의 revoked_at 으로 다시 채운다) * - IP · User-Agent 는 로그인 · 갱신 때 둔다 — 로그인된 브라우저 목록 (계약 5절). 12단계 전에 연 세션은 둘 다 null + * - 제한(restriction — 13단계)은 로그인 · 갱신 때 정하고 액세스 토큰에도 싣는다. 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 지운다 (계약 6절 "제한된 세션") * - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts) */ @Entity({ name: 'auth_sessions' }) @@ -97,4 +109,15 @@ export class AuthSessionEntity { nullable: true, }) userAgent!: string | null; + + /** + * 제한된 세션 (13단계 — 계약 6절) — passwordChange · twoFactorSetup · null(제한 없음). + * 로그인 때 엔진의 답(비밀번호 만료 · 2단계 켜짐)과 정책으로 정하고, 갱신 때 다시 정한다. 푸는 요청이 성공하면 지운다 + */ + @Column({ + type: 'varchar', + length: RESTRICTION_COLUMN_LENGTH, + nullable: true, + }) + restriction!: SessionRestriction | null; } diff --git a/backend/src/modules/auth/auth-session.repository.ts b/backend/src/modules/auth/auth-session.repository.ts index 3ea26d0..615ae96 100644 --- a/backend/src/modules/auth/auth-session.repository.ts +++ b/backend/src/modules/auth/auth-session.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, MoreThan, type Repository } from 'typeorm'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; import { AuthSessionEntity, type SessionRevokedReason, @@ -18,6 +19,8 @@ export interface RotateSessionCommand { /** 이 갱신의 클라이언트 IP · User-Agent 원문 — 로그인된 브라우저 목록 (계약 5절). 모르면 null */ readonly ip: string | null; readonly userAgent: string | null; + /** 이 갱신에서 다시 정한 제한 (13단계 — 계약 6절 "제한된 세션") — 없으면 null */ + readonly restriction: SessionRestriction | null; } /** 조건부 UPDATE … RETURNING id 의 결과 행 */ @@ -81,11 +84,75 @@ export class AuthSessionRepository { expiresAt: command.nextExpiresAt, ip: command.ip, userAgent: command.userAgent, + restriction: command.restriction, }, ); return result.affected === 1; } + /** + * 세션 하나의 제한을 바꾼다 — 지금 `from` 일 때만(조건부 UPDATE 한 번) (13단계 — 푸는 요청이 성공했다: 다음 제한으로 · 없으면 null). + * 그사이 다른 제한으로 바뀌었으면 건드리지 않는다. 바꿨으면 true. 끝난 세션도 상관없다(쓰이지 않는다) + */ + async replaceRestriction( + id: string, + from: SessionRestriction, + to: SessionRestriction | null, + ): Promise { + const result = await this.sessions.update( + { id, restriction: from }, + { restriction: to }, + ); + return (result.affected ?? 0) > 0; + } + + /** 세션 하나의 제한을 지운다 — 지금 그 제한일 때만 → 지웠으면 true */ + clearRestriction( + id: string, + restriction: SessionRestriction, + ): Promise { + return this.replaceRestriction(id, restriction, null); + } + + /** + * 그 직원의 살아 있는 세션 가운데 그 제한이 붙은 것을 끝낸다 — `keepId` 세션만 남긴다 → 끝낸 수 (13단계 — 2단계를 켰다: + * twoFactorSetup 이 붙은 다른 세션은 2단계를 거친 적이 없다). 끊은 세션 기억 · 15초 동기화를 탄다(revokeWhere) + */ + async revokeRestrictedOf( + userEmail: string, + restriction: SessionRestriction, + reason: SessionRevokedReason, + now: Date, + keepId: string | null, + ): Promise { + const ids = await this.revokeWhere( + `user_email = :userEmail AND restriction = :restriction AND revoked_at IS NULL AND expires_at > :now AND absolute_expires_at > :now${keepId !== null ? ' AND id <> :keepId' : ''}`, + { userEmail, restriction, now, ...(keepId !== null ? { keepId } : {}) }, + reason, + now, + ); + return ids.length; + } + + /** + * 세션 하나를 끝낸다 — 지금 그 제한이 붙어 있고 끝나지 않았을 때만(조건부 UPDATE 한 번) → 끝냈으면 true (13단계 — 갱신이 2단계를 거치지 않은 세션을 끊는다. + * 그사이 2단계 켜기가 이 세션의 제한을 지웠으면 끊지 않는다) + */ + async revokeIfRestricted( + id: string, + restriction: SessionRestriction, + reason: SessionRevokedReason, + now: Date, + ): Promise { + const ids = await this.revokeWhere( + 'id = :id AND restriction = :restriction AND revoked_at IS NULL', + { id, restriction }, + reason, + now, + ); + return ids.length > 0; + } + /** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다. 끝냈으면 true */ async revoke( id: string, diff --git a/backend/src/modules/auth/auth-sessions.controller.ts b/backend/src/modules/auth/auth-sessions.controller.ts index 548e7da..a07970d 100644 --- a/backend/src/modules/auth/auth-sessions.controller.ts +++ b/backend/src/modules/auth/auth-sessions.controller.ts @@ -17,6 +17,7 @@ import { } from '@nestjs/swagger'; import type { Request } from 'express'; +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -34,9 +35,11 @@ import { AuthSessionPageDto, EndedSessionsDto } from './dto/auth-session.dto'; // 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다. // 판단은 AuthSessionsService 가 한다. 끊은 세션은 바로 막힌다(액세스 토큰의 sid + 서버가 기억한 끊은 세션) +// 13단계 — /api/auth/* 라 제한된 세션도 부른다(계약 6절 "제한된 세션") @ApiTags('Auth') @ApiCookieAuth() @UseGuards(JwtAuthGuard) +@AllowRestrictedSession() @ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') @Controller('auth/sessions') export class AuthSessionsController { diff --git a/backend/src/modules/auth/auth-sessions.service.spec.ts b/backend/src/modules/auth/auth-sessions.service.spec.ts index aa302c8..06d6f6b 100644 --- a/backend/src/modules/auth/auth-sessions.service.spec.ts +++ b/backend/src/modules/auth/auth-sessions.service.spec.ts @@ -31,6 +31,7 @@ const session = ( revokedReason: null, ip: '211.186.95.252', userAgent: CHROME, + restriction: null, ...extra, }); diff --git a/backend/src/modules/auth/auth-token.types.ts b/backend/src/modules/auth/auth-token.types.ts index a0afadd..128657d 100644 --- a/backend/src/modules/auth/auth-token.types.ts +++ b/backend/src/modules/auth/auth-token.types.ts @@ -1,3 +1,5 @@ +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; + /** * JWT 페이로드. * `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다. @@ -16,6 +18,13 @@ export interface AuthTokenPayload { * 12단계 전에 발급한 액세스 토큰에는 없다 — 만료까지 지금처럼 쓰인다 */ readonly sid?: string; + /** + * 제한된 세션의 제한 (13단계 — 계약 6절 "제한된 세션") — 액세스 토큰에만, 제한이 있을 때만 싣는다. + * 가드가 허용한 핸들러 밖이면 403 AUTH_010. 세션 표의 restriction 과 같은 값이다(로그인 · 갱신 때 정한다) + */ + readonly rst?: SessionRestriction; + /** 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 액세스 토큰에만, 유예 중일 때만 */ + readonly tfd?: string; /** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */ readonly jti?: string; readonly iat?: number; diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 77fc1b4..b5fc3e6 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -1,6 +1,7 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'; import type { Request, Response } from 'express'; +import { ALLOW_RESTRICTED_SESSION } from '@/common/decorators/allow-restricted-session.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; import { AuthController } from './auth.controller'; @@ -22,6 +23,14 @@ const session = { refreshToken: 'new.refresh', refreshTtlSeconds: 1209600, }, + state: { restriction: null, twoFactorDeadline: null }, +}; + +/** 응답 본문 — 직원 + 제한 · 유예 (13단계) */ +const sessionBody = { + ...session.user, + restriction: null, + twoFactorDeadline: null, }; function fakeResponse() { @@ -60,6 +69,7 @@ describe('AuthController', () => { refresh: jest.Mock; logout: jest.Mock; changePassword: jest.Mock; + sessionStateOf: jest.Mock; }; let controller: AuthController; @@ -69,6 +79,9 @@ describe('AuthController', () => { refresh: jest.fn(), logout: jest.fn().mockResolvedValue(undefined), changePassword: jest.fn().mockResolvedValue(undefined), + sessionStateOf: jest + .fn() + .mockResolvedValue({ restriction: null, twoFactorDeadline: null }), }; controller = new AuthController(auth as unknown as AuthService, settings); }); @@ -118,7 +131,7 @@ describe('AuthController', () => { '203.0.113.10', { otpCode: '123456', userAgent: CHROME }, ); - expect(result).toEqual({ status: 'signedIn', ...session.user }); + expect(result).toEqual({ status: 'signedIn', ...sessionBody }); expect(res.cookie).toHaveBeenCalledTimes(2); }); @@ -147,6 +160,8 @@ describe('AuthController', () => { status: 'twoFactorRequired', email: null, name: null, + restriction: null, + twoFactorDeadline: null, }); expect(res.cookie).not.toHaveBeenCalled(); }); @@ -166,7 +181,7 @@ describe('AuthController', () => { ip: '203.0.113.10', userAgent: CHROME, }); - expect(user).toEqual(session.user); + expect(user).toEqual(sessionBody); expect(res.cookie).toHaveBeenCalledWith( 'access_token', 'new.access', @@ -180,7 +195,11 @@ describe('AuthController', () => { }); it('동시 갱신 유예(tokens: null)면 쿠키를 굽지 않는다 — 먼저 끝난 요청의 새 쿠키를 덮어쓰지 않게', async () => { - auth.refresh.mockResolvedValue({ user: session.user, tokens: null }); + auth.refresh.mockResolvedValue({ + user: session.user, + tokens: null, + state: { restriction: 'twoFactorSetup', twoFactorDeadline: null }, + }); const res = fakeResponse(); const user = await controller.refresh( @@ -189,7 +208,7 @@ describe('AuthController', () => { res as unknown as Response, ); - expect(user).toEqual(session.user); + expect(user).toEqual({ ...sessionBody, restriction: 'twoFactorSetup' }); expect(res.cookie).not.toHaveBeenCalled(); expect(res.clearCookie).not.toHaveBeenCalled(); }); @@ -278,4 +297,58 @@ describe('AuthController', () => { expect(auth.logout).toHaveBeenCalledWith('old.refresh'); expect(res.clearCookie).toHaveBeenCalledTimes(2); }); + + it('제한된 세션도 /api/auth/* 를 부른다 — 컨트롤러에 @AllowRestrictedSession (13단계)', () => { + expect(Reflect.getMetadata(ALLOW_RESTRICTED_SESSION, AuthController)).toBe( + true, + ); + }); + + it('me 는 서비스가 정한 세션 상태(세션 표의 지금 제한 · 정책으로 다시 본 유예)를 싣는다 (13단계)', async () => { + auth.sessionStateOf.mockResolvedValueOnce({ + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + const user = { + email: 'ara.kim@arasystem.test', + name: '김아라', + sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + }; + await expect(controller.me(user)).resolves.toEqual({ + email: 'ara.kim@arasystem.test', + name: '김아라', + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + expect(auth.sessionStateOf).toHaveBeenCalledWith(user); + await expect( + controller.me({ email: 'ara.kim@arasystem.test', name: '김아라' }), + ).resolves.toEqual(sessionBody); + }); + + it('로그인 응답에 세션의 제한 · 유예를 싣는다 — 제한된 세션도 쿠키를 받는다 (13단계)', async () => { + auth.login.mockResolvedValue({ + ...session, + state: { + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }, + }); + const res = fakeResponse(); + + 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(result).toEqual({ + status: 'signedIn', + ...session.user, + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + expect(res.cookie).toHaveBeenCalledTimes(2); + }); }); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 70fd982..94bb5c2 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -16,6 +16,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -43,8 +44,10 @@ import { LoginDto } from './dto/login.dto'; /** * 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다. * 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다. + * 13단계: `/api/auth/*` 는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다 — 로그인 · me · 갱신의 응답에 restriction · twoFactorDeadline 이 있다 */ @ApiTags('Auth') +@AllowRestrictedSession() @Controller('auth') export class AuthController { constructor( @@ -95,23 +98,46 @@ export class AuthController { userAgent: req.get('user-agent') ?? null, }); if (result.status === 'twoFactorRequired') { - return { status: result.status, email: null, name: null }; + return { + status: result.status, + email: null, + name: null, + restriction: null, + twoFactorDeadline: null, + }; } setSessionCookies(res, result.tokens, this.settings); - return { status: result.status, ...result.user }; + return { + status: result.status, + email: result.user.email, + name: result.user.name, + restriction: result.state.restriction, + twoFactorDeadline: result.state.twoFactorDeadline, + }; } @Get('me') @UseGuards(JwtAuthGuard) @ApiCookieAuth() - @ApiOperation({ summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)' }) + @ApiOperation({ + summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)', + description: + '13단계: restriction 은 세션 표의 지금 값(제한을 푼 뒤의 옛 토큰으로도 마친 단계가 다시 보이지 않는다), twoFactorDeadline 은 정책과 켜짐으로 다시 본 값이다.', + }) @ApiStandardResponse(AuthUserDto, { description: '로그인 상태' }) @ApiErrorResponse( 'AUTH_001', '로그인 안 됨 — 앱 시작 시에는 정상 상태이므로 알림을 띄우지 않는다', ) - me(@CurrentUser() user: AuthenticatedUser): AuthUserDto { - return { email: user.email, name: user.name }; + async me(@CurrentUser() user: AuthenticatedUser): Promise { + // 제한은 세션 표의 지금 값, 유예는 정책으로 다시 본 값 (13단계 — 판단은 AuthService) + const state = await this.auth.sessionStateOf(user); + return { + email: user.email, + name: user.name, + restriction: state.restriction, + twoFactorDeadline: state.twoFactorDeadline, + }; } /** @@ -125,7 +151,8 @@ export class AuthController { description: '액세스 쿠키가 없거나 만료돼도 동작한다. 실패(AUTH_001)하면 두 쿠키를 지운다. ' + '이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. 다른 탭이 방금 회전한 직후의 이전 토큰은 30초 동안 ' + - '새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다).', + '새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다). ' + + '13단계: 제한(restriction)은 갱신 때 다시 정한다 — 비밀번호 만료는 엔진에서 다시 알아채고, 2단계 인증 필수는 정책과 켜짐(우리 기록 → 엔진)으로 본다.', }) @ApiStandardResponse(AuthUserDto, { description: '회전 성공' }) @ApiErrorResponse( @@ -145,7 +172,12 @@ export class AuthController { ); // 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게 if (result.tokens) setSessionCookies(res, result.tokens, this.settings); - return result.user; + return { + email: result.user.email, + name: result.user.name, + restriction: result.state.restriction, + twoFactorDeadline: result.state.twoFactorDeadline, + }; } catch (error) { // 더는 쓸 수 없는 세션이면 쿠키를 지운다. 메일 엔진 장애처럼 일시적인 실패는 세션을 남긴다 if (error instanceof BusinessException && error.code === 'AUTH_001') { @@ -172,7 +204,8 @@ export class AuthController { '지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' + '지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다. ' + '12단계: 2단계 인증을 켠 직원은 otpCode 가 필수다(엔진이 본인 자격을 받지 않아 대리 접근으로 코드와 함께 보낸다 — 2단계 인증은 그대로 남는다). ' + - '바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다.', + '바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다. ' + + '13단계: 비밀번호가 만료된 세션(restriction passwordChange)의 푸는 길이다 — 지금 비밀번호는 임시 비밀번호. 바뀌면 이 세션의 제한을 지운다(클라이언트는 곧바로 /auth/refresh).', }) @ApiStandardResponse(undefined, { description: '바꿨다 (data: null)', diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index d681906..5e40a58 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -6,7 +6,12 @@ import type { AppPasswordsService } from '@/modules/account-security/app-passwor 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 { TwoFactorPolicyService } from '@/modules/account-security/two-factor-policy.service'; +import { + AccountLoginDisabledException, + PasswordExpiredException, + 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'; @@ -100,6 +105,7 @@ class FakeSessions { expiresAt: command.nextExpiresAt, ip: command.ip, userAgent: command.userAgent, + restriction: command.restriction, }); } return Promise.resolve(alive); @@ -115,6 +121,39 @@ class FakeSessions { }, ); + /** 실제 저장소와 같은 조건 — 지금 그 제한이고 끝나지 않았을 때만 끝낸다 (13단계 — 재리뷰 N2) */ + readonly revokeIfRestricted = jest.fn( + ( + id: string, + restriction: AuthSessionEntity['restriction'], + reason: AuthSessionEntity['revokedReason'], + now: Date, + ) => { + const row = this.rows.get(id); + if (row && row.restriction === restriction && row.revokedAt === null) { + Object.assign(row, { revokedAt: now, revokedReason: reason }); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + ); + + /** 실제 저장소와 같은 조건 — 지금 `from` 일 때만 바꾼다 (13단계) */ + readonly replaceRestriction = jest.fn( + ( + id: string, + from: AuthSessionEntity['restriction'], + to: AuthSessionEntity['restriction'], + ) => { + const row = this.rows.get(id); + if (row && row.restriction === from) { + row.restriction = to; + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + ); + /** 실제 저장소와 같은 조건 — 그 직원의 살아 있는 세션 가운데 keepId 를 뺀 것만 끝낸다 */ readonly revokeAllOf = jest.fn( ( @@ -215,6 +254,11 @@ describe('AuthService', () => { let twoFactorLocks: TwoFactorLocks; let loginEvents: { recordWebmail: jest.Mock }; let appPasswords: { removeAllAfterPasswordChange: jest.Mock }; + /** 2단계 인증 필수 정책 대역 (13단계) — 처음은 제한 · 유예 없음 */ + let twoFactorPolicy: { + sessionState: jest.Mock; + isEnabledInEngine: jest.Mock; + }; let jwt: JwtService; let service: AuthService; let logs: string[]; @@ -232,6 +276,7 @@ describe('AuthService', () => { twoFactorLocks, loginEvents as unknown as LoginEventsService, appPasswords as unknown as AppPasswordsService, + twoFactorPolicy as unknown as TwoFactorPolicyService, ); const decode = (token: string) => @@ -278,6 +323,12 @@ describe('AuthService', () => { appPasswords = { removeAllAfterPasswordChange: jest.fn().mockResolvedValue(0), }; + twoFactorPolicy = { + sessionState: jest + .fn() + .mockResolvedValue({ restriction: null, deadline: null }), + isEnabledInEngine: jest.fn().mockResolvedValue(false), + }; jwt = new JwtService({}); service = build(); @@ -1733,4 +1784,380 @@ describe('AuthService', () => { expect(logs.join('\n')).toMatch(/\[SYS_001\].*로그아웃 세션 폐기/); }); }); + + describe('13단계 — 제한된 세션 (계약 6절)', () => { + const expired = () => new PasswordExpiredException(ARA, 'ara.kim'); + const DEADLINE = new Date('2026-11-30T15:00:00.000Z'); + + it('비밀번호가 만료된 계정은 로그인하되 passwordChange 제한 — 쿠키를 받고 세션 · 액세스 토큰에 제한이 실린다', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(result.state).toEqual({ + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + expect(result.user).toEqual({ email: ARA, name: 'ara.kim' }); + expect(sessions.only().restriction).toBe('passwordChange'); + expect(decode(result.tokens.accessToken).rst).toBe('passwordChange'); + // 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다 + expect(directory.recordLogin).not.toHaveBeenCalled(); + // 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다 + expect(loginEvents.recordWebmail).toHaveBeenCalledWith( + expect.objectContaining({ email: ARA, isSuccess: true }), + ); + }); + + it('2단계 인증을 켠 직원도 코드 뒤에 만료를 알아챈다 — 402 의 정식 주소를 읽을 때 만료 예외로 정식 주소를 얻는다', async () => { + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + mail.getProfile.mockRejectedValue(expired()); + + const first = await service.login(ARA, PASSWORD, CLIENT_IP); + expect(first.status).toBe('twoFactorRequired'); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP, { + otpCode: '123456', + }); + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(result.state.restriction).toBe('passwordChange'); + // 켜짐은 엔진의 답(402)으로 안다 + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, true); + }); + + it('2단계 인증 필수가 시작됐고 꺼져 있으면 twoFactorSetup — 켜짐은 엔진의 답(200 = 꺼짐)으로 넘긴다', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(twoFactorPolicy.sessionState).toHaveBeenCalledWith(ARA, false); + expect(result.state.restriction).toBe('twoFactorSetup'); + expect(sessions.only().restriction).toBe('twoFactorSetup'); + expect(directory.recordLogin).toHaveBeenCalled(); + }); + + it('비밀번호 만료가 2단계 필수보다 먼저다 — 유예의 시작 시각은 함께 싣는다', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: null, + deadline: DEADLINE, + }); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(result.state).toEqual({ + restriction: 'passwordChange', + twoFactorDeadline: DEADLINE.toISOString(), + }); + const access = decode(result.tokens.accessToken); + expect(access.rst).toBe('passwordChange'); + expect(access.tfd).toBe(DEADLINE.toISOString()); + }); + + it('제한이 없으면 액세스 토큰에 rst · tfd 가 없다', async () => { + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + const access = decode(result.tokens.accessToken); + expect(access).not.toHaveProperty('rst'); + expect(access).not.toHaveProperty('tfd'); + expect(sessions.only().restriction).toBeNull(); + }); + + it('갱신 때 제한을 다시 정한다 — 엔진에서 만료를 다시 알아채고, 풀렸으면 정책(켜짐은 우리 기록 → 엔진)으로', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + + // 아직 바꾸지 않았다 — 갱신해도 passwordChange (세션은 끊지 않는다) + mail.getProfile.mockRejectedValueOnce(expired()); + const stillExpired = await service.refresh(login.tokens.refreshToken); + expect(stillExpired.state.restriction).toBe('passwordChange'); + expect(sessions.only().revokedAt).toBeNull(); + expect(sessions.only().restriction).toBe('passwordChange'); + + // 바꿨다 — 엔진의 만료가 풀렸고 필수 정책이 남았다 + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const next = await service.refresh(stillExpired.tokens!.refreshToken); + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith( + ARA, + undefined, + expect.any(Date), + ); + expect(next.state.restriction).toBe('twoFactorSetup'); + expect(sessions.only().restriction).toBe('twoFactorSetup'); + expect(decode(next.tokens!.accessToken).rst).toBe('twoFactorSetup'); + expect(next.user.name).toBe('김아라'); + }); + + it('동시 갱신 유예의 응답은 먼저 회전한 요청이 세션 표에 적은 제한을 준다', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const token = login.tokens.refreshToken; + await service.refresh(token); + + const grace = await service.refresh(token); + expect(grace.tokens).toBeNull(); + expect(grace.state.restriction).toBe('twoFactorSetup'); + }); + + it('비밀번호를 바꾸면 이 요청 세션(액세스 토큰의 sid)의 passwordChange 제한을 지운다 — 다음 제한이 없으면 null', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + expect(sessions.only().restriction).toBe('passwordChange'); + + await service.changePassword( + { email: ARA, name: 'ara.kim', sessionId }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ); + + expect(sessions.replaceRestriction).toHaveBeenCalledWith( + sessionId, + 'passwordChange', + null, + ); + // 2단계는 꺼져 있다(엔진의 답 200) — 다음 제한은 정책과 그 값으로 + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, false); + expect(sessions.only().restriction).toBeNull(); + expect(sessions.only().revokedAt).toBeNull(); + }); + + it('다음 제한이 남았으면 지우지 않고 그 제한으로 바꾼다 — 2단계 필수가 시작됐고 꺼져 있으면 twoFactorSetup (리뷰 M2)', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + // 비밀번호 만료가 먼저다 + expect(sessions.only().restriction).toBe('passwordChange'); + + await service.changePassword( + { email: ARA, name: 'ara.kim', sessionId }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ); + + expect(sessions.only().restriction).toBe('twoFactorSetup'); + }); + + it('2단계를 켠 직원(402 길)의 비밀번호 바꾸기는 켜짐으로 다음 제한을 정한다', async () => { + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + sessions.rows.get(sessionId)!.restriction = 'passwordChange'; + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + twoFactorPolicy.sessionState.mockClear(); + + await service.changePassword( + { email: ARA, name: 'ara.kim', sessionId }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + '123456', + ); + + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, true); + expect(sessions.only().restriction).toBeNull(); + }); + + it('제한 없는 세션의 보통 비밀번호 바꾸기는 제한을 붙이지 않는다 — 지금 passwordChange 일 때만 바꾼다', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: null, + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + + await service.changePassword( + { email: ARA, name: '김아라', sessionId: sessions.only().id }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ); + expect(sessions.only().restriction).toBeNull(); + }); + + it('갱신 — 세션 표가 twoFactorSetup 인데 계정이 이미 켜짐이면 제한을 지우지 않고 세션을 끊는다(AUTH_001) (리뷰 M1)', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(sessions.only().restriction).toBe('twoFactorSetup'); + + // 다른 세션에서 2단계를 켰다 — 엔진에 묻는다(우리 기록이 아니라) + twoFactorPolicy.isEnabledInEngine.mockResolvedValueOnce(true); + await expect( + service.refresh(login.tokens.refreshToken), + ).rejects.toMatchObject({ code: 'AUTH_001' }); + expect(sessions.only()).toMatchObject({ + restriction: 'twoFactorSetup', + revokedReason: 'two-factor-not-verified', + }); + expect(sessions.only().revokedAt).not.toBeNull(); + expect(logs.join('\n')).toContain( + '2단계 인증을 거치지 않은 세션을 끊었습니다', + ); + }); + + it('갱신 — 그사이 2단계 켜기가 이 세션의 제한을 지웠으면 끊지 않고 보통 회전으로 이어 간다 (재리뷰 N2)', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + + // 갱신이 엔진에 묻는 사이 이 세션이 켜기를 마쳐 제한을 지웠다 + twoFactorPolicy.isEnabledInEngine.mockImplementationOnce(() => { + sessions.rows.get(sessionId)!.restriction = null; + return Promise.resolve(true); + }); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: null, + deadline: null, + }); + const next = await service.refresh(login.tokens.refreshToken); + expect(next.tokens).not.toBeNull(); + expect(next.state.restriction).toBeNull(); + expect(sessions.only().revokedAt).toBeNull(); + // 켜짐은 이미 알았다 — 정책에 그 값을 넘긴다 + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith( + ARA, + true, + expect.any(Date), + ); + }); + + it('로그인 — 잠긴 계정(2단계를 켜 엔진이 402)의 맞는 비밀번호도 틀린 비밀번호와 같은 401 AUTH_004 · 실패로 센다 (실사용 시험)', async () => { + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + mail.getProfile.mockRejectedValue(new AccountLoginDisabledException(ARA)); + + await expect( + service.login(ARA, PASSWORD, CLIENT_IP), + ).rejects.toMatchObject({ code: 'AUTH_004' }); + await expect( + service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: '123456' }), + ).rejects.toMatchObject({ code: 'AUTH_004' }); + // 틀린 비밀번호와 같은 기록 + expect(loginEvents.recordWebmail).toHaveBeenCalledWith( + expect.objectContaining({ + email: ARA, + isSuccess: false, + failureReason: 'wrongPassword', + }), + ); + expect(sessions.rows.size).toBe(0); + }); + + it('갱신 — 세션 표가 twoFactorSetup 이고 아직 꺼져 있으면 그대로 정책으로(켜짐을 한 번만 묻는다)', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + + const next = await service.refresh(login.tokens.refreshToken); + expect(next.state.restriction).toBe('twoFactorSetup'); + expect(twoFactorPolicy.isEnabledInEngine).toHaveBeenCalledTimes(1); + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith( + ARA, + false, + expect.any(Date), + ); + }); + + it('me 의 상태 — 제한은 세션 표의 지금 값(푼 뒤 옛 토큰이어도 null), 유예는 정책으로 다시 본다', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + const tokenUser = { + email: ARA, + name: 'ara.kim', + sessionId, + restriction: 'passwordChange' as const, + twoFactorDeadline: null, + }; + + await expect(service.sessionStateOf(tokenUser)).resolves.toEqual({ + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + + // 비밀번호를 바꿔 세션의 제한이 지워졌다 — 옛 토큰(rst 그대로)으로 물어도 제한 없음 + sessions.rows.get(sessionId)!.restriction = null; + twoFactorPolicy.sessionState.mockResolvedValueOnce({ + restriction: null, + deadline: DEADLINE, + }); + await expect(service.sessionStateOf(tokenUser)).resolves.toEqual({ + restriction: null, + twoFactorDeadline: DEADLINE.toISOString(), + }); + }); + + it('me 의 상태 — 정책을 읽지 못하면 토큰에 실린 유예를 그대로, 세션 id 가 없는 옛 토큰은 토큰의 제한', async () => { + twoFactorPolicy.sessionState.mockRejectedValueOnce( + new BusinessException('MAIL_001'), + ); + await expect( + service.sessionStateOf({ + email: ARA, + name: '김아라', + twoFactorDeadline: DEADLINE.toISOString(), + }), + ).resolves.toEqual({ + restriction: null, + twoFactorDeadline: DEADLINE.toISOString(), + }); + }); + + it('제한 바꾸기가 실패해도 비밀번호 바꾸기는 성공이다 — error 로그만', async () => { + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + sessions.replaceRestriction.mockRejectedValueOnce(new Error('db down')); + + await expect( + service.changePassword( + { email: ARA, name: '김아라' }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ), + ).resolves.toBeUndefined(); + expect(logs.join('\n')).toContain('세션의 제한을 바꾸지 못했습니다'); + }); + }); }); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 3d17c4b..914717a 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { randomUUID } from 'crypto'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { isAppPasswordShape } from '@/modules/account-security/app-password'; @@ -15,7 +16,13 @@ import { 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 { TwoFactorPolicyService } from '@/modules/account-security/two-factor-policy.service'; +import { + AccountLoginDisabledException, + MAIL_PROVIDER, + PasswordExpiredException, + type MailProvider, +} from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { MailAccountProfile, @@ -50,6 +57,15 @@ import { type LoginAttemptTicket, } from './login-attempt.service'; +/** + * 세션의 제한 · 2단계 인증 필수의 유예 (13단계 — 계약 6절 "제한된 세션") — 로그인 · 갱신 · me 의 응답과 액세스 토큰에 싣는다 + */ +export interface SessionState { + readonly restriction: SessionRestriction | null; + /** 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 아니면 null */ + readonly twoFactorDeadline: string | null; +} + export interface AuthResult { readonly user: AuthenticatedUser; /** @@ -57,6 +73,8 @@ export interface AuthResult { * 이긴 요청의 응답이 이미 새 쿠키를 구웠으므로 덮어쓰지 않는다. */ readonly tokens: SessionTokens | null; + /** 세션의 제한 · 유예 (13단계) */ + readonly state: SessionState; } /** @@ -67,6 +85,8 @@ export type LoginResult = readonly status: 'signedIn'; readonly user: AuthenticatedUser; readonly tokens: SessionTokens; + /** 세션의 제한 · 유예 (13단계 — 제한된 세션도 쿠키를 받는다) */ + readonly state: SessionState; } | { readonly status: 'twoFactorRequired'; @@ -89,6 +109,13 @@ export interface LoginOptions { readonly userAgent?: string | null; } +/** 로그인 · 갱신에서 읽은 프로필 — 비밀번호가 만료된 계정은 메일 기능 없이 정식 주소만 안다 (13단계) */ +interface LoadedProfile { + readonly profile: MailAccountProfile; + /** 엔진의 비밀번호가 만료됐다 — 세션에 passwordChange 제한을 붙인다 */ + readonly passwordExpired: boolean; +} + /** 리프레시 토큰에서 꺼낸 값 */ interface RefreshClaims { readonly sub: string; @@ -103,6 +130,14 @@ function toUser(profile: MailAccountProfile): AuthenticatedUser { return { email: profile.email, name: profile.name }; } +/** 비밀번호가 만료된 계정의 프로필 — 엔진이 알려 준 정식 주소와 메일 기능 없이 쓸 이름 (13단계) */ +function expiredProfile(error: PasswordExpiredException): LoadedProfile { + return { + profile: { email: error.account, name: error.displayName }, + passwordExpired: true, + }; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -146,6 +181,8 @@ export class AuthService { private readonly twoFactorLocks: TwoFactorLocks, private readonly loginEvents: LoginEventsService, private readonly appPasswords: AppPasswordsService, + /** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */ + private readonly twoFactorPolicy: TwoFactorPolicyService, ) {} /** @@ -193,6 +230,7 @@ export class AuthService { }); let profile: MailAccountProfile; + let passwordExpired = false; let isTwoFactor = false; try { if (!loginAddress) { @@ -211,10 +249,18 @@ export class AuthService { throw this.loginFailed(address, 'rejected'); } if (check.kind === 'valid') { - profile = await this.profileOf(check.account, address); + ({ profile, passwordExpired } = await this.profileOf( + check.account, + address, + client, + )); } else { - // 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다) - profile = await this.profileOf(address, address); + // 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정 · 잠긴 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다) + ({ profile, passwordExpired } = await this.profileOf( + address, + address, + client, + )); // 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다 ticket.verified(); if (options.otpCode == null) { @@ -242,8 +288,18 @@ export class AuthService { throw error; } - const tokens = await this.startSession(profile, client); - await this.recordInDirectory(profile); + // 제한(13단계): 비밀번호 만료가 먼저, 그다음 2단계 인증 필수 — 켜짐은 엔진의 답(402)으로 안다 + const twoFactor = await this.twoFactorPolicy.sessionState( + normalizeEmail(profile.email), + isTwoFactor, + ); + const state: SessionState = { + restriction: passwordExpired ? 'passwordChange' : twoFactor.restriction, + twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null, + }; + const tokens = await this.startSession(profile, client, state); + // 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다) + if (!passwordExpired) await this.recordInDirectory(profile); await this.loginEvents.recordWebmail({ email: profile.email, isSuccess: true, @@ -253,9 +309,9 @@ export class AuthService { userAgent: client.userAgent ?? null, }); this.logger.log( - `로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}`, + `로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}${state.restriction !== null ? ` (제한 ${state.restriction})` : ''}`, ); - return { status: 'signedIn', user: toUser(profile), tokens }; + return { status: 'signedIn', user: toUser(profile), tokens, state }; } /** @@ -295,15 +351,16 @@ export class AuthService { private async profileOf( account: string, address: string, - ): Promise { + client: ClientInfo, + ): Promise { if (!this.isLoginAddress(account)) { throw this.loginFailed(address, 'rejected'); } - const profile = await this.loadProfileForLogin(account, address); - if (!this.isLoginAddress(profile.email)) { + const loaded = await this.loadProfileForLogin(account, address, client); + if (!this.isLoginAddress(loaded.profile.email)) { throw this.loginFailed(address, 'rejected'); } - return profile; + return loaded; } /** 웹메일 로그인 실패를 기록에 남긴다 (던지지 않는다) */ @@ -435,6 +492,8 @@ export class AuthService { let releaseLock: (() => void) | null = null; try { let outcome: PasswordChangeOutcome; + // 2단계 인증이 켜졌는가 — 402 길이면 켜짐 (바꾼 뒤 다음 제한 twoFactorSetup 을 정할 때 쓴다) + let twoFactorEnabled = false; try { const check = await this.mail.verifyCredentials( address, @@ -450,6 +509,7 @@ export class AuthService { ); } else { // 402 — 비밀번호는 맞았다 (코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다) + twoFactorEnabled = true; ticket.verified(); if (otpCode == null) { throw this.otpCodeRequired(address); @@ -477,6 +537,12 @@ export class AuthService { } await this.settlePasswordChange(outcome, ticket, address, refreshToken); + await this.advancePasswordChangeRestriction( + user, + address, + refreshToken, + twoFactorEnabled, + ); } finally { releaseLock?.(); } @@ -585,6 +651,38 @@ export class AuthService { } } + /** + * 비밀번호를 바꿨다 — **이 요청의 세션**(액세스 토큰의 sid, 없으면 리프레시 쿠키)의 passwordChange 제한을 다음 제한으로 바꾼다 + * (13단계 — 계약 6절 "제한된 세션": 2단계 인증 필수가 시작됐고 아직 켜지 않았으면 twoFactorSetup, 아니면 null — 옛 토큰으로도 다음 단계 밖은 막힌다). + * 엔진은 바꾸는 순간 만료를 스스로 푼다. 지금 passwordChange 일 때만 바꾼다(조건부 UPDATE — 제한 없는 세션의 보통 바꾸기는 건드리지 않는다). + * 실패해도 던지지 않는다 — 비밀번호는 바뀌었고, 다음 갱신이 엔진 · 정책으로 다시 정한다 + */ + private async advancePasswordChangeRestriction( + user: AuthenticatedUser, + address: string, + refreshToken: string | undefined, + twoFactorEnabled: boolean, + ): Promise { + const sessionId = + user.sessionId ?? (await this.currentSessionId(refreshToken, address)); + if (sessionId === null) return; + try { + const next = await this.twoFactorPolicy.sessionState( + address, + twoFactorEnabled, + ); + await this.sessions.replaceRestriction( + sessionId, + 'passwordChange', + next.restriction, + ); + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 비밀번호를 바꿨지만 세션의 제한을 바꾸지 못했습니다 — 다음 갱신이 다시 정합니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + } + } + private currentPasswordRejected( address: string, reason: string, @@ -647,6 +745,35 @@ export class AuthService { } } + /** + * me 의 세션 상태 (13단계 — 계약 6절) — 제한은 **세션 표의 지금 값**(푼 뒤 옛 토큰으로 새로고침해도 마친 단계로 되돌아가지 않게), + * 유예의 시작 시각은 정책과 켜짐(우리 기록 → 엔진)으로 다시 본다 — 그사이 켰거나 정책이 바뀌었을 수 있다. + * 읽지 못하면 액세스 토큰의 값을 쓴다 (앱 시작이 막히지 않게) + */ + async sessionStateOf(user: AuthenticatedUser): Promise { + let restriction = user.restriction ?? null; + if (user.sessionId) { + try { + const row = await this.sessions.findById(user.sessionId); + restriction = row?.restriction ?? null; + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 세션의 제한을 읽지 못해 토큰의 값으로 답합니다: user=${maskEmail(user.email)}, reason=${errorMessage(error)}`, + ); + } + } + let twoFactorDeadline = user.twoFactorDeadline ?? null; + try { + const twoFactor = await this.twoFactorPolicy.sessionState( + normalizeEmail(user.email), + ); + twoFactorDeadline = twoFactor.deadline?.toISOString() ?? null; + } catch { + // 정책 · 엔진을 읽지 못했다 — 토큰에 실린 값(로그인 · 갱신 때 정한 것)으로 답한다 + } + return { restriction, twoFactorDeadline }; + } + /** * 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200): * 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다. @@ -712,8 +839,12 @@ export class AuthService { } if (state === 'grace') { - const profile = await this.mail.getProfile(claims.sub); - return { user: toUser(profile), tokens: null }; + const { profile } = await this.profileForGrace(claims.sub); + return { + user: toUser(profile), + tokens: null, + state: await this.graceState(session, profile.email), + }; } if (state === 'reused') { @@ -735,13 +866,58 @@ export class AuthService { now: Date, client: ClientInfo, ): Promise { - const profile = await this.loadProfileForRefresh(claims, now); + const { profile, passwordExpired } = await this.loadProfileForRefresh( + claims, + now, + ); + const address = normalizeEmail(profile.email); + // 2단계를 거친 적이 없는 세션(twoFactorSetup)인데 계정은 이미 켜짐 — 다른 세션에서 켰다. 제한을 지우지 않고 끊는다 (13단계 — 계약 6절). + // 켜짐은 엔진에 묻는다(우리 기록이 오래됐으면 갱신마다 끊기므로 — 재리뷰 N1). 끊기는 조건부 — 그사이 이 세션이 켜 제한이 지워졌으면 보통 회전으로(N2) + let knownEnabled: boolean | undefined; + if (session.restriction === 'twoFactorSetup') { + knownEnabled = await this.twoFactorPolicy.isEnabledInEngine(address); + if ( + knownEnabled && + (await this.sessions.revokeIfRestricted( + claims.sid, + 'twoFactorSetup', + 'two-factor-not-verified', + now, + )) + ) { + this.logger.warn( + `[AUTH_001]${traceTag()} 2단계 인증을 거치지 않은 세션을 끊었습니다 — 그사이 계정에 2단계가 켜졌습니다: user=${maskEmail(address)}, sid=${claims.sid}`, + ); + throw new BusinessException('AUTH_001', { + context: { + reason: 'session-two-factor-not-verified', + sid: claims.sid, + }, + }); + } + } + // 제한은 갱신 때 다시 정한다(13단계): 비밀번호 만료(엔진에서 다시 알아챈다)가 먼저, 그다음 정책 — 켜짐은 우리 기록 → 엔진 + const twoFactor = await this.twoFactorPolicy.sessionState( + address, + knownEnabled, + now, + ); + const sessionState: SessionState = { + restriction: passwordExpired ? 'passwordChange' : twoFactor.restriction, + twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null, + }; const expiresAt = nextIdleExpiry( now, this.settings.refreshTtlSeconds, session.absoluteExpiresAt, ); - const tokens = await this.signTokens(profile, claims.sid, expiresAt, now); + const tokens = await this.signTokens( + profile, + claims.sid, + expiresAt, + now, + sessionState, + ); const rotated = await this.sessions.rotate({ id: claims.sid, @@ -751,13 +927,18 @@ export class AuthService { now, ip: client.ip || null, userAgent: truncateUserAgent(client.userAgent), + restriction: sessionState.restriction, }); - if (rotated) return { user: toUser(profile), tokens }; + if (rotated) { + return { user: toUser(profile), tokens, state: sessionState }; + } // 그 사이 다른 요청이 먼저 회전했거나 세션을 끊었다 — 지금 상태로 다시 판단한다 const latest = await this.sessions.findById(claims.sid); const state = this.classify(latest, claims, presentedHash, now); - if (state === 'grace') return { user: toUser(profile), tokens: null }; + if (state === 'grace') { + return { user: toUser(profile), tokens: null, state: sessionState }; + } if (state === 'current') { // 조건부 UPDATE 와 같은 조건인데 바뀌지 않았다 — 서버 쪽 불일치(시계 어긋남 등)라 사용자 잘못이 아니다. // 세션을 끊지 않는다: AUTH_001 이 아니라 SYS_001(500)로 답해 컨트롤러가 쿠키를 지우지 않게 하고, @@ -780,10 +961,17 @@ export class AuthService { private async loadProfileForRefresh( claims: RefreshClaims, now: Date, - ): Promise { + ): Promise { try { - return await this.mail.getProfile(claims.sub, { fresh: true }); + return { + profile: await this.mail.getProfile(claims.sub, { fresh: true }), + passwordExpired: false, + }; } catch (error) { + // 비밀번호가 만료됐다(관리자가 재설정했거나 아직 바꾸지 않았다) — 세션은 남기고 passwordChange 제한을 붙인다 (13단계) + if (error instanceof PasswordExpiredException) { + return expiredProfile(error); + } if (error instanceof BusinessException && error.code === 'AUTH_001') { await this.sessions.revoke(claims.sid, 'account-unavailable', now); } @@ -795,10 +983,24 @@ export class AuthService { private async loadProfileForLogin( account: string, address: string, - ): Promise { + client: ClientInfo, + ): Promise { try { - return await this.mail.getProfile(account, { fresh: true }); + return { + profile: await this.mail.getProfile(account, { fresh: true }), + passwordExpired: false, + }; } catch (error) { + // 비밀번호는 맞았고 엔진의 비밀번호가 만료됐다 — 로그인은 하되 새 비밀번호 단계로 보낸다 (13단계 — 계약 6절) + if (error instanceof PasswordExpiredException) { + return expiredProfile(error); + } + if (error instanceof AccountLoginDisabledException) { + // 잠긴 계정 — 2단계를 켰으면 엔진이 2단계를 먼저 봐 비밀번호가 맞을 때만 여기 온다. 틀린 비밀번호와 같은 AUTH_004 · 기록 · 실패 한도로 + // (잠긴 · 털린 계정의 비밀번호가 맞는지 바깥에서 알 수 없게 — 2단계가 꺼진 잠긴 계정은 엔진이 401 로 답해 이미 그렇다) + await this.recordLoginFailure(address, client, 'wrongPassword'); + throw this.loginFailed(address, 'account-login-disabled'); + } if (error instanceof BusinessException && error.code === 'AUTH_001') { throw this.loginFailed(address, 'mailbox-unavailable'); } @@ -810,6 +1012,7 @@ export class AuthService { private async startSession( profile: MailAccountProfile, client: ClientInfo, + state: SessionState, ): Promise { const now = new Date(); const id = randomUUID(); @@ -821,7 +1024,7 @@ export class AuthService { this.settings.refreshTtlSeconds, absoluteExpiresAt, ); - const tokens = await this.signTokens(profile, id, expiresAt, now); + const tokens = await this.signTokens(profile, id, expiresAt, now, state); await this.sessions.create({ id, @@ -836,6 +1039,7 @@ export class AuthService { revokedReason: null, ip: client.ip || null, userAgent: truncateUserAgent(client.userAgent), + restriction: state.restriction, }); return tokens; } @@ -884,6 +1088,7 @@ export class AuthService { sessionId: string, refreshExpiresAt: Date, now: Date, + state: SessionState, ): Promise { const { jwtSecret, accessTtlSeconds } = this.settings; const refreshTtlSeconds = Math.max( @@ -891,11 +1096,16 @@ export class AuthService { Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000), ); // 액세스 토큰에도 세션 id 를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (계약 5절 "로그인된 브라우저") + // 제한 · 유예(13단계)도 싣는다 — 가드가 제한을 보고 me 가 그대로 돌려준다 (있을 때만) const access: AuthTokenPayload = { sub: profile.email, typ: 'access', name: profile.name, sid: sessionId, + ...(state.restriction !== null ? { rst: state.restriction } : {}), + ...(state.twoFactorDeadline !== null + ? { tfd: state.twoFactorDeadline } + : {}), }; const refresh: AuthTokenPayload = { sub: profile.email, @@ -921,6 +1131,43 @@ export class AuthService { return { accessToken, refreshToken, refreshTtlSeconds }; } + /** 동시 갱신 유예의 프로필 — 기억한 세션으로 읽는다. 비밀번호가 만료된 계정은 주소의 @ 앞을 이름으로 (13단계) */ + private async profileForGrace(email: string): Promise { + try { + return { + profile: await this.mail.getProfile(email), + passwordExpired: false, + }; + } catch (error) { + if (error instanceof PasswordExpiredException) { + return expiredProfile(error); + } + throw error; + } + } + + /** + * 동시 갱신 유예의 상태 — 제한은 먼저 회전한 요청이 세션 표에 적은 값, 유예의 시작 시각은 정책으로 (13단계). + * 정책을 읽지 못하면 유예 없이 답한다 — 이긴 요청의 응답이 이미 같은 값을 주었다 + */ + private async graceState( + session: AuthSessionEntity | null, + email: string, + ): Promise { + const restriction = session?.restriction ?? null; + try { + const twoFactor = await this.twoFactorPolicy.sessionState( + normalizeEmail(email), + ); + return { + restriction, + twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null, + }; + } catch { + return { restriction, twoFactorDeadline: null }; + } + } + private loginFailed(email: string, reason: string): BusinessException { return new BusinessException('AUTH_004', { context: { reason, email: maskEmail(email) }, diff --git a/backend/src/modules/auth/dto/auth-user.dto.ts b/backend/src/modules/auth/dto/auth-user.dto.ts index 1eb8269..c2b1a0d 100644 --- a/backend/src/modules/auth/dto/auth-user.dto.ts +++ b/backend/src/modules/auth/dto/auth-user.dto.ts @@ -1,7 +1,31 @@ import { ApiProperty } from '@nestjs/swagger'; +import { + SESSION_RESTRICTIONS, + type SessionRestriction, +} from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +/** 제한 칸의 Swagger 설명 — 로그인 · me · 갱신이 같은 글을 쓴다 */ +export const RESTRICTION_PROPERTY = { + description: + '제한된 세션 (13단계 — 계약 6절) — passwordChange(새 비밀번호를 정할 때까지) · twoFactorSetup(2단계 인증을 켤 때까지) · null(제한 없음). ' + + '제한된 동안 다른 API 는 403 AUTH_010 이다', + enum: SESSION_RESTRICTIONS, + nullable: true, + example: null, +} as const; + +/** 유예 칸의 Swagger 설명 */ +export const TWO_FACTOR_DEADLINE_PROPERTY = { + description: + '2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 아니면 null. 화면은 메일함 위에 그 1ms 앞 날짜(시작일 전날)까지로 안내한다', + type: String, + format: 'date-time', + nullable: true, + example: null, +} as const; + /** 로그인한 직원 — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다 */ export class AuthUserDto implements AuthenticatedUser { @ApiProperty({ @@ -12,4 +36,10 @@ export class AuthUserDto implements AuthenticatedUser { @ApiProperty({ description: '표시 이름', example: '김아라' }) name!: string; + + @ApiProperty(RESTRICTION_PROPERTY) + restriction!: SessionRestriction | null; + + @ApiProperty(TWO_FACTOR_DEADLINE_PROPERTY) + twoFactorDeadline!: string | null; } diff --git a/backend/src/modules/auth/dto/login-result.dto.ts b/backend/src/modules/auth/dto/login-result.dto.ts index a2c28b7..f2500f4 100644 --- a/backend/src/modules/auth/dto/login-result.dto.ts +++ b/backend/src/modules/auth/dto/login-result.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; +import { + RESTRICTION_PROPERTY, + TWO_FACTOR_DEADLINE_PROPERTY, +} from './auth-user.dto'; + /** * POST /api/auth/login 의 응답 (계약 5절 "로그인") — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다. * twoFactorRequired 는 비밀번호가 맞았다는 뜻이다 — 쿠키가 없고 email · name 은 null (코드와 비밀번호를 함께 다시 보낸다) @@ -28,4 +34,16 @@ export class LoginResultDto { example: '김아라', }) name!: string | null; + + @ApiProperty({ + ...RESTRICTION_PROPERTY, + description: `${RESTRICTION_PROPERTY.description}. twoFactorRequired 면 null — 제한된 세션도 쿠키를 받는다`, + }) + restriction!: SessionRestriction | null; + + @ApiProperty({ + ...TWO_FACTOR_DEADLINE_PROPERTY, + description: `${TWO_FACTOR_DEADLINE_PROPERTY.description}. twoFactorRequired 면 null`, + }) + twoFactorDeadline!: string | null; } diff --git a/backend/src/modules/auth/jwt.strategy.spec.ts b/backend/src/modules/auth/jwt.strategy.spec.ts index d0c7cea..228241a 100644 --- a/backend/src/modules/auth/jwt.strategy.spec.ts +++ b/backend/src/modules/auth/jwt.strategy.spec.ts @@ -1,4 +1,6 @@ import { BusinessException } from '@/common/errors/business.exception'; +import type { AuthSessionEntity } from './auth-session.entity'; +import type { AuthSessionRepository } from './auth-session.repository'; import { JwtStrategy } from './jwt.strategy'; import type { RevokedSessions } from './revoked-sessions'; @@ -8,6 +10,14 @@ const revoked = { isRevoked: (sessionId: string) => revokedIds.has(sessionId), } as unknown as RevokedSessions; +/** 세션 표 대역 — 제한이 실린 토큰만 읽는다 (13단계) */ +const rows = new Map>(); +const sessions = { + findById: jest.fn((id: string) => + Promise.resolve((rows.get(id) as AuthSessionEntity | undefined) ?? null), + ), +}; + const strategy = new JwtStrategy( { jwtSecret: 'unit-test-secret-unit-test-secret-1234', @@ -16,11 +26,16 @@ const strategy = new JwtStrategy( secureCookies: false, }, revoked, + sessions as unknown as AuthSessionRepository, ); -function codeOf(run: () => unknown): string | undefined { +const SID = '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a'; + +async function codeOf( + run: () => Promise, +): Promise { try { - run(); + await run(); } catch (error) { return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; } @@ -28,20 +43,24 @@ function codeOf(run: () => unknown): string | undefined { } describe('JwtStrategy.validate', () => { - afterEach(() => revokedIds.clear()); + afterEach(() => { + revokedIds.clear(); + rows.clear(); + sessions.findById.mockClear(); + }); - it('액세스 토큰이면 직원 정보를 돌려준다', () => { - expect( + it('액세스 토큰이면 직원 정보를 돌려준다', async () => { + await expect( strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', }), - ).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' }); + ).resolves.toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' }); }); - it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', () => { - const user = strategy.validate({ + it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', async () => { + const user = await strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', @@ -55,36 +74,38 @@ describe('JwtStrategy.validate', () => { }); }); - it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', () => { - expect( + it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', async () => { + await expect( strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', - sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + sid: SID, }), - ).toStrictEqual({ + ).resolves.toStrictEqual({ email: 'ara.kim@arasystem.test', name: '김아라', - sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + sessionId: SID, }); + // 제한이 없는 토큰은 DB 를 묻지 않는다 + expect(sessions.findById).not.toHaveBeenCalled(); }); - it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', () => { - revokedIds.add('9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a'); + it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', async () => { + revokedIds.add(SID); expect( - codeOf(() => + await codeOf(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', - sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + sid: SID, }), ), ).toBe('AUTH_001'); // 다른 세션 · sid 가 없는 12단계 전의 토큰은 그대로 통과한다 expect( - codeOf(() => + await codeOf(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', @@ -94,7 +115,7 @@ describe('JwtStrategy.validate', () => { ), ).toBeUndefined(); expect( - codeOf(() => + await codeOf(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', @@ -104,15 +125,88 @@ describe('JwtStrategy.validate', () => { ).toBeUndefined(); }); - it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => { - expect(() => - strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }), - ).toThrow(BusinessException); + it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', async () => { + expect( + await codeOf(() => + strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }), + ), + ).toBe('AUTH_001'); + }); - try { - strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }); - } catch (error) { - expect((error as BusinessException).code).toBe('AUTH_001'); + it('제한이 실린 토큰은 세션 표의 지금 제한을 쓴다 · 유예의 시작 시각은 토큰 그대로 (13단계)', async () => { + rows.set(SID, { id: SID, restriction: 'passwordChange', revokedAt: null }); + await expect( + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + sid: SID, + rst: 'passwordChange', + tfd: '2026-11-30T15:00:00.000Z', + }), + ).resolves.toStrictEqual({ + email: 'ara.kim@arasystem.test', + name: '김아라', + sessionId: SID, + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + }); + + it('제한을 푼 뒤의 옛 토큰은 제한 없음으로 — 세션 표가 지웠으면 null (마친 단계로 되돌아가지 않게)', async () => { + rows.set(SID, { id: SID, restriction: null, revokedAt: null }); + const user = await strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + sid: SID, + rst: 'passwordChange', + }); + expect(user.restriction).toBeNull(); + }); + + it('제한이 실렸는데 세션 행이 없거나 끝났거나 sid 가 없으면 AUTH_001', async () => { + for (const payload of [{ sid: SID }, { sid: undefined }]) { + expect( + await codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + rst: 'twoFactorSetup', + ...payload, + }), + ), + ).toBe('AUTH_001'); } + rows.set(SID, { + id: SID, + restriction: 'twoFactorSetup', + revokedAt: new Date(), + }); + expect( + await codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + rst: 'twoFactorSetup', + sid: SID, + }), + ), + ).toBe('AUTH_001'); + }); + + it('모르는 제한이 실린 토큰은 AUTH_001 — 제한을 모르고 통과시키지 않는다', async () => { + expect( + await codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + rst: 'somethingElse' as never, + }), + ), + ).toBe('AUTH_001'); }); }); diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 658c5fc..0559f87 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -3,12 +3,14 @@ import { PassportStrategy } from '@nestjs/passport'; import type { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { isSessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard'; import { readCookie } from '@/shared/utils/cookies'; import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants'; import { AUTH_SETTINGS, type AuthSettings } from './auth.settings'; +import { AuthSessionRepository } from './auth-session.repository'; import type { AuthTokenPayload } from './auth-token.types'; import { RevokedSessions } from './revoked-sessions'; @@ -24,12 +26,15 @@ function accessTokenFromCookie(request: Request): string | null { * 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다. * 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, 그리고 **끊은 세션인지**를 확인한다 * (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다). + * 13단계: 제한(`rst`)이 실린 토큰만 세션 표의 **지금 제한**을 읽어 쓴다 — 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 세션의 제한을 지운 뒤 + * 옛 토큰으로 온 요청(새로고침 · 다른 탭)이 마친 단계로 되돌아가지 않게. 제한이 없는 토큰은 지금처럼 DB 를 묻지 않는다 */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { constructor( @Inject(AUTH_SETTINGS) settings: AuthSettings, private readonly revoked: RevokedSessions, + private readonly sessions: AuthSessionRepository, ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]), @@ -39,7 +44,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { }); } - validate(payload: AuthTokenPayload): AuthenticatedUser { + async validate(payload: AuthTokenPayload): Promise { if ( payload.typ !== 'access' || typeof payload.sub !== 'string' || @@ -50,6 +55,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { context: { reason: 'not-an-access-token' }, }); } + if (payload.rst !== undefined && !isSessionRestriction(payload.rst)) { + // 서명은 맞는데 모르는 제한 — 이 서버가 만든 토큰이 아니거나 판이 다르다. 제한을 모르고 통과시키지 않는다 (닫힌 쪽) + throw new BusinessException('AUTH_001', { + context: { reason: 'unknown-session-restriction' }, + }); + } const sessionId = typeof payload.sid === 'string' ? payload.sid : null; if (sessionId !== null && this.revoked.isRevoked(sessionId)) { // 로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊은 세션 — 클라이언트는 갱신을 한 번 시도하고(세션이 끊겨 401) 로그인 화면으로 간다 @@ -65,6 +76,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { ? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND } : {}), ...(sessionId !== null ? { sessionId } : {}), + // 제한된 세션 · 2단계 인증 필수의 유예 (13단계) — 있을 때만. 가드가 제한을 본다(세션 표의 지금 값) + ...(payload.rst !== undefined + ? { restriction: await this.currentRestriction(sessionId) } + : {}), + ...(typeof payload.tfd === 'string' + ? { twoFactorDeadline: payload.tfd } + : {}), }; } + + /** + * 제한이 실린 토큰 — 세션 표의 지금 제한 (13단계). 행이 없거나 끝난 세션이면 AUTH_001(갱신이 다시 정한다). + * 제한은 sid 가 있는 토큰(12단계 뒤)에만 실린다 — sid 가 없으면 받지 않는다 + */ + private async currentRestriction( + sessionId: string | null, + ): Promise { + const row = + sessionId === null ? null : await this.sessions.findById(sessionId); + if (!row || row.revokedAt !== null) { + throw new BusinessException('AUTH_001', { + context: { reason: 'restricted-session-missing', sid: sessionId }, + }); + } + return row.restriction; + } } diff --git a/backend/src/modules/auth/revoked-sessions.spec.ts b/backend/src/modules/auth/revoked-sessions.spec.ts index e02eee7..c11b581 100644 --- a/backend/src/modules/auth/revoked-sessions.spec.ts +++ b/backend/src/modules/auth/revoked-sessions.spec.ts @@ -1,8 +1,12 @@ +import { Logger } from '@nestjs/common'; 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'; +import { + REVOKED_SESSION_SYNC_INTERVAL_MS, + RevokedSessions, +} from './revoked-sessions'; const NOW = Date.parse('2026-10-31T03:00:00Z'); @@ -62,4 +66,80 @@ describe('RevokedSessions (끊은 세션 id 기억 — 계약 5절 "로그인된 expect(revoked.size).toBe(1); expect(revoked.isRevoked('fresh', NOW)).toBe(true); }); + + describe('15초 DB 동기화 (13단계 — 운영 도구가 끊은 세션)', () => { + it('다른 프로세스가 끊은 세션을 다음 동기화에서 읽어 막는다', async () => { + await revoked.onApplicationBootstrap(); + expect(revoked.isRevoked('by-ops', NOW)).toBe(false); + + repository.find.mockResolvedValue([ + { id: 'by-ops', revokedAt: new Date(NOW + 5_000) }, + ]); + await expect( + revoked.syncFromDatabase(NOW + REVOKED_SESSION_SYNC_INTERVAL_MS), + ).resolves.toBe(1); + expect(revoked.isRevoked('by-ops', NOW + 20_000)).toBe(true); + }); + + it('창은 지난 동기화를 시작한 때보다 60초 앞부터 — 늦게 커밋된 끊기도 잡는다', async () => { + await revoked.onApplicationBootstrap(); // 지난 동기화 = NOW + repository.find.mockClear(); + await revoked.syncFromDatabase(NOW + 15_000); + expect(repository.find).toHaveBeenCalledWith({ + select: { id: true, revokedAt: true }, + where: { revokedAt: MoreThan(new Date(NOW - 60_000)) }, + order: { revokedAt: 'ASC' }, + }); + repository.find.mockClear(); + await revoked.syncFromDatabase(NOW + 30_000); + expect(repository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { revokedAt: MoreThan(new Date(NOW + 15_000 - 60_000)) }, + }), + ); + }); + + it('오래 읽지 못했어도 창은 1시간보다 앞서지 않는다', async () => { + await revoked.syncFromDatabase(NOW); // 처음 — 1시간 전부터 + expect(repository.find).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { + revokedAt: MoreThan(new Date(NOW - REVOKED_SESSION_MEMORY_MS)), + }, + }), + ); + const later = NOW + 3 * REVOKED_SESSION_MEMORY_MS; + await revoked.syncFromDatabase(later); + expect(repository.find).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { + revokedAt: MoreThan(new Date(later - REVOKED_SESSION_MEMORY_MS)), + }, + }), + ); + }); + + it('DB 를 읽지 못해도 던지지 않고(warn 한 번) 다음 주기에 그사이의 끊기까지 읽는다', async () => { + await revoked.onApplicationBootstrap(); + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + repository.find.mockRejectedValue(new Error('db down')); + await expect(revoked.syncFromDatabase(NOW + 15_000)).resolves.toBe(0); + await expect(revoked.syncFromDatabase(NOW + 30_000)).resolves.toBe(0); + expect(warn).toHaveBeenCalledTimes(1); + + repository.find.mockResolvedValue([ + { id: 'while-down', revokedAt: new Date(NOW + 10_000) }, + ]); + await revoked.syncFromDatabase(NOW + 45_000); + // 지난 성공(NOW) - 60초부터 읽는다 — 실패한 동안 끊긴 것도 든다 + expect(repository.find).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { revokedAt: MoreThan(new Date(NOW - 60_000)) }, + }), + ); + expect(revoked.isRevoked('while-down', NOW + 45_000)).toBe(true); + }); + }); }); diff --git a/backend/src/modules/auth/revoked-sessions.ts b/backend/src/modules/auth/revoked-sessions.ts index 4419770..beb3d60 100644 --- a/backend/src/modules/auth/revoked-sessions.ts +++ b/backend/src/modules/auth/revoked-sessions.ts @@ -9,6 +9,22 @@ import { AuthSessionEntity } from './auth-session.entity'; /** 지난 기억을 지우는 주기 */ const SWEEP_INTERVAL_MS = 60_000; +/** + * DB 에서 끊은 세션을 다시 채우는 주기 (13단계 — 계약 6절 "도구로 끊은 세션도 15초 안에 막힌다"). + * 운영 도구(dist/cli/ops.js)는 다른 프로세스라 그 프로세스의 기억은 이 서버에 닿지 않는다 — 세션 끊기는 DB 로 하고, 서버는 이 주기로 DB 를 읽는다 + */ +export const REVOKED_SESSION_SYNC_INTERVAL_MS = 15_000; + +/** + * 동기화 창의 겹침 — 지난 동기화를 시작한 때보다 이만큼 앞에서부터 다시 읽는다. + * 다른 프로세스가 `revoked_at` 을 적은 뒤 늦게 커밋해도(그사이 동기화가 지나가도) 다음 동기화가 잡는다. 겹쳐 읽은 세션은 기억이 하나로 합친다 + */ +const SYNC_OVERLAP_MS = 60_000; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** * 끊은 세션 id 기억 (계약 5절 "로그인된 브라우저" — 끊은 세션은 바로 막는다). * @@ -19,15 +35,21 @@ const SWEEP_INTERVAL_MS = 60_000; * 세션 표의 조건부 UPDATE 가 끝낸 id 를 여기에 넣는다 (auth-session.repository.ts — 한 곳) * - 서버가 다시 뜨면(onApplicationBootstrap) 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다 — 읽지 못하면 기동을 멈춘다 * (끊은 세션의 토큰이 만료까지 다시 쓰이는 일이 없게. 운영은 마이그레이션도 기동 때 DB 에 하므로 DB 없이는 어차피 뜨지 않는다) + * - 13단계: 15초마다(REVOKED_SESSION_SYNC_INTERVAL_MS) DB 에서 새로 끊긴 세션을 읽어 더한다 — 운영 도구가 끊은 세션(다른 프로세스)도 15초 안에 막힌다. + * 읽지 못하면 다음 주기에 다시 — 겹침(SYNC_OVERLAP_MS)이 있어 그사이 끊긴 것도 잡는다. 인스턴스를 늘려도 15초 안에 맞는다 * - 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(); + /** 마지막으로 DB 에서 채우기를 시작한 때 (epoch ms) — null 이면 아직 채우지 않았다 */ + private lastSyncedAt: number | null = null; + /** 동기화가 도는 중인가 — 한 번에 하나만 */ + private syncing = false; + /** 동기화 실패를 알렸는가 — 이어지는 실패는 회복할 때까지 한 번만 warn */ + private syncFailureReported = false; constructor( @InjectRepository(AuthSessionEntity) @@ -36,19 +58,47 @@ export class RevokedSessions implements OnApplicationBootstrap { /** 기동 — 최근 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()); + const startedAt = Date.now(); + const loaded = await this.loadRevokedSince( + startedAt - REVOKED_SESSION_MEMORY_MS, + ); + this.lastSyncedAt = startedAt; + if (loaded > 0) { + this.logger.log(`최근 1시간 안에 끊은 세션 ${loaded}개를 불러왔습니다.`); } - if (rows.length > 0) { - this.logger.log( - `최근 1시간 안에 끊은 세션 ${rows.length}개를 불러왔습니다.`, - ); + } + + /** + * DB 에서 새로 끊긴 세션을 읽어 더한다 (13단계 — 15초마다) → 읽은 수. 실패해도 던지지 않는다(warn 한 번 — 다음 주기에 다시). + * 창: 지난 동기화를 시작한 때 - SYNC_OVERLAP_MS 부터(1시간보다 앞이면 1시간 전부터) + */ + @Interval(REVOKED_SESSION_SYNC_INTERVAL_MS) + async syncFromDatabase(now: number = Date.now()): Promise { + if (this.syncing) return 0; + this.syncing = true; + try { + const memoryStart = now - REVOKED_SESSION_MEMORY_MS; + const since = + this.lastSyncedAt === null + ? memoryStart + : Math.max(this.lastSyncedAt - SYNC_OVERLAP_MS, memoryStart); + const loaded = await this.loadRevokedSince(since); + this.lastSyncedAt = now; + if (this.syncFailureReported) { + this.syncFailureReported = false; + this.logger.log('끊은 세션 동기화가 다시 됩니다.'); + } + return loaded; + } catch (error) { + if (!this.syncFailureReported) { + this.syncFailureReported = true; + this.logger.warn( + `[SYS_001] 끊은 세션을 DB 에서 읽지 못했습니다 — 다음 주기에 다시 합니다(이 서버가 끊은 세션은 그대로 막힌다): ${errorMessage(error)}`, + ); + } + return 0; + } finally { + this.syncing = false; } } @@ -81,6 +131,19 @@ export class RevokedSessions implements OnApplicationBootstrap { return this.revokedAt.size; } + /** `since` 뒤에 끊긴 세션을 읽어 기억한다 → 읽은 수 (끊은 차례로 — sweep 이 앞에서부터 본다) */ + private async loadRevokedSince(since: number): Promise { + const rows = await this.sessions.find({ + select: { id: true, revokedAt: true }, + where: { revokedAt: MoreThan(new Date(since)) }, + order: { revokedAt: 'ASC' }, + }); + for (const row of rows) { + if (row.revokedAt) this.remember(row.id, row.revokedAt.getTime()); + } + return rows.length; + } + private remember(id: string, at: number): void { // 늦게 끊은 시각이 이긴다 — 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다) const previous = this.revokedAt.get(id); diff --git a/backend/src/modules/contacts/contact-groups.controller.ts b/backend/src/modules/contacts/contact-groups.controller.ts index d22239a..2581a9c 100644 --- a/backend/src/modules/contacts/contact-groups.controller.ts +++ b/backend/src/modules/contacts/contact-groups.controller.ts @@ -16,6 +16,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -50,7 +51,7 @@ const NAME_RULES = `이름은 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LE @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(CONTACT_GROUPS_ROUTE) export class ContactGroupsController { diff --git a/backend/src/modules/contacts/contact-restorations.controller.ts b/backend/src/modules/contacts/contact-restorations.controller.ts index f056512..c7cd164 100644 --- a/backend/src/modules/contacts/contact-restorations.controller.ts +++ b/backend/src/modules/contacts/contact-restorations.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -26,7 +27,7 @@ import { @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(CONTACT_RESTORATIONS_ROUTE) export class ContactRestorationsController { constructor(private readonly contacts: ContactsService) {} diff --git a/backend/src/modules/contacts/contacts.controller.ts b/backend/src/modules/contacts/contacts.controller.ts index b4a5d6a..a145c8a 100644 --- a/backend/src/modules/contacts/contacts.controller.ts +++ b/backend/src/modules/contacts/contacts.controller.ts @@ -14,6 +14,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -57,7 +58,7 @@ const SIZE_RULE = @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(CONTACTS_ROUTE) export class ContactsController { diff --git a/backend/src/modules/contacts/directory-entries.controller.ts b/backend/src/modules/contacts/directory-entries.controller.ts index d419d67..59aca74 100644 --- a/backend/src/modules/contacts/directory-entries.controller.ts +++ b/backend/src/modules/contacts/directory-entries.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -38,7 +39,7 @@ const UNREADABLE = @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(DIRECTORY_ENTRIES_ROUTE) export class DirectoryEntriesController { constructor( diff --git a/backend/src/modules/file-sharing/file-sharing.repository.ts b/backend/src/modules/file-sharing/file-sharing.repository.ts index 67c08b2..dd04ee4 100644 --- a/backend/src/modules/file-sharing/file-sharing.repository.ts +++ b/backend/src/modules/file-sharing/file-sharing.repository.ts @@ -127,6 +127,27 @@ export class FileSharingRepository { return (result.affected ?? 0) > 0; } + /** 그 직원의 살아 있는 링크(끊지 않았고 · 취소하지 않았고 · 기간 안) id — 운영 도구의 잠금 (13단계) */ + async aliveIdsOf(owner: string, now: Date): Promise { + const rows = await this.links + .createQueryBuilder('link') + .select('link.id', 'id') + .where('link.owner_email = :owner', { owner }) + .andWhere('link.revoked_at IS NULL') + .andWhere('link.canceled_at IS NULL') + .andWhere('link.expires_at >= :now', { now }) + .getRawMany<{ id: string }>(); + return rows.map((row) => row.id); + } + + /** 링크 하나와 파일 (주인과 상관없이) — 운영 도구의 링크 끊기 (13단계) */ + async findById(id: string): Promise { + const link = await this.links.findOneBy({ id }); + if (!link) return null; + const [record] = await this.withFiles([link]); + return record; + } + /** * 받은 수를 하나 더한다 — 상한 아래일 때만 (한 문장이라 동시에 받아도 상한을 넘지 않는다). * @returns 더했으면 true, 상한에 닿았으면 false diff --git a/backend/src/modules/file-sharing/file-sharing.service.ts b/backend/src/modules/file-sharing/file-sharing.service.ts index 947aaf6..e0af247 100644 --- a/backend/src/modules/file-sharing/file-sharing.service.ts +++ b/backend/src/modules/file-sharing/file-sharing.service.ts @@ -347,6 +347,57 @@ export class FileSharingService implements BeforeApplicationShutdown { return this.summarize(record, await this.fileStatuses([record])); } + // ── 운영 도구 (13단계 — 계약 6절 "운영 도구") ───────────────────────────────────── + + /** + * 신고된 공개 링크 주소로 링크를 찾는다 (운영 도구의 「링크 끊기」) — 우리 공개 화면 주소가 아니거나 없는 링크 · 취소한 링크면 null. + * 주소 뒤의 쿼리 · 조각은 무시한다(받는 쪽 장비가 붙였을 수 있다) + */ + async findForOperator(url: string): Promise { + const token = tokenFromShareUrl(this.publicOrigin, url, { + allowQuery: true, + }); + if (token === null) return null; + const [record] = await this.repository.findByTokenHashes([ + hashShareToken(token), + ]); + return record && record.link.canceledAt === null ? record : null; + } + + /** + * 운영자가 링크를 끊는다 (`revoked_by = operator`) — 이번에 끊었으면 true (이미 끊었거나 기간이 지났으면 false). + * 파일의 보관 기간을 다시 맞춘다 (보낸 사람이 끊을 때와 같다) + */ + async revokeAsOperator(linkId: string): Promise { + const record = await this.repository.findById(linkId); + if (!record || record.link.canceledAt !== null) return false; + const revoked = await this.repository.revoke( + linkId, + 'operator', + new Date(), + ); + if (revoked) { + await this.refreshRetention(record.files.map((file) => file.fileId)); + this.logger.log( + `운영자가 대용량 첨부 링크를 끊었습니다: user=${maskEmail(record.link.ownerEmail)}, link=${linkId}, 파일 ${record.files.length}개`, + ); + } + return revoked; + } + + /** 그 직원의 살아 있는 링크를 모두 운영자 끊기로 → 끊은 수 (운영 도구의 잠금 — 묻고 한다) */ + async revokeAllOfOwnerAsOperator(owner: string): Promise { + const ids = await this.repository.aliveIdsOf( + owner.toLowerCase(), + new Date(), + ); + let revoked = 0; + for (const id of ids) { + if (await this.revokeAsOperator(id)) revoked += 1; + } + return revoked; + } + // ── 인용 속 링크의 상태 (POST /api/share-link-lookups) ───────────────────────── /** 주소마다 상태 · 끝나는 날만 — 누구의 링크든 같다. 요청 차례 그대로 */ diff --git a/backend/src/modules/file-sharing/share-link-lookup.controller.ts b/backend/src/modules/file-sharing/share-link-lookup.controller.ts index 6015834..d9b6c82 100644 --- a/backend/src/modules/file-sharing/share-link-lookup.controller.ts +++ b/backend/src/modules/file-sharing/share-link-lookup.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { NoStore } from '@/common/decorators/no-store.decorator'; @@ -31,7 +32,7 @@ export const SHARE_LINK_LOOKUPS_ROUTE = 'share-link-lookups'; @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(SHARE_LINK_LOOKUPS_ROUTE) export class ShareLinkLookupController { constructor(private readonly sharing: FileSharingService) {} diff --git a/backend/src/modules/file-storage/file-scan.service.spec.ts b/backend/src/modules/file-storage/file-scan.service.spec.ts index d2f2bfd..39de709 100644 --- a/backend/src/modules/file-storage/file-scan.service.spec.ts +++ b/backend/src/modules/file-storage/file-scan.service.spec.ts @@ -90,6 +90,16 @@ class FakeRepository { return Promise.resolve(true); } + /** 실제 저장소와 같은 조건 — ready 이고 아직 위험으로 적히지 않았을 때만 */ + markInfected(id: string, changes: Partial) { + const row = this.rows.get(id); + if (!row || row.state !== 'ready' || row.scanStatus === 'infected') { + return Promise.resolve(false); + } + Object.assign(row, changes, { scanStatus: 'infected' }); + return Promise.resolve(true); + } + update(id: string, changes: Partial) { const row = this.rows.get(id); if (row) Object.assign(row, changes); @@ -305,6 +315,8 @@ describe('FileScanService — 악성 검사 차례 (11단계)', () => { fileId: 'bad', name: 'bad.zip', scanStatus: 'infected', + size: repository.rows.get('bad')?.size, + uploadedAt: expect.any(Date) as Date, }, ]); expect(warn).toHaveBeenCalledWith( @@ -312,6 +324,23 @@ describe('FileScanService — 악성 검사 차례 (11단계)', () => { ); }); + it('같은 파일의 검사가 겹쳐 다른 쪽이 먼저 위험으로 적었으면 알리지 않는다 — 알림 메일은 파일마다 한 번 (리뷰 L1)', async () => { + repository.rows.set('bad', file('bad')); + verdicts.set('bad', { kind: 'infected', signature: 'X' }); + // 이 검사가 끝나기 전에 다른 검사(다른 인스턴스)가 먼저 위험으로 적었다 + blobs.openRead.mockImplementation((id: string) => { + const row = repository.rows.get(id); + if (row) row.scanStatus = 'infected'; + return Promise.resolve(Readable.from([Buffer.from(id)])); + }); + const events: FileScanVerdictEvent[] = []; + service.onVerdict((event) => events.push(event)); + + await run(); + expect(repository.rows.get('bad')?.scanStatus).toBe('infected'); + expect(events).toEqual([]); + }); + it('검사기에 닿지 못하면 검사 중 그대로 두고 1분부터 늘려 다시 — 이어서 실패하면 error (닫힌 쪽)', async () => { repository.rows.set('x', file('x')); verdicts.set('x', new ClamavUnavailableError('connect ECONNREFUSED')); diff --git a/backend/src/modules/file-storage/file-scan.service.ts b/backend/src/modules/file-storage/file-scan.service.ts index 229dce3..413fa95 100644 --- a/backend/src/modules/file-storage/file-scan.service.ts +++ b/backend/src/modules/file-storage/file-scan.service.ts @@ -226,8 +226,8 @@ export class FileScanService implements OnModuleInit, OnApplicationShutdown { scanStatus: 'infected' as const, scannedAt: now, }; - const changed = await this.repository.transition(file.id, ['ready'], { - scanStatus: 'infected', + // 처음 위험으로 적을 때만 changed — 검사가 겹쳐도 알림은 파일마다 한 번 (scan_status IS DISTINCT FROM 'infected') + const changed = await this.repository.markInfected(file.id, { scanSignature: verdict.signature, scannedAt: now, scanNextAt: null, @@ -247,11 +247,14 @@ export class FileScanService implements OnModuleInit, OnApplicationShutdown { ); } if (!changed) return; + // 위험 판정을 처음 적었을 때만 여기까지 온다(changed) — 받은편지함 알림 메일도 파일마다 한 번이다 (13단계) const event: FileScanVerdictEvent = { ownerEmail: file.ownerEmail, fileId: file.id, name: file.name, scanStatus: 'infected', + size: file.size, + uploadedAt: file.completedAt ?? file.createdAt, }; for (const listener of this.listeners) { try { diff --git a/backend/src/modules/file-storage/file-storage.repository.ts b/backend/src/modules/file-storage/file-storage.repository.ts index bc4d4ec..348d500 100644 --- a/backend/src/modules/file-storage/file-storage.repository.ts +++ b/backend/src/modules/file-storage/file-storage.repository.ts @@ -182,6 +182,26 @@ export class FileStorageRepository { return (result.affected ?? 0) > 0; } + /** + * 위험 판정을 적는다 — 아직 위험으로 적히지 않았고 올리기를 끝낸(ready) 파일일 때만 (조건부 UPDATE 한 번). + * 같은 파일의 검사가 겹쳐도(인스턴스 둘 · 다시 검사) 처음 적은 쪽만 true — 받은편지함 알림 메일은 파일마다 한 번이다 (13단계) + * @returns 적었으면 true + */ + async markInfected( + id: string, + changes: Partial, + ): Promise { + const result = await this.files + .createQueryBuilder() + .update(StoredFileEntity) + .set({ ...changes, scanStatus: 'infected' }) + .where('id = :id', { id }) + .andWhere(`state = 'ready'`) + .andWhere(`scan_status IS DISTINCT FROM 'infected'`) + .execute(); + return (result.affected ?? 0) > 0; + } + /** * 기간이 지난 파일을 지움(기간 지남)으로 — 그 순간에도 기간이 지났을 때만(그사이 임시 저장 · 링크로 늘었으면 그대로 둔다). * @returns 바꿨으면 true diff --git a/backend/src/modules/file-storage/file-storage.types.ts b/backend/src/modules/file-storage/file-storage.types.ts index dd4db3e..36d994c 100644 --- a/backend/src/modules/file-storage/file-storage.types.ts +++ b/backend/src/modules/file-storage/file-storage.types.ts @@ -110,6 +110,10 @@ export interface FileScanVerdictEvent { readonly fileId: string; readonly name: string; readonly scanStatus: ScanStatus; + /** 파일 크기 (바이트) — 받은편지함 알림 메일의 「크기」 (13단계) */ + readonly size: number; + /** 올린 때(다 올린 때 — 없으면 만들기 시작한 때) — 받은편지함 알림 메일의 「올린 때」 (13단계) */ + readonly uploadedAt: Date; } /** 내려받을 파일 내용 — 범위가 있으면 그 부분만 */ diff --git a/backend/src/modules/file-storage/file-upload.controller.ts b/backend/src/modules/file-storage/file-upload.controller.ts index 635850c..900798c 100644 --- a/backend/src/modules/file-storage/file-upload.controller.ts +++ b/backend/src/modules/file-storage/file-upload.controller.ts @@ -27,6 +27,7 @@ import type { Request } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -69,7 +70,7 @@ function header(req: Request, name: string): string | undefined { @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(FILE_UPLOADS_ROUTE) export class FileUploadController { constructor(private readonly files: FileStorageService) {} @@ -252,7 +253,7 @@ export class FileUploadController { @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(FILE_QUOTA_ROUTE) export class FileQuotaController { constructor(private readonly files: FileStorageService) {} diff --git a/backend/src/modules/mail-signature/mail-signature.controller.ts b/backend/src/modules/mail-signature/mail-signature.controller.ts index a9d7be5..d2be4e8 100644 --- a/backend/src/modules/mail-signature/mail-signature.controller.ts +++ b/backend/src/modules/mail-signature/mail-signature.controller.ts @@ -26,6 +26,7 @@ import type { Response } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -60,7 +61,7 @@ const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif']; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('mail-signature') export class MailSignatureController { constructor(private readonly signatures: MailSignatureService) {} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts index b6ea0fa..98dc12c 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -23,7 +24,7 @@ export const MAIL_USER_SETTINGS_ROUTE = 'mail-settings'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(MAIL_USER_SETTINGS_ROUTE) export class MailUserSettingsController { constructor(private readonly settings: MailUserSettingsService) {} diff --git a/backend/src/modules/mail/draft.controller.ts b/backend/src/modules/mail/draft.controller.ts index fd1cbc4..334c17a 100644 --- a/backend/src/modules/mail/draft.controller.ts +++ b/backend/src/modules/mail/draft.controller.ts @@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -36,8 +37,7 @@ const SAVE_BAD_REQUEST = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @Controller(DRAFTS_ROUTE) diff --git a/backend/src/modules/mail/images/mail-image.controller.ts b/backend/src/modules/mail/images/mail-image.controller.ts index ff4d83e..74ae599 100644 --- a/backend/src/modules/mail/images/mail-image.controller.ts +++ b/backend/src/modules/mail/images/mail-image.controller.ts @@ -17,7 +17,10 @@ import { import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; -import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { + ApiErrorResponse, + ApiSessionErrors, +} from '@/common/decorators/api-standard-response.decorator'; import { CurrentUser, type AuthenticatedUser, @@ -68,7 +71,7 @@ export function releaseWhenDone(res: Response, release: () => void): void { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(MAIL_IMAGES_ROUTE) export class MailImageController { constructor(private readonly proxy: MailImageProxyService) {} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 5efb4f3..cf213ac 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -7949,6 +7949,19 @@ describe('JmapMailProvider', () => { }, }); }); + + it('첫 메서드(제출 찾기)가 막히면 그 오류로 — 뒤 메서드의 invalidResultReference(엔진 장애 503)로 보이지 않는다 (실사용 시험)', async () => { + const { provider } = fakeClient([ + [ + ['error', { type: 'forbidden' }, 'pq'], + ['error', { type: 'invalidResultReference' }, 'pg'], + ['error', { type: 'invalidResultReference' }, 'g'], + ], + ]); + await expect(provider.listScheduledMessages(ARA)).rejects.toMatchObject( + { code: 'AUTH_003' }, + ); + }); }); describe('붙잡은 발송 취소 (cancelPendingSend)', () => { diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 601ed11..bbfbfac 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -852,6 +852,11 @@ export class JmapMailProvider implements MailProvider { const responses = await this.jmap.call(user, (accountId) => [ ['Identity/get', { accountId, ids: null }, 'i'], ]); + const identities = findResult(responses, 'i', 'Identity/get'); + if (!identities.ok && identities.error.type === 'forbidden') { + // 세션은 열렸는데 메일 메서드가 막혔다 — 그사이 비밀번호가 만료된 계정이면 그렇게 알린다(13단계, 아니면 아래가 지금처럼 던진다) + await this.jmap.throwIfPasswordExpiredFor(session.username); + } const { list = [] } = expectResult>( responses, 'i', @@ -2589,6 +2594,9 @@ export class JmapMailProvider implements MailProvider { 'g', ], ]); + // 첫 메서드(제출 찾기)의 오류부터 본다 — 막혔으면(forbidden) 뒤 메서드는 그 결과를 참조하지 못해 invalidResultReference 로 끝난다. + // 뒤의 것을 알리면 권한 문제가 엔진 장애(503 · ERROR)로 보였다 (실사용 시험 — 재설정 직후 15초) + expectResult(responses, `${HELD_CALLS}q`, 'EmailSubmission/query'); const { list: submissions = [] } = expectResult< JmapGetResult >(responses, `${HELD_CALLS}g`, 'EmailSubmission/get'); diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts new file mode 100644 index 0000000..f860ec3 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts @@ -0,0 +1,646 @@ +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { + MailFilterSet, + MailVacationState, +} from '@/modules/mail/mail.types'; +import { + LOCK_FORWARD_RULE_NAME, + OpsEngineError, +} from '@/modules/ops/ops-engine.port'; +import type { JmapAccountSecurityProvider } from './jmap-account-security.provider'; +import type { JmapFilterScriptStore } from './jmap-filter-script'; +import { JmapOpsEngine, permissionsWithLogin } from './jmap-ops-engine'; + +const DOMAIN = 'arasystem.test'; +const ADMIN = `admin@${DOMAIN}`; +const ARA = `ara.kim@${DOMAIN}`; + +const settings: MailSettings = { + jmapBaseUrl: 'http://mail:8080', + domain: DOMAIN, + gatewayUser: `mail-gateway@${DOMAIN}`, + gatewayPassword: 'gateway-secret', + attachmentMaxBytes: 1, + attachmentTotalMaxBytes: 1, + blockedAccounts: new Set([ADMIN, `mail-directory@${DOMAIN}`]), + directoryAccount: `mail-directory@${DOMAIN}`, +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const adminSession = { + username: ADMIN, + apiUrl: 'https://mail.example/jmap/', + primaryAccounts: { 'urn:stalwart:jmap': 'adm' }, +}; + +/** 계정 하나 (관리 API 의 모양) */ +const araAccount = { + id: 'acc1', + name: 'ara.kim', + emailAddress: ARA, + description: '김아라', + permissions: { '@type': 'Inherit' }, + credentials: { + '0': { + '@type': 'Password', + secret: '****', + otpAuth: '****', + expiresAt: null, + }, + '1': { '@type': 'AppPassword', secret: '****' }, + }, + quotas: { maxDiskQuota: 1073741824 }, + usedDiskQuota: 2048, +}; + +describe('permissionsWithLogin — 로그인 권한 빼기 · 되돌리기 (13단계)', () => { + it('상속(Inherit)이면 Merge 로 authenticate 만 뺀다 · 되돌리면 다시 상속', () => { + const locked = permissionsWithLogin({ '@type': 'Inherit' }, false); + expect(locked).toEqual({ + '@type': 'Merge', + enabledPermissions: {}, + disabledPermissions: { authenticate: true }, + }); + expect(permissionsWithLogin(locked as never, true)).toEqual({ + '@type': 'Inherit', + }); + }); + + it('Merge 에 다른 권한이 있으면 그것은 지킨다', () => { + const current = { + '@type': 'Merge', + enabledPermissions: { emailSend: true }, + disabledPermissions: { jmapPrincipalQuery: true, authenticate: true }, + }; + expect(permissionsWithLogin(current, true)).toEqual({ + '@type': 'Merge', + enabledPermissions: { emailSend: true }, + disabledPermissions: { jmapPrincipalQuery: true }, + }); + }); + + it('이미 그 상태면 unchanged · 직접 정한 권한(Replace)은 바꾸지 않는다', () => { + expect(permissionsWithLogin({ '@type': 'Inherit' }, true)).toBe( + 'unchanged', + ); + expect(() => + permissionsWithLogin({ '@type': 'Replace', permissions: {} }, false), + ).toThrow(OpsEngineError); + }); +}); + +describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { + let fetchMock: jest.SpyInstance; + let accountSecurity: { deleteAllAppPasswords: jest.Mock }; + let filterScripts: { + getFilters: jest.Mock; + setFilters: jest.Mock; + getVacation: jest.Mock; + setVacation: jest.Mock; + }; + let engine: JmapOpsEngine; + + /** n 번째 관리 API 호출의 본문 */ + const bodyAt = (index: number) => + JSON.parse( + (fetchMock.mock.calls[index] as [string, RequestInit])[1].body as string, + ) as { methodCalls: [string, Record, string][] }; + + const signedIn = async () => { + fetchMock + .mockResolvedValueOnce(json(adminSession)) + .mockResolvedValueOnce( + json({ methodResponses: [['x:Account/get', { list: [] }, 'p']] }), + ); + await expect(engine.signIn('admin-secret')).resolves.toBe('ok'); + }; + + beforeEach(() => { + fetchMock = jest.spyOn(global, 'fetch'); + accountSecurity = { deleteAllAppPasswords: jest.fn().mockResolvedValue(3) }; + const filters: MailFilterSet = { rules: [], version: 'v1', isActive: true }; + const vacation: MailVacationState = { + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', + isActive: true, + }; + filterScripts = { + getFilters: jest.fn().mockResolvedValue(filters), + setFilters: jest.fn().mockResolvedValue(filters), + getVacation: jest.fn().mockResolvedValue(vacation), + setVacation: jest.fn().mockResolvedValue(vacation), + }; + engine = new JmapOpsEngine( + settings, + ADMIN, + accountSecurity as unknown as JmapAccountSecurityProvider, + filterScripts as unknown as JmapFilterScriptStore, + ); + }); + + afterEach(() => fetchMock.mockRestore()); + + it('관리자 로그인 — 401 은 rejected, 402 는 two-factor, 관리 권한이 없으면 not-admin', async () => { + fetchMock.mockResolvedValueOnce(json({}, 401)); + await expect(engine.signIn('x')).resolves.toBe('rejected'); + fetchMock.mockResolvedValueOnce(json({}, 402)); + await expect(engine.signIn('x')).resolves.toBe('two-factor'); + fetchMock + .mockResolvedValueOnce(json(adminSession)) + .mockResolvedValueOnce( + json({ methodResponses: [['error', { type: 'forbidden' }, 'p']] }), + ); + await expect(engine.signIn('x')).resolves.toBe('not-admin'); + // 관리자 Basic 은 관리자 주소로 · 엔진 안 주소(MAIL_JMAP_URL)에 경로만 붙인다 + const [url, init] = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(url).toBe('http://mail:8080/jmap/session'); + const authorization = (init.headers as Record) + .authorization; + expect(Buffer.from(authorization.slice(6), 'base64').toString()).toBe( + `${ADMIN}:x`, + ); + expect((fetchMock.mock.calls[3] as [string])[0]).toBe( + 'http://mail:8080/jmap/', + ); + }); + + it('로그인하지 않았으면 관리 API 를 부르지 않는다', async () => { + await expect(engine.findEmployee(ARA)).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + }); + + it('직원 찾기 — 잠금 · 2단계 · 앱 비밀번호 수 · 용량을 읽는다, 관리자 주소는 직원이 아니다', async () => { + await signedIn(); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/query', { ids: ['acc1'] }, 'q']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }), + ); + await expect(engine.findEmployee(ARA)).resolves.toEqual({ + id: 'acc1', + email: ARA, + name: '김아라', + locked: false, + customPermissions: false, + passwordExpired: false, + twoFactorEnabled: true, + appPasswords: 1, + usedBytes: 2048, + quotaBytes: 1073741824, + }); + expect(bodyAt(2).methodCalls[0][1]).toMatchObject({ + filter: { name: 'ara.kim' }, + }); + await expect(engine.findEmployee(ADMIN)).resolves.toBeNull(); + }); + + it('입사 — 직원 역할 · 임시 비밀번호 · 지난 만료 · 용량을 한 번에 만든다', async () => { + await signedIn(); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Domain/query', { ids: ['d1'] }, 'q'], + ['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { created: { a: { id: 'acc9' } } }, 's'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/get', + { + list: [ + { + ...araAccount, + id: 'acc9', + emailAddress: `hong@${DOMAIN}`, + credentials: { + '0': { + '@type': 'Password', + expiresAt: '2020-01-01T00:00:00Z', + }, + }, + }, + ], + }, + 'g', + ], + ], + }), + ); + const created = await engine.createEmployee({ + localPart: 'hong', + name: '홍길동', + quotaBytes: 5, + tempPassword: 'Temp1234Temp1234', + }); + expect(created).toMatchObject({ id: 'acc9', passwordExpired: true }); + const create = bodyAt(3).methodCalls[0][1].create as Record< + string, + Record + >; + expect(create.a).toMatchObject({ + '@type': 'User', + name: 'hong', + domainId: 'd1', + description: '홍길동', + roles: { '@type': 'User' }, + quotas: { maxDiskQuota: 5 }, + }); + const credential = ( + create.a.credentials as Record> + )['0']; + expect(credential.secret).toBe('Temp1234Temp1234'); + expect(Date.parse(credential.expiresAt as string)).toBeLessThan(Date.now()); + }); + + it('약한 임시 비밀번호(invalidProperties secret)는 weak-password, 같은 이름은 exists', async () => { + await signedIn(); + const domain = json({ + methodResponses: [ + ['x:Domain/query', { ids: ['d1'] }, 'q'], + ['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'], + ], + }); + fetchMock.mockResolvedValueOnce(domain).mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/set', + { + notCreated: { + a: { type: 'invalidProperties', properties: ['secret'] }, + }, + }, + 's', + ], + ], + }), + ); + await expect( + engine.createEmployee({ + localPart: 'hong', + name: '홍', + quotaBytes: null, + tempPassword: 'weak', + }), + ).rejects.toMatchObject({ kind: 'weak-password' }); + + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/set', + { notCreated: { a: { type: 'primaryKeyViolation' } } }, + 's', + ], + ], + }), + ); + await expect( + engine.createEmployee({ + localPart: 'hong', + name: '홍', + quotaBytes: null, + tempPassword: 'x', + }), + ).rejects.toMatchObject({ kind: 'exists' }); + }); + + it('재설정 · 2단계 해제 · 잠금은 비밀번호 자격의 칸 · 권한 칸을 패치한다', async () => { + await signedIn(); + const account = json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }); + const updated = json({ + methodResponses: [['x:Account/set', { updated: { acc1: null } }, 's']], + }); + fetchMock + .mockResolvedValueOnce(account) + .mockResolvedValueOnce(updated) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { updated: { acc1: null } }, 's'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { updated: { acc1: null } }, 's'], + ], + }), + ); + + await engine.resetPassword('acc1', 'NewTemp1234NewTe'); + const reset = ( + bodyAt(3).methodCalls[0][1].update as Record< + string, + Record + > + ).acc1; + expect(reset['credentials/0/secret']).toBe('NewTemp1234NewTe'); + expect(Date.parse(reset['credentials/0/expiresAt'] as string)).toBeLessThan( + Date.now(), + ); + // 2단계 인증(OTP)은 칸에 넣지 않는다 — 관리자의 비밀번호 바꾸기는 OTP 를 남긴다(엔진 확인) + expect(reset).not.toHaveProperty('credentials/0/otpAuth'); + + await engine.removeTwoFactor('acc1'); + expect( + (bodyAt(5).methodCalls[0][1].update as Record).acc1, + ).toEqual({ 'credentials/0/otpAuth': null }); + + await engine.setLoginEnabled('acc1', false); + expect( + (bodyAt(7).methodCalls[0][1].update as Record).acc1, + ).toEqual({ + permissions: { + '@type': 'Merge', + enabledPermissions: {}, + disabledPermissions: { authenticate: true }, + }, + }); + }); + + it('주소만으로 직원인지 — 관리자 · 게이트웨이 · 사내 목록 · 다른 도메인은 아니다 (엔진에 묻지 않는다)', () => { + expect(engine.isEmployeeAddress(ARA)).toBe(true); + expect(engine.isEmployeeAddress(` ${ARA.toUpperCase()} `)).toBe(true); + for (const address of [ + ADMIN, + `mail-gateway@${DOMAIN}`, + `mail-directory@${DOMAIN}`, + 'ara.kim@other.test', + ]) { + expect(engine.isEmployeeAddress(address)).toBe(false); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('계정 종류(@type)를 읽고 User 만 직원이다 — Group(공유 메일함)은 현황 · 명령에서 뺀다', async () => { + await signedIn(); + const teamAccount = { + ...araAccount, + id: 'grp1', + '@type': 'Group', + name: 'team', + emailAddress: `team@${DOMAIN}`, + }; + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/query', { ids: ['acc1', 'grp1'] }, 'q'], + [ + 'x:Account/get', + { list: [{ ...araAccount, '@type': 'User' }, teamAccount] }, + 'g', + ], + ], + }), + ); + const employees = await engine.listEmployees(); + expect(employees.map((employee) => employee.email)).toEqual([ARA]); + // 칸을 적어야 돌려준다 — @type 을 함께 읽는다 + expect(bodyAt(2).methodCalls[1][1].properties).toContain('@type'); + + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/query', { ids: ['grp1'] }, 'q']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [teamAccount] }, 'g']], + }), + ); + await expect(engine.findEmployee(`team@${DOMAIN}`)).resolves.toBeNull(); + }); + + it('메서드의 forbidden 은 그 일의 거절(refused)이다 — 관리자 로그인이 끊긴 것은 HTTP 401 · 403 뿐', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [['error', { type: 'forbidden' }, 's']], + }), + ); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'refused', + detail: 'forbidden', + }); + // 로그인은 그대로다 — 다음 호출도 보낸다 + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/set', { destroyed: ['acc1'] }, 's']], + }), + ); + await expect(engine.deleteAccount('acc1')).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(json({}, 403)); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + }); + + it('머리글 뒤 본문이 멈추면 제한 시간에 끊는다(timeout) — 본문 읽기도 같은 시간 안', async () => { + jest.useFakeTimers(); + try { + await signedIn(); + // 머리글은 곧 오고 본문은 오지 않는 응답 — 신호가 끊기면 읽기도 끊긴다 + fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { + const signal = init.signal as AbortSignal; + const body = new ReadableStream({ + start(controller) { + signal.addEventListener('abort', () => + controller.error(new DOMException('aborted', 'AbortError')), + ); + }, + }); + return Promise.resolve( + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + const pending = engine.deleteAccount('acc1'); + const settled = expect(pending).rejects.toMatchObject({ + kind: 'unavailable', + detail: 'timeout', + }); + await jest.advanceTimersByTimeAsync(15_000); + await settled; + } finally { + jest.useRealTimers(); + } + }); + + it('본문이 JSON 이 아니면 malformed-response', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce( + new Response('oops', { status: 200 }), + ); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'unavailable', + detail: 'malformed-response', + }); + }); + + it('관리자 로그인이 그사이 끊기면(401) not-signed-in — 다시 로그인할 때까지 부르지 않는다', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce(json({}, 401)); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('잠금 Sieve — 넘기기 규칙을 맨 앞에(모든 메일 · 사본) · 자동 응답을 켠다, 떼기는 그 규칙만 빼고 자동 응답을 끈다', async () => { + const userRule = { + id: 'r1', + name: '내 규칙', + isEnabled: true, + match: 'any' as const, + conditions: [ + { field: 'from' as const, operator: 'contains' as const, value: 'x' }, + ], + actions: { + moveToMailboxId: null, + markAsRead: true, + flag: false, + forwardTo: null, + }, + }; + filterScripts.getFilters.mockResolvedValue({ + rules: [userRule], + version: 'v1', + isActive: true, + }); + await engine.applyLockRules(ARA, { + forwardTo: `doyun.lee@${DOMAIN}`, + autoReply: { subject: '부재 안내', body: '퇴사했습니다.' }, + }); + const saved = (filterScripts.setFilters.mock.calls[0] as unknown[])[1] as { + rules: { + name: string; + match: string; + actions: { forwardTo: string | null }; + }[]; + version: string; + }; + expect(saved.version).toBe('v1'); + expect(saved.rules.map((rule) => rule.name)).toEqual([ + LOCK_FORWARD_RULE_NAME, + '내 규칙', + ]); + expect(saved.rules[0]).toMatchObject({ + match: 'always', + actions: { forwardTo: `doyun.lee@${DOMAIN}` }, + }); + expect(filterScripts.setVacation).toHaveBeenCalledWith(ARA, { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재 안내', + textBody: '퇴사했습니다.', + }); + + filterScripts.getFilters.mockResolvedValue({ + rules: saved.rules, + version: 'v2', + isActive: true, + }); + filterScripts.getVacation.mockResolvedValue({ + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재 안내', + textBody: '퇴사했습니다.', + isActive: true, + }); + await expect(engine.getLockRules(ARA)).resolves.toEqual({ + forwardTo: `doyun.lee@${DOMAIN}`, + autoReply: true, + }); + await engine.clearLockRules(ARA); + expect(filterScripts.setFilters).toHaveBeenLastCalledWith(ARA, { + rules: [saved.rules[1]], + version: 'v2', + }); + expect(filterScripts.setVacation).toHaveBeenLastCalledWith( + ARA, + expect.objectContaining({ isEnabled: false, subject: '부재 안내' }), + ); + }); + + it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => { + filterScripts.getFilters.mockResolvedValue({ + rules: Array.from({ length: 50 }, (_, index) => ({ + id: `r${index}`, + name: `규칙 ${index}`, + isEnabled: true, + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: true, + flag: false, + forwardTo: null, + }, + })), + version: 'v1', + isActive: true, + }); + await expect( + engine.applyLockRules(ARA, { + forwardTo: `doyun.lee@${DOMAIN}`, + autoReply: null, + }), + ).rejects.toMatchObject({ kind: 'too-many-rules' }); + expect(filterScripts.setFilters).not.toHaveBeenCalled(); + }); + + it('앱 비밀번호 지우기는 대리 접근 어댑터에 맡긴다', async () => { + await expect(engine.deleteAppPasswords(ARA)).resolves.toBe(3); + expect(accountSecurity.deleteAllAppPasswords).toHaveBeenCalledWith(ARA); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.ts new file mode 100644 index 0000000..d1eeca5 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.ts @@ -0,0 +1,784 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { isEmployeeAddress } from '@/modules/auth/employee-address'; +import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants'; +import { + MAIL_SETTINGS, + isMailEngineConfigured, + type MailSettings, +} from '@/modules/mail/mail.settings'; +import type { MailFilterRule } from '@/modules/mail/mail.types'; +import { + LOCK_FORWARD_RULE_NAME, + OPS_ADMIN_ACCOUNT, + OpsEngineError, + type EngineEmployee, + type EngineMailingList, + type LockRules, + type LockRulesState, + type OpsEngine, + type OpsSignInResult, +} from '@/modules/ops/ops-engine.port'; +import { JmapAccountSecurityProvider } from './jmap-account-security.provider'; +import { JmapFilterScriptStore } from './jmap-filter-script'; +import { basicAuthorization, toRelativeReference } from './jmap.client'; +import { fallbackDisplayName } from './jmap.mapper'; +import type { + JmapError, + JmapInvocation, + JmapResponseBody, + JmapSessionResource, +} from './jmap.types'; + +/** 관리 API(Stalwart `x:` 객체)의 기능 — 관리자 세션의 primaryAccounts 에서 이 이름으로 계정 id 를 찾는다 */ +const MANAGEMENT_CAPABILITY = 'urn:stalwart:jmap'; +const CORE_CAPABILITY = 'urn:ietf:params:jmap:core'; +const SESSION_PATH = '/jmap/session'; +/** 관리 API 한 번의 제한 시간 */ +const ADMIN_REQUEST_TIMEOUT_MS = 15_000; +/** 목록을 한 번에 읽는 상한 — 직원 · 공용 주소 수보다 넉넉하게 */ +const LIST_LIMIT = 1_000; +/** 계정에서 읽는 칸 — 현황 · 명령에 쓰는 것만 */ +const ACCOUNT_PROPERTIES = [ + // 계정 종류 — 칸을 적어야 돌려준다(2026-11-02 확인). User 만 직원이다(Group 은 공유 메일함) + '@type', + 'name', + 'emailAddress', + 'description', + 'permissions', + 'credentials', + 'quotas', + 'usedDiskQuota', +]; +/** 만료를 지난 날로 — "다음 로그인 때 바꾸기" (엔진이 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다 — 2026-11-02 확인) */ +const EXPIRED_AT_OFFSET_MS = 60_000; + +const HTTP_UNAUTHORIZED = 401; +const HTTP_PAYMENT_REQUIRED = 402; +const HTTP_FORBIDDEN = 403; + +/** 엔진의 계정 (관리 API — 읽는 칸만) */ +interface JmapAccount { + readonly id?: string; + readonly '@type'?: unknown; + readonly name?: unknown; + readonly emailAddress?: unknown; + readonly description?: unknown; + readonly permissions?: JmapPermissions; + readonly credentials?: Record; + readonly quotas?: { readonly maxDiskQuota?: unknown } | null; + readonly usedDiskQuota?: unknown; +} + +interface JmapPermissions { + readonly '@type'?: unknown; + readonly enabledPermissions?: Record; + readonly disabledPermissions?: Record; + readonly permissions?: Record; +} + +interface JmapCredential { + readonly '@type'?: unknown; + readonly expiresAt?: unknown; + /** 켜져 있으면 "****"(가린 값), 꺼져 있으면 칸이 없다 */ + readonly otpAuth?: unknown; +} + +interface JmapMailingList { + readonly id?: string; + readonly name?: unknown; + readonly emailAddress?: unknown; + readonly description?: unknown; + readonly recipients?: Record | null; +} + +interface AdminSession { + readonly authorization: string; + readonly accountId: string; + readonly apiPath: string; +} + +interface SetResult { + readonly created?: Record; + readonly notCreated?: Record; + readonly updated?: Record; + readonly notUpdated?: Record; + readonly destroyed?: string[]; + readonly notDestroyed?: Record; +} + +interface QueryResult { + readonly ids?: string[]; +} + +interface GetResult { + readonly list?: T[]; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** 엔진의 SetError → 도구의 까닭 (종류만 — 설명 문장은 넣지 않는다) */ +function refusal(error: JmapError | undefined): OpsEngineError { + const type = error?.type ?? 'unknown'; + if (type === 'invalidProperties' && error?.properties?.includes('secret')) { + return new OpsEngineError('weak-password', type); + } + if (type === 'primaryKeyViolation' || type === 'alreadyExists') { + return new OpsEngineError('exists', type); + } + if (type === 'notFound') return new OpsEngineError('not-found', type); + return new OpsEngineError('refused', type); +} + +/** 계정 권한 칸에서 로그인 권한이 빠졌는가 */ +function isLocked(permissions: JmapPermissions | undefined): boolean { + const type = permissions?.['@type']; + if (type === 'Merge') { + return permissions?.disabledPermissions?.authenticate === true; + } + if (type === 'Replace') + return permissions?.permissions?.authenticate !== true; + return false; +} + +/** 비밀번호 자격(@type Password)의 id — 계정을 만들 때 "0" 이다 */ +function passwordCredentialId(account: JmapAccount): string | null { + for (const [id, credential] of Object.entries(account.credentials ?? {})) { + if (credential?.['@type'] === 'Password') return id; + } + return null; +} + +/** + * 로그인 권한을 빼거나 되돌린 권한 칸 — 상속(Inherit)이면 Merge 로 빼고, Merge 면 그 안에서 바꾼다. + * 되돌린 뒤 Merge 에 남은 것이 없으면 상속으로 돌린다. 직접 정한 권한(Replace)은 바꾸지 않는다 + */ +export function permissionsWithLogin( + permissions: JmapPermissions | undefined, + enabled: boolean, +): JmapPermissions | 'unchanged' { + const type = permissions?.['@type'] ?? 'Inherit'; + if (type === 'Replace') { + throw new OpsEngineError('custom-permissions'); + } + const enabledPermissions: Record = {}; + const disabledPermissions: Record = {}; + if (type === 'Merge') { + for (const [name, on] of Object.entries( + permissions?.enabledPermissions ?? {}, + )) { + if (on === true) enabledPermissions[name] = true; + } + for (const [name, on] of Object.entries( + permissions?.disabledPermissions ?? {}, + )) { + if (on === true) disabledPermissions[name] = true; + } + } + const lockedNow = disabledPermissions.authenticate === true; + if (enabled ? !lockedNow : lockedNow) return 'unchanged'; + if (enabled) { + delete disabledPermissions.authenticate; + } else { + delete enabledPermissions.authenticate; + disabledPermissions.authenticate = true; + } + if ( + Object.keys(enabledPermissions).length === 0 && + Object.keys(disabledPermissions).length === 0 + ) { + return { '@type': 'Inherit' }; + } + return { '@type': 'Merge', enabledPermissions, disabledPermissions }; +} + +/** + * Stalwart 운영 어댑터 — OpsEngine 의 구현 (13단계 — 계약 6절 "운영 도구"). + * - 관리자 Basic: `x:Account` · `x:MailingList` · `x:Domain`(계정을 만들 도메인 id) 관리 API 에만 — 비밀번호는 이 객체의 메모리에만, 로그에 남기지 않는다 + * - 게이트웨이 대리 접근: 앱 비밀번호(JmapAccountSecurityProvider) · 잠금 Sieve(JmapFilterScriptStore — 직원의 필터 · 부재중과 한 스크립트. + * 엔진은 계정마다 스크립트 하나만 켠다 — 따로 스크립트를 켜면 직원의 필터 · 부재중 · 차단이 꺼진다, 2026-11-02 다시 확인) + * 엔진 동작(2026-11-02 개발 엔진 확인): 만들 때 만료를 함께 받는다 · 관리자의 비밀번호 바꾸기는 OTP 를 남긴다 · + * OTP 지우기는 비밀번호 자격의 otpAuth 를 null 로 · 잠금은 계정 권한의 Merge disabledPermissions.authenticate + */ +@Injectable() +export class JmapOpsEngine implements OpsEngine { + private admin: AdminSession | null = null; + private domainId: string | null = null; + + constructor( + @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, + @Inject(OPS_ADMIN_ACCOUNT) private readonly adminAccount: string, + private readonly accountSecurity: JmapAccountSecurityProvider, + private readonly filterScripts: JmapFilterScriptStore, + ) {} + + async signIn(adminPassword: string): Promise { + const authorization = basicAuthorization(this.adminAccount, adminPassword); + const reply = await this.exchange( + SESSION_PATH, + { headers: { authorization } }, + async (response) => { + if ( + response.status === HTTP_UNAUTHORIZED || + response.status === HTTP_FORBIDDEN + ) { + await discard(response); + return 'rejected' as const; + } + if (response.status === HTTP_PAYMENT_REQUIRED) { + await discard(response); + return 'two-factor' as const; + } + if (!response.ok) { + await discard(response); + throw new OpsEngineError('unavailable', `http-${response.status}`); + } + return (await readJson(response)) as JmapSessionResource | null; + }, + ); + if (reply === 'rejected' || reply === 'two-factor') return reply; + const session = reply; + const accountId = session?.primaryAccounts?.[MANAGEMENT_CAPABILITY]; + if (!accountId || !session?.apiUrl) return 'not-admin'; + this.admin = { + authorization, + accountId, + apiPath: toRelativeReference(session.apiUrl), + }; + // 관리 권한 — 데이터를 읽지 않는 계정 목록 호출 하나 (직원이면 forbidden) + const [probe] = await this.call([ + ['x:Account/get', { accountId, ids: [], properties: ['name'] }, 'p'], + ]); + if (!probe || probe[0] === 'error') { + this.admin = null; + return 'not-admin'; + } + return 'ok'; + } + + isEmployeeAddress(email: string): boolean { + return this.isEmployee(email.trim().toLowerCase()); + } + + async findEmployee(email: string): Promise { + const address = email.trim().toLowerCase(); + if (!this.isEmployee(address)) return null; + const account = await this.findAccount(address); + return account ? this.toEmployee(account) : null; + } + + async listEmployees(): Promise { + const accounts = await this.getAll( + 'Account', + ACCOUNT_PROPERTIES, + ); + return accounts + .map((account) => this.toEmployee(account)) + .filter((employee): employee is EngineEmployee => employee !== null) + .filter((employee) => this.isEmployee(employee.email)) + .sort((a, b) => a.email.localeCompare(b.email)); + } + + async addressInUse(email: string): Promise { + const address = email.trim().toLowerCase(); + if (await this.findAccount(address)) return true; + const lists = await this.listMailingLists(); + return lists.some((list) => list.email === address); + } + + async createEmployee(input: { + readonly localPart: string; + readonly name: string; + readonly quotaBytes: number | null; + readonly tempPassword: string; + }): Promise { + const domainId = await this.requireDomainId(); + const result = await this.set('Account', { + create: { + a: { + '@type': 'User', + name: input.localPart, + domainId, + description: input.name, + roles: { '@type': 'User' }, + credentials: { + 0: { + '@type': 'Password', + secret: input.tempPassword, + expiresAt: this.expiredAt(), + }, + }, + ...(input.quotaBytes !== null + ? { quotas: { maxDiskQuota: input.quotaBytes } } + : {}), + }, + }, + }); + const failure = result.notCreated?.a; + if (failure) throw refusal(failure); + const id = result.created?.a?.id; + if (!id) throw new OpsEngineError('refused', 'no-created-id'); + const created = await this.getAccounts([id]); + const employee = created[0] ? this.toEmployee(created[0]) : null; + if (!employee) throw new OpsEngineError('refused', 'created-unreadable'); + return employee; + } + + async resetPassword(id: string, tempPassword: string): Promise { + const credentialId = await this.requirePasswordCredential(id); + await this.update('Account', id, { + [`credentials/${credentialId}/secret`]: tempPassword, + [`credentials/${credentialId}/expiresAt`]: this.expiredAt(), + }); + } + + async removeTwoFactor(id: string): Promise { + const credentialId = await this.requirePasswordCredential(id); + await this.update('Account', id, { + [`credentials/${credentialId}/otpAuth`]: null, + }); + } + + async setLoginEnabled(id: string, enabled: boolean): Promise { + const [account] = await this.getAccounts([id]); + if (!account) throw new OpsEngineError('not-found'); + const next = permissionsWithLogin(account.permissions, enabled); + if (next === 'unchanged') return; + await this.update('Account', id, { permissions: next }); + } + + async deleteAccount(id: string): Promise { + const result = await this.set('Account', { destroy: [id] }); + const failure = result.notDestroyed?.[id]; + if (failure && failure.type !== 'notFound') throw refusal(failure); + } + + async listMailingLists(): Promise { + const lists = await this.getAll('MailingList', null); + return lists + .map((list) => this.toMailingList(list)) + .filter((list): list is EngineMailingList => list !== null) + .sort((a, b) => a.email.localeCompare(b.email)); + } + + async createMailingList(input: { + readonly localPart: string; + readonly name: string; + readonly members: readonly string[]; + }): Promise { + const domainId = await this.requireDomainId(); + const result = await this.set('MailingList', { + create: { + l: { + '@type': 'MailingList', + name: input.localPart, + domainId, + description: input.name, + recipients: Object.fromEntries( + input.members.map((member) => [member, true]), + ), + }, + }, + }); + const failure = result.notCreated?.l; + if (failure) throw refusal(failure); + const id = result.created?.l?.id; + const created = (await this.listMailingLists()).find( + (list) => list.id === id, + ); + if (!created) throw new OpsEngineError('refused', 'created-unreadable'); + return created; + } + + async setMailingListMember( + id: string, + member: string, + included: boolean, + ): Promise { + await this.update('MailingList', id, { + [`recipients/${member}`]: included ? true : null, + }); + } + + // ── 게이트웨이 대리 접근 (직원의 앱 비밀번호 · Sieve) ────────────────────────── + + deleteAppPasswords(email: string): Promise { + return this.accountSecurity.deleteAllAppPasswords(email); + } + + async getLockRules(email: string): Promise { + const [filters, vacation] = await Promise.all([ + this.filterScripts.getFilters(email), + this.filterScripts.getVacation(email), + ]); + const rule = filters.rules.find(isLockRule); + return { + forwardTo: rule?.actions.forwardTo ?? null, + autoReply: vacation.isEnabled, + }; + } + + async applyLockRules(email: string, rules: LockRules): Promise { + if (rules.forwardTo !== null) { + const filters = await this.filterScripts.getFilters(email); + const others = filters.rules.filter((rule) => !isLockRule(rule)); + if (others.length + 1 > MAX_MAIL_FILTER_RULES) { + throw new OpsEngineError('too-many-rules'); + } + // 맨 앞에 — 엔진은 한 통에 리다이렉트 하나만 한다(뒤의 전달은 버린다). 사본은 그 직원의 받은편지함에도 남는다 + const lockRule: MailFilterRule = { + id: randomUUID(), + name: LOCK_FORWARD_RULE_NAME, + isEnabled: true, + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: false, + flag: false, + forwardTo: rules.forwardTo, + }, + }; + await this.filterScripts.setFilters(email, { + rules: [lockRule, ...others], + version: filters.version, + }); + } + if (rules.autoReply !== null) { + await this.filterScripts.setVacation(email, { + isEnabled: true, + fromDate: null, + toDate: null, + subject: rules.autoReply.subject, + textBody: rules.autoReply.body, + }); + } + } + + async clearLockRules(email: string): Promise { + const filters = await this.filterScripts.getFilters(email); + if (filters.rules.some(isLockRule)) { + await this.filterScripts.setFilters(email, { + rules: filters.rules.filter((rule) => !isLockRule(rule)), + version: filters.version, + }); + } + const vacation = await this.filterScripts.getVacation(email); + if (vacation.isEnabled) { + await this.filterScripts.setVacation(email, { + isEnabled: false, + fromDate: vacation.fromDate, + toDate: vacation.toDate, + subject: vacation.subject, + textBody: vacation.textBody, + }); + } + } + + // ── 관리 API ─────────────────────────────────────────────────────────────── + + private isEmployee(address: string): boolean { + const { domain, gatewayUser, blockedAccounts } = this.settings; + return ( + domain !== null && + isEmployeeAddress(address, domain, gatewayUser, blockedAccounts) + ); + } + + private expiredAt(): string { + return new Date(Date.now() - EXPIRED_AT_OFFSET_MS) + .toISOString() + .replace(/\.\d+Z$/, 'Z'); + } + + private toEmployee(account: JmapAccount): EngineEmployee | null { + const id = account.id; + const email = asString(account.emailAddress)?.toLowerCase() ?? null; + if (!id || email === null) return null; + // 직원은 User 계정뿐이다 — Group(공유 메일함)은 현황 · 명령의 대상이 아니다. 칸이 없으면(옛 엔진) User 로 본다 + const type = asString(account['@type']); + if (type !== null && type !== 'User') return null; + const credentials = Object.values(account.credentials ?? {}); + const password = credentials.find( + (credential) => credential?.['@type'] === 'Password', + ); + const expiresAt = asString(password?.expiresAt); + const description = asString(account.description)?.trim(); + return { + id, + email, + name: description || fallbackDisplayName(email), + locked: isLocked(account.permissions), + customPermissions: account.permissions?.['@type'] === 'Replace', + passwordExpired: + expiresAt !== null && Date.parse(expiresAt) <= Date.now(), + twoFactorEnabled: + password?.otpAuth !== undefined && password.otpAuth !== null, + appPasswords: credentials.filter( + (credential) => credential?.['@type'] === 'AppPassword', + ).length, + usedBytes: asNumber(account.usedDiskQuota), + quotaBytes: asNumber(account.quotas?.maxDiskQuota), + }; + } + + private toMailingList(list: JmapMailingList): EngineMailingList | null { + const email = asString(list.emailAddress)?.toLowerCase() ?? null; + if (!list.id || email === null) return null; + return { + id: list.id, + email, + name: asString(list.description) ?? asString(list.name) ?? email, + members: Object.entries(list.recipients ?? {}) + .filter(([, on]) => on === true) + .map(([address]) => address.toLowerCase()) + .sort(), + }; + } + + /** 주소로 계정 하나 — 로컬 이름으로 거른 뒤 주소가 정확히 같은 것만 */ + private async findAccount(address: string): Promise { + const localPart = address.slice(0, address.indexOf('@')); + const [query] = await this.call([ + [ + 'x:Account/query', + { + accountId: this.session().accountId, + filter: { name: localPart }, + limit: LIST_LIMIT, + }, + 'q', + ], + ]); + const ids = this.expect(query, 'x:Account/query').ids ?? []; + if (ids.length === 0) return null; + const accounts = await this.getAccounts(ids); + return ( + accounts.find( + (account) => asString(account.emailAddress)?.toLowerCase() === address, + ) ?? null + ); + } + + private async getAccounts(ids: readonly string[]): Promise { + const [get] = await this.call([ + [ + 'x:Account/get', + { + accountId: this.session().accountId, + ids, + properties: ACCOUNT_PROPERTIES, + }, + 'g', + ], + ]); + return this.expect>(get, 'x:Account/get').list ?? []; + } + + private async getAll( + type: 'Account' | 'MailingList', + properties: readonly string[] | null, + ): Promise { + const accountId = this.session().accountId; + const responses = await this.call([ + [`x:${type}/query`, { accountId, limit: LIST_LIMIT }, 'q'], + [ + `x:${type}/get`, + { + accountId, + '#ids': { resultOf: 'q', name: `x:${type}/query`, path: '/ids' }, + ...(properties ? { properties } : {}), + }, + 'g', + ], + ]); + const get = responses.find((invocation) => invocation[2] === 'g'); + return this.expect>(get, `x:${type}/get`).list ?? []; + } + + private async requireDomainId(): Promise { + if (this.domainId) return this.domainId; + const domain = this.settings.domain; + const accountId = this.session().accountId; + const responses = await this.call([ + ['x:Domain/query', { accountId, limit: LIST_LIMIT }, 'q'], + [ + 'x:Domain/get', + { + accountId, + '#ids': { resultOf: 'q', name: 'x:Domain/query', path: '/ids' }, + properties: ['name'], + }, + 'g', + ], + ]); + const get = responses.find((invocation) => invocation[2] === 'g'); + const found = ( + this.expect>( + get, + 'x:Domain/get', + ).list ?? [] + ).find((item) => asString(item.name)?.toLowerCase() === domain); + if (!found?.id) throw new OpsEngineError('not-found', 'domain'); + this.domainId = found.id; + return found.id; + } + + private async requirePasswordCredential(id: string): Promise { + const [account] = await this.getAccounts([id]); + if (!account) throw new OpsEngineError('not-found'); + const credentialId = passwordCredentialId(account); + if (credentialId === null) { + throw new OpsEngineError('not-found', 'password-credential'); + } + return credentialId; + } + + private async update( + type: 'Account' | 'MailingList', + id: string, + patch: Record, + ): Promise { + const result = await this.set(type, { update: { [id]: patch } }); + const failure = result.notUpdated?.[id]; + if (failure) throw refusal(failure); + if (!result.updated || !(id in result.updated)) { + throw new OpsEngineError('refused', 'not-updated'); + } + } + + private async set( + type: 'Account' | 'MailingList', + args: Record, + ): Promise { + const [invocation] = await this.call([ + [`x:${type}/set`, { accountId: this.session().accountId, ...args }, 's'], + ]); + return this.expect(invocation, `x:${type}/set`); + } + + private expect(invocation: JmapInvocation | undefined, method: string): T { + if (!invocation) throw new OpsEngineError('unavailable', 'no-response'); + const [name, args] = invocation; + if (name === 'error') { + // 메서드의 forbidden 은 그 일을 할 권한이 없다는 뜻이다(예: Group 계정 만들기) — 로그인이 끊긴 것은 HTTP 401 · 403 뿐이다(call) + throw new OpsEngineError( + 'refused', + (args as JmapError).type ?? 'unknown', + ); + } + if (name !== method) throw new OpsEngineError('unavailable', String(name)); + return args as T; + } + + private session(): AdminSession { + if (!this.admin) throw new OpsEngineError('not-signed-in'); + return this.admin; + } + + private async call(methodCalls: JmapInvocation[]): Promise { + const session = this.session(); + const body = await this.exchange( + session.apiPath, + { + method: 'POST', + headers: { + authorization: session.authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + using: [CORE_CAPABILITY, MANAGEMENT_CAPABILITY], + methodCalls, + }), + }, + async (response) => { + if ( + response.status === HTTP_UNAUTHORIZED || + response.status === HTTP_FORBIDDEN + ) { + await discard(response); + this.admin = null; + throw new OpsEngineError('not-signed-in', `http-${response.status}`); + } + if (!response.ok) { + await discard(response); + throw new OpsEngineError('unavailable', `http-${response.status}`); + } + return (await readJson(response)) as JmapResponseBody | null; + }, + ); + if (!body || !Array.isArray(body.methodResponses)) { + throw new OpsEngineError('unavailable', 'malformed-response'); + } + return body.methodResponses; + } + + /** + * 엔진에 한 번 — 머리글을 받고 본문을 다 읽을 때까지 **한 제한 시간** 안에서(머리글 뒤 본문이 멈춰도 도구가 기다리지 않게), + * 다른 곳으로 보내지 않는다(인증 머리글이 다른 호스트로 가지 않게) + */ + private async exchange( + path: string, + init: RequestInit, + handle: (response: Response) => Promise, + ): Promise { + if (!isMailEngineConfigured(this.settings)) { + throw new OpsEngineError('unavailable', 'mail-not-configured'); + } + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(), + ADMIN_REQUEST_TIMEOUT_MS, + ); + try { + const response = await fetch(`${this.settings.jmapBaseUrl}${path}`, { + ...init, + redirect: 'manual', + signal: controller.signal, + }); + return await handle(response); + } catch (error) { + if ( + error instanceof OpsEngineError || + error instanceof BusinessException + ) { + throw error; + } + throw new OpsEngineError( + 'unavailable', + controller.signal.aborted ? 'timeout' : 'network', + ); + } finally { + clearTimeout(timer); + } + } +} + +/** 읽지 않을 본문을 버린다 (연결을 돌려준다) */ +async function discard(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); +} + +/** + * 본문 JSON — 모양이 JSON 이 아니면 null(부르는 쪽이 malformed 로). 읽다가 끊기거나 시간이 다 되면 던진다(exchange 가 timeout · network 로) + */ +async function readJson(response: Response): Promise { + const text = await response.text(); + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +function isLockRule(rule: MailFilterRule): boolean { + return rule.name === LOCK_FORWARD_RULE_NAME; +} diff --git a/backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts b/backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts new file mode 100644 index 0000000..77caf66 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts @@ -0,0 +1,207 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { SystemMailMessage } from '@/modules/system-mail/system-mailbox.port'; +import type { JmapClient } from './jmap.client'; +import { JmapSystemMailbox, buildSystemMailRaw } from './jmap-system-mailbox'; +import { decodeHeaderText } from './mime-text'; + +const ARA = 'ara.kim@arasystem.test'; + +const message: SystemMailMessage = { + kind: 'password-reset', + to: { email: ARA, name: '김아라' }, + subject: '관리자가 비밀번호를 재설정했습니다', + html: '

안녕하세요 — 알림

', + text: '안녕하세요 — 알림\n\n끝', +}; + +const settings = { + domain: 'arasystem.test', +} as unknown as MailSettings; + +/** 원문의 머리글 한 줄 */ +function header(raw: string, name: string): string | undefined { + const head = raw.slice(0, raw.indexOf('\r\n\r\n')); + return head + .split('\r\n') + .find((line) => line.toLowerCase().startsWith(`${name.toLowerCase()}:`)) + ?.slice(name.length + 1) + .trim(); +} + +/** multipart 의 부분 하나를 풀어 본문 글로 */ +function partBody(raw: string, type: string): string { + const start = raw.indexOf(`Content-Type: ${type}`); + const body = raw.slice(raw.indexOf('\r\n\r\n', start) + 4); + const end = body.indexOf('\r\n--'); + return Buffer.from( + body.slice(0, end).replace(/\r\n/g, ''), + 'base64', + ).toString('utf8'); +} + +describe('buildSystemMailRaw — 알림 메일 원문 (13단계)', () => { + const raw = buildSystemMailRaw({ + from: 'postmaster@arasystem.test', + message, + date: new Date('2026-10-30T06:20:00Z'), + messageId: 'ara-notice-1@arasystem.test', + boundary: 'ara-notice-b', + }); + + it('보낸 사람 ARA SYSTEM , 받는 사람 이름 <주소>, 자동 발송 표시', () => { + expect(header(raw, 'From')).toBe('ARA SYSTEM '); + expect(decodeHeaderText(header(raw, 'To') ?? '')).toBe(`김아라 <${ARA}>`); + expect(decodeHeaderText(header(raw, 'Subject') ?? '')).toBe( + '관리자가 비밀번호를 재설정했습니다', + ); + expect(header(raw, 'Date')).toBe('Fri, 30 Oct 2026 06:20:00 +0000'); + expect(header(raw, 'Message-ID')).toBe(''); + expect(header(raw, 'Auto-Submitted')).toBe('auto-generated'); + expect(header(raw, 'X-Ara-Notice')).toBe('password-reset'); + expect(header(raw, 'Content-Type')).toBe( + 'multipart/alternative; boundary="ara-notice-b"', + ); + }); + + it('머리는 ASCII 뿐이고(한글은 encoded-word), 텍스트 · HTML 두 부분이 같은 글을 담는다', () => { + const head = raw.slice(0, raw.indexOf('\r\n\r\n')); + expect(head).toMatch(/^[\x20-\x7e\r\n]*$/); + expect(partBody(raw, 'text/plain')).toBe( + message.text.replace(/\n/g, '\r\n'), + ); + expect(partBody(raw, 'text/html')).toBe(message.html); + expect(raw.endsWith('--ara-notice-b--\r\n')).toBe(true); + }); + + it('주소에 줄바꿈 · 꺾쇠가 있으면 만들지 않는다 (머리글이 깨지지 않게)', () => { + for (const email of [ + 'a@b.test\r\nBcc: x@y.test', + '', + 'a b@c.test', + ]) { + expect(() => + buildSystemMailRaw({ + from: 'postmaster@arasystem.test', + message: { ...message, to: { email, name: 'x' } }, + date: new Date(), + messageId: 'm@x', + boundary: 'b', + }), + ).toThrow(BusinessException); + } + }); +}); + +describe('JmapSystemMailbox — 받은편지함에 넣기 (13단계)', () => { + let jmap: { + call: jest.Mock; + upload: jest.Mock; + getSession: jest.Mock; + }; + let mailbox: JmapSystemMailbox; + + beforeEach(() => { + jmap = { + call: jest.fn(), + upload: jest.fn().mockResolvedValue({ + blobId: 'blob-1', + type: 'message/rfc822', + size: 1000, + }), + getSession: jest.fn().mockResolvedValue({ username: ARA }), + }; + mailbox = new JmapSystemMailbox(jmap as unknown as JmapClient, settings); + }); + + it('원문을 올리고 받은편지함에 안 읽음 · 받은 시각 지금으로 Email/import 한다 → 메일 id', async () => { + jmap.call + .mockResolvedValueOnce([ + [ + 'Mailbox/get', + { + list: [ + { id: 'm-sent', role: 'sent' }, + { id: 'm-inbox', role: 'inbox' }, + ], + }, + 'm', + ], + ]) + .mockResolvedValueOnce([ + ['Email/import', { created: { notice: { id: 'e-1' } } }, 'i'], + ]); + + await expect(mailbox.deliver(ARA, message)).resolves.toBe('e-1'); + + expect(jmap.upload).toHaveBeenCalledWith( + ARA, + expect.any(Blob), + 'message/rfc822', + ); + const build = (jmap.call.mock.calls[1] as unknown[])[1] as ( + id: string, + ) => unknown[]; + const [[method, args]] = build('acc') as [ + [string, { emails: Record> }], + ]; + expect(method).toBe('Email/import'); + expect(args.emails.notice).toMatchObject({ + blobId: 'blob-1', + mailboxIds: { 'm-inbox': true }, + keywords: {}, + }); + expect(args.emails.notice.receivedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, + ); + }); + + it('엔진이 넣지 않으면(notCreated) 코드 있는 실패로 던진다', async () => { + jmap.call + .mockResolvedValueOnce([ + ['Mailbox/get', { list: [{ id: 'm-inbox', role: 'inbox' }] }, 'm'], + ]) + .mockResolvedValueOnce([ + [ + 'Email/import', + { notCreated: { notice: { type: 'overQuota' } } }, + 'i', + ], + ]); + + await expect(mailbox.deliver(ARA, message)).rejects.toBeInstanceOf( + BusinessException, + ); + }); + + it('받은편지함이 없으면 MAIL_001 — 올리지 않는다', async () => { + jmap.call.mockResolvedValueOnce([['Mailbox/get', { list: [] }, 'm']]); + await expect(mailbox.deliver(ARA, message)).rejects.toMatchObject({ + code: 'MAIL_001', + }); + expect(jmap.upload).not.toHaveBeenCalled(); + }); + + it('지우기는 방금 넣은 메일 하나만 destroy — 이미 없으면 그대로 끝난다', async () => { + jmap.call.mockResolvedValueOnce([ + ['Email/set', { notDestroyed: { 'e-1': { type: 'notFound' } } }, 'd'], + ]); + await expect(mailbox.remove(ARA, 'e-1')).resolves.toBeUndefined(); + const build = (jmap.call.mock.calls[0] as unknown[])[1] as ( + id: string, + ) => unknown[]; + expect(build('acc')).toEqual([ + ['Email/set', { accountId: 'acc', destroy: ['e-1'] }, 'd'], + ]); + }); + + it('표시 이름 — 직원 주소의 identity 이름, 없거나 읽지 못하면 @ 앞', async () => { + jmap.call.mockResolvedValueOnce([ + ['Identity/get', { list: [{ email: ARA, name: '김아라' }] }, 'i'], + ]); + await expect(mailbox.displayNameOf(ARA)).resolves.toBe('김아라'); + + jmap.call.mockRejectedValueOnce(new BusinessException('MAIL_001')); + await expect(mailbox.displayNameOf(ARA)).resolves.toBe('ara.kim'); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-system-mailbox.ts b/backend/src/modules/mail/jmap/jmap-system-mailbox.ts new file mode 100644 index 0000000..3971e13 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-system-mailbox.ts @@ -0,0 +1,206 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import type { + SystemMailbox, + SystemMailMessage, +} from '@/modules/system-mail/system-mailbox.port'; +import { JmapClient } from './jmap.client'; +import { + fallbackDisplayName, + pickIdentity, + toJmapUtcDate, +} from './jmap.mapper'; +import { expectResult, firstSetError } from './jmap-results'; +import { jmapFailure } from './jmap.errors'; +import type { + JmapGetResult, + JmapIdentity, + JmapMailbox, + JmapSetResult, +} from './jmap.types'; +import { base64TextBody, encodeHeaderWords } from './mime-text'; + +/** 보낸 사람 표시 이름 (계약 6절 — `ARA SYSTEM >`) */ +export const SYSTEM_SENDER_NAME = 'ARA SYSTEM'; +/** 보낸 사람 주소의 로컬 부분 */ +export const SYSTEM_SENDER_LOCAL_PART = 'postmaster'; + +const CRLF = '\r\n'; +/** 원문의 형식 — Email/import 는 올린 blob 을 메일 원문으로 읽는다 */ +const MESSAGE_TYPE = 'message/rfc822'; +/** 알림 메일을 엔진에 넣을 때의 이름 */ +const IMPORT_KEY = 'notice'; + +/** 원문의 머리 · 본문을 만드는 데 쓰는 값 */ +export interface SystemMailRawInput { + readonly from: string; + readonly message: SystemMailMessage; + readonly date: Date; + readonly messageId: string; + readonly boundary: string; +} + +/** 머리글 값에 줄바꿈 · 꺾쇠가 들어가지 못하게 — 주소는 서버가 아는 직원 주소지만 원문을 깨뜨리지 않게 한 번 더 본다 */ +const SAFE_ADDRESS = /^[^\s<>"\\,;:@]+@[^\s<>"\\,;:@]+$/; + +/** RFC 5322 날짜 — 'Mon, 02 Nov 2026 02:40:00 +0000' */ +function rfc5322Date(date: Date): string { + return date.toUTCString().replace(/GMT$/, '+0000'); +} + +/** + * 알림 메일의 원문 (순수 함수) — multipart/alternative(텍스트 · HTML, 둘 다 UTF-8 base64). + * - 받는 사람 이름 · 제목은 RFC 2047 encoded-word(ASCII 만 남는다 — 머리글에 줄바꿈이 끼지 못한다) + * - `Auto-Submitted: auto-generated`(RFC 3834) — 부재중 자동 응답 · 다른 자동 응답이 이 메일에 답하지 않게 + * - `X-Ara-Notice` — 알림의 종류 (사람은 보지 않는다 — 검색 · 문제 확인용) + */ +export function buildSystemMailRaw(input: SystemMailRawInput): string { + const { from, message, date, messageId, boundary } = input; + if (!SAFE_ADDRESS.test(from) || !SAFE_ADDRESS.test(message.to.email)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'system-mail-address-invalid' }, + }); + } + const headers = [ + `From: ${SYSTEM_SENDER_NAME} <${from}>`, + `To: ${encodeHeaderWords(message.to.name)} <${message.to.email}>`, + `Subject: ${encodeHeaderWords(message.subject)}`, + `Date: ${rfc5322Date(date)}`, + `Message-ID: <${messageId}>`, + 'MIME-Version: 1.0', + 'Auto-Submitted: auto-generated', + `X-Ara-Notice: ${message.kind}`, + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ]; + const part = (type: string, body: string) => + [ + `--${boundary}`, + `Content-Type: ${type}; charset="utf-8"`, + 'Content-Transfer-Encoding: base64', + '', + base64TextBody(body), + ].join(CRLF); + return [ + ...headers, + '', + part('text/plain', message.text), + part('text/html', message.html), + `--${boundary}--`, + '', + ].join(CRLF); +} + +/** + * Stalwart(JMAP) 알림 메일 어댑터 — SystemMailbox 의 구현 (13단계 — 계약 6절 "받은편지함 알림 메일"). + * 게이트웨이 대리 접근으로 원문을 올리고(`message/rfc822`) `Email/import` 로 받은편지함에 넣는다 — 안 읽음(`keywords {}`) · 받은 시각 지금. + * 엔진 안의 JMAP 생성이라 SPF · DKIM · 필터 · 스팸 판정을 거치지 않는다 (2026-10-31 개발 엔진 확인). + * 그 직원의 비밀번호가 만료됐거나(메일 기능이 없다) 잠겼으면(대리 접근 403) 넣지 못한다 — 부르는 쪽이 차례를 지킨다(운영 도구). + * 메일 내용은 로그 · 오류 context 에 남기지 않는다 + */ +@Injectable() +export class JmapSystemMailbox implements SystemMailbox { + constructor( + private readonly jmap: JmapClient, + @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, + ) {} + + async deliver(user: string, message: SystemMailMessage): Promise { + const { domain } = this.settings; + if (!domain) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mail-not-configured' }, + }); + } + const inboxId = await this.inboxOf(user); + const raw = buildSystemMailRaw({ + from: `${SYSTEM_SENDER_LOCAL_PART}@${domain}`, + message, + date: new Date(), + messageId: `ara-notice-${randomUUID()}@${domain}`, + boundary: `ara-notice-${randomBytes(12).toString('hex')}`, + }); + const blob = await this.jmap.upload( + user, + new Blob([raw], { type: MESSAGE_TYPE }), + MESSAGE_TYPE, + ); + const responses = await this.jmap.call(user, (accountId) => [ + [ + 'Email/import', + { + accountId, + emails: { + [IMPORT_KEY]: { + blobId: blob.blobId, + mailboxIds: { [inboxId]: true }, + keywords: {}, + receivedAt: toJmapUtcDate(new Date().toISOString()), + }, + }, + }, + 'i', + ], + ]); + const result = expectResult(responses, 'i', 'Email/import'); + const refusal = firstSetError(result.notCreated); + if (refusal) throw jmapFailure(refusal, { method: 'Email/import' }); + const id = result.created?.[IMPORT_KEY]?.id; + if (typeof id !== 'string' || id === '') { + throw new BusinessException('MAIL_001', { + context: { reason: 'system-mail-import-unanswered' }, + }); + } + return id; + } + + /** 방금 넣은 알림만 지운다 — 이 요청이 만든 메일이라 조건 없이 지운다(nestjs 스킬 "조건 없는 destroy 는 방금 만든 메일에만") */ + async remove(user: string, emailId: string): Promise { + const responses = await this.jmap.call(user, (accountId) => [ + ['Email/set', { accountId, destroy: [emailId] }, 'd'], + ]); + const result = expectResult(responses, 'd', 'Email/set'); + const refusal = Object.values(result.notDestroyed ?? {}).find( + (error) => error?.type !== 'notFound', + ); + if (refusal) throw jmapFailure(refusal, { method: 'Email/set' }); + } + + async displayNameOf(user: string): Promise { + try { + const session = await this.jmap.getSession(user); + const responses = await this.jmap.call(user, (accountId) => [ + ['Identity/get', { accountId, ids: null }, 'i'], + ]); + const { list = [] } = expectResult>( + responses, + 'i', + 'Identity/get', + ); + const name = pickIdentity(list, session.username)?.name?.trim(); + return name || fallbackDisplayName(user); + } catch { + return fallbackDisplayName(user); + } + } + + private async inboxOf(user: string): Promise { + const responses = await this.jmap.call(user, (accountId) => [ + ['Mailbox/get', { accountId, ids: null, properties: ['role'] }, 'm'], + ]); + const { list = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const inbox = list.find((mailbox) => mailbox.role === 'inbox'); + if (!inbox?.id) { + throw new BusinessException('MAIL_001', { + context: { reason: 'inbox-missing' }, + }); + } + return inbox.id; + } +} diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index 3c810b9..f475bee 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -1,6 +1,10 @@ import type { Cache } from 'cache-manager'; import { BusinessException } from '@/common/errors/business.exception'; +import { + AccountLoginDisabledException, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; import type { MailSettings } from '@/modules/mail/mail.settings'; import { runWithRequestContext } from '@/shared/logger/request-context'; import { @@ -11,6 +15,7 @@ import { classifyPasswordChange, expandTemplate, toRelativeReference, + isPasswordExpiredPermissions, } from './jmap.client'; import type { JmapInvocation } from './jmap.types'; @@ -619,17 +624,15 @@ describe('JmapClient', () => { ).resolves.toBe('outcome-unknown'); }); - it('엔진이 계정 관리 기능을 알려 주지 않으면 세션을 새로 받고, 그래도 없으면 MAIL_001 — 바꾸기를 보내지 않는다', async () => { - fetchMock - .mockResolvedValueOnce(json(sessionBody)) - .mockResolvedValueOnce(json(sessionBody)); + it('엔진이 계정 관리 기능을 알려 주지 않으면(세션 자원을 새로 읽어도) MAIL_001 — 바꾸기를 보내지 않는다', async () => { + fetchMock.mockResolvedValueOnce(json(sessionBody)); expect( await codeOf( client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'), ), ).toBe('MAIL_001'); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); @@ -719,6 +722,116 @@ describe('JmapClient', () => { }); }); + describe('비밀번호가 만료된 계정 (13단계 — 계약 6절)', () => { + /** 만료된 계정의 세션 자원 — 메일 기능이 없고 계정 관리 기능만 있다 (2026-11-02 개발 엔진) */ + const expiredSession = { + ...sessionBody, + primaryAccounts: { 'urn:stalwart:jmap': 'x1' }, + }; + const EXPIRED_PERMISSIONS = [ + 'emailReceive', + 'authenticateWithAlias', + 'authenticate', + 'sysAccountPasswordUpdate', + 'sysAccountPasswordGet', + ]; + + it('메일 기능이 없고 권한이 만료의 다섯뿐이면 PasswordExpiredException — 정식 주소와 @ 앞 이름, 세션은 기억하지 않는다', async () => { + fetchMock + .mockResolvedValueOnce( + json({ ...expiredSession, username: 'Ara.Kim@arasystem.test' }), + ) + .mockResolvedValueOnce( + json({ permissions: EXPIRED_PERMISSIONS, edition: 'community' }), + ); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(PasswordExpiredException); + // 메일 API 의 응답으로 나가면 401 — 화면이 곧 갱신해 새 비밀번호 단계(또는 세션 만료)로 간다 + expect((failure as PasswordExpiredException).code).toBe('AUTH_001'); + expect((failure as PasswordExpiredException).account).toBe(ARA); + expect((failure as PasswordExpiredException).displayName).toBe('ara.kim'); + expect(requestAt(1).url).toBe('http://mail:8080/api/account'); + expect(decodeBasic(requestAt(1).authorization)).toBe( + `${ARA}%mail-gateway@arasystem.test:gateway-secret`, + ); + expect(await cache.get(`mail:jmap-session:${ARA}`)).toBeFalsy(); + }); + + it('메일 기능이 없지만 만료의 모양이 아니면(역할 설정 실수 등) 지금처럼 MAIL_001 — 만료로 보지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(json(expiredSession)) + .mockResolvedValueOnce(json({ permissions: ['authenticate'] })); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf(PasswordExpiredException); + expect((failure as BusinessException).code).toBe('MAIL_001'); + }); + + it('계정 정보를 읽지 못하면 만료로 보지 않고 MAIL_001', async () => { + fetchMock + .mockResolvedValueOnce(json(expiredSession)) + .mockResolvedValueOnce(json({ type: 'error' }, 500)); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + expect(failure).not.toBeInstanceOf(PasswordExpiredException); + expect((failure as BusinessException).code).toBe('MAIL_001'); + }); + + it('계정 관리 호출은 만료된 계정에도 열린다 — 메일 기능 없이 계정 관리 계정 id 로 (2단계 인증 상태 · 2단계 직원의 비밀번호 바꾸기)', async () => { + fetchMock + .mockResolvedValueOnce(json(expiredSession)) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:AccountPassword/get', { list: [{}] }, 'g']], + }), + ); + + await client.callAccount(ARA, (accountId) => [ + ['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'g'], + ]); + + expect(JSON.parse(requestAt(1).init.body as string)).toEqual({ + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [ + [ + 'x:AccountPassword/get', + { accountId: 'x1', ids: ['singleton'] }, + 'g', + ], + ], + }); + }); + + it('만료의 모양 (isPasswordExpiredPermissions) — 바꾸기가 있고 다섯 밖의 권한이 없을 때만', () => { + expect(isPasswordExpiredPermissions(EXPIRED_PERMISSIONS)).toBe(true); + expect( + isPasswordExpiredPermissions([ + 'authenticate', + 'sysAccountPasswordUpdate', + ]), + ).toBe(true); + expect( + isPasswordExpiredPermissions([...EXPIRED_PERMISSIONS, 'jmapEmailGet']), + ).toBe(false); + expect(isPasswordExpiredPermissions(['authenticate'])).toBe(false); + expect(isPasswordExpiredPermissions([])).toBe(false); + expect(isPasswordExpiredPermissions(undefined)).toBe(false); + expect(isPasswordExpiredPermissions('sysAccountPasswordUpdate')).toBe( + false, + ); + }); + }); + describe('대리 접근 세션 (getSession)', () => { it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => { fetchMock.mockResolvedValueOnce(json(sessionBody)); @@ -830,13 +943,20 @@ describe('JmapClient', () => { ), ); + // 게이트웨이 자신의 권한에 impersonate 가 없다 — 설정 문제 + fetchMock.mockResolvedValueOnce(json({ permissions: ['authenticate'] })); + const code = await runWithRequestContext({ requestId: 'trace-403' }, () => codeOf(client.getSession(ARA)), ); expect(code).toBe('MAIL_001'); - // 게이트웨이 확인을 하지 않는다 — 403 은 인증이 이미 된 상태다 - expect(fetchMock).toHaveBeenCalledTimes(1); + // 게이트웨이 자신의 권한을 읽었다(본인 Basic — 대리 접근이 아니다) + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(requestAt(1).url).toBe('http://mail:8080/api/account'); + expect(decodeBasic(requestAt(1).authorization)).toBe( + 'mail-gateway@arasystem.test:gateway-secret', + ); expect(errorLog).toHaveBeenCalledWith( expect.stringMatching( /^\[MAIL_001\]\[trace-403\] .*대리 접근 권한이 없습니다/, @@ -844,6 +964,61 @@ describe('JmapClient', () => { ); }); + it('대리 접근 403 인데 게이트웨이는 대리 접근할 수 있으면 그 계정 문제(잠김) — AccountLoginDisabledException(MAIL_001) · warn, 확인은 1분 기억', async () => { + const errorLog = jest + .spyOn( + Reflect.get(client, 'logger') as { error: (message: string) => void }, + 'error', + ) + .mockImplementation(() => undefined); + const warnLog = jest + .spyOn( + Reflect.get(client, 'logger') as { warn: (message: string) => void }, + 'warn', + ) + .mockImplementation(() => undefined); + fetchMock + .mockResolvedValueOnce(json({ title: 'Forbidden', status: 403 }, 403)) + .mockResolvedValueOnce( + json({ permissions: ['authenticate', 'impersonate'] }), + ); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AccountLoginDisabledException); + expect((failure as AccountLoginDisabledException).code).toBe('MAIL_001'); + expect((failure as AccountLoginDisabledException).account).toBe(ARA); + expect(errorLog).not.toHaveBeenCalled(); + expect(warnLog).toHaveBeenCalledWith( + expect.stringContaining('그 계정에 로그인 권한이 없습니다(잠김)'), + ); + + // 1분 안의 다음 403 은 게이트웨이를 다시 묻지 않는다 + fetchMock.mockResolvedValueOnce(json({ title: 'Forbidden' }, 403)); + await expect( + client.getSession(ARA, { fresh: true }), + ).rejects.toBeInstanceOf(AccountLoginDisabledException); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('게이트웨이 권한을 읽지 못하면 설정 문제 쪽으로 — 지금까지와 같은 MAIL_001 + error', async () => { + jest + .spyOn( + Reflect.get(client, 'logger') as { error: (message: string) => void }, + 'error', + ) + .mockImplementation(() => undefined); + fetchMock + .mockResolvedValueOnce(json({ title: 'Forbidden' }, 403)) + .mockRejectedValueOnce(new TypeError('fetch failed')); + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + expect(failure).not.toBeInstanceOf(AccountLoginDisabledException); + expect((failure as BusinessException).code).toBe('MAIL_001'); + }); + it('막는 계정(메일 관리자)은 엔진에 대리 접근을 요청하지 않고 AUTH_001 (대소문자 무관)', async () => { for (const user of ['admin@arasystem.test', ' Admin@AraSystem.TEST']) { expect(await codeOf(client.getSession(user, { fresh: true }))).toBe( @@ -994,6 +1169,54 @@ describe('JmapClient', () => { fetchMock.mockResolvedValueOnce(json(sessionBody)); }); + it('메일 메서드가 forbidden 이고 그 계정이 비밀번호 만료 모양이면 PasswordExpiredException(401 AUTH_001) — 기억한 세션도 버린다 (실사용 시험)', async () => { + // 세션은 기억에서 — 그사이 운영 도구가 비밀번호를 재설정했다(엔진이 권한을 줄였다) + await client.getSession(ARA); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['error', { type: 'forbidden' }, 'q'], + ['error', { type: 'invalidResultReference' }, 'g'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + permissions: [ + 'authenticate', + 'authenticateWithAlias', + 'sysAccountPasswordGet', + 'sysAccountPasswordUpdate', + 'emailReceive', + ], + }), + ); + + const failure = await client + .call(ARA, (accountId) => [ + ['EmailSubmission/query', { accountId }, 'q'], + ]) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(PasswordExpiredException); + expect((failure as PasswordExpiredException).code).toBe('AUTH_001'); + expect(await cache.get(`mail:jmap-session:${ARA}`)).toBeFalsy(); + }); + + it('메일 메서드가 forbidden 이어도 만료 모양이 아니면 응답을 그대로 돌려준다 — 해석은 부른 쪽(AUTH_003 등)', async () => { + const responses = [['error', { type: 'forbidden' }, 'm']]; + fetchMock + .mockResolvedValueOnce(json({ methodResponses: responses })) + .mockResolvedValueOnce( + json({ + permissions: ['authenticate', 'emailReceive', 'jmapEmailGet'], + }), + ); + await expect( + client.call(ARA, (accountId) => [['Mailbox/get', { accountId }, 'm']]), + ).resolves.toEqual(responses); + }); + it('세션의 accountId 로 호출을 만들고 내부 주소의 API 경로로 보낸다', async () => { const responses = [['Mailbox/get', { list: [] }, 'm']]; fetchMock.mockResolvedValueOnce(json({ methodResponses: responses })); diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index 5b01bc7..9135941 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -6,6 +6,10 @@ import type { ReadableStream as NodeReadableStream } from 'stream/web'; import { BusinessException } from '@/common/errors/business.exception'; import type { ErrorCode } from '@/common/errors/error-codes'; +import { + AccountLoginDisabledException, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, isMailEngineConfigured, @@ -19,6 +23,7 @@ import type { } from '@/modules/mail/mail.types'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; +import { fallbackDisplayName } from './jmap.mapper'; import type { JmapInvocation, JmapProblem, @@ -142,6 +147,64 @@ export function classifyPasswordChange( return { kind: 'unknown', detail: String(name) }; } +/** + * 엔진의 계정 정보(권한 목록) — Stalwart 관리 API. 본인 Basic · 대리 접근 모두 그 계정의 지금 권한을 준다 + * (`{ permissions: [...], edition, locale }` — 2026-11-02 개발 엔진(0.16.27) 확인) + */ +const ACCOUNT_INFO_PATH = '/api/account'; + +/** + * 비밀번호 자격이 만료된 계정의 권한 — 엔진이 이 다섯으로 줄인다 (0.16.27 소스 `access_token.rs` 의 assert_is_valid · 2026-11-02 개발 엔진 확인: + * 본인 Basic 은 200, 대리 접근 세션도 열리지만 둘 다 메일 기능이 없다. 본인이 비밀번호를 바꾸면 엔진이 만료를 스스로 푼다) + */ +const EXPIRED_PASSWORD_PERMISSIONS: ReadonlySet = new Set([ + 'authenticate', + 'authenticateWithAlias', + 'sysAccountPasswordGet', + 'sysAccountPasswordUpdate', + 'emailReceive', +]); + +/** + * 권한 목록이 "비밀번호 만료"의 모양인가 (순수 함수) — 비밀번호 바꾸기가 있고 다섯 밖의 권한이 하나도 없다. + * 역할 설정 실수로 메일 권한만 빠진 계정(바꾸기도 없음)은 만료가 아니다 — 비밀번호를 바꿔도 풀리지 않으니 바꾸기 단계로 보내지 않는다 + */ +export function isPasswordExpiredPermissions(permissions: unknown): boolean { + return ( + Array.isArray(permissions) && + permissions.includes('sysAccountPasswordUpdate') && + permissions.every( + (permission) => + typeof permission === 'string' && + EXPIRED_PASSWORD_PERMISSIONS.has(permission), + ) + ); +} + +/** 엔진 계정 정보의 응답 — 권한 목록만 읽는다 */ +interface AccountInfoReply { + readonly permissions?: unknown; +} + +/** 대리 접근 권한의 이름 — 게이트웨이 자신의 권한(/api/account)에 있어야 한다 (2026-11-02 개발 엔진: authenticate · impersonate 둘) */ +const IMPERSONATE_PERMISSION = 'impersonate'; +/** 게이트웨이가 대리 접근할 수 있다고 확인한 것을 기억하는 시간 — 잠긴 계정의 로그인 시도마다 엔진에 더 묻지 않게 */ +const GATEWAY_IMPERSONATION_MEMORY_MS = 60_000; + +/** 메서드 오류 forbidden 인가 — 메일 메서드가 막혔다 */ +function isForbiddenInvocation(invocation: JmapInvocation): boolean { + return ( + invocation[0] === 'error' && + (invocation[1] as JmapProblem | undefined)?.type === 'forbidden' + ); +} + +/** 계정 관리 기능만 쓰는 세션 — 비밀번호가 만료돼 메일 기능이 없는 계정도 열린다 (2단계 인증 · 앱 비밀번호 · 2단계 직원의 비밀번호 바꾸기) */ +interface ManagementSession { + readonly apiPath: string; + readonly managementAccountId: string; +} + /** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지. 오래 걸리는 처리는 남은 시간이 이보다 적으면 그만큼만 준다 (jmap-mail.provider.ts) */ export const JMAP_REQUEST_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = JMAP_REQUEST_TIMEOUT_MS; @@ -235,11 +298,6 @@ 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)으로 쓰지 않는다 @@ -391,6 +449,8 @@ export class JmapClient { private readonly logger = new Logger(JmapClient.name); /** 관리 권한 확인 응답을 해석하지 못했다는 error 로그를 이미 남겼는가 (엔진 API 가 바뀐 경우 — 한 번만 알린다) */ private managementProbeUnclearReported = false; + /** 게이트웨이가 대리 접근할 수 있다고 확인한 때부터 이 시각까지 다시 묻지 않는다 (GATEWAY_IMPERSONATION_MEMORY_MS) */ + private gatewayImpersonationKnownUntil = 0; constructor( @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, @@ -725,20 +785,15 @@ export class JmapClient { if (cached) return cached; } - const session = await this.exchange( - SESSION_PATH, - { headers: { authorization: this.impersonation(user) } }, + const resource = await this.fetchSessionResource( + user, options.timeoutMs ?? REQUEST_TIMEOUT_MS, - 'session', - async (response) => { - await this.assertImpersonationAccepted(response, user); - await this.assertOk(response, 'session'); - return this.toSession( - await readJson(response, 'session'), - user, - ); - }, ); + // 메일 기능이 없다 — 비밀번호가 만료된 계정이면 그렇게 알린다(13단계), 아니면 아래 toSession 이 MAIL_001 + if (!resource.primaryAccounts?.[MAIL_CAPABILITY]) { + await this.throwIfPasswordExpired(user, resource); + } + const session = this.toSession(resource, user); // 엔진이 알려 준 정식 주소로 한 번 더 — 로컬 이름(admin)이나 별칭으로 들어와도 막는 계정이면 캐시하지 않는다 this.assertNotBlocked(session.username); @@ -746,6 +801,76 @@ export class JmapClient { return session; } + /** 대리 접근으로 엔진 세션 자원을 읽는다 — 401 · 403 은 다른 대리 접근 호출과 같은 분류 */ + private async fetchSessionResource( + user: string, + timeoutMs: number, + ): Promise { + return this.exchange( + SESSION_PATH, + { headers: { authorization: this.impersonation(user) } }, + timeoutMs, + 'session', + async (response) => { + await this.assertImpersonationAccepted(response, user); + await this.assertOk(response, 'session'); + return readJson(response, 'session'); + }, + ); + } + + /** + * 그 계정의 비밀번호가 만료됐으면 PasswordExpiredException 으로 던진다(기억한 세션도 버린다) — 아니거나 알 수 없으면 아무것도 하지 않는다. + * 세션은 열렸는데 메일 메서드가 forbidden 일 때 부른다(13단계): 엔진은 비밀번호를 바꾼 직후 잠깐(0.5초 남짓) 옛 세션 자원을 주고, + * 이 서버는 세션을 5분 기억한다 — 그 사이에 만료된 계정이면 프로필 읽기가 forbidden 으로 끝난다 + */ + async throwIfPasswordExpiredFor(user: string): Promise { + if (await this.isPasswordExpired(user)) { + await this.cache.del(sessionCacheKey(user)); + const account = user.trim().toLowerCase(); + throw new PasswordExpiredException(account, fallbackDisplayName(account)); + } + } + + /** 대리 접근으로 그 계정의 권한을 읽어 "비밀번호 만료"의 모양인가 — 읽지 못하면 false */ + private async isPasswordExpired(user: string): Promise { + try { + return await this.exchange( + ACCOUNT_INFO_PATH, + { headers: { authorization: this.impersonation(user) } }, + REQUEST_TIMEOUT_MS, + 'account-info', + async (response) => { + if (!response.ok) { + await discard(response); + return false; + } + const reply = await readJson( + response, + 'account-info', + ); + return isPasswordExpiredPermissions(reply.permissions); + }, + ); + } catch { + return false; + } + } + + /** + * 메일 기능이 없는 세션 — 그 계정의 권한이 "비밀번호 만료"의 다섯뿐이면 PasswordExpiredException(정식 주소)으로 던진다 (13단계 — 계약 6절). + * 권한을 읽지 못하거나 다른 모양이면 아무것도 하지 않는다(부르는 쪽이 지금처럼 MAIL_001 — 세션이 모자람) + */ + private async throwIfPasswordExpired( + user: string, + resource: JmapSessionResource, + ): Promise { + if (!(await this.isPasswordExpired(user))) return; + const account = resource.username?.trim().toLowerCase() || user; + this.assertNotBlocked(account); + throw new PasswordExpiredException(account, fallbackDisplayName(account)); + } + /** * 계정 관리 기능(`urn:stalwart:jmap` — x:AccountPassword · x:AppPassword)을 대리 접근으로 부른다 (12단계 — 2단계 인증 · 앱 비밀번호). * 계정 id 는 세션의 `primaryAccounts['urn:stalwart:jmap']`(메일 계정 id 와 따로 본다), `using` 은 core · 그 기능뿐이다. @@ -849,20 +974,47 @@ export class JmapClient { }; } - /** 계정 관리 기능의 계정 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 }); + /** + * 계정 관리 기능의 계정 id 가 있는 대리 접근 세션 — 기억한 메일 세션에 그 값이 있으면 그것을, 없으면 세션 자원을 다시 읽는다. + * 메일 기능이 없는 계정(비밀번호 만료 — 13단계)도 연다: 만료된 동안에도 2단계 인증 상태 읽기 · 2단계 직원의 비밀번호 바꾸기는 엔진이 받는다 + * (2026-11-02 확인). 엔진이 그 기능을 알려 주지 않으면 MAIL_001 + */ + private async accountSession(user: string): Promise { + this.assertNotBlocked(user); + const cached = await this.cache.get(sessionCacheKey(user)); + if (cached?.managementAccountId) { + return { + apiPath: cached.apiPath, + managementAccountId: cached.managementAccountId, + }; } - const { managementAccountId } = session; - if (!managementAccountId) { + + const resource = await this.fetchSessionResource(user, REQUEST_TIMEOUT_MS); + this.assertNotBlocked(resource.username?.trim().toLowerCase() || user); + const managementAccountId = + resource.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY]; + if (!managementAccountId || !resource.apiUrl) { throw engineFailure({ reason: 'account-capability-missing', call: 'account-session', }); } - return { ...session, managementAccountId }; + // 메일 기능도 있으면 메일 세션으로 함께 기억한다 (다음 메일 호출이 다시 묻지 않게) + if (resource.primaryAccounts?.[MAIL_CAPABILITY]) { + try { + await this.cache.set( + sessionCacheKey(user), + this.toSession(resource, user), + SESSION_CACHE_TTL_MS, + ); + } catch { + // 메일 세션이 모자라도 계정 관리 호출은 이어 간다 + } + } + return { + apiPath: toRelativeReference(resource.apiUrl), + managementAccountId, + }; } /** @@ -962,7 +1114,7 @@ export class JmapClient { methodCalls: build(session.accountId), }); - return this.exchange( + const responses = await this.exchange( session.apiPath, { method: 'POST', @@ -1004,6 +1156,12 @@ export class JmapClient { return parsed.methodResponses; }, ); + // 메일 메서드가 막혔다 — 그사이 비밀번호가 만료된 계정이면(엔진이 권한을 비밀번호 바꾸기만으로 줄였다 · 세션은 5분 기억한다) 그렇게 알린다 (13단계). + // 아니면 그대로 돌려준다 — 해석은 부른 쪽이 한다(AUTH_003 등) + if (responses.some(isForbiddenInvocation)) { + await this.throwIfPasswordExpiredFor(user); + } + return responses; } /** @@ -1460,9 +1618,11 @@ export class JmapClient { * 대리 접근 거부를 해석한다 (Stalwart v0.16 개발 엔진에서 확인): * - **401**: 대상 계정이 없거나(다른 도메인 주소 포함) 게이트웨이 비밀번호가 틀렸다 — 응답이 같다. * 그래서 게이트웨이로 직접 로그인해 구분한다: 게이트웨이도 거부되면 설정 문제(MAIL_001), 아니면 계정 없음(AUTH_001) - * - **403** "You do not have enough permissions": 인증은 됐지만 **대리 접근 권한이 없다** — 게이트웨이 설정 문제다. - * 계정 없음으로 보면 설정 실수 한 번에 전 직원의 세션이 폐기된다(리프레시가 AUTH_001 → 세션 폐기). - * 그래서 MAIL_001(503) + error 로그로 답하고 세션은 건드리지 않는다. + * - **403** "You do not have enough permissions": 인증은 됐지만 대리 접근이 막혔다 — 게이트웨이에 대리 접근 권한이 없거나(설정 문제) + * **대상 계정에 로그인 권한(authenticate)이 없다**(잠김 — 13단계 운영 도구 · 엔진 관리 화면). 게이트웨이 자신의 권한(/api/account)에 + * impersonate 가 있으면 그 계정 문제다 → AccountLoginDisabledException(여전히 MAIL_001 — 로그인만 AUTH_004 로 바꾼다, warn). + * 없거나 읽지 못하면 설정 문제로 본다 → MAIL_001(503) + error. 어느 쪽도 계정 없음(AUTH_001)으로 보지 않는다 — + * 설정 실수 한 번에 전 직원의 세션이 폐기되지 않게(리프레시가 AUTH_001 → 세션 폐기). 세션은 건드리지 않는다. */ private async assertImpersonationAccepted( response: Response, @@ -1479,6 +1639,13 @@ export class JmapClient { await this.cache.del(sessionCacheKey(user)); if (response.status === HTTP_FORBIDDEN) { + if (await this.gatewayCanImpersonate()) { + // 게이트웨이는 대리 접근할 수 있다 — 이 계정이 로그인할 수 없다(잠김). 서버 장애가 아니다 + this.logger.warn( + `[MAIL_001]${traceTag()} 대리 접근이 막혔습니다(403) — 게이트웨이는 대리 접근할 수 있으니 그 계정에 로그인 권한이 없습니다(잠김): user=${maskEmail(user)}`, + ); + throw new AccountLoginDisabledException(user.trim().toLowerCase()); + } this.logger.error( `[MAIL_001]${traceTag()} 메일 게이트웨이 계정에 대리 접근 권한이 없습니다(403) — 계정 문제가 아니라 설정 문제로 보고 세션을 유지합니다. 게이트웨이 계정의 대리 접근(impersonate) 권한과 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요.`, ); @@ -1504,6 +1671,48 @@ export class JmapClient { }); } + /** + * 게이트웨이가 대리 접근할 수 있는가 — 자신의 권한(/api/account)에 impersonate 가 있는가. 읽지 못하면 false(설정 문제 쪽 — 지금까지와 같은 503). + * 있으면 1분 기억한다(잠긴 계정의 로그인 시도마다 엔진에 더 묻지 않게) + */ + private async gatewayCanImpersonate(): Promise { + if (this.gatewayImpersonationKnownUntil > Date.now()) return true; + const { gatewayUser, gatewayPassword } = this.requireConfigured(); + try { + const allowed = await this.exchange( + ACCOUNT_INFO_PATH, + { + headers: { + authorization: basicAuthorization(gatewayUser, gatewayPassword), + }, + }, + REQUEST_TIMEOUT_MS, + 'gateway-permissions', + async (response) => { + if (!response.ok) { + await discard(response); + return false; + } + const reply = await readJson( + response, + 'gateway-permissions', + ); + return ( + Array.isArray(reply.permissions) && + reply.permissions.includes(IMPERSONATE_PERMISSION) + ); + }, + ); + if (allowed) { + this.gatewayImpersonationKnownUntil = + Date.now() + GATEWAY_IMPERSONATION_MEMORY_MS; + } + return allowed; + } catch { + return false; + } + } + private async gatewayAccepted(): Promise { const { gatewayUser, gatewayPassword } = this.requireConfigured(); return this.exchange( diff --git a/backend/src/modules/mail/mail-attachment.controller.ts b/backend/src/modules/mail/mail-attachment.controller.ts index 6bf3b63..979eae0 100644 --- a/backend/src/modules/mail/mail-attachment.controller.ts +++ b/backend/src/modules/mail/mail-attachment.controller.ts @@ -17,6 +17,7 @@ import { import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -36,7 +37,7 @@ import { MailService } from './mail.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('mail-attachments') export class MailAttachmentController { constructor(private readonly mail: MailService) {} diff --git a/backend/src/modules/mail/mail-block.controller.ts b/backend/src/modules/mail/mail-block.controller.ts index 6d9a922..c544b39 100644 --- a/backend/src/modules/mail/mail-block.controller.ts +++ b/backend/src/modules/mail/mail-block.controller.ts @@ -14,6 +14,7 @@ import type { Response } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -43,7 +44,7 @@ const BLOCK_NOTE = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse( 'MAIL_001', '메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) · 이 서버가 모르는 새 판의 원본(되돌린 배포) — 같은 요청을 다시 보내면 된다', diff --git a/backend/src/modules/mail/mail-events.controller.ts b/backend/src/modules/mail/mail-events.controller.ts index 2259838..5665e09 100644 --- a/backend/src/modules/mail/mail-events.controller.ts +++ b/backend/src/modules/mail/mail-events.controller.ts @@ -8,7 +8,10 @@ import { } from '@nestjs/swagger'; import type { Response } from 'express'; -import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { + ApiErrorResponse, + ApiSessionErrors, +} from '@/common/decorators/api-standard-response.decorator'; import { CurrentUser, type AuthenticatedUser, @@ -61,6 +64,7 @@ export function eventStreamSink(res: Response): MailEventSink { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) +@ApiSessionErrors() @Controller('mail-events') export class MailEventsController { constructor(private readonly events: MailEventsService) {} diff --git a/backend/src/modules/mail/mail-filter.controller.ts b/backend/src/modules/mail/mail-filter.controller.ts index 10693be..e0fa4fa 100644 --- a/backend/src/modules/mail/mail-filter.controller.ts +++ b/backend/src/modules/mail/mail-filter.controller.ts @@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -46,7 +47,7 @@ const APPLY_NOTE = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(MAIL_FILTERS_ROUTE) export class MailFilterController { @@ -106,7 +107,7 @@ export class MailFilterController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(MAIL_FILTER_RUNS_ROUTE) export class MailFilterRunController { constructor(private readonly mail: MailService) {} diff --git a/backend/src/modules/mail/mail-large-files.service.spec.ts b/backend/src/modules/mail/mail-large-files.service.spec.ts index 64e9543..00249c4 100644 --- a/backend/src/modules/mail/mail-large-files.service.spec.ts +++ b/backend/src/modules/mail/mail-large-files.service.spec.ts @@ -11,6 +11,7 @@ import type { FileScanService, } from '@/modules/file-storage/file-scan.service'; import type { FileStorageService } from '@/modules/file-storage/file-storage.service'; +import type { SystemMailService } from '@/modules/system-mail/system-mail.service'; import type { FileUseProblem, StoredFileView, @@ -161,6 +162,9 @@ function setup() { }), }; const events = { notify: jest.fn() }; + const systemMail = { + notifyDangerousFile: jest.fn().mockResolvedValue('notice-1'), + }; const provider = { getMessage: jest.fn(), getMessageSubjects: jest.fn().mockResolvedValue(new Map()), @@ -171,6 +175,7 @@ function setup() { scans as unknown as FileScanService, events as unknown as MailEventsService, provider as unknown as MailProvider, + systemMail as unknown as SystemMailService, ); service.onModuleInit(); return { @@ -179,10 +184,17 @@ function setup() { sharing, events, provider, + systemMail, verdict: (event: Parameters[0]) => listener?.(event), }; } +/** 판정 소식의 크기 · 올린 때 (13단계에 더한 칸) */ +const UPLOADED = { + size: 50_538_291, + uploadedAt: new Date('2026-10-30T06:58:00Z'), +}; + async function codeOf(run: () => Promise): Promise { try { await run(); @@ -579,12 +591,14 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () = fileId: FILE_A, name: '견적서.zip', scanStatus: 'infected', + ...UPLOADED, }); verdict({ ownerEmail: ARA.email, fileId: FILE_B, name: '도면.dwg', scanStatus: 'clean', + ...UPLOADED, }); expect(events.notify).toHaveBeenCalledTimes(1); expect(events.notify).toHaveBeenCalledWith(ARA.email, { @@ -593,4 +607,28 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () = }); }); }); + + it('위험 판정이면 받은편지함 알림 메일도 넣는다 — 파일 이름 · 크기 · 올린 때 (13단계, 깨끗함은 넣지 않는다)', () => { + const { systemMail, verdict } = setup(); + verdict({ + ownerEmail: ARA.email, + fileId: FILE_A, + name: '견적서.zip', + scanStatus: 'infected', + ...UPLOADED, + }); + verdict({ + ownerEmail: ARA.email, + fileId: FILE_B, + name: '도면.dwg', + scanStatus: 'clean', + ...UPLOADED, + }); + expect(systemMail.notifyDangerousFile).toHaveBeenCalledTimes(1); + expect(systemMail.notifyDangerousFile).toHaveBeenCalledWith(ARA.email, { + name: '견적서.zip', + size: UPLOADED.size, + uploadedAt: UPLOADED.uploadedAt, + }); + }); }); diff --git a/backend/src/modules/mail/mail-large-files.service.ts b/backend/src/modules/mail/mail-large-files.service.ts index b0d59ef..6f4d9a6 100644 --- a/backend/src/modules/mail/mail-large-files.service.ts +++ b/backend/src/modules/mail/mail-large-files.service.ts @@ -29,6 +29,7 @@ import type { MissingLargeFile, StoredFileView, } from '@/modules/file-storage/file-storage.types'; +import { SystemMailService } from '@/modules/system-mail/system-mail.service'; import { traceTag } from '@/shared/logger/request-context'; import { kstEndOfDayAfter } from '@/shared/utils/kst-date'; import { maskEmail } from '@/shared/utils/mask'; @@ -131,7 +132,7 @@ function unusableFiles( /** * 메일과 대용량 첨부(11단계 — 계약 4절 "발송 · 임시 저장과 함께" · "우리 웹메일에서 읽기" · "보낸 사람 관리")를 잇는다. * 파일은 파일 모듈, 링크는 공유 모듈이 맡고 — 메일 → 공유 → 저장 한 방향 — 이 서비스는 메일 쪽 규칙(덩이 · 자리 · 머리글 · 보낸 사람)만 둔다. - * 위험 판정 소식을 실시간 알림(`large-file`)으로 보낸다. + * 위험 판정 소식을 실시간 알림(`large-file`)으로 보내고, 받은편지함 알림 메일도 넣는다(13단계 — 그때 웹메일을 열어 두지 않은 직원도 알게). */ @Injectable() export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { @@ -144,6 +145,8 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { private readonly scans: FileScanService, private readonly events: MailEventsService, @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + /** 13단계 — 위험 판정의 받은편지함 알림 메일 */ + private readonly systemMail: SystemMailService, ) {} onModuleInit(): void { @@ -158,6 +161,12 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { scanStatus: 'infected', }, }); + // 받은편지함 알림 메일 (계약 6절 — 파일마다 한 번: 검사 차례가 위험 판정을 처음 적을 때만 이 소식을 보낸다). 넣지 못해도 던지지 않는다 + void this.systemMail.notifyDangerousFile(event.ownerEmail, { + name: event.name, + size: event.size, + uploadedAt: event.uploadedAt, + }); }); } diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index c4258bd..46f83be 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -12,6 +12,7 @@ import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module'; +import { SystemMailModule } from '@/modules/system-mail/system-mail.module'; import { isAllowedAttachment } from './attachment.policy'; import { DraftController } from './draft.controller'; import { MailImageController } from './images/mail-image.controller'; @@ -124,6 +125,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { FileSharingModule, // 끊은 세션 기억 (12단계) — 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝낸다. 이 모듈은 메일을 가져오지 않는다 AuthSessionModule, + // 받은편지함 알림 메일 (13단계) — 위험 파일 판정을 그 직원의 받은편지함에. 이 모듈은 메일을 가져오지 않는다 + SystemMailModule, ], controllers: [ MailboxController, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 6c3885f..36150a8 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -64,6 +64,34 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER'); */ export class SendNotStartedException extends BusinessException {} +/** + * 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다 + * (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다. + * 인증 모듈은 로그인 · 갱신에서 이것을 받아 세션에 passwordChange 제한을 붙인다 — 형식으로 가른다(context 는 로그 전용, 갱신은 세션을 끊지 않는다). + * 메일 API 의 응답으로 나가면 **401 AUTH_001** 이다 — 화면은 곧 갱신하고, 세션이 끊겼으면 세션 만료, 아직이면 갱신이 만료를 알아채 새 비밀번호 단계로 간다 + * (운영 도구의 재설정 직후 끊은 세션이 막히기 전 15초 동안 빈 칸 · 503 이 보이지 않게). + * `account` 는 엔진이 알려 준 정식 주소, `displayName` 은 메일 기능 없이 쓸 표시 이름(주소의 @ 앞 — 표시 이름은 메일 기능으로만 읽는다) + */ +export class PasswordExpiredException extends BusinessException { + constructor( + readonly account: string, + readonly displayName: string, + ) { + super('AUTH_001', { context: { reason: 'password-expired' } }); + } +} + +/** + * 엔진이 그 계정의 대리 접근을 막았다(403)인데 게이트웨이는 대리 접근할 수 있다 — 그 계정에 로그인 권한(authenticate)이 없다(잠김) (13단계). + * 게이트웨이 설정 문제와 가르는 것은 어댑터다(게이트웨이 자신의 권한에 impersonate 가 있는가). 응답으로 나가면 지금처럼 503 MAIL_001 이고 + * 로그인만 이것을 AUTH_004 로 바꾼다 — 2단계를 켠 잠긴 계정이 맞는 비밀번호에만 503 을 받아 비밀번호가 맞는지 바깥에서 알 수 있던 길(실사용 시험) + */ +export class AccountLoginDisabledException extends BusinessException { + constructor(readonly account: string) { + super('MAIL_001', { context: { reason: 'account-login-disabled' } }); + } +} + export interface MailProvider { /** * 직원 자격 증명을 메일 엔진에 확인한다 (CredentialCheck — 맞음 · 틀림 · 2단계 인증 코드 필요). 비밀번호는 저장하지 않는다. diff --git a/backend/src/modules/mail/mail.settings.ts b/backend/src/modules/mail/mail.settings.ts index d5f3814..0e34ca8 100644 --- a/backend/src/modules/mail/mail.settings.ts +++ b/backend/src/modules/mail/mail.settings.ts @@ -137,6 +137,21 @@ export function buildMailSettings( }; } +/** + * 메일 엔진 관리자 주소 — `@` (소문자, 도메인이 없으면 null). + * 운영 도구(13단계)가 관리 API 에 들어갈 때 쓴다 — 막는 목록(blockedAccounts)에 넣는 것과 같은 값이다 + */ +export function buildMailAdminAccount( + read: (key: string) => string | undefined, +): string | null { + const domain = readTrimmed(read, 'MAIL_DOMAIN')?.toLowerCase() ?? null; + if (domain === null) return null; + const local = ( + readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART + ).toLowerCase(); + return `${local}@${domain}`; +} + /** 콤마로 나열한 주소 목록 → 소문자 주소 (형식 검사는 env 검증이 한다) */ export function parseAccountList(raw: string | undefined): string[] { return (raw ?? '') diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 07f29ca..51a8c7f 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -15,6 +15,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -63,8 +64,7 @@ const BULK_NO_PROGRESS = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @@ -200,7 +200,7 @@ export class MailboxController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller('mail-vacation') export class MailVacationController { @@ -252,7 +252,7 @@ export class MailVacationController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller('mail-quota') export class MailQuotaController { @@ -276,7 +276,7 @@ export class MailQuotaController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse( 'MAIL_001', '메일 엔진 장애 (엔진이 상태를 알려 주지 않음 포함)', diff --git a/backend/src/modules/mail/message-deletion.controller.ts b/backend/src/modules/mail/message-deletion.controller.ts index 9509ebd..109a922 100644 --- a/backend/src/modules/mail/message-deletion.controller.ts +++ b/backend/src/modules/mail/message-deletion.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -27,8 +28,7 @@ import { MailService } from './mail.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @Controller('message-deletions') diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index a792de8..e1d22b1 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -29,6 +29,7 @@ import type { Request, Response } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -83,8 +84,7 @@ const LARGE_FILE_THROTTLE_LIMIT = 300; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @Controller(MESSAGES_ROUTE) diff --git a/backend/src/modules/mail/recipient-suggestion.controller.ts b/backend/src/modules/mail/recipient-suggestion.controller.ts index cc429c1..2b88aa4 100644 --- a/backend/src/modules/mail/recipient-suggestion.controller.ts +++ b/backend/src/modules/mail/recipient-suggestion.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -18,7 +19,7 @@ import { RecipientSuggestionService } from './recipient-suggestions.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('recipient-suggestions') export class RecipientSuggestionController { constructor(private readonly suggestions: RecipientSuggestionService) {} diff --git a/backend/src/modules/mail/scheduled-message.controller.ts b/backend/src/modules/mail/scheduled-message.controller.ts index e325632..2a54fcc 100644 --- a/backend/src/modules/mail/scheduled-message.controller.ts +++ b/backend/src/modules/mail/scheduled-message.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -30,7 +31,7 @@ import { ScheduledMessagesService } from './scheduled-messages.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(SCHEDULED_MESSAGES_ROUTE) export class ScheduledMessageController { diff --git a/backend/src/modules/mail/share-link.controller.ts b/backend/src/modules/mail/share-link.controller.ts index 5ece335..c408031 100644 --- a/backend/src/modules/mail/share-link.controller.ts +++ b/backend/src/modules/mail/share-link.controller.ts @@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -39,7 +40,7 @@ export const SHARE_LINKS_ROUTE = 'share-links'; @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(SHARE_LINKS_ROUTE) export class ShareLinkController { constructor(private readonly largeFiles: MailLargeFilesService) {} diff --git a/backend/src/modules/ops/account-commands.ts b/backend/src/modules/ops/account-commands.ts new file mode 100644 index 0000000..7a62447 --- /dev/null +++ b/backend/src/modules/ops/account-commands.ts @@ -0,0 +1,446 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { SystemMailService } from '@/modules/system-mail/system-mail.service'; +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { twoFactorRequirementFor } from '@/modules/system-settings/system-settings.types'; +import { OpsAuditService } from './ops-audit.service'; +import { OpsDataService, totalRows } from './ops-data.service'; +import { + OPS_ENGINE, + OpsEngineError, + type EngineEmployee, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { + askAddress, + askEmployee, + confirmPlan, + failureLine, + showTempPassword, + step, +} from './ops-prompts'; +import { + bytesText, + failureDetail, + isUnknownOutcome, + toDisplayName, + toQuotaBytes, +} from './ops-text'; +import { generateTempPassword } from './temp-password'; + +/** 임시 비밀번호를 엔진이 약하다고 받지 않을 때 새로 만들어 다시 하는 횟수 */ +const WEAK_PASSWORD_RETRIES = 2; + +/** + * 운영 도구 — 계정 명령 (13단계 — 계약 6절 "운영 도구"): 입사 · 비밀번호 재설정 · 2단계 해제. + * 묻고 → 바뀔 것을 보이고 → y/N → 차례대로 하고 → 실행 기록(ops_audit_log)을 남긴다. 임시 비밀번호는 한 번만 보이고 어디에도 남기지 않는다 + */ +@Injectable() +export class AccountCommands { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly data: OpsDataService, + private readonly audit: OpsAuditService, + private readonly systemMail: SystemMailService, + private readonly settings: SystemSettingsService, + ) {} + + private get domain(): string { + return this.mailSettings.domain ?? ''; + } + + /** 입사 — 엔진 계정(직원 역할) + 임시 비밀번호(무작위 · 한 번만) + 만료(첫 로그인 때 바꾸기) + 메일함 용량 */ + async hire(io: OpsIo, operator: string): Promise { + const email = await askAddress(io, this.domain, '새 직원 주소'); + if (email === null) return; + if (!this.engine.isEmployeeAddress(email)) { + io.print( + '그 주소는 직원 주소로 쓸 수 없습니다 — 관리자 · 게이트웨이 · 사내 목록 · 막은 계정의 이름입니다.', + ); + return; + } + try { + if (await this.engine.addressInUse(email)) { + io.print('그 주소는 엔진에서 이미 쓰고 있습니다 (계정 · 공용 주소).'); + return; + } + } catch (error) { + io.print(failureLine(error)); + return; + } + const name = toDisplayName(await io.ask('이름 (메일에 보이는 이름): ')); + if (name === null) { + io.print('이름은 한 줄 1~100자로 넣어 주세요.'); + return; + } + const quota = toQuotaBytes( + await io.ask('메일함 용량 GB (빈 값이면 제한 없음): '), + ); + if (quota === 'invalid') { + io.print('용량은 0 보다 큰 숫자(GB, 소수 한 자리까지)로 넣어 주세요.'); + return; + } + + // 같은 주소를 쓰던 옛 직원의 기록이 남았으면 먼저 지운다 — 새 사람에게 옛 로그인 기록 · 서명 · 2단계 기록 · 2단계 필수 예외가 넘어가지 않게 + const leftoverRows = totalRows(await this.data.countRowsOf(email)); + const leftoverExempt = await this.data.isTwoFactorExempt(email); + const leftoverParts = [ + ...(leftoverRows > 0 ? [`우리 DB 기록 ${leftoverRows}줄`] : []), + ...(leftoverExempt ? ['2단계 인증 필수의 예외 주소'] : []), + ]; + let purgeLeftover = false; + if (leftoverParts.length > 0) { + io.print( + `이 주소에 같은 주소를 쓰던 사람의 것이 남아 있습니다 — ${leftoverParts.join(' · ')}`, + ); + purgeLeftover = await io.confirm('입사 전에 지울까요? (권함)'); + } + + const lines = [ + `주소 ${email}`, + `이름 ${name}`, + `메일함 ${quota === null ? '제한 없음' : bytesText(quota)}`, + '비밀번호 임시 비밀번호(무작위) — 만든 뒤 한 번만 보입니다', + ' 첫 로그인 때 새 비밀번호를 정합니다(엔진의 비밀번호 만료)', + ...(purgeLeftover + ? [`옛 기록 지우기 — ${leftoverParts.join(' · ')}`] + : []), + ]; + if (!(await confirmPlan(io, '입사', lines))) return; + + try { + if (purgeLeftover) { + await this.data.purgeAddress(email); + step(io, true, `옛 기록을 지웠습니다 — ${leftoverParts.join(' · ')}`); + } + const localPart = email.slice(0, email.indexOf('@')); + let password = ''; + for (let attempt = 0; ; attempt += 1) { + password = generateTempPassword(); + try { + await this.engine.createEmployee({ + localPart, + name, + quotaBytes: quota, + tempPassword: password, + }); + break; + } catch (error) { + const weak = + error instanceof OpsEngineError && error.kind === 'weak-password'; + if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error; + } + } + step(io, true, '엔진 계정을 만들었습니다.'); + showTempPassword(io, email, password); + await this.audit.record({ + operator, + command: 'hire', + target: email, + result: 'success', + detail: `용량 ${quota === null ? '제한 없음' : bytesText(quota)}${purgeLeftover ? ` · 옛 기록 지움(${leftoverParts.join(' · ')})` : ''}`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'hire', + target: email, + result: 'failure', + detail: failureDetail(error), + }); + } + } + + /** 비밀번호 재설정 — 묻고 확인한 뒤 runPasswordReset */ + async resetPassword(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (employee.locked) { + io.print( + '잠긴 계정입니다 — 먼저 「잠금 풀기」를 하세요(잠금 풀기에서 비밀번호 재설정을 함께 할 수 있습니다).', + ); + return; + } + if (!(await confirmPlan(io, '비밀번호 재설정', this.resetPlan(employee)))) { + return; + } + await this.runPasswordReset(io, operator, employee); + } + + /** 비밀번호 재설정의 바뀔 것 — 이미 만료된 계정은 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다(엔진이 막는다) */ + resetPlan(employee: EngineEmployee): string[] { + const expired = employee.passwordExpired; + return [ + `대상 ${employee.name} <${employee.email}>`, + expired + ? `1. 앱 비밀번호 — 건너뜀(비밀번호가 이미 만료된 계정이라 엔진이 막는다)${employee.appPasswords > 0 ? ` · ${employee.appPasswords}개가 남아 있다 — 직원이 새 비밀번호를 정한 뒤 다시 재설정` : ''}` + : `1. 앱 비밀번호 ${employee.appPasswords}개 지우기 (메일 프로그램은 새 앱 비밀번호로 다시 연결)`, + '2. 웹메일 로그인 모두 끊기', + expired + ? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)' + : '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)', + '4. 임시 비밀번호 + 만료 — 첫 로그인 때 새 비밀번호를 정한다 (그사이 새로 로그인한 세션도 끊는다)', + ` 2단계 인증은 그대로 (${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} — 따로 「2단계 해제」)`, + ]; + } + + /** + * 비밀번호 재설정의 차례 (계약 6절 — 만료 뒤에는 대리 접근이 막히므로 대리 접근 일을 먼저 한다): + * 1. 앱 비밀번호 지우기(대리 접근) — 지우지 못하면 아무것도 바꾸지 않고 멈춘다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게) + * 2. 웹메일 세션 끊기(DB) — 알림 메일이 "끊었습니다"라고 말하기 전에 끝내 둔다. 끊지 못하면 멈춘다 + * 3. 받은편지함 알림(대리 접근) — 넣지 못해도 이어 간다 + * 4. 엔진에 임시 비밀번호 + 만료 — 실패하면 넣은 알림을 지운다(아직 만료 전이라 지울 수 있다) + * 5. 세션 한 번 더 끊기 — 2와 4 사이에 옛 비밀번호로 새로 로그인한 세션 + * 이미 만료된 계정(지난 재설정 뒤 아직 바꾸지 않음)은 1 · 3 을 건너뛴다 — 엔진이 대리 접근을 막는다 + */ + async runPasswordReset( + io: OpsIo, + operator: string, + employee: EngineEmployee, + ): Promise { + const notes: string[] = []; + const fail = async (detail: string): Promise => { + await this.audit.record({ + operator, + command: 'password-reset', + target: employee.email, + result: 'failure', + detail: [...notes, detail].join(' · '), + }); + }; + + // 1. 앱 비밀번호 + if (employee.passwordExpired) { + step( + io, + employee.appPasswords === 0, + employee.appPasswords === 0 + ? '앱 비밀번호 — 비밀번호가 이미 만료된 계정이라 건너뜁니다(만들 수 없었습니다).' + : `앱 비밀번호 ${employee.appPasswords}개 — 비밀번호가 만료된 동안은 엔진이 지우기를 막습니다. 직원이 새 비밀번호를 정한 뒤 다시 재설정하세요.`, + ); + notes.push('앱 비밀번호 건너뜀(이미 만료)'); + } else { + try { + const removed = await this.engine.deleteAppPasswords(employee.email); + step(io, true, `앱 비밀번호 ${removed}개를 지웠습니다.`); + notes.push(`앱 비밀번호 ${removed}개 지움`); + } catch (error) { + step( + io, + false, + `앱 비밀번호를 지우지 못해 멈춥니다 — 아무것도 바꾸지 않았습니다. ${failureLine(error)}`, + ); + await fail(`앱 비밀번호 지우기 실패 · ${failureDetail(error)}`); + return; + } + } + + // 2. 웹메일 세션 끊기 — 서버는 15초 안에 막는다 + let ended: number; + try { + ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`, + ); + } catch (error) { + step( + io, + false, + `웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${failureLine(error)}`, + ); + await fail(`세션 끊기 실패 · ${failureDetail(error)}`); + return; + } + + // 3. 받은편지함 알림 — 넣지 못해도 이어 간다. 만료된 계정에는 넣을 수 없다(계약 6절) + let noticeId: string | null = null; + if (employee.passwordExpired) { + step( + io, + true, + '받은편지함 알림 메일 — 비밀번호가 만료된 계정이라 건너뜁니다.', + ); + notes.push('알림 메일 건너뜀(이미 만료)'); + } else { + noticeId = await this.systemMail.notifyPasswordReset(employee.email, { + name: employee.name, + }); + step( + io, + noticeId !== null, + noticeId !== null + ? '받은편지함 알림 메일을 넣었습니다.' + : '받은편지함 알림 메일을 넣지 못했습니다 — 재설정은 이어 갑니다.', + ); + notes.push(noticeId !== null ? '알림 메일 넣음' : '알림 메일 못 넣음'); + } + + // 4. 엔진 — 임시 비밀번호 + 만료 + let password = ''; + try { + for (let attempt = 0; ; attempt += 1) { + password = generateTempPassword(); + try { + await this.engine.resetPassword(employee.id, password); + break; + } catch (error) { + const weak = + error instanceof OpsEngineError && error.kind === 'weak-password'; + if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error; + } + } + step(io, true, '임시 비밀번호와 만료를 걸었습니다.'); + } catch (error) { + notes.push(`세션 ${ended}개 끊음`); + if (isUnknownOutcome(error)) { + // 엔진이 바꿨을 수 있다 — 알림 메일은 그대로 둔다(바뀌었다면 사실이다). 다시 하면 새 임시 비밀번호를 만든다 + step( + io, + false, + `비밀번호를 바꿨는지 모릅니다(웹메일 로그인은 끊었습니다) — ${failureLine(error)} 알림 메일은 그대로 둡니다.`, + ); + await fail(`비밀번호 바꾸기 결과 모름 · ${failureDetail(error)}`); + return; + } + step( + io, + false, + `비밀번호를 바꾸지 못했습니다(웹메일 로그인은 끊었습니다). ${failureLine(error)}`, + ); + if (noticeId !== null) { + const removed = await this.systemMail.remove(employee.email, noticeId); + step( + io, + removed, + removed + ? '넣었던 알림 메일을 지웠습니다.' + : '넣었던 알림 메일을 지우지 못했습니다 — 직원에게 무시하라고 알려 주세요.', + ); + } + await fail(`비밀번호 바꾸기 실패 · ${failureDetail(error)}`); + return; + } + + // 5. 그사이 옛 비밀번호로 새로 로그인한 세션 + let result: 'success' | 'partial' = 'success'; + try { + const late = await this.data.revokeSessions(employee.email); + ended += late; + if (late > 0) { + step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`); + } + } catch (error) { + result = 'partial'; + step( + io, + false, + `그사이 새로 로그인한 세션을 확인하지 못했습니다 — 걱정되면 다시 「비밀번호 재설정」을 하세요. ${failureLine(error)}`, + ); + notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`); + } + notes.push(`세션 ${ended}개 끊음`); + + showTempPassword(io, employee.email, password); + await this.audit.record({ + operator, + command: 'password-reset', + target: employee.email, + result, + detail: notes.join(' · '), + }); + } + + /** 2단계 해제 — 휴대폰 분실: 엔진 OTP 지우기 + 켠 때 기록 정리 + 받은편지함 알림 */ + async resetTwoFactor(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (!employee.twoFactorEnabled) { + io.print('그 직원은 2단계 인증이 꺼져 있습니다 — 할 것이 없습니다.'); + return; + } + const requirement = twoFactorRequirementFor( + (await this.settings.reload()).twoFactor, + employee.email, + new Date(), + ); + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + '1. 엔진의 2단계 인증(OTP) 지우기 — 지금은 비밀번호만으로 로그인한다', + '2. 웹메일의 2단계 인증 기록 정리', + employee.passwordExpired + ? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)' + : '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)', + ' 그대로: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저', + ...(requirement.isRequired + ? [ + ' 회사 정책으로 필수인 직원 — 다음 로그인 때 새 휴대폰으로 켜는 단계가 나온다', + ] + : []), + ]; + if (!(await confirmPlan(io, '2단계 해제', lines))) return; + + try { + await this.engine.removeTwoFactor(employee.id); + step(io, true, '엔진의 2단계 인증을 지웠습니다.'); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'two-factor-reset', + target: employee.email, + result: 'failure', + detail: failureDetail(error), + }); + return; + } + const notes: string[] = []; + let result: 'success' | 'partial' = 'success'; + try { + await this.data.forgetTwoFactor(employee.email); + step(io, true, '웹메일의 2단계 인증 기록을 정리했습니다.'); + } catch (error) { + // 엔진이 기준이다 — 다음 상태 읽기가 기록을 다시 맞춘다 + result = 'partial'; + step( + io, + false, + `기록을 정리하지 못했습니다(다음 상태 읽기가 맞춥니다). ${failureLine(error)}`, + ); + notes.push(`기록 정리 실패 · ${failureDetail(error)}`); + } + if (employee.passwordExpired) { + step( + io, + true, + '받은편지함 알림 메일 — 비밀번호가 만료된 계정이라 건너뜁니다.', + ); + notes.push('알림 메일 건너뜀(이미 만료)'); + } else { + const noticeId = await this.systemMail.notifyTwoFactorReset( + employee.email, + { name: employee.name }, + ); + step( + io, + noticeId !== null, + noticeId !== null + ? '받은편지함 알림 메일을 넣었습니다.' + : '받은편지함 알림 메일을 넣지 못했습니다.', + ); + notes.push(noticeId !== null ? '알림 메일 넣음' : '알림 메일 못 넣음'); + } + await this.audit.record({ + operator, + command: 'two-factor-reset', + target: employee.email, + result, + detail: notes.join(' · '), + }); + } +} diff --git a/backend/src/modules/ops/admin-commands.ts b/backend/src/modules/ops/admin-commands.ts new file mode 100644 index 0000000..2a0c34a --- /dev/null +++ b/backend/src/modules/ops/admin-commands.ts @@ -0,0 +1,557 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { + checkSupportContact, + type SystemSettings, +} from '@/modules/system-settings/system-settings.types'; +import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date'; +import { OpsAuditService } from './ops-audit.service'; +import { OpsDataService } from './ops-data.service'; +import { + OPS_ENGINE, + type EngineEmployee, + type EngineMailingList, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { + RULE, + askAddress, + confirmPlan, + failureLine, + logUnknownFailure, + step, +} from './ops-prompts'; +import { + bytesText, + failureDetail, + timeText, + toAddress, + toDisplayName, +} from './ops-text'; + +/** 공용 주소 하나의 구성원 상한 — 오타로 아주 긴 목록을 만들지 않게 */ +const MAILING_LIST_MAX_MEMBERS = 200; + +/** 2단계 인증 필수 정책을 보이는 글 */ +export function policyText(settings: SystemSettings): string { + const { twoFactor } = settings; + if (!twoFactor.required) return '꺼짐'; + const start = twoFactor.startDate ?? '켠 때부터'; + const exempt = twoFactor.exempt.length; + return `켜짐 · 시작일 ${start}${exempt > 0 ? ` · 예외 ${exempt}명` : ''}`; +} + +/** 현황의 한 줄 */ +export function employeeLine( + employee: EngineEmployee, + lastLogin: Date | undefined, +): string { + const usage = + employee.quotaBytes === null + ? bytesText(employee.usedBytes) + : `${bytesText(employee.usedBytes)} / ${bytesText(employee.quotaBytes)}`; + const flags = [ + employee.locked ? '잠김' : null, + employee.passwordExpired ? '임시 비밀번호' : null, + ] + .filter(Boolean) + .join(' · '); + return [ + `${employee.email} (${employee.name})`, + ` 상태 ${flags || '정상'} · 2단계 ${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} · 앱 비밀번호 ${employee.appPasswords}개`, + ` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usage}`, + ].join('\n'); +} + +/** + * 운영 도구 — 관리 명령 (13단계 — 계약 6절 "운영 도구"): 링크 끊기 · 공용 주소 · 현황 · 정책 · 설정. + * 바꾸는 명령은 실행 기록(ops_audit_log)을 남긴다. 신고된 링크의 주소(토큰이 들어 있다)는 보이지도 남기지도 않는다 + */ +@Injectable() +export class AdminCommands { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly data: OpsDataService, + private readonly audit: OpsAuditService, + private readonly sharing: FileSharingService, + private readonly settings: SystemSettingsService, + ) {} + + private get domain(): string { + return this.mailSettings.domain ?? ''; + } + + /** 링크 끊기 — 신고된 공개 링크 주소로 → revoked_by = operator */ + async revokeLink(io: OpsIo, operator: string): Promise { + const url = await io.ask('신고된 공개 링크 주소 (https://…/s/…): '); + // 우리 링크가 아니거나 없으면 null — DB 오류는 삼키지 않는다(그것을 "우리 링크 아님"으로 보이면 신고를 놓친다) + let record: Awaited>; + try { + record = await this.sharing.findForOperator(url); + } catch (error) { + io.print(failureLine(error)); + return; + } + if (record === null) { + io.print('우리 공개 링크 주소가 아니거나 없는(취소한) 링크입니다.'); + return; + } + const { link, files } = record; + if (link.revokedAt !== null) { + io.print( + `이미 끊긴 링크입니다 (${timeText(link.revokedAt)} · ${link.revokedBy === 'operator' ? '운영자' : '보낸 사람'}).`, + ); + return; + } + const lines = [ + `보낸 사람 ${link.senderName ?? ''} <${link.ownerEmail}>`, + `보낸 때 ${timeText(link.sentAt)} · 끝나는 때 ${timeText(link.expiresAt)}`, + `파일 ${files.length}개`, + ...files.map( + (file) => + ` ${file.name} (${bytesText(file.size)}) · 받은 수 ${file.downloadCount}`, + ), + '끊으면 받는 사람이 더 내려받지 못한다(되돌릴 수 없다)', + ]; + if (!(await confirmPlan(io, '링크 끊기', lines))) return; + try { + const revoked = await this.sharing.revokeAsOperator(link.id); + step( + io, + revoked, + revoked + ? '링크를 끊었습니다.' + : '끊지 않았습니다 — 그사이 끊겼거나 기간이 지났습니다.', + ); + await this.audit.record({ + operator, + command: 'link-revoke', + target: link.ownerEmail, + result: revoked ? 'success' : 'failure', + detail: `link=${link.id} · 파일 ${files.length}개${revoked ? '' : ' · 이미 끝난 링크'}`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'link-revoke', + target: link.ownerEmail, + result: 'failure', + detail: `link=${link.id} · ${failureDetail(error)}`, + }); + } + } + + /** 공용 주소 — 목록 · 만들기 · 구성원 넣기 · 빼기 */ + async mailingLists(io: OpsIo, operator: string): Promise { + io.print( + '공용 주소 — 1. 목록 2. 만들기 3. 구성원 넣기 4. 구성원 빼기 0. 돌아가기', + ); + const choice = await io.ask('고르세요: '); + try { + if (choice === '1') await this.printMailingLists(io); + else if (choice === '2') await this.createMailingList(io, operator); + else if (choice === '3') await this.changeMember(io, operator, true); + else if (choice === '4') await this.changeMember(io, operator, false); + } catch (error) { + io.print(failureLine(error)); + } + } + + private async printMailingLists(io: OpsIo): Promise { + const lists = await this.engine.listMailingLists(); + if (lists.length === 0) { + io.print('공용 주소가 없습니다.'); + return; + } + for (const list of lists) { + io.print( + `${list.email} (${list.name}) — 구성원 ${list.members.length}명`, + ); + for (const member of list.members) io.print(` ${member}`); + } + } + + private async createMailingList(io: OpsIo, operator: string): Promise { + const email = await askAddress(io, this.domain, '새 공용 주소'); + if (email === null) return; + if (await this.engine.addressInUse(email)) { + io.print('그 주소는 엔진에서 이미 쓰고 있습니다 (계정 · 공용 주소).'); + return; + } + const name = toDisplayName(await io.ask('이름 (예: 대표 문의): ')); + if (name === null) { + io.print('이름은 한 줄 1~100자로 넣어 주세요.'); + return; + } + const members = await this.askMembers(io); + if (members === null) return; + const lines = [ + `주소 ${email}`, + `이름 ${name}`, + `구성원 ${members.join(', ')}`, + '이 주소로 온 메일은 구성원 각자의 받은편지함에 사본으로 들어간다', + ]; + if (!(await confirmPlan(io, '공용 주소 만들기', lines))) return; + try { + await this.engine.createMailingList({ + localPart: email.slice(0, email.indexOf('@')), + name, + members, + }); + step(io, true, '공용 주소를 만들었습니다.'); + await this.audit.record({ + operator, + command: 'mailing-list', + target: email, + result: 'success', + detail: `만들기 · 구성원 ${members.length}명`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'mailing-list', + target: email, + result: 'failure', + detail: `만들기 · ${failureDetail(error)}`, + }); + } + } + + /** 구성원 — 쉼표로 나눈 사내 주소(엔진에 있는 직원) 1명 이상 */ + private async askMembers(io: OpsIo): Promise { + const input = await io.ask('구성원 주소 (쉼표로 나눠 여러 명): '); + const members = [ + ...new Set( + input + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '') + .map((part) => toAddress(part, this.domain)), + ), + ]; + if ( + members.length === 0 || + members.length > MAILING_LIST_MAX_MEMBERS || + members.some((member) => member === null) + ) { + io.print( + `구성원은 @${this.domain} 주소로 1~${MAILING_LIST_MAX_MEMBERS}명 넣어 주세요.`, + ); + return null; + } + const addresses = members as string[]; + for (const address of addresses) { + if (!(await this.engine.findEmployee(address))) { + io.print(`${address} — 엔진에 그 직원 계정이 없습니다.`); + return null; + } + } + return addresses; + } + + private async changeMember( + io: OpsIo, + operator: string, + include: boolean, + ): Promise { + const listEmail = await askAddress(io, this.domain, '공용 주소'); + if (listEmail === null) return; + const list = (await this.engine.listMailingLists()).find( + (item: EngineMailingList) => item.email === listEmail, + ); + if (!list) { + io.print('그런 공용 주소가 없습니다.'); + return; + } + const member = await askAddress( + io, + this.domain, + include ? '넣을 구성원 주소' : '뺄 구성원 주소', + ); + if (member === null) return; + if (include && list.members.includes(member)) { + io.print('이미 구성원입니다.'); + return; + } + if (!include && !list.members.includes(member)) { + io.print('구성원이 아닙니다.'); + return; + } + if (include && !(await this.engine.findEmployee(member))) { + io.print('엔진에 그 직원 계정이 없습니다.'); + return; + } + if (!include && list.members.length === 1) { + io.print( + '마지막 구성원은 뺄 수 없습니다 — 공용 주소에는 구성원이 한 명 이상 있어야 합니다.', + ); + return; + } + const action = include ? '구성원 넣기' : '구성원 빼기'; + if ( + !(await confirmPlan(io, action, [ + `공용 주소 ${list.email} (${list.name})`, + `${include ? '넣을' : '뺄'} 사람 ${member}`, + ])) + ) { + return; + } + try { + await this.engine.setMailingListMember(list.id, member, include); + step(io, true, `${action}를 했습니다.`); + await this.audit.record({ + operator, + command: 'mailing-list', + target: list.email, + result: 'success', + detail: `${action} · ${member}`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'mailing-list', + target: list.email, + result: 'failure', + detail: `${action} · ${member} · ${failureDetail(error)}`, + }); + } + } + + /** 현황 — 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 (읽기만 — 기록하지 않는다) */ + async status(io: OpsIo): Promise { + try { + const [employees, settings] = await Promise.all([ + this.engine.listEmployees(), + this.settings.reload(), + ]); + const lastLogins = await this.data.lastWebmailLogins( + employees.map((employee) => employee.email), + ); + io.print(''); + io.print(RULE); + io.print(` 직원 ${employees.length}명`); + io.print(RULE); + for (const employee of employees) { + io.print(employeeLine(employee, lastLogins.get(employee.email))); + } + io.print(RULE); + io.print(` 2단계 인증 필수 정책 ${policyText(settings)}`); + io.print( + ` 문의 연락처 ${settings.supportContact ?? '(정하지 않음)'}`, + ); + io.print(RULE); + } catch (error) { + io.print(failureLine(error)); + } + } + + /** 정책 · 설정 — 2단계 인증 필수(켜기 · 시작일 · 예외), 문의 연락처 */ + async policy(io: OpsIo, operator: string): Promise { + const settings = await this.settings.reload(); + io.print(`2단계 인증 필수 정책: ${policyText(settings)}`); + if (settings.twoFactor.exempt.length > 0) { + io.print(` 예외: ${settings.twoFactor.exempt.join(', ')}`); + } + io.print(`문의 연락처: ${settings.supportContact ?? '(정하지 않음)'}`); + io.print( + '1. 2단계 인증 필수 켜기 · 시작일 2. 2단계 인증 필수 끄기 3. 예외 주소 넣기 4. 예외 주소 빼기 5. 문의 연락처 0. 돌아가기', + ); + const choice = await io.ask('고르세요: '); + try { + if (choice === '1') await this.requireTwoFactor(io, operator); + else if (choice === '2') await this.stopRequiringTwoFactor(io, operator); + else if (choice === '3') await this.changeExempt(io, operator, true); + else if (choice === '4') await this.changeExempt(io, operator, false); + else if (choice === '5') await this.setSupportContact(io, operator); + } catch (error) { + io.print(failureLine(error)); + } + } + + private async requireTwoFactor(io: OpsIo, operator: string): Promise { + const today = kstDateString(new Date()); + const input = await io.ask( + `시작일 (YYYY-MM-DD, 한국 날짜 — 그날 0시부터 막는다. 빈 값이면 오늘 ${today}): `, + ); + const startDate = input === '' ? today : input; + if (kstStartOfDate(startDate) === null) { + io.print('날짜는 2026-12-01 처럼 넣어 주세요.'); + return; + } + // 안내의 숫자만 — 읽지 못하면 그 줄을 뺀다(모르는 오류는 로그에) + const employees = await this.engine.listEmployees().catch((error) => { + logUnknownFailure(error); + return null; + }); + const withoutTwoFactor = employees?.filter( + (employee) => !employee.locked && !employee.twoFactorEnabled, + ).length; + const lines = [ + `시작일 ${startDate} 0시(KST) 부터 — 그전에는 웹메일이 안내만 한다`, + '시작일부터 2단계를 켜지 않은 직원은 로그인 뒤 켜는 단계를 먼저 거친다(예외 주소 빼고)', + '이미 로그인한 직원도 다음 갱신(최대 1시간) 때 적용된다', + ...(withoutTwoFactor !== undefined + ? [`지금 2단계를 켜지 않은 직원 ${withoutTwoFactor}명`] + : []), + ...(startDate <= today + ? [ + '주의: 시작일이 오늘이거나 지났다 — 바로 적용된다(로그인한 직원은 다음 갱신 때)', + ] + : []), + ]; + if (!(await confirmPlan(io, '2단계 인증 필수 켜기', lines))) return; + await this.saveSettings( + io, + operator, + 'policy', + '2단계 인증 필수', + { twoFactor: { required: true, startDate } }, + `켜기 · 시작일 ${startDate}`, + ); + } + + private async stopRequiringTwoFactor( + io: OpsIo, + operator: string, + ): Promise { + if ( + !(await confirmPlan(io, '2단계 인증 필수 끄기', [ + '로그인 뒤 켜는 단계 · 웹메일의 안내가 없어진다(이미 켠 2단계 인증은 그대로)', + ])) + ) { + return; + } + await this.saveSettings( + io, + operator, + 'policy', + '2단계 인증 필수', + { twoFactor: { required: false } }, + '끄기', + ); + } + + private async changeExempt( + io: OpsIo, + operator: string, + include: boolean, + ): Promise { + const email = await askAddress( + io, + this.domain, + include ? '예외로 둘 직원 주소' : '예외에서 뺄 직원 주소', + ); + if (email === null) return; + const current = (await this.settings.reload()).twoFactor.exempt; + if (include === current.includes(email)) { + io.print(include ? '이미 예외입니다.' : '예외가 아닙니다.'); + return; + } + if ( + !(await confirmPlan(io, include ? '예외 주소 넣기' : '예외 주소 빼기', [ + `주소 ${email}`, + include + ? '이 직원은 2단계 인증 필수 정책에서 빠진다' + : '이 직원도 2단계 인증 필수 정책을 따른다', + ])) + ) { + return; + } + // y/N 뒤에 서버 설정 줄을 잠그고 그때의 목록에 넣고 뺀다 — 묻는 동안 다른 운영자가 바꾼 것을 잃지 않게 (재리뷰 N5) + const detail = include ? '예외 넣기' : '예외 빼기'; + try { + const changed = await this.data.changeTwoFactorExempt(email, include); + step( + io, + true, + changed + ? '저장했습니다 — 서버는 10초 안에 새 값을 씁니다.' + : `그사이 이미 ${include ? '예외가 되었습니다' : '예외에서 빠졌습니다'} — 바꾼 것이 없습니다.`, + ); + await this.audit.record({ + operator, + command: 'policy', + target: email, + result: 'success', + detail: changed ? detail : `${detail} · 이미 그 상태`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'policy', + target: email, + result: 'failure', + detail: `${detail} · ${failureDetail(error)}`, + }); + } + } + + private async setSupportContact(io: OpsIo, operator: string): Promise { + const input = await io.ask( + '문의 연락처 (한 줄 200자 — 예: IT 담당 홍길동 · 02-000-0000. 빈 값이면 지운다): ', + ); + const checked = checkSupportContact(input); + if (!checked.ok) { + io.print( + checked.problem === 'too-long' + ? '200자까지 넣을 수 있습니다.' + : '한 줄로 넣어 주세요(줄바꿈 · 탭 없이).', + ); + return; + } + const lines = [ + checked.value === null + ? '연락처를 지운다 — 화면은 지금 글 그대로' + : `연락처 ${checked.value}`, + '로그인 화면 · 2단계 코드 단계 · 받은편지함 알림 메일의 마지막 줄에 글자로만 보인다', + ]; + if (!(await confirmPlan(io, '문의 연락처', lines))) return; + await this.saveSettings( + io, + operator, + 'support-contact', + null, + { supportContact: checked.value }, + checked.value === null ? '지우기' : '정하기', + ); + } + + private async saveSettings( + io: OpsIo, + operator: string, + command: 'policy' | 'support-contact', + target: string | null, + patch: Parameters[0], + detail: string, + ): Promise { + try { + await this.settings.update(patch, new Date()); + step(io, true, '저장했습니다 — 서버는 10초 안에 새 값을 씁니다.'); + await this.audit.record({ + operator, + command, + target, + result: 'success', + detail, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command, + target, + result: 'failure', + detail: `${detail} · ${failureDetail(error)}`, + }); + } + } +} diff --git a/backend/src/modules/ops/lock-commands.ts b/backend/src/modules/ops/lock-commands.ts new file mode 100644 index 0000000..00ce17e --- /dev/null +++ b/backend/src/modules/ops/lock-commands.ts @@ -0,0 +1,680 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import { + MAX_VACATION_SUBJECT_LENGTH, + MAX_VACATION_TEXT_LENGTH, +} from '@/modules/mail/mail.constants'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { AccountCommands } from './account-commands'; +import { OpsAuditService } from './ops-audit.service'; +import { + OpsDataService, + totalRows, + type AddressPurge, + type AddressRows, +} from './ops-data.service'; +import { + OPS_ENGINE, + type EngineEmployee, + type EngineMailingList, + type LockRules, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { + NOT_EMPLOYEE_ACCOUNT, + NOT_EMPLOYEE_ADDRESS, + askAddress, + askEmployee, + confirmPlan, + failureLine, + logUnknownFailure, + step, +} from './ops-prompts'; +import { bytesText, failureDetail, isUnknownOutcome } from './ops-text'; + +/** 엔진이 Sieve 문자열에서 늘 변수로 펼치는 글 — 자동 응답에 받지 않는다 (sieve-script.ts 와 같은 규칙) */ +const SIEVE_VARIABLE = '${'; +/** 자동 응답 내용의 한 줄로 받지 않는 제어 문자 (줄바꿈은 따로 잇는다) */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/; + +/** 표의 이름 — 퇴사 마무리에서 지울 줄을 보일 때 */ +const ROW_LABELS: Record = { + loginEvents: '로그인 기록', + appPasswordUsages: '앱 비밀번호 사용 기록', + twoFactorSettings: '2단계 인증 기록', + signatures: '서명', + signatureImages: '서명 그림', + mailSettings: '보내기 · 읽기 설정', + directory: '사내 목록 기록', + files: '대용량 파일', + shareLinks: '대용량 첨부 링크', +}; + +/** 남은 줄을 보이는 글 */ +export function rowsLines(rows: AddressRows): string[] { + return (Object.keys(ROW_LABELS) as (keyof AddressRows)[]) + .filter((key) => rows[key] > 0) + .map((key) => ` ${ROW_LABELS[key]} ${rows[key]}줄`); +} + +/** + * 퇴사 마무리가 그 주소를 지울 곳 — DB 줄 · 2단계 필수 예외 · 공용 주소 구성원 (바뀔 것에 보이고 y/N 뒤에 지운다). + * `keptLists` 는 그 직원이 마지막 구성원이라 운영자가 남기기로 한 공용 주소다(재리뷰 N4) + */ +interface AddressFootprint { + readonly rows: AddressRows; + readonly exempt: boolean; + readonly lists: readonly EngineMailingList[]; + readonly keptLists: readonly EngineMailingList[]; +} + +/** 그 직원이 마지막 구성원인가 — 빼면 받는 사람이 없다 */ +function isLastMember(list: EngineMailingList): boolean { + return list.members.length === 1; +} + +function footprintEmpty(footprint: AddressFootprint): boolean { + return ( + totalRows(footprint.rows) === 0 && + !footprint.exempt && + footprint.lists.length === 0 && + footprint.keptLists.length === 0 + ); +} + +/** 퇴사 마무리의 바뀔 것 — 우리 DB · 예외 주소 · 공용 주소 */ +function footprintLines(footprint: AddressFootprint, first: number): string[] { + const lines = [ + `${first}. 우리 DB 의 그 주소 기록 지우기 — 같은 주소를 다시 쓸 때 섞이지 않게`, + ...rowsLines(footprint.rows), + ...(footprint.exempt ? [' 2단계 인증 필수의 예외 주소에서 빼기'] : []), + ' 웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다', + ]; + if (footprint.lists.length > 0) { + lines.push( + `${first + 1}. 공용 주소 ${footprint.lists.length}개의 구성원에서 빼기 — ${footprint.lists.map((list) => list.email).join(', ')}`, + ...footprint.lists + .filter(isLastMember) + .map( + (list) => + ` ${list.email} — 마지막 구성원이다(빼면 받는 사람이 없다)`, + ), + ); + } + lines.push( + ...footprint.keptLists.map( + (list) => + ` 남김: ${list.email} — 마지막 구성원(다른 구성원을 넣은 뒤 「공용 주소」에서 빼세요)`, + ), + ' 실행 기록(운영 도구)은 남긴다', + ); + return lines; +} + +/** + * 운영 도구 — 잠금 명령 (13단계 — 계약 6절 "운영 도구"): 잠금 · 잠금 풀기 · 퇴사 마무리. + * - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근 Sieve)을 먼저 → 로그인 권한(authenticate) 빼기 → 웹메일 세션 끊기 → (묻고) 공개 링크 끊기. + * 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). 메일은 계속 받는다 + * - 잠금 풀기: 로그인 권한 되돌리기 → (묻고) 넘기기 · 자동 응답 떼기 → 비밀번호 재설정을 함께 권한다 + * - 퇴사 마무리: 잠긴 계정만 — 주소를 다시 쳐서 확인한 뒤 엔진 계정 지우기(메일 영구 삭제) → 우리 DB 의 그 주소 줄 정리 + */ +@Injectable() +export class LockCommands { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly data: OpsDataService, + private readonly audit: OpsAuditService, + private readonly sharing: FileSharingService, + private readonly accounts: AccountCommands, + ) {} + + private get domain(): string { + return this.mailSettings.domain ?? ''; + } + + async lock(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (employee.locked) { + io.print('이미 잠긴 계정입니다.'); + return; + } + if (employee.customPermissions) { + io.print( + '이 계정은 권한이 직접 정해져 있어 이 도구로 잠그지 않습니다 — 엔진 관리 화면에서 하세요.', + ); + return; + } + + const rules = await this.askLockRules(io, employee); + if (rules === null) return; + const revokeLinks = await io.confirm( + '그 직원이 보낸 대용량 첨부 공개 링크도 모두 끊을까요?', + ); + + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + ...(rules.forwardTo !== null + ? [`1. 받는 메일의 사본을 ${rules.forwardTo} 로 넘기기 (원본도 남는다)`] + : []), + ...(rules.autoReply !== null + ? [`2. 자동 응답 — 제목 「${rules.autoReply.subject}」`] + : []), + '3. 로그인 권한 빼기 — 웹메일 · 메일 프로그램 · 앱 비밀번호가 모두 막힌다. 메일은 계속 받는다', + '4. 웹메일 로그인 모두 끊기', + ...(revokeLinks ? ['5. 보낸 대용량 첨부 공개 링크 모두 끊기'] : []), + ]; + if (!(await confirmPlan(io, '잠금', lines))) return; + + const notes: string[] = []; + const sieveRequested = rules.forwardTo !== null || rules.autoReply !== null; + // 1 · 2. Sieve — 로그인 권한을 빼기 전에 (뺀 뒤에는 대리 접근이 막힌다) + if (sieveRequested) { + try { + await this.engine.applyLockRules(employee.email, rules); + step(io, true, '받는 메일 넘기기 · 자동 응답을 걸었습니다.'); + notes.push( + [ + rules.forwardTo !== null ? `넘기기 → ${rules.forwardTo}` : null, + rules.autoReply !== null ? '자동 응답' : null, + ] + .filter(Boolean) + .join(' · '), + ); + } catch (error) { + step( + io, + false, + `넘기기 · 자동 응답을 걸지 못해 멈춥니다 — 잠그지 않았습니다. ${failureLine(error)}`, + ); + await this.audit.record({ + operator, + command: 'lock', + target: employee.email, + result: 'failure', + detail: `Sieve 실패 · ${failureDetail(error)}`, + }); + return; + } + } + + // 3. 로그인 권한 빼기 + try { + await this.engine.setLoginEnabled(employee.id, false); + step(io, true, '로그인 권한을 뺐습니다.'); + } catch (error) { + const unknown = isUnknownOutcome(error); + step( + io, + false, + unknown + ? `잠갔는지 모릅니다 — ${failureLine(error)}` + : `잠그지 못했습니다(로그인 권한을 빼지 못함). ${failureLine(error)}`, + ); + notes.push( + `잠금 ${unknown ? '결과 모름' : '실패'} · ${failureDetail(error)}`, + ); + // 결과를 모르면 잠겼을 수 있다 — 걸어 둔 넘기기 · 자동 응답은 그대로 둔다(다시 「잠금」을 하면 이어 쓴다) + await this.afterLockFailed( + io, + employee, + sieveRequested && !unknown, + notes, + ); + await this.audit.record({ + operator, + command: 'lock', + target: employee.email, + result: 'failure', + detail: notes.join(' · '), + }); + return; + } + + // 4 · 5. 세션 · 링크 + let result: 'success' | 'partial' = 'success'; + try { + const ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`, + ); + notes.push(`세션 ${ended}개 끊음`); + } catch (error) { + result = 'partial'; + step(io, false, `웹메일 로그인을 끊지 못했습니다. ${failureLine(error)}`); + notes.push(`세션 끊기 실패 · ${failureDetail(error)}`); + } + if (revokeLinks) { + try { + const revoked = await this.sharing.revokeAllOfOwnerAsOperator( + employee.email, + ); + step(io, true, `공개 링크 ${revoked}개를 끊었습니다.`); + notes.push(`링크 ${revoked}개 끊음`); + } catch (error) { + result = 'partial'; + step(io, false, `공개 링크를 끊지 못했습니다. ${failureLine(error)}`); + notes.push(`링크 끊기 실패 · ${failureDetail(error)}`); + } + } + await this.audit.record({ + operator, + command: 'lock', + target: employee.email, + result, + detail: notes.join(' · ') || null, + }); + } + + /** + * 로그인 권한을 빼지 못했다 — 웹메일 세션은 끊어 둔다(잠그려던 계정이다 — 털림 · 퇴사, 다시 「잠금」을 하면 된다). + * 걸어 둔 넘기기 · 자동 응답은 계정이 그대로 쓰이므로 뗄지 묻는다 + */ + private async afterLockFailed( + io: OpsIo, + employee: EngineEmployee, + sieveApplied: boolean, + notes: string[], + ): Promise { + try { + const ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개는 끊었습니다(15초 안에 막힙니다) — 다시 「잠금」을 하세요.`, + ); + notes.push(`세션 ${ended}개 끊음`); + } catch (error) { + step(io, false, `웹메일 로그인도 끊지 못했습니다. ${failureLine(error)}`); + notes.push(`세션 끊기 실패 · ${failureDetail(error)}`); + } + if ( + !sieveApplied || + !(await io.confirm( + '걸어 둔 받는 메일 넘기기 · 자동 응답을 뗄까요? (잠그지 못해 계정은 그대로 쓰입니다)', + )) + ) { + return; + } + try { + await this.engine.clearLockRules(employee.email); + step(io, true, '넘기기 · 자동 응답을 뗐습니다.'); + notes.push('Sieve 뗌'); + } catch (error) { + step( + io, + false, + `떼지 못했습니다 — 직원이 설정 › 필터 · 부재중에서 끌 수 있습니다. ${failureLine(error)}`, + ); + notes.push(`Sieve 떼기 실패 · ${failureDetail(error)}`); + } + } + + /** 넘기기 · 자동 응답을 묻는다 — 잘못 넣으면 null(잠그지 않는다) */ + private async askLockRules( + io: OpsIo, + employee: EngineEmployee, + ): Promise { + let forwardTo: string | null = null; + if (await io.confirm('받는 메일의 사본을 동료에게 넘길까요?')) { + const target = await askEmployee( + io, + this.engine, + this.domain, + '받을 동료 주소', + ); + if (target === null) return null; + if (target.email === employee.email) { + io.print('자기 자신에게는 넘기지 않습니다.'); + return null; + } + if (target.locked) { + io.print('그 동료도 잠긴 계정입니다 — 다른 주소를 고르세요.'); + return null; + } + forwardTo = target.email; + } + let autoReply: LockRules['autoReply'] = null; + if (await io.confirm('보낸 사람에게 자동 응답을 보낼까요?')) { + const subject = (await io.ask('자동 응답 제목: ')).trim(); + if ( + subject === '' || + subject.length > MAX_VACATION_SUBJECT_LENGTH || + CONTROL_CHARACTER.test(subject) || + subject.includes(SIEVE_VARIABLE) + ) { + io.print( + `제목은 한 줄 1~${MAX_VACATION_SUBJECT_LENGTH}자로 넣어 주세요 ('\${' 는 쓸 수 없습니다).`, + ); + return null; + } + io.print( + '자동 응답 내용 — 여러 줄을 넣을 수 있습니다. 빈 줄을 넣으면 끝납니다.', + ); + const lines: string[] = []; + for (;;) { + const line = await io.ask('> '); + if (line === '') break; + lines.push(line); + } + const body = lines.join('\n'); + if ( + body === '' || + body.length > MAX_VACATION_TEXT_LENGTH || + lines.some((line) => CONTROL_CHARACTER.test(line)) || + body.includes(SIEVE_VARIABLE) + ) { + io.print( + `내용은 1~${MAX_VACATION_TEXT_LENGTH}자로 넣어 주세요 ('\${' 는 쓸 수 없습니다).`, + ); + return null; + } + autoReply = { subject, body }; + } + return { forwardTo, autoReply }; + } + + async unlock(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (!employee.locked) { + io.print('잠긴 계정이 아닙니다.'); + return; + } + if (employee.customPermissions) { + io.print( + '이 계정은 권한이 직접 정해져 있어 이 도구로 풀지 않습니다 — 엔진 관리 화면에서 하세요.', + ); + return; + } + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + '1. 로그인 권한 되돌리기 — 지금 비밀번호 · 앱 비밀번호로 다시 들어올 수 있다', + ' 잠글 때 건 넘기기 · 자동 응답은 푼 뒤에 뗄지 묻는다', + ' 털린 계정이면 비밀번호 재설정을 함께 하기를 권한다(다음에 묻는다)', + ]; + if (!(await confirmPlan(io, '잠금 풀기', lines))) return; + + try { + await this.engine.setLoginEnabled(employee.id, true); + step(io, true, '로그인 권한을 되돌렸습니다.'); + } catch (error) { + step(io, false, `풀지 못했습니다. ${failureLine(error)}`); + await this.audit.record({ + operator, + command: 'unlock', + target: employee.email, + result: 'failure', + detail: failureDetail(error), + }); + return; + } + const notes: string[] = []; + let result: 'success' | 'partial' = 'success'; + try { + const rules = await this.engine.getLockRules(employee.email); + if (rules.forwardTo !== null || rules.autoReply) { + const what = [ + rules.forwardTo !== null ? `넘기기(→ ${rules.forwardTo})` : null, + rules.autoReply ? '자동 응답' : null, + ] + .filter(Boolean) + .join(' · '); + if (await io.confirm(`걸려 있는 것을 뗄까요? — ${what}`)) { + await this.engine.clearLockRules(employee.email); + step(io, true, `뗐습니다 — ${what}`); + notes.push(`${what} 뗌`); + } + } + } catch (error) { + result = 'partial'; + step( + io, + false, + `넘기기 · 자동 응답을 확인하지 못했습니다 — 직원이 설정 › 필터 · 부재중에서 끌 수 있습니다. ${failureLine(error)}`, + ); + notes.push(`Sieve 확인 실패 · ${failureDetail(error)}`); + } + await this.audit.record({ + operator, + command: 'unlock', + target: employee.email, + result, + detail: notes.join(' · ') || null, + }); + + if ( + await io.confirm('비밀번호 재설정을 함께 할까요? (털린 계정이면 권함)') + ) { + const refreshed = await this.engine + .findEmployee(employee.email) + .catch((error) => { + logUnknownFailure(error); + return null; + }); + if (refreshed === null) { + io.print( + '계정을 다시 읽지 못했습니다 — 「비밀번호 재설정」을 따로 하세요.', + ); + return; + } + if ( + await confirmPlan( + io, + '비밀번호 재설정', + this.accounts.resetPlan(refreshed), + ) + ) { + await this.accounts.runPasswordReset(io, operator, refreshed); + } + } + } + + async offboard(io: OpsIo, operator: string): Promise { + const email = await askAddress(io, this.domain, '퇴사한 직원 주소'); + if (email === null) return; + if (!this.engine.isEmployeeAddress(email)) { + io.print(NOT_EMPLOYEE_ADDRESS); + return; + } + let employee: EngineEmployee | null; + let footprint: AddressFootprint; + try { + employee = await this.engine.findEmployee(email); + // 계정은 있는데 직원(User)이 아니다 — 공유 메일함(Group) · 공용 주소. 이 도구로 지우지 않는다 + if (employee === null && (await this.engine.addressInUse(email))) { + io.print(NOT_EMPLOYEE_ACCOUNT); + return; + } + footprint = await this.footprintOf(email); + } catch (error) { + io.print(failureLine(error)); + return; + } + footprint = await this.chooseLastMemberLists(io, footprint); + + if (employee === null) { + // 엔진 계정은 이미 없다(지난 마무리가 DB 정리 전에 멈췄거나 엔진 관리 화면에서 지웠다) — 남은 것만 지운다 + if (footprintEmpty(footprint)) { + io.print('엔진에 그 계정이 없고 우리 DB 에 남은 기록도 없습니다.'); + return; + } + io.print('엔진에 그 계정이 없습니다 — 남은 기록만 지웁니다.'); + await this.purgeOnly(io, operator, email, footprint); + return; + } + if (!employee.locked) { + io.print('잠긴 계정만 마무리할 수 있습니다 — 먼저 「잠금」을 하세요.'); + return; + } + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + `1. 엔진 계정 지우기 — 메일 ${bytesText(employee.usedBytes)} 이 영구 삭제된다(되돌릴 수 없다)`, + ...footprintLines(footprint, 2), + ]; + if (!(await confirmPlan(io, '퇴사 마무리', lines))) return; + if (!(await this.retyped(io, employee.email))) return; + + try { + await this.engine.deleteAccount(employee.id); + step(io, true, '엔진 계정을 지웠습니다.'); + } catch (error) { + step( + io, + false, + isUnknownOutcome(error) + ? `엔진 계정을 지웠는지 모릅니다 — ${failureLine(error)} 이미 지워졌으면 다시 할 때 남은 기록만 지웁니다.` + : `엔진 계정을 지우지 못했습니다 — 아무것도 바꾸지 않았습니다. ${failureLine(error)}`, + ); + await this.audit.record({ + operator, + command: 'offboard', + target: employee.email, + result: 'failure', + detail: failureDetail(error), + }); + return; + } + await this.purge(io, operator, employee.email, footprint, '엔진 계정 지움'); + } + + /** 그 주소가 남은 곳 — DB 줄 · 2단계 필수 예외 · 공용 주소 구성원 */ + private async footprintOf(email: string): Promise { + const [rows, exempt, lists] = await Promise.all([ + this.data.countRowsOf(email), + this.data.isTwoFactorExempt(email), + this.engine.listMailingLists(), + ]); + return { + rows, + exempt, + lists: lists.filter((list) => list.members.includes(email)), + keptLists: [], + }; + } + + /** + * 그 직원이 마지막 구성원인 공용 주소 — 빼면 받는 사람이 없다(「공용 주소」 명령은 마지막 구성원을 빼지 않는다). 그래도 뺄지 묻는다 (재리뷰 N4) + */ + private async chooseLastMemberLists( + io: OpsIo, + footprint: AddressFootprint, + ): Promise { + const last = footprint.lists.filter(isLastMember); + if (last.length === 0) return footprint; + io.print( + `그 직원이 마지막 구성원인 공용 주소가 있습니다 — 빼면 받는 사람이 없습니다: ${last.map((list) => list.email).join(', ')}`, + ); + if ( + await io.confirm( + '그 공용 주소에서도 뺄까요? (n 이면 남깁니다 — 다른 구성원을 넣은 뒤 「공용 주소」에서 빼세요)', + ) + ) { + return footprint; + } + return { + ...footprint, + lists: footprint.lists.filter((list) => !isLastMember(list)), + keptLists: last, + }; + } + + /** 엔진 계정이 없는 주소의 남은 것만 지운다 */ + private async purgeOnly( + io: OpsIo, + operator: string, + email: string, + footprint: AddressFootprint, + ): Promise { + const lines = [`주소 ${email}`, ...footprintLines(footprint, 1)]; + if (!(await confirmPlan(io, '퇴사 마무리(기록만)', lines))) return; + if (!(await this.retyped(io, email))) return; + await this.purge(io, operator, email, footprint, '엔진 계정 없음'); + } + + /** 우리 DB(한 트랜잭션 — 예외 주소 포함) → 공용 주소 구성원 → 실행 기록 */ + private async purge( + io: OpsIo, + operator: string, + email: string, + footprint: AddressFootprint, + first: string, + ): Promise { + const notes = [first]; + let result: 'success' | 'partial' = 'success'; + let purged: AddressPurge; + try { + purged = await this.data.purgeAddress(email); + } catch (error) { + step( + io, + false, + `우리 DB 의 기록을 지우지 못했습니다 — 「퇴사 마무리」를 다시 하면 남은 것만 지웁니다. ${failureLine(error)}`, + ); + await this.audit.record({ + operator, + command: 'offboard', + target: email, + result: 'partial', + detail: [...notes, `기록 지우기 실패 · ${failureDetail(error)}`].join( + ' · ', + ), + }); + return; + } + step(io, true, `우리 DB 의 기록 ${totalRows(purged.rows)}줄을 지웠습니다.`); + notes.push(`기록 ${totalRows(purged.rows)}줄 지움`); + if (purged.exemptRemoved) { + step(io, true, '2단계 인증 필수의 예외 주소에서 뺐습니다.'); + notes.push('예외 주소에서 뺌'); + } + if (purged.sessionsRevoked > 0) { + notes.push(`세션 ${purged.sessionsRevoked}개 끊음`); + } + if (purged.filesLeftForCleanup > 0) { + io.print( + ` 디스크의 파일 ${purged.filesLeftForCleanup}개는 정리 작업이 지웁니다.`, + ); + } + for (const list of footprint.lists) { + try { + await this.engine.setMailingListMember(list.id, email, false); + step(io, true, `공용 주소 ${list.email} 의 구성원에서 뺐습니다.`); + notes.push(`${list.email} 구성원에서 뺌`); + } catch (error) { + result = 'partial'; + step( + io, + false, + `공용 주소 ${list.email} 의 구성원에서 빼지 못했습니다 — 「공용 주소」에서 빼 주세요. ${failureLine(error)}`, + ); + notes.push(`${list.email} 빼기 실패 · ${failureDetail(error)}`); + } + } + await this.audit.record({ + operator, + command: 'offboard', + target: email, + result, + detail: notes.join(' · '), + }); + } + + /** 주소를 다시 쳐서 확인 — 정확히 같아야 한다 (소문자로 견준다) */ + private async retyped(io: OpsIo, email: string): Promise { + const typed = ( + await io.ask('지우려면 주소 전체를 다시 쳐 주세요: ') + ).toLowerCase(); + if (typed !== email) { + io.print('주소가 같지 않아 하지 않았습니다.'); + return false; + } + return true; + } +} diff --git a/backend/src/modules/ops/ops-audit-log.entity.ts b/backend/src/modules/ops/ops-audit-log.entity.ts new file mode 100644 index 0000000..83de0c5 --- /dev/null +++ b/backend/src/modules/ops/ops-audit-log.entity.ts @@ -0,0 +1,47 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** 운영자 이름 길이 */ +export const OPS_OPERATOR_MAX_LENGTH = 100; +/** 명령 이름 길이 */ +const COMMAND_COLUMN_LENGTH = 32; +/** 대상 — 주소 · 공용 주소 · 설정 이름 (메일 주소 최대 길이) */ +export const OPS_TARGET_MAX_LENGTH = 254; +/** 결과 — success · failure · partial */ +const RESULT_COLUMN_LENGTH = 16; +/** 덧붙이는 말 — 비밀은 넣지 않는다 */ +export const OPS_DETAIL_MAX_LENGTH = 500; + +/** 실행 결과 */ +export type OpsAuditResult = 'success' | 'failure' | 'partial'; + +/** + * 운영 도구의 실행 기록 (13단계 — 계약 6절 "운영 도구") — 언제 · 누가 · 명령 · 대상 · 결과. + * 비밀(관리자 비밀번호 · 임시 비밀번호 · 코드)은 남기지 않는다. 퇴사 마무리도 이 표는 지우지 않는다(DB 백업에 든다) + */ +@Entity({ name: 'ops_audit_log' }) +export class OpsAuditLogEntity { + /** 차례 번호 (bigint — 문자열로 읽힌다) */ + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id!: string; + + @Index('IDX_ops_audit_log_occurred_at') + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + /** 운영자 이름 — 도구를 시작할 때 넣은 이름 */ + @Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH }) + operator!: string; + + /** 명령 — hire · password-reset · two-factor-reset · lock · unlock · offboard · link-revoke · mailing-list · policy · support-contact */ + @Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH }) + command!: string; + + @Column({ type: 'varchar', length: OPS_TARGET_MAX_LENGTH, nullable: true }) + target!: string | null; + + @Column({ type: 'varchar', length: RESULT_COLUMN_LENGTH }) + result!: OpsAuditResult; + + @Column({ type: 'varchar', length: OPS_DETAIL_MAX_LENGTH, nullable: true }) + detail!: string | null; +} diff --git a/backend/src/modules/ops/ops-audit.service.ts b/backend/src/modules/ops/ops-audit.service.ts new file mode 100644 index 0000000..cedaac3 --- /dev/null +++ b/backend/src/modules/ops/ops-audit.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { + OPS_DETAIL_MAX_LENGTH, + OPS_OPERATOR_MAX_LENGTH, + OPS_TARGET_MAX_LENGTH, + OpsAuditLogEntity, + type OpsAuditResult, +} from './ops-audit-log.entity'; + +/** 실행 기록 한 줄 — 비밀(비밀번호 · 코드)은 넣지 않는다 */ +export interface OpsAuditEntry { + readonly operator: string; + readonly command: string; + readonly target: string | null; + readonly result: OpsAuditResult; + readonly detail?: string | null; +} + +function clip(text: string | null | undefined, max: number): string | null { + if (text === null || text === undefined || text === '') return null; + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +/** + * 운영 도구의 실행 기록 (계약 6절 — ops_audit_log). 기록하지 못해도 명령의 결과는 그대로다 — 남기지 못했다는 것을 알린다(false) + */ +@Injectable() +export class OpsAuditService { + private readonly logger = new Logger(OpsAuditService.name); + + constructor( + @InjectRepository(OpsAuditLogEntity) + private readonly rows: Repository, + ) {} + + async record(entry: OpsAuditEntry, at: Date = new Date()): Promise { + try { + await this.rows.insert({ + occurredAt: at, + operator: + clip(entry.operator, OPS_OPERATOR_MAX_LENGTH) ?? '(이름 없음)', + command: entry.command, + target: clip(entry.target, OPS_TARGET_MAX_LENGTH), + result: entry.result, + detail: clip(entry.detail, OPS_DETAIL_MAX_LENGTH), + }); + return true; + } catch (error) { + this.logger.error( + `[SYS_001] 운영 도구의 실행 기록을 남기지 못했습니다: command=${entry.command}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } +} diff --git a/backend/src/modules/ops/ops-cli.ts b/backend/src/modules/ops/ops-cli.ts new file mode 100644 index 0000000..172aa79 --- /dev/null +++ b/backend/src/modules/ops/ops-cli.ts @@ -0,0 +1,189 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { AccountCommands } from './account-commands'; +import { AdminCommands } from './admin-commands'; +import { LockCommands } from './lock-commands'; +import { OPS_OPERATOR_MAX_LENGTH } from './ops-audit-log.entity'; +import { OPS_ENGINE, OpsEngineError, type OpsEngine } from './ops-engine.port'; +import { OpsInputClosed, type OpsIo } from './ops-io'; +import { RULE, failureLine } from './ops-prompts'; + +/** 운영자 이름에 받지 않는 제어 문자 */ +// eslint-disable-next-line no-control-regex +const OPERATOR_CONTROL = /[\u0000-\u001f]/; + +/** 관리자 비밀번호를 다시 묻는 횟수 */ +const SIGN_IN_ATTEMPTS = 3; + +/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열 개와 차례 그대로) */ +export const OPS_MENU = [ + ['1', '입사'], + ['2', '비밀번호 재설정'], + ['3', '2단계 해제'], + ['4', '잠금'], + ['5', '잠금 풀기'], + ['6', '퇴사 마무리'], + ['7', '링크 끊기'], + ['8', '공용 주소'], + ['9', '현황'], + ['10', '정책 · 설정'], + ['0', '끝내기'], +] as const; + +/** + * 운영 도구의 차례 (13단계 — 계약 6절 "운영 도구") — 운영자 이름 · 관리자 비밀번호(화면에 보이지 않게 · 저장하지 않음)를 묻고 메뉴를 돈다. + * 명령마다 묻고 → 바뀔 것을 보이고 → y/N → 실행 → 실행 기록. 입력이 끝나면(Ctrl+D · Ctrl+C) 조용히 끝낸다 + */ +@Injectable() +export class OpsCli { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + private readonly accounts: AccountCommands, + private readonly locks: LockCommands, + private readonly admin: AdminCommands, + ) {} + + /** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */ + async run(io: OpsIo): Promise { + try { + io.print(RULE); + io.print(' ARA SYSTEM 운영 도구'); + io.print( + ' 바꾸기 전에 늘 바뀔 것을 보여 주고 y/N 으로 묻습니다. 한 일은 실행 기록에 남습니다.', + ); + io.print(RULE); + if (!io.interactive) { + // 비밀번호를 묻기 전에 — 파이프로 흘려 넣은 비밀번호는 셸 기록 · 프로세스 목록에 남을 수 있다 + io.print( + '이 도구는 터미널에서 사람이 돌립니다 — 관리자 비밀번호를 화면에 보이지 않게 묻기 위해서입니다.', + ); + io.print( + ' sudo docker exec -it -backend node dist/cli/ops.js', + ); + io.print( + '(-it 를 빠뜨리면 표준 입력이 터미널이 아니라 이 안내가 나옵니다)', + ); + return 1; + } + if (io.pipedForTesting) { + io.print( + '주의: 터미널이 아닌 입력으로 돌고 있습니다(OPS_ALLOW_NO_TTY=1 — 자동 시험용). 사람은 docker exec -it 로 실행하세요.', + ); + } + const operator = await this.askOperator(io); + if (operator === null) return 1; + if (!(await this.signIn(io))) return 1; + await this.menu(io, operator); + return 0; + } catch (error) { + if (error instanceof OpsInputClosed) { + io.print(''); + io.print('끝냅니다.'); + return 0; + } + throw error; + } + } + + private async askOperator(io: OpsIo): Promise { + for (let attempt = 0; attempt < SIGN_IN_ATTEMPTS; attempt += 1) { + const name = ( + await io.ask('운영자 이름 (실행 기록에 남습니다): ') + ).trim(); + if ( + name !== '' && + name.length <= OPS_OPERATOR_MAX_LENGTH && + !OPERATOR_CONTROL.test(name) + ) { + return name; + } + io.print(`이름은 한 줄 1~${OPS_OPERATOR_MAX_LENGTH}자로 넣어 주세요.`); + } + return null; + } + + private async signIn(io: OpsIo): Promise { + for (let attempt = 0; attempt < SIGN_IN_ATTEMPTS; attempt += 1) { + const password = await io.askSecret( + '메일 엔진 관리자 비밀번호 (화면에 보이지 않습니다 · 저장하지 않습니다): ', + ); + if (password === '') continue; + try { + const result = await this.engine.signIn(password); + if (result === 'ok') return true; + if (result === 'two-factor') { + io.print( + '관리자 계정에 2단계 인증이 켜져 있어 이 도구로 들어갈 수 없습니다.', + ); + return false; + } + if (result === 'not-admin') { + io.print('그 계정에는 계정 관리 권한이 없습니다.'); + return false; + } + io.print('관리자 비밀번호가 맞지 않습니다.'); + } catch (error) { + io.print(failureLine(error)); + return false; + } + } + io.print('들어가지 못했습니다.'); + return false; + } + + private async menu(io: OpsIo, operator: string): Promise { + for (;;) { + io.print(''); + io.print( + OPS_MENU.map(([key, label]) => `${key.padStart(2)}. ${label}`).join( + '\n', + ), + ); + const choice = (await io.ask('번호를 고르세요: ')).trim(); + if (choice === '0') { + io.print('끝냅니다.'); + return; + } + try { + await this.dispatch(io, operator, choice); + } catch (error) { + if (error instanceof OpsInputClosed) throw error; + io.print(failureLine(error)); + if (error instanceof OpsEngineError && error.kind === 'not-signed-in') { + return; + } + } + } + } + + private async dispatch( + io: OpsIo, + operator: string, + choice: string, + ): Promise { + switch (choice) { + case '1': + return this.accounts.hire(io, operator); + case '2': + return this.accounts.resetPassword(io, operator); + case '3': + return this.accounts.resetTwoFactor(io, operator); + case '4': + return this.locks.lock(io, operator); + case '5': + return this.locks.unlock(io, operator); + case '6': + return this.locks.offboard(io, operator); + case '7': + return this.admin.revokeLink(io, operator); + case '8': + return this.admin.mailingLists(io, operator); + case '9': + return this.admin.status(io); + case '10': + return this.admin.policy(io, operator); + default: + io.print('메뉴의 번호를 넣어 주세요.'); + } + } +} diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts new file mode 100644 index 0000000..31879dc --- /dev/null +++ b/backend/src/modules/ops/ops-commands.spec.ts @@ -0,0 +1,1243 @@ +import { Logger } from '@nestjs/common'; + +import type { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { SystemMailService } from '@/modules/system-mail/system-mail.service'; +import type { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { + DEFAULT_SYSTEM_SETTINGS, + type SystemSettings, +} from '@/modules/system-settings/system-settings.types'; +import { AccountCommands } from './account-commands'; +import { AdminCommands } from './admin-commands'; +import { LockCommands } from './lock-commands'; +import type { OpsAuditEntry, OpsAuditService } from './ops-audit.service'; +import { OpsCli } from './ops-cli'; +import type { AddressRows, OpsDataService } from './ops-data.service'; +import { + OpsEngineError, + type EngineEmployee, + type EngineMailingList, + type LockRules, + type OpsEngine, + type OpsSignInResult, +} from './ops-engine.port'; +import { OpsInputClosed, type OpsIo } from './ops-io'; + +const DOMAIN = 'arasystem.test'; +const ARA = `ara.kim@${DOMAIN}`; +const DOYUN = `doyun.lee@${DOMAIN}`; +const OPERATOR = '운영자 김'; + +const mailSettings = { domain: DOMAIN } as unknown as MailSettings; + +const NO_ROWS: AddressRows = { + loginEvents: 0, + appPasswordUsages: 0, + twoFactorSettings: 0, + signatures: 0, + signatureImages: 0, + mailSettings: 0, + directory: 0, + files: 0, + shareLinks: 0, +}; + +function employee(overrides: Partial = {}): EngineEmployee { + return { + id: 'acc-ara', + email: ARA, + name: '김아라', + locked: false, + customPermissions: false, + passwordExpired: false, + twoFactorEnabled: false, + appPasswords: 2, + usedBytes: 1024, + quotaBytes: null, + ...overrides, + }; +} + +/** 줄 입력 흉내 — 답을 차례로 주고, 보인 줄을 모은다. 답이 떨어지면 입력이 끝난 것으로 */ +class ScriptedIo implements OpsIo { + readonly printed: string[] = []; + readonly asked: string[] = []; + + constructor( + private readonly answers: string[], + readonly interactive = true, + ) {} + + print(line = ''): void { + this.printed.push(line); + } + + ask(question: string): Promise { + this.asked.push(question); + return this.next().then((answer) => answer.trim()); + } + + askSecret(question: string): Promise { + this.asked.push(question); + return this.next(); + } + + async confirm(question: string): Promise { + this.asked.push(question); + return ['y', 'yes', '예'].includes((await this.next()).toLowerCase()); + } + + close(): void {} + + get output(): string { + return this.printed.join('\n'); + } + + private next(): Promise { + const answer = this.answers.shift(); + return answer === undefined + ? Promise.reject(new OpsInputClosed()) + : Promise.resolve(answer); + } +} + +/** 직원이 아닌 주소 (관리자 · 게이트웨이 · 사내 목록 · 막은 계정) */ +const ADMIN = `admin@${DOMAIN}`; + +/** 엔진 흉내 — 부른 차례를 남긴다 */ +class FakeEngine implements OpsEngine { + readonly calls: string[] = []; + /** 직원(User)이 아닌 계정이 쓰는 주소 — Group 계정 등 */ + readonly otherAccounts = new Set(); + readonly employees = new Map(); + readonly lists: EngineMailingList[] = []; + readonly createdPasswords: string[] = []; + readonly resetPasswords: string[] = []; + signInResults: OpsSignInResult[] = ['ok']; + lockRules: { forwardTo: string | null; autoReply: boolean } = { + forwardTo: null, + autoReply: false, + }; + /** 메서드 이름 → 던질 실패 */ + readonly failures = new Map(); + + private fail(name: string): void { + const failure = this.failures.get(name); + if (failure) { + this.failures.delete(name); + throw failure; + } + } + + signIn(): Promise { + this.calls.push('signIn'); + return Promise.resolve(this.signInResults.shift() ?? 'rejected'); + } + + isEmployeeAddress(email: string): boolean { + return email.endsWith(`@${DOMAIN}`) && email !== ADMIN; + } + + findEmployee(email: string): Promise { + this.calls.push(`find:${email}`); + return Promise.resolve(this.employees.get(email) ?? null); + } + + listEmployees(): Promise { + return Promise.resolve([...this.employees.values()]); + } + + addressInUse(email: string): Promise { + return Promise.resolve( + this.employees.has(email) || + this.otherAccounts.has(email) || + this.lists.some((l) => l.email === email), + ); + } + + createEmployee(input: { + localPart: string; + name: string; + quotaBytes: number | null; + tempPassword: string; + }): Promise { + this.calls.push(`create:${input.localPart}`); + this.createdPasswords.push(input.tempPassword); + this.fail('createEmployee'); + const created = employee({ + id: `acc-${input.localPart}`, + email: `${input.localPart}@${DOMAIN}`, + name: input.name, + quotaBytes: input.quotaBytes, + passwordExpired: true, + appPasswords: 0, + }); + this.employees.set(created.email, created); + return Promise.resolve(created); + } + + resetPassword(id: string, tempPassword: string): Promise { + this.calls.push(`resetPassword:${id}`); + this.resetPasswords.push(tempPassword); + this.fail('resetPassword'); + return Promise.resolve(); + } + + removeTwoFactor(id: string): Promise { + this.calls.push(`removeTwoFactor:${id}`); + this.fail('removeTwoFactor'); + return Promise.resolve(); + } + + setLoginEnabled(id: string, enabled: boolean): Promise { + this.calls.push(`setLogin:${id}:${enabled}`); + this.fail('setLoginEnabled'); + return Promise.resolve(); + } + + deleteAccount(id: string): Promise { + this.calls.push(`delete:${id}`); + this.fail('deleteAccount'); + return Promise.resolve(); + } + + listMailingLists(): Promise { + return Promise.resolve(this.lists); + } + + createMailingList(input: { + localPart: string; + name: string; + members: readonly string[]; + }): Promise { + this.calls.push(`createList:${input.localPart}:${input.members.join(',')}`); + const list = { + id: 'l1', + email: `${input.localPart}@${DOMAIN}`, + name: input.name, + members: [...input.members], + }; + this.lists.push(list); + return Promise.resolve(list); + } + + setMailingListMember( + id: string, + member: string, + included: boolean, + ): Promise { + this.calls.push(`member:${id}:${member}:${included}`); + this.fail('setMailingListMember'); + return Promise.resolve(); + } + + deleteAppPasswords(email: string): Promise { + this.calls.push(`deleteAppPasswords:${email}`); + this.fail('deleteAppPasswords'); + return Promise.resolve(2); + } + + getLockRules(): Promise<{ forwardTo: string | null; autoReply: boolean }> { + this.calls.push('getLockRules'); + return Promise.resolve(this.lockRules); + } + + applyLockRules(email: string, rules: LockRules): Promise { + this.calls.push( + `applyLockRules:${email}:${rules.forwardTo ?? '-'}:${rules.autoReply?.subject ?? '-'}`, + ); + this.fail('applyLockRules'); + return Promise.resolve(); + } + + clearLockRules(email: string): Promise { + this.calls.push(`clearLockRules:${email}`); + this.fail('clearLockRules'); + return Promise.resolve(); + } +} + +describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () => { + let engine: FakeEngine; + let audit: { entries: OpsAuditEntry[]; record: jest.Mock }; + let data: { + revokeSessions: jest.Mock; + forgetTwoFactor: jest.Mock; + countRowsOf: jest.Mock; + isTwoFactorExempt: jest.Mock; + changeTwoFactorExempt: jest.Mock; + purgeAddress: jest.Mock; + lastWebmailLogins: jest.Mock; + }; + let systemMail: { + notifyPasswordReset: jest.Mock; + notifyTwoFactorReset: jest.Mock; + remove: jest.Mock; + }; + let sharing: { + revokeAllOfOwnerAsOperator: jest.Mock; + findForOperator: jest.Mock; + revokeAsOperator: jest.Mock; + }; + let settingsValue: SystemSettings; + let settings: { reload: jest.Mock; update: jest.Mock }; + let accounts: AccountCommands; + let locks: LockCommands; + let admin: AdminCommands; + + /** 엔진 · 서비스가 불린 차례 (한 줄로) */ + const order = (): string[] => [...engine.calls]; + + let errorLogs: string[]; + + beforeEach(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + errorLogs = []; + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation( + (message: unknown) => void errorLogs.push(String(message)), + ); + engine = new FakeEngine(); + engine.employees.set(ARA, employee()); + engine.employees.set( + DOYUN, + employee({ id: 'acc-doyun', email: DOYUN, name: '이도윤' }), + ); + const entries: OpsAuditEntry[] = []; + audit = { + entries, + record: jest.fn((entry: OpsAuditEntry) => { + entries.push(entry); + return Promise.resolve(true); + }), + }; + data = { + revokeSessions: jest.fn((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(3); + }), + forgetTwoFactor: jest.fn((email: string) => { + engine.calls.push(`forgetTwoFactor:${email}`); + return Promise.resolve(); + }), + countRowsOf: jest.fn().mockResolvedValue(NO_ROWS), + isTwoFactorExempt: jest.fn().mockResolvedValue(false), + // 실제처럼 그때의 목록에 넣고 뺀다 — 바꿨으면 true + changeTwoFactorExempt: jest.fn((email: string, include: boolean) => { + const exempt = settingsValue.twoFactor.exempt; + if (include === exempt.includes(email)) return Promise.resolve(false); + settingsValue = { + ...settingsValue, + twoFactor: { + ...settingsValue.twoFactor, + exempt: include + ? [...exempt, email] + : exempt.filter((entry) => entry !== email), + }, + }; + return Promise.resolve(true); + }), + purgeAddress: jest.fn((email: string) => { + engine.calls.push(`purge:${email}`); + return Promise.resolve({ + rows: { ...NO_ROWS, loginEvents: 4 }, + exemptRemoved: false, + sessionsRevoked: 0, + filesLeftForCleanup: 0, + }); + }), + lastWebmailLogins: jest.fn().mockResolvedValue(new Map()), + }; + systemMail = { + notifyPasswordReset: jest.fn((email: string) => { + engine.calls.push(`notifyPasswordReset:${email}`); + return Promise.resolve('notice-1'); + }), + notifyTwoFactorReset: jest.fn((email: string) => { + engine.calls.push(`notifyTwoFactorReset:${email}`); + return Promise.resolve('notice-2'); + }), + remove: jest.fn((email: string, id: string) => { + engine.calls.push(`removeNotice:${email}:${id}`); + return Promise.resolve(true); + }), + }; + sharing = { + revokeAllOfOwnerAsOperator: jest.fn((email: string) => { + engine.calls.push(`revokeLinks:${email}`); + return Promise.resolve(2); + }), + findForOperator: jest.fn().mockResolvedValue(null), + revokeAsOperator: jest.fn().mockResolvedValue(true), + }; + settingsValue = DEFAULT_SYSTEM_SETTINGS; + settings = { + reload: jest.fn(() => Promise.resolve(settingsValue)), + update: jest.fn( + (patch: { + twoFactor?: Partial; + supportContact?: string | null; + }) => { + settingsValue = { + twoFactor: { ...settingsValue.twoFactor, ...patch.twoFactor }, + supportContact: + patch.supportContact === undefined + ? settingsValue.supportContact + : patch.supportContact, + }; + return Promise.resolve(settingsValue); + }, + ), + }; + accounts = new AccountCommands( + engine, + mailSettings, + data as unknown as OpsDataService, + audit as unknown as OpsAuditService, + systemMail as unknown as SystemMailService, + settings as unknown as SystemSettingsService, + ); + locks = new LockCommands( + engine, + mailSettings, + data as unknown as OpsDataService, + audit as unknown as OpsAuditService, + sharing as unknown as FileSharingService, + accounts, + ); + admin = new AdminCommands( + engine, + mailSettings, + data as unknown as OpsDataService, + audit as unknown as OpsAuditService, + sharing as unknown as FileSharingService, + settings as unknown as SystemSettingsService, + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('입사', () => { + it('계정을 만들고 임시 비밀번호를 한 번만 보인다 — 실행 기록에는 비밀이 없다', async () => { + const io = new ScriptedIo(['hong.gildong', '홍길동', '10', 'y']); + await accounts.hire(io, OPERATOR); + + expect(order()).toContain('create:hong.gildong'); + const [password] = engine.createdPasswords; + expect(password).toMatch(/^[A-Za-z0-9]{16}$/); + expect(io.printed.filter((line) => line.includes(password))).toHaveLength( + 1, + ); + expect(audit.entries).toEqual([ + { + operator: OPERATOR, + command: 'hire', + target: `hong.gildong@${DOMAIN}`, + result: 'success', + detail: '용량 10 GB', + }, + ]); + expect(JSON.stringify(audit.entries)).not.toContain(password); + }); + + it('n 이면 아무것도 만들지 않는다', async () => { + await accounts.hire( + new ScriptedIo(['hong.gildong', '홍길동', '', 'n']), + OPERATOR, + ); + expect(order().some((call) => call.startsWith('create:'))).toBe(false); + expect(audit.entries).toEqual([]); + }); + + it('이미 쓰는 주소 · 다른 도메인 · 잘못된 용량은 묻는 데서 멈춘다', async () => { + const used = new ScriptedIo(['ara.kim']); + await accounts.hire(used, OPERATOR); + expect(used.output).toContain('이미 쓰고 있습니다'); + + const other = new ScriptedIo(['hong@example.com']); + await accounts.hire(other, OPERATOR); + expect(other.output).toContain('주소 모양이 맞지 않거나'); + + const quota = new ScriptedIo(['hong.gildong', '홍길동', '-1']); + await accounts.hire(quota, OPERATOR); + expect(quota.output).toContain('용량은'); + expect(order().some((call) => call.startsWith('create:'))).toBe(false); + }); + + it('같은 주소의 옛 기록이 남았으면 묻고 지운 뒤 만든다', async () => { + data.countRowsOf.mockResolvedValueOnce({ ...NO_ROWS, loginEvents: 5 }); + await accounts.hire( + new ScriptedIo(['hong.gildong', '홍길동', '', 'y', 'y']), + OPERATOR, + ); + expect(order()).toEqual([ + `purge:hong.gildong@${DOMAIN}`, + 'create:hong.gildong', + ]); + }); + + it('엔진이 임시 비밀번호를 약하다고 받지 않으면 새로 만들어 다시 한다', async () => { + engine.failures.set( + 'createEmployee', + new OpsEngineError('weak-password'), + ); + await accounts.hire( + new ScriptedIo(['hong.gildong', '홍길동', '', 'y']), + OPERATOR, + ); + expect(engine.createdPasswords).toHaveLength(2); + expect(engine.createdPasswords[0]).not.toBe(engine.createdPasswords[1]); + expect(audit.entries[0].result).toBe('success'); + }); + }); + + describe('비밀번호 재설정', () => { + it('차례 — 앱 비밀번호 지우기 → 세션 끊기 → 알림 메일(말하는 일이 끝난 뒤) → 엔진 임시 비밀번호 + 만료 → 그사이 새 세션 끊기', async () => { + data.revokeSessions + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(3); + }) + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(1); + }); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + + expect(order()).toEqual([ + `find:${ARA}`, + `deleteAppPasswords:${ARA}`, + `revokeSessions:${ARA}`, + `notifyPasswordReset:${ARA}`, + 'resetPassword:acc-ara', + `revokeSessions:${ARA}`, + ]); + const [password] = engine.resetPasswords; + expect(io.printed.filter((line) => line.includes(password))).toHaveLength( + 1, + ); + expect(io.output).toContain( + '그사이 새로 로그인한 웹메일 1개도 끊었습니다', + ); + expect(audit.entries[0]).toMatchObject({ + command: 'password-reset', + target: ARA, + result: 'success', + detail: '앱 비밀번호 2개 지움 · 알림 메일 넣음 · 세션 4개 끊음', + }); + }); + + it('엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다(아직 만료 전이라 지울 수 있다)', async () => { + engine.failures.set('resetPassword', new OpsEngineError('unavailable')); + await accounts.resetPassword(new ScriptedIo(['ara.kim', 'y']), OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `deleteAppPasswords:${ARA}`, + `revokeSessions:${ARA}`, + `notifyPasswordReset:${ARA}`, + 'resetPassword:acc-ara', + `removeNotice:${ARA}:notice-1`, + ]); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('비밀번호를 바꿨는지 모르면(엔진 응답 없음) 알림 메일을 지우지 않고 "결과를 모릅니다 — 다시"로 알린다 (재리뷰 N3)', async () => { + engine.failures.set( + 'resetPassword', + new OpsEngineError('unavailable', 'timeout'), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `deleteAppPasswords:${ARA}`, + `revokeSessions:${ARA}`, + `notifyPasswordReset:${ARA}`, + 'resetPassword:acc-ara', + ]); + expect(io.output).toContain('비밀번호를 바꿨는지 모릅니다'); + expect(io.output).toContain('알림 메일은 그대로 둡니다'); + expect(audit.entries[0]).toMatchObject({ result: 'failure' }); + expect(audit.entries[0].detail).toContain('결과 모름'); + }); + + it('세션을 끊지 못하면 알림을 넣지 않고 멈춘다 — 알림이 사실과 어긋나지 않게', async () => { + data.revokeSessions.mockRejectedValueOnce(new Error('connection lost')); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([`find:${ARA}`, `deleteAppPasswords:${ARA}`]); + expect(io.output).toContain('비밀번호는 그대로입니다'); + expect(audit.entries[0].result).toBe('failure'); + // 모르는 오류는 오류 로그에 남는다(L8) + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + }); + + it('앱 비밀번호를 지우지 못하면 아무것도 바꾸지 않고 멈춘다', async () => { + engine.failures.set( + 'deleteAppPasswords', + new OpsEngineError('unavailable'), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([`find:${ARA}`, `deleteAppPasswords:${ARA}`]); + expect(io.output).toContain('아무것도 바꾸지 않았습니다'); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('알림 메일을 넣지 못해도 재설정은 이어 간다', async () => { + systemMail.notifyPasswordReset.mockResolvedValueOnce(null); + await accounts.resetPassword(new ScriptedIo(['ara.kim', 'y']), OPERATOR); + expect(order()).toContain('resetPassword:acc-ara'); + expect(audit.entries[0].detail).toContain('알림 메일 못 넣음'); + }); + + it('이미 만료된 계정은 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다(엔진이 대리 접근을 막는다) — 바뀔 것에도 보인다', async () => { + engine.employees.set( + ARA, + employee({ passwordExpired: true, appPasswords: 0 }), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `revokeSessions:${ARA}`, + 'resetPassword:acc-ara', + `revokeSessions:${ARA}`, + ]); + expect(io.output).toContain('1. 앱 비밀번호 — 건너뜀'); + expect(io.output).toContain('3. 받은편지함 알림 메일 — 건너뜀'); + expect(audit.entries[0].detail).toContain('알림 메일 건너뜀(이미 만료)'); + }); + + it('잠긴 계정은 먼저 풀라고 안내하고 하지 않는다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + const io = new ScriptedIo(['ara.kim']); + await accounts.resetPassword(io, OPERATOR); + expect(io.output).toContain('잠금 풀기'); + expect(order()).toEqual([`find:${ARA}`]); + }); + }); + + describe('2단계 해제', () => { + it('엔진 OTP 지우기 → 켠 때 기록 정리 → 알림 메일', async () => { + engine.employees.set(ARA, employee({ twoFactorEnabled: true })); + await accounts.resetTwoFactor(new ScriptedIo(['ara.kim', 'y']), OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'removeTwoFactor:acc-ara', + `forgetTwoFactor:${ARA}`, + `notifyTwoFactorReset:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'two-factor-reset', + result: 'success', + }); + }); + + it('꺼져 있으면 할 것이 없다 — 묻지 않고 DB 도 바꾸지 않는다(기록 없이 바꾸지 않게)', async () => { + const io = new ScriptedIo(['ara.kim']); + await accounts.resetTwoFactor(io, OPERATOR); + expect(io.output).toContain('꺼져 있습니다'); + expect(order()).toEqual([`find:${ARA}`]); + expect(data.forgetTwoFactor).not.toHaveBeenCalled(); + expect(audit.entries).toEqual([]); + }); + + it('비밀번호가 만료된 계정은 알림을 건너뛴다', async () => { + engine.employees.set( + ARA, + employee({ twoFactorEnabled: true, passwordExpired: true }), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetTwoFactor(io, OPERATOR); + expect(order()).not.toContain(`notifyTwoFactorReset:${ARA}`); + expect(io.output).toContain('3. 받은편지함 알림 메일 — 건너뜀'); + }); + }); + + describe('잠금 · 잠금 풀기', () => { + it('차례 — 넘기기 · 자동 응답(Sieve)을 먼저 → 로그인 권한 빼기 → 세션 끊기 → 링크 끊기', async () => { + const io = new ScriptedIo([ + 'ara.kim', + 'y', + 'doyun.lee', + 'y', + '부재 안내', + '퇴사했습니다.', + '', + 'y', + 'y', + ]); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `find:${DOYUN}`, + `applyLockRules:${ARA}:${DOYUN}:부재 안내`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + `revokeLinks:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'lock', + result: 'success', + }); + }); + + it('로그인 권한을 빼지 못하면 세션은 끊어 두고, 걸어 둔 넘기기 · 자동 응답을 뗄지 묻는다', async () => { + engine.failures.set( + 'setLoginEnabled', + new OpsEngineError('refused', 'invalidProperties'), + ); + const io = new ScriptedIo([ + 'ara.kim', + 'y', + 'doyun.lee', + 'n', + 'n', + 'y', + 'y', + ]); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `find:${DOYUN}`, + `applyLockRules:${ARA}:${DOYUN}:-`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + `clearLockRules:${ARA}`, + ]); + expect(io.output).toContain('다시 「잠금」을 하세요'); + expect(audit.entries[0]).toMatchObject({ result: 'failure' }); + expect(audit.entries[0].detail).toContain('세션 3개 끊음'); + expect(audit.entries[0].detail).toContain('Sieve 뗌'); + }); + + it('잠갔는지 모르면(엔진 응답 없음) "결과를 모릅니다 — 다시"로 알리고, 세션은 끊되 Sieve 떼기는 묻지 않는다 (재리뷰 N3)', async () => { + engine.failures.set( + 'setLoginEnabled', + new OpsEngineError('unavailable', 'timeout'), + ); + const io = new ScriptedIo(['ara.kim', 'y', 'doyun.lee', 'n', 'n', 'y']); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `find:${DOYUN}`, + `applyLockRules:${ARA}:${DOYUN}:-`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + ]); + expect(io.output).toContain('잠갔는지 모릅니다'); + expect(io.output).toContain('같은 명령을 다시 하세요'); + expect(io.asked.some((question) => question.includes('뗄까요'))).toBe( + false, + ); + expect(audit.entries[0].detail).toContain('잠금 결과 모름'); + }); + + it('로그인 권한을 빼지 못했고 Sieve 를 걸지 않았으면 떼기를 묻지 않는다', async () => { + engine.failures.set('setLoginEnabled', new OpsEngineError('refused')); + const io = new ScriptedIo(['ara.kim', 'n', 'n', 'n', 'y']); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + ]); + expect(io.asked.some((question) => question.includes('뗄까요'))).toBe( + false, + ); + }); + + it('Sieve 를 걸지 못하면 잠그지 않는다', async () => { + engine.failures.set( + 'applyLockRules', + new OpsEngineError('too-many-rules'), + ); + const io = new ScriptedIo(['ara.kim', 'y', 'doyun.lee', 'n', 'n', 'y']); + await locks.lock(io, OPERATOR); + expect(order()).not.toContain('setLogin:acc-ara:false'); + expect(io.output).toContain('잠그지 않았습니다'); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('넘기기 · 자동 응답이 없으면 Sieve 는 건드리지 않는다 · 자동 응답에 ${ 는 받지 않는다', async () => { + await locks.lock( + new ScriptedIo(['ara.kim', 'n', 'n', 'n', 'y']), + OPERATOR, + ); + expect(order()).toEqual([ + `find:${ARA}`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + ]); + + engine.employees.set(ARA, employee()); + engine.calls.length = 0; + const io = new ScriptedIo(['ara.kim', 'n', 'y', '안내 ${x}']); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([`find:${ARA}`]); + }); + + it('자기 자신 · 잠긴 동료에게는 넘기지 않는다', async () => { + const self = new ScriptedIo(['ara.kim', 'y', 'ara.kim']); + await locks.lock(self, OPERATOR); + expect(self.output).toContain('자기 자신'); + + engine.employees.set( + DOYUN, + employee({ id: 'acc-doyun', email: DOYUN, locked: true }), + ); + const locked = new ScriptedIo(['ara.kim', 'y', 'doyun.lee']); + await locks.lock(locked, OPERATOR); + expect(locked.output).toContain('잠긴 계정'); + expect(order().some((call) => call.startsWith('setLogin'))).toBe(false); + }); + + it('잠금 풀기 — 권한을 되돌린 뒤 넘기기 · 자동 응답을 묻고 떼고, 비밀번호 재설정을 권한다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lockRules = { forwardTo: DOYUN, autoReply: true }; + const io = new ScriptedIo(['ara.kim', 'y', 'y', 'n']); + await locks.unlock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'setLogin:acc-ara:true', + 'getLockRules', + `clearLockRules:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'unlock', + result: 'success', + }); + }); + }); + + describe('직원이 아닌 주소 · 계정', () => { + it('관리자 · 게이트웨이 · 막은 계정 주소는 "직원 주소가 아님" — 엔진에 묻지 않고, 퇴사 마무리도 기록 정리를 권하지 않는다', async () => { + for (const run of [ + (io: ScriptedIo) => locks.lock(io, OPERATOR), + (io: ScriptedIo) => locks.offboard(io, OPERATOR), + (io: ScriptedIo) => accounts.resetPassword(io, OPERATOR), + (io: ScriptedIo) => accounts.hire(io, OPERATOR), + ]) { + const io = new ScriptedIo(['admin']); + await run(io); + expect(io.output).toMatch(/직원 주소(가 아닙니다|로 쓸 수 없습니다)/); + expect(io.output).not.toContain('기록만 지웁니다'); + } + expect(order()).toEqual([]); + expect(data.countRowsOf).not.toHaveBeenCalled(); + }); + + it('Group(공유 메일함) · 공용 주소처럼 직원이 아닌 계정은 "직원 계정이 아님" — 퇴사 마무리가 지우지 않는다', async () => { + engine.otherAccounts.add(`team@${DOMAIN}`); + const lockIo = new ScriptedIo(['team']); + await locks.lock(lockIo, OPERATOR); + expect(lockIo.output).toContain('직원 계정이 아닙니다'); + + const offboardIo = new ScriptedIo(['team']); + await locks.offboard(offboardIo, OPERATOR); + expect(offboardIo.output).toContain('직원 계정이 아닙니다'); + expect(order().some((call) => call.startsWith('purge:'))).toBe(false); + }); + + it('엔진에 없는 직원 주소는 "엔진에 그 직원 계정이 없습니다"', async () => { + const io = new ScriptedIo(['nobody']); + await locks.lock(io, OPERATOR); + expect(io.output).toContain('엔진에 그 직원 계정이 없습니다'); + }); + }); + + describe('퇴사 마무리', () => { + it('잠긴 계정만 — 잠기지 않았으면 하지 않는다', async () => { + const io = new ScriptedIo(['ara.kim']); + await locks.offboard(io, OPERATOR); + expect(io.output).toContain('잠긴 계정만'); + expect(order().some((call) => call.startsWith('delete:'))).toBe(false); + }); + + it('주소를 다시 쳐서 틀리면 지우지 않는다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + const io = new ScriptedIo(['ara.kim', 'y', 'ara.kim@arasystem.tes']); + await locks.offboard(io, OPERATOR); + expect(io.output).toContain('주소가 같지 않아'); + expect(order().some((call) => call.startsWith('delete:'))).toBe(false); + }); + + it('엔진 계정을 지운 뒤 우리 DB 의 그 주소 기록을 지운다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'delete:acc-ara', + `purge:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'offboard', + result: 'success', + }); + }); + + it('2단계 필수의 예외 주소 · 공용 주소 구성원에서도 뺀다 — 바뀔 것에 보이고 y/N 뒤에, 세션 행은 지우지 않는다(정리 작업)', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lists.push( + { + id: 'l-info', + email: `info@${DOMAIN}`, + name: '안내', + members: [ARA, DOYUN], + }, + { + id: 'l-sales', + email: `sales@${DOMAIN}`, + name: '영업', + members: [DOYUN], + }, + ); + data.isTwoFactorExempt.mockResolvedValue(true); + data.purgeAddress.mockImplementationOnce((email: string) => { + engine.calls.push(`purge:${email}`); + return Promise.resolve({ + rows: { ...NO_ROWS, loginEvents: 4 }, + exemptRemoved: true, + sessionsRevoked: 1, + filesLeftForCleanup: 0, + }); + }); + + // n 이면 아무것도 바꾸지 않는다 + const no = new ScriptedIo(['ara.kim', 'n']); + await locks.offboard(no, OPERATOR); + expect(no.output).toContain('2단계 인증 필수의 예외 주소에서 빼기'); + expect(no.output).toContain( + `공용 주소 1개의 구성원에서 빼기 — info@${DOMAIN}`, + ); + expect(no.output).toContain( + '웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다', + ); + expect(order()).toEqual([`find:${ARA}`]); + + engine.calls.length = 0; + const io = new ScriptedIo(['ara.kim', 'y', ARA]); + await locks.offboard(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'delete:acc-ara', + `purge:${ARA}`, + `member:l-info:${ARA}:false`, + ]); + expect(io.output).toContain('2단계 인증 필수의 예외 주소에서 뺐습니다'); + expect(audit.entries[0]).toMatchObject({ result: 'success' }); + expect(audit.entries[0].detail).toContain(`info@${DOMAIN} 구성원에서 뺌`); + expect(audit.entries[0].detail).toContain('예외 주소에서 뺌'); + }); + + it('공용 주소 구성원에서 빼지 못하면 partial — 「공용 주소」에서 빼라고 알린다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lists.push({ + id: 'l-info', + email: `info@${DOMAIN}`, + name: '안내', + members: [ARA, DOYUN], + }); + engine.failures.set( + 'setMailingListMember', + new OpsEngineError('refused'), + ); + const io = new ScriptedIo(['ara.kim', 'y', ARA]); + await locks.offboard(io, OPERATOR); + expect(io.output).toContain('「공용 주소」에서 빼 주세요'); + expect(audit.entries[0].result).toBe('partial'); + }); + + it('마지막 구성원인 공용 주소 — 바뀔 것에 "빼면 받는 사람이 없다"를 보이고 남길지 묻는다 (재리뷰 N4)', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lists.push( + { id: 'l-solo', email: `solo@${DOMAIN}`, name: '혼자', members: [ARA] }, + { + id: 'l-info', + email: `info@${DOMAIN}`, + name: '안내', + members: [ARA, DOYUN], + }, + ); + + // n — 남긴다 + const keep = new ScriptedIo(['ara.kim', 'n', 'y', ARA]); + await locks.offboard(keep, OPERATOR); + expect(keep.output).toContain( + `빼면 받는 사람이 없습니다: solo@${DOMAIN}`, + ); + expect(keep.output).toContain(`남김: solo@${DOMAIN} — 마지막 구성원`); + expect(order()).toContain(`member:l-info:${ARA}:false`); + expect(order()).not.toContain(`member:l-solo:${ARA}:false`); + + // y — 뺀다(바뀔 것에 마지막 구성원이라고 보인다) + engine.calls.length = 0; + engine.employees.set(ARA, employee({ locked: true })); + const remove = new ScriptedIo(['ara.kim', 'y', 'y', ARA]); + await locks.offboard(remove, OPERATOR); + expect(remove.output).toContain( + `solo@${DOMAIN} — 마지막 구성원이다(빼면 받는 사람이 없다)`, + ); + expect(order()).toContain(`member:l-solo:${ARA}:false`); + }); + + it('엔진 계정을 지웠는지 모르면(엔진 응답 없음) DB 를 건드리지 않고 "결과를 모릅니다 — 다시"로 알린다 (재리뷰 N3)', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.failures.set( + 'deleteAccount', + new OpsEngineError('unavailable', 'network'), + ); + const io = new ScriptedIo(['ara.kim', 'y', ARA]); + await locks.offboard(io, OPERATOR); + expect(order()).not.toContain(`purge:${ARA}`); + expect(io.output).toContain('엔진 계정을 지웠는지 모릅니다'); + expect(io.output).not.toContain('아무것도 바꾸지 않았습니다'); + }); + + it('엔진 계정 지우기가 실패하면 DB 를 건드리지 않는다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.failures.set('deleteAccount', new OpsEngineError('unavailable')); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).not.toContain(`purge:${ARA}`); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('엔진 계정이 이미 없고 기록만 남았으면 기록만 지운다', async () => { + engine.employees.delete(ARA); + data.countRowsOf.mockResolvedValueOnce({ ...NO_ROWS, loginEvents: 2 }); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).toEqual([`find:${ARA}`, `purge:${ARA}`]); + }); + + it('엔진 계정도 남은 것도 없으면 할 것이 없다 — 예외 주소만 남았어도 지운다', async () => { + engine.employees.delete(ARA); + const nothing = new ScriptedIo(['ara.kim']); + await locks.offboard(nothing, OPERATOR); + expect(nothing.output).toContain('남은 기록도 없습니다'); + + data.isTwoFactorExempt.mockResolvedValueOnce(true); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).toContain(`purge:${ARA}`); + }); + }); + + describe('링크 끊기 · 공용 주소 · 정책', () => { + it('신고된 주소의 링크를 운영자 끊기로 — 실행 기록에는 링크 id 만(주소 · 토큰은 남기지 않는다)', async () => { + const token = 'A'.repeat(43); + sharing.findForOperator.mockResolvedValueOnce({ + link: { + id: 'link-1', + ownerEmail: ARA, + senderName: '김아라', + sentAt: new Date('2026-10-30T06:00:00Z'), + expiresAt: new Date('2026-11-29T14:59:59Z'), + revokedAt: null, + revokedBy: null, + }, + files: [{ name: '도면.dwg', size: 2048, downloadCount: 1 }], + }); + const io = new ScriptedIo([`https://files.${DOMAIN}/s/${token}`, 'y']); + await admin.revokeLink(io, OPERATOR); + expect(sharing.revokeAsOperator).toHaveBeenCalledWith('link-1'); + expect(audit.entries[0]).toMatchObject({ + command: 'link-revoke', + target: ARA, + result: 'success', + }); + expect(JSON.stringify(audit.entries)).not.toContain(token); + expect(io.output).not.toContain(token); + }); + + it('우리 공개 링크가 아니면 알리고 끝낸다', async () => { + const io = new ScriptedIo(['https://example.com/x']); + await admin.revokeLink(io, OPERATOR); + expect(io.output).toContain('우리 공개 링크 주소가 아니거나'); + expect(sharing.revokeAsOperator).not.toHaveBeenCalled(); + }); + + it('링크를 찾다 DB 오류가 나면 "우리 링크 아님"으로 삼키지 않는다 — 오류 로그에 남기고 알린다', async () => { + sharing.findForOperator.mockRejectedValueOnce( + new Error('connection terminated'), + ); + const io = new ScriptedIo([ + `https://files.${DOMAIN}/s/${'A'.repeat(43)}`, + ]); + await admin.revokeLink(io, OPERATOR); + expect(io.output).not.toContain('우리 공개 링크 주소가 아니거나'); + expect(io.output).toContain('오류 로그'); + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + }); + + it('공용 주소 만들기 — 구성원은 엔진에 있는 직원만', async () => { + await admin.mailingLists( + new ScriptedIo(['2', 'info', '대표 문의', 'ara.kim, doyun.lee', 'y']), + OPERATOR, + ); + expect(order()).toContain(`createList:info:${ARA},${DOYUN}`); + + engine.calls.length = 0; + const io = new ScriptedIo(['2', 'sales', '영업', 'nobody']); + await admin.mailingLists(io, OPERATOR); + expect(io.output).toContain('엔진에 그 직원 계정이 없습니다'); + expect(order().some((call) => call.startsWith('createList'))).toBe(false); + }); + + it('2단계 인증 필수 켜기 — 시작일(한국 날짜)을 받아 저장한다 · 잘못된 날짜는 저장하지 않는다', async () => { + await admin.policy(new ScriptedIo(['1', '2026-12-01', 'y']), OPERATOR); + expect(settings.update).toHaveBeenCalledWith( + { twoFactor: { required: true, startDate: '2026-12-01' } }, + expect.any(Date), + ); + expect(audit.entries[0]).toMatchObject({ + command: 'policy', + result: 'success', + detail: '켜기 · 시작일 2026-12-01', + }); + + settings.update.mockClear(); + await admin.policy(new ScriptedIo(['1', '2026-02-30']), OPERATOR); + expect(settings.update).not.toHaveBeenCalled(); + }); + + it('시작일을 비우면 오늘 — 바로 적용된다고 알린다', async () => { + const io = new ScriptedIo(['1', '', 'y']); + await admin.policy(io, OPERATOR); + expect(io.output).toContain('시작일이 오늘이거나 지났다 — 바로 적용된다'); + expect(settings.update).toHaveBeenCalledWith( + { + twoFactor: { + required: true, + startDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) as string, + }, + }, + expect.any(Date), + ); + }); + + it('문의 연락처 — 한 줄 200자, 빈 값은 지우기', async () => { + await admin.policy( + new ScriptedIo(['5', 'IT 담당 홍길동 · 02-000-0000', 'y']), + OPERATOR, + ); + expect(settings.update).toHaveBeenLastCalledWith( + { supportContact: 'IT 담당 홍길동 · 02-000-0000' }, + expect.any(Date), + ); + await admin.policy(new ScriptedIo(['5', '', 'y']), OPERATOR); + expect(settings.update).toHaveBeenLastCalledWith( + { supportContact: null }, + expect.any(Date), + ); + settings.update.mockClear(); + const io = new ScriptedIo(['5', '가'.repeat(201)]); + await admin.policy(io, OPERATOR); + expect(settings.update).not.toHaveBeenCalled(); + expect(io.output).toContain('200자까지'); + }); + + it('예외 주소 넣기 · 빼기 — y/N 뒤에 행 잠금 안에서 그때의 목록에 넣고 뺀다 (재리뷰 N5)', async () => { + await admin.policy(new ScriptedIo(['3', 'ara.kim', 'y']), OPERATOR); + expect(data.changeTwoFactorExempt).toHaveBeenLastCalledWith(ARA, true); + expect(settingsValue.twoFactor.exempt).toEqual([ARA]); + await admin.policy(new ScriptedIo(['4', 'ara.kim', 'y']), OPERATOR); + expect(data.changeTwoFactorExempt).toHaveBeenLastCalledWith(ARA, false); + expect(settingsValue.twoFactor.exempt).toEqual([]); + // 설정 서비스의 통째 쓰기로 덮지 않는다 + expect(settings.update).not.toHaveBeenCalled(); + }); + + it('묻는 사이 다른 운영자가 이미 바꿨으면 바꾼 것이 없다고 알린다', async () => { + data.changeTwoFactorExempt.mockResolvedValueOnce(false); + const io = new ScriptedIo(['3', 'ara.kim', 'y']); + await admin.policy(io, OPERATOR); + expect(io.output).toContain('그사이 이미 예외가 되었습니다'); + expect(audit.entries.at(-1)?.detail).toContain('이미 그 상태'); + }); + + it('현황 — 직원마다 한 줄 묶음과 정책 · 연락처 (읽기만 — 기록하지 않는다)', async () => { + data.lastWebmailLogins.mockResolvedValueOnce( + new Map([[ARA, new Date('2026-10-30T06:20:00Z')]]), + ); + const io = new ScriptedIo([]); + await admin.status(io); + expect(io.output).toContain(`${ARA} (김아라)`); + expect(io.output).toContain('2026년 10월 30일 (금) 오후 3:20'); + expect(io.output).toContain('2단계 인증 필수 정책 꺼짐'); + expect(audit.entries).toEqual([]); + }); + }); + + describe('도구의 차례 (OpsCli)', () => { + const cli = () => new OpsCli(engine, accounts, locks, admin); + + it('운영자 이름 · 관리자 비밀번호(세 번까지)를 묻고 메뉴를 돈다 — 0 이면 끝낸다', async () => { + engine.signInResults = ['rejected', 'ok']; + const io = new ScriptedIo([OPERATOR, 'wrong', 'right', '9', '0']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('관리자 비밀번호가 맞지 않습니다'); + expect(io.output).toContain('끝냅니다'); + expect(engine.calls.filter((call) => call === 'signIn')).toHaveLength(2); + }); + + it('비밀번호가 세 번 틀리면 들어가지 못한다(종료 코드 1)', async () => { + engine.signInResults = ['rejected', 'rejected', 'rejected']; + await expect( + cli().run(new ScriptedIo([OPERATOR, 'a', 'b', 'c'])), + ).resolves.toBe(1); + }); + + it('관리자 계정에 2단계 인증이 켜져 있으면 들어가지 못한다', async () => { + engine.signInResults = ['two-factor']; + const io = new ScriptedIo([OPERATOR, 'pw']); + await expect(cli().run(io)).resolves.toBe(1); + expect(io.output).toContain('2단계 인증이 켜져 있어'); + }); + + it('입력이 끝나면(Ctrl+D) 조용히 끝낸다', async () => { + await expect(cli().run(new ScriptedIo([OPERATOR, 'pw']))).resolves.toBe( + 0, + ); + }); + + it('표준 입력이 터미널이 아니면 비밀번호를 묻기 전에 멈춘다 — docker exec -it 로 실행하라고 알린다', async () => { + const io = new ScriptedIo([OPERATOR, 'pw'], false); + await expect(cli().run(io)).resolves.toBe(1); + expect(io.output).toContain('docker exec -it'); + expect(io.asked).toEqual([]); + expect(engine.calls).not.toContain('signIn'); + }); + + it('시험용 변수(OPS_ALLOW_NO_TTY=1)로 터미널이 아닌 입력을 받는 중이면 첫 화면에 경고 한 줄', async () => { + const io = Object.assign(new ScriptedIo([OPERATOR, 'pw', '0']), { + pipedForTesting: true, + }); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('OPS_ALLOW_NO_TTY=1 — 자동 시험용'); + }); + + it('메뉴에서 모르는 오류가 나면 오류 로그에 남기고 메뉴를 이어 간다', async () => { + jest.spyOn(admin, 'status').mockRejectedValueOnce(new TypeError('boom')); + const io = new ScriptedIo([OPERATOR, 'pw', '9', '0']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('오류 로그'); + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + expect(errorLogs.join('\n')).toContain('TypeError'); + }); + + it('관리자 로그인이 끊기면(비밀번호가 바뀜) 메뉴를 끝낸다', async () => { + jest + .spyOn(engine, 'listEmployees') + .mockRejectedValueOnce(new OpsEngineError('not-signed-in')); + jest + .spyOn(admin, 'status') + .mockRejectedValueOnce(new OpsEngineError('not-signed-in')); + const io = new ScriptedIo([OPERATOR, 'pw', '9', '9']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('도구를 다시 시작하세요'); + }); + }); +}); diff --git a/backend/src/modules/ops/ops-data.service.ts b/backend/src/modules/ops/ops-data.service.ts new file mode 100644 index 0000000..b06b8ae --- /dev/null +++ b/backend/src/modules/ops/ops-data.service.ts @@ -0,0 +1,294 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { type DataSource, type EntityManager, In } from 'typeorm'; + +import { AppPasswordUsageEntity } from '@/modules/account-security/app-password-usage.entity'; +import { LoginEventEntity } from '@/modules/account-security/login-event.entity'; +import { TwoFactorSettingEntity } from '@/modules/account-security/two-factor-setting.entity'; +import { AuthSessionRepository } from '@/modules/auth/auth-session.repository'; +import { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity'; +import { + FILE_BLOB_STORE, + type FileBlobStore, +} from '@/modules/file-storage/file-blob-store'; +import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity'; +import { MailDirectoryEntryEntity } from '@/modules/mail-directory/mail-directory.entity'; +import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signature-image.entity'; +import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity'; +import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage'; +import { MailUserSettingsEntity } from '@/modules/mail-user-settings/mail-user-settings.entity'; +import { + SYSTEM_SETTINGS_ROW_ID, + SystemSettingsEntity, +} from '@/modules/system-settings/system-settings.entity'; + +/** + * 그 주소로 남은 우리 DB 의 줄 수 — 표마다 (퇴사 마무리 · 입사 때 겹침 확인). + * 웹메일 세션은 세지도 지우지도 않는다 — 끊은 세션 행은 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 이 지운다(계약 6절). + * 끊긴 행은 로그인된 브라우저 목록에 보이지 않고 쓸 수도 없다 + */ +export interface AddressRows { + readonly loginEvents: number; + readonly appPasswordUsages: number; + readonly twoFactorSettings: number; + readonly signatures: number; + readonly signatureImages: number; + readonly mailSettings: number; + readonly directory: number; + readonly files: number; + readonly shareLinks: number; +} + +/** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */ +export interface AddressPurge { + readonly rows: AddressRows; + readonly exemptRemoved: boolean; + readonly sessionsRevoked: number; + readonly filesLeftForCleanup: number; +} + +export function totalRows(rows: AddressRows): number { + return (Object.values(rows) as number[]).reduce( + (sum, count) => sum + count, + 0, + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 예외 주소 목록에서 그 주소를 뺀 것 (대소문자 · 앞뒤 공백 무시 — 서버 설정 읽기와 같은 규칙) */ +export function exemptWithout( + exempt: unknown, + email: string, +): { exempt: string[]; removed: boolean } { + const entries = Array.isArray(exempt) + ? exempt.filter((entry): entry is string => typeof entry === 'string') + : []; + const kept = entries.filter( + (entry) => entry.trim().toLowerCase() !== email.trim().toLowerCase(), + ); + return { exempt: kept, removed: kept.length !== entries.length }; +} + +/** + * 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리. + * 우리 DB 는 주소로 직원을 가린다 — 지운 주소를 새 사람이 다시 쓰면 옛 로그인 기록 · 서명 · 설정 · 2단계 기록 · 2단계 필수 예외가 그대로 남으므로 + * 퇴사 마무리가 그 주소의 줄을 지운다. 세션 행은 끊기만 한다(정리 cron 이 지운다). 실행 기록(ops_audit_log)은 지우지 않는다. + */ +@Injectable() +export class OpsDataService { + private readonly logger = new Logger(OpsDataService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly sessions: AuthSessionRepository, + @Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore, + private readonly signatureImages: SignatureImageStorage, + ) {} + + /** + * 그 직원의 웹메일 세션을 모두 끊는다 → 끊은 수. 서버는 끊은 세션을 15초 안에 DB 에서 읽어 막는다(revoked-sessions.ts) + */ + revokeSessions(email: string): Promise { + return this.sessions.revokeAllOf( + email, + 'ended-by-operator', + new Date(), + null, + ); + } + + /** 웹메일에서 2단계 인증을 켠 때 기록을 지운다 (2단계 해제 — 엔진이 기준이고 이 기록은 켠 때뿐이다) */ + async forgetTwoFactor(email: string): Promise { + await this.dataSource.manager.delete(TwoFactorSettingEntity, { + userEmail: email, + }); + } + + /** 마지막으로 웹메일에 로그인한 때 (성공한 웹메일 로그인 기록 — 90일 보존 안) */ + async lastWebmailLogins( + emails: readonly string[], + ): Promise> { + if (emails.length === 0) return new Map(); + const rows = await this.dataSource.manager + .createQueryBuilder(LoginEventEntity, 'event') + .select('event.user_email', 'email') + .addSelect('MAX(event.occurred_at)', 'last') + .where({ userEmail: In([...emails]), method: 'webmail', isSuccess: true }) + .groupBy('event.user_email') + .getRawMany<{ email: string; last: Date | string }>(); + return new Map( + rows.map((row) => [ + row.email, + row.last instanceof Date ? row.last : new Date(row.last), + ]), + ); + } + + /** 그 주소로 남은 줄 수 */ + async countRowsOf(email: string): Promise { + return this.rowsOf(this.dataSource.manager, email, 'count'); + } + + /** 그 주소가 2단계 인증 필수의 예외 주소 목록에 있는가 (서버 설정 표 — 기억 없이 읽는다) */ + async isTwoFactorExempt(email: string): Promise { + const row = await this.dataSource.manager.findOneBy(SystemSettingsEntity, { + id: SYSTEM_SETTINGS_ROW_ID, + }); + return exemptWithout(row?.twoFactorExempt, email).removed; + } + + /** + * 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다 + * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 + 2단계 필수의 예외 주소에서 빼기). + * 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다 + */ + async purgeAddress(email: string): Promise { + // 디스크에서 지울 파일 — 줄을 지우기 전에 이름을 읽어 둔다 + const fileIds = ( + await this.dataSource.manager.find(StoredFileEntity, { + select: { id: true }, + where: { ownerEmail: email }, + }) + ).map((file) => file.id); + const imageNames = ( + await this.dataSource.manager.find(MailSignatureImageEntity, { + select: { fileName: true }, + where: { userEmail: email }, + }) + ).map((image) => image.fileName); + + // 살아 있는 세션은 끊는다 — 행은 지우지 않는다: 끊은 기록을 서버(다른 프로세스)가 15초 동기화로 읽어 남은 액세스 토큰을 막는다(계약 6절) + const sessionsRevoked = await this.revokeSessions(email); + const now = new Date(); + const { rows, exemptRemoved } = await this.dataSource.transaction( + async (manager) => ({ + rows: await this.rowsOf(manager, email, 'delete'), + exemptRemoved: await this.removeExempt(manager, email, now), + }), + ); + + let filesLeftForCleanup = 0; + for (const id of fileIds) { + try { + await this.blobs.remove(id); + } catch (error) { + filesLeftForCleanup += 1; + this.logger.warn( + `[SYS_001] 퇴사 마무리 — 대용량 파일을 디스크에서 지우지 못해 정리 작업에 맡깁니다: id=${id}, reason=${errorMessage(error)}`, + ); + } + } + for (const name of imageNames) { + try { + await this.signatureImages.remove(name); + } catch (error) { + filesLeftForCleanup += 1; + this.logger.warn( + `[SYS_001] 퇴사 마무리 — 서명 그림을 디스크에서 지우지 못해 정리 작업에 맡깁니다: reason=${errorMessage(error)}`, + ); + } + } + return { rows, exemptRemoved, sessionsRevoked, filesLeftForCleanup }; + } + + /** + * 2단계 인증 필수의 예외 주소에 넣거나 뺀다 — 서버 설정 줄을 잠그고 읽고 쓴다(운영 도구 둘이 함께 바꿔도 잃지 않게 · 퇴사 마무리와 같은 잠금) → + * 바꿨으면 true, 이미 그 상태면 false (재리뷰 N5) + */ + async changeTwoFactorExempt( + email: string, + include: boolean, + ): Promise { + const now = new Date(); + return this.dataSource.transaction(async (manager) => { + if (!include) return this.removeExempt(manager, email, now); + const row = await manager.findOne(SystemSettingsEntity, { + where: { id: SYSTEM_SETTINGS_ROW_ID }, + lock: { mode: 'pessimistic_write' }, + }); + if (!row) { + throw new Error( + 'system_settings 줄이 없습니다 — 마이그레이션을 확인하세요', + ); + } + const { exempt, removed: present } = exemptWithout( + row.twoFactorExempt, + email, + ); + if (present) return false; + await manager.update( + SystemSettingsEntity, + { id: SYSTEM_SETTINGS_ROW_ID }, + { twoFactorExempt: [...exempt, email], updatedAt: now }, + ); + return true; + }); + } + + /** 2단계 인증 필수의 예외 주소에서 뺀다 → 뺐으면 true (서버 설정 줄을 잠그고 — 운영 도구 둘이 함께 바꿔도 잃지 않게) */ + private async removeExempt( + manager: EntityManager, + email: string, + now: Date, + ): Promise { + const row = await manager.findOne(SystemSettingsEntity, { + where: { id: SYSTEM_SETTINGS_ROW_ID }, + lock: { mode: 'pessimistic_write' }, + }); + if (!row) return false; + const { exempt, removed } = exemptWithout(row.twoFactorExempt, email); + if (!removed) return false; + await manager.update( + SystemSettingsEntity, + { id: SYSTEM_SETTINGS_ROW_ID }, + { twoFactorExempt: exempt, updatedAt: now }, + ); + return true; + } + + /** 표마다 세거나 지운다 — 같은 차례 · 같은 조건 */ + private async rowsOf( + manager: EntityManager, + email: string, + mode: 'count' | 'delete', + ): Promise { + const run = async ( + entity: + | typeof LoginEventEntity + | typeof AppPasswordUsageEntity + | typeof TwoFactorSettingEntity + | typeof MailSignatureEntity + | typeof MailSignatureImageEntity + | typeof MailUserSettingsEntity + | typeof MailDirectoryEntryEntity + | typeof StoredFileEntity + | typeof ShareLinkEntity, + where: Record, + ): Promise => { + if (mode === 'count') return manager.countBy(entity, where); + return (await manager.delete(entity, where)).affected ?? 0; + }; + return { + loginEvents: await run(LoginEventEntity, { userEmail: email }), + appPasswordUsages: await run(AppPasswordUsageEntity, { + userEmail: email, + }), + twoFactorSettings: await run(TwoFactorSettingEntity, { + userEmail: email, + }), + signatures: await run(MailSignatureEntity, { userEmail: email }), + signatureImages: await run(MailSignatureImageEntity, { + userEmail: email, + }), + mailSettings: await run(MailUserSettingsEntity, { userEmail: email }), + directory: await run(MailDirectoryEntryEntity, { email }), + // 조각 줄은 외래 키(CASCADE)로 함께 지워진다 + files: await run(StoredFileEntity, { ownerEmail: email }), + // 링크의 파일 줄은 외래 키(CASCADE)로 함께 지워진다 + shareLinks: await run(ShareLinkEntity, { ownerEmail: email }), + }; + } +} diff --git a/backend/src/modules/ops/ops-engine.port.ts b/backend/src/modules/ops/ops-engine.port.ts new file mode 100644 index 0000000..355da75 --- /dev/null +++ b/backend/src/modules/ops/ops-engine.port.ts @@ -0,0 +1,151 @@ +/** + * 운영 도구의 엔진 포트 (13단계 — 계약 6절 "운영 도구") — 명령 로직은 이 인터페이스만 안다. + * 지금 구현은 Stalwart — `modules/mail/jmap/jmap-ops-engine.ts`. + * - 관리자 Basic(실행할 때 받은 비밀번호 — 저장하지 않는다)은 계정 관리 API(`x:Account` · `x:MailingList` · 계정을 만들 도메인)에만 쓴다 + * - 직원의 앱 비밀번호 · Sieve(받는 메일 넘기기 · 자동 응답)는 웹메일과 같은 게이트웨이 대리 접근으로 한다 — 관리자 계정에서는 대리 접근 권한(impersonate)을 뺀다(인프라) + * 실패는 BusinessException 으로 던진다 — 엔진의 거절은 context 에 종류(jmapType)만 싣는다(설명 문장 · 비밀번호는 넣지 않는다) + */ +export const OPS_ENGINE = Symbol('OPS_ENGINE'); + +/** 관리자 로그인 결과 */ +export type OpsSignInResult = 'ok' | 'rejected' | 'two-factor' | 'not-admin'; + +/** 엔진의 직원 계정 하나 (현황 · 명령의 대상) */ +export interface EngineEmployee { + /** 엔진의 계정 id — 불투명 */ + readonly id: string; + /** 주소 (소문자) */ + readonly email: string; + /** 표시 이름 — 없으면 주소의 @ 앞 */ + readonly name: string; + /** 잠김 — 로그인 권한(authenticate)을 뺐다 */ + readonly locked: boolean; + /** 계정 권한이 직접 정해진(Replace) 계정 — 잠그고 푸는 것을 이 도구가 하지 않는다 */ + readonly customPermissions: boolean; + /** 비밀번호가 만료됐다(임시 비밀번호 — 첫 로그인 때 바꾼다) */ + readonly passwordExpired: boolean; + /** 2단계 인증(OTP)이 켜져 있다 */ + readonly twoFactorEnabled: boolean; + /** 앱 비밀번호 수 */ + readonly appPasswords: number; + /** 메일함 사용량 (바이트) — 모르면 null */ + readonly usedBytes: number | null; + /** 메일함 용량 한도 (바이트) — 없으면 null(무제한) */ + readonly quotaBytes: number | null; +} + +/** 공용 주소(MailingList) — 구성원 받은편지함으로 복사한다 */ +export interface EngineMailingList { + readonly id: string; + readonly email: string; + readonly name: string; + /** 구성원 주소 (소문자) */ + readonly members: readonly string[]; +} + +/** 잠글 때 거는 Sieve — 받는 메일 넘기기(사본) · 자동 응답 */ +export interface LockRules { + /** 사본을 받을 사내 주소 — 넘기지 않으면 null */ + readonly forwardTo: string | null; + /** 자동 응답 — 걸지 않으면 null */ + readonly autoReply: { + readonly subject: string; + readonly body: string; + } | null; +} + +/** 지금 걸린 잠금 Sieve */ +export interface LockRulesState { + readonly forwardTo: string | null; + readonly autoReply: boolean; +} + +export interface OpsEngine { + /** 관리자 로그인 — 맞으면 이 프로세스가 끝날 때까지 메모리에만 둔다 */ + signIn(adminPassword: string): Promise; + + /** + * 직원으로 다룰 수 있는 주소인가 — 사내 도메인이고 관리자 · 게이트웨이 · 사내 목록 · 막은 계정이 아니다 (엔진에 묻지 않는다). + * 아니면 명령은 "직원 주소가 아님"으로 멈춘다 — "엔진에 없음"과 가른다(없음은 기록만 남은 주소의 정리로 이어진다) + */ + isEmployeeAddress(email: string): boolean; + /** 주소로 직원 계정 찾기 (직원 주소가 아니거나 · User 가 아닌 계정(Group)이거나 · 없으면 null) */ + findEmployee(email: string): Promise; + /** 직원 계정 모두 (주소 차례 — User 계정 가운데 직원 주소만) */ + listEmployees(): Promise; + /** 그 주소를 쓰는 계정 · 목록이 엔진에 있는가 (직원이 아닌 것 포함 — 입사 때 겹침 확인) */ + addressInUse(email: string): Promise; + + /** 입사 — 직원 역할 · 임시 비밀번호 · 만료(첫 로그인 때 바꾸기) · 메일함 용량(없으면 무제한) */ + createEmployee(input: { + readonly localPart: string; + readonly name: string; + readonly quotaBytes: number | null; + readonly tempPassword: string; + }): Promise; + /** 임시 비밀번호 + 만료 — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */ + resetPassword(id: string, tempPassword: string): Promise; + /** 2단계 인증(OTP)을 지운다 — 비밀번호는 그대로 */ + removeTwoFactor(id: string): Promise; + /** 로그인 권한(authenticate) 빼기 · 되돌리기 — 메일은 계속 받는다 */ + setLoginEnabled(id: string, enabled: boolean): Promise; + /** 계정 지우기 — 그 계정의 메일이 영구 삭제된다 */ + deleteAccount(id: string): Promise; + + listMailingLists(): Promise; + createMailingList(input: { + readonly localPart: string; + readonly name: string; + readonly members: readonly string[]; + }): Promise; + setMailingListMember( + id: string, + member: string, + included: boolean, + ): Promise; + + /** 앱 비밀번호를 모두 지운다 → 지운 수 (대리 접근 — 잠겼거나 비밀번호가 만료된 계정은 엔진이 막는다) */ + deleteAppPasswords(email: string): Promise; + /** 지금 걸린 잠금 Sieve (대리 접근) */ + getLockRules(email: string): Promise; + /** 잠금 Sieve 를 건다 — 직원의 필터 · 부재중 · 차단과 한 스크립트(엔진은 스크립트 하나만 켠다) */ + applyLockRules(email: string, rules: LockRules): Promise; + /** 잠금 Sieve 를 뗀다 — 넘기기 규칙을 빼고 자동 응답을 끈다 */ + clearLockRules(email: string): Promise; +} + +/** 관리자 계정 주소 — `@` (운영 모듈이 설정에서 만든다) */ +export const OPS_ADMIN_ACCOUNT = Symbol('OPS_ADMIN_ACCOUNT'); + +/** 잠글 때 넣는 넘기기 규칙의 이름 — 풀 때 이 이름의 규칙을 찾아 뗀다 (필터 이름 50자 안) */ +export const LOCK_FORWARD_RULE_NAME = '잠금 — 받는 메일 넘기기 (운영 도구)'; + +/** 엔진이 받지 않은 까닭 — 명령이 한국어로 알린다 */ +export type OpsEngineErrorKind = + /** 엔진이 임시 비밀번호를 약하다고 받지 않음 — 새로 만들어 다시 한다 */ + | 'weak-password' + /** 같은 이름(주소)이 이미 있다 */ + | 'exists' + /** 대상이 없다 */ + | 'not-found' + /** 계정 권한이 직접 정해져 이 도구가 바꾸지 않는다 */ + | 'custom-permissions' + /** 필터가 이미 가득하다(50개) — 넘기기 규칙을 더할 수 없다 */ + | 'too-many-rules' + /** 관리자로 들어가지 않았다 · 관리자 비밀번호가 그사이 바뀌었다 */ + | 'not-signed-in' + /** 그 밖의 거절 */ + | 'refused' + /** 엔진에 닿지 못함 · 응답을 읽지 못함 */ + | 'unavailable'; + +/** 엔진의 거절 — detail 은 엔진 오류의 종류(type)만(설명 문장 · 비밀번호는 넣지 않는다) */ +export class OpsEngineError extends Error { + constructor( + readonly kind: OpsEngineErrorKind, + readonly detail: string | null = null, + ) { + super(`ops-engine:${kind}${detail ? `/${detail}` : ''}`); + this.name = 'OpsEngineError'; + } +} diff --git a/backend/src/modules/ops/ops-helpers.spec.ts b/backend/src/modules/ops/ops-helpers.spec.ts new file mode 100644 index 0000000..42d7469 --- /dev/null +++ b/backend/src/modules/ops/ops-helpers.spec.ts @@ -0,0 +1,223 @@ +import { PassThrough } from 'stream'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { OpsEngineError } from './ops-engine.port'; +import { OpsInputClosed, TerminalIo, isYes } from './ops-io'; +import { + failureDetail, + failureText, + toAddress, + toDisplayName, + toQuotaBytes, +} from './ops-text'; +import { TEMP_PASSWORD_LENGTH, generateTempPassword } from './temp-password'; + +describe('임시 비밀번호 (13단계)', () => { + it('16자 · 헷갈리는 글자 없음 · 대문자 · 소문자 · 숫자가 하나씩은 있다', () => { + for (let index = 0; index < 200; index += 1) { + const password = generateTempPassword(); + expect(password).toHaveLength(TEMP_PASSWORD_LENGTH); + expect(password).toMatch(/^[A-HJ-NP-Za-km-np-z2-9]+$/); + expect(password).toMatch(/[A-Z]/); + expect(password).toMatch(/[a-z]/); + expect(password).toMatch(/\d/); + } + }); + + it('세 가지가 섞이지 않은 뽑기는 버리고 다시 뽑는다', () => { + // 처음 16번은 늘 첫 글자(A) — 대문자뿐이라 버려진다. 그다음은 차례로 돈다 + let calls = 0; + const pick = (max: number) => + calls++ < TEMP_PASSWORD_LENGTH ? 0 : calls % max; + const password = generateTempPassword(pick); + expect(calls).toBeGreaterThan(TEMP_PASSWORD_LENGTH); + expect(password).toMatch(/[a-z]/); + }); +}); + +describe('운영 도구의 값 읽기 · 글', () => { + it('주소 — @도메인 을 붙이고 소문자로, 다른 도메인 · 모양이 틀린 것은 null', () => { + expect(toAddress('Hong.Gildong', 'arasystem.test')).toBe( + 'hong.gildong@arasystem.test', + ); + expect(toAddress(' ara.kim@ARASYSTEM.test ', 'arasystem.test')).toBe( + 'ara.kim@arasystem.test', + ); + for (const input of [ + '', + 'a@example.com', + '.hong', + 'hong.', + 'ho..ng', + 'hong gildong', + '홍길동', + 'a'.repeat(65), + 'a@b@arasystem.test', + ]) { + expect(toAddress(input, 'arasystem.test')).toBeNull(); + } + }); + + it('이름 — 한 줄 1~100자', () => { + expect(toDisplayName(' 홍길동 ')).toBe('홍길동'); + expect(toDisplayName('')).toBeNull(); + expect(toDisplayName('가'.repeat(101))).toBeNull(); + expect(toDisplayName('홍\n길동')).toBeNull(); + }); + + it('메일함 용량(GB) — 빈 값은 무제한, 소수 한 자리까지, 0 · 음수 · 너무 큰 값은 invalid', () => { + expect(toQuotaBytes('')).toBeNull(); + expect(toQuotaBytes('1')).toBe(1024 ** 3); + expect(toQuotaBytes('2.5')).toBe(2.5 * 1024 ** 3); + for (const input of ['0', '-1', '1.25', '1e3', 'abc', '10001']) { + expect(toQuotaBytes(input)).toBe('invalid'); + } + }); + + it('실패의 글 — 엔진의 까닭 · 코드만(설명 문장 없이)', () => { + expect(failureText(new OpsEngineError('exists'))).toContain( + '이미 있습니다', + ); + expect(failureText(new OpsEngineError('not-signed-in'))).toContain( + '도구를 다시 시작하세요', + ); + expect(failureText(new OpsEngineError('refused', 'overQuota'))).toContain( + 'overQuota', + ); + expect(failureText(new BusinessException('MAIL_001'))).toContain( + '메일 엔진', + ); + expect( + failureDetail( + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ), + ).toBe('MAIL_001/timeout'); + expect(failureDetail(new OpsEngineError('refused', 'forbidden'))).toBe( + 'engine:refused/forbidden', + ); + }); + + it('y/N — y · yes · 예 · ㅇ 만 예', () => { + for (const yes of ['y', 'Y', 'yes', '예', 'ㅇ', ' y ']) { + expect(isYes(yes)).toBe(true); + } + for (const no of ['', 'n', 'no', '아니오', 'yy']) { + expect(isYes(no)).toBe(false); + } + }); +}); + +describe('TerminalIo — 터미널 입출력', () => { + function setup() { + const input = new PassThrough(); + const output = new PassThrough(); + let written = ''; + output.on('data', (chunk: Buffer) => { + written += chunk.toString('utf8'); + }); + const io = new TerminalIo(input, output); + return { input, io, written: () => written }; + } + + it('한꺼번에 들어온 줄(붙여 넣기 · 파이프)도 잃지 않고 차례로 꺼낸다', async () => { + const { input, io } = setup(); + input.write('운영자\n비밀\n9\n'); + await expect(io.ask('이름: ')).resolves.toBe('운영자'); + await expect(io.askSecret('비밀번호: ')).resolves.toBe('비밀'); + await expect(io.ask('번호: ')).resolves.toBe('9'); + io.close(); + }); + + it('비밀은 앞뒤 공백도 그대로 · y/N 은 y 일 때만 예', async () => { + const { input, io } = setup(); + const secret = io.askSecret('비밀번호: '); + input.write(' pass word \n'); + await expect(secret).resolves.toBe(' pass word '); + const yes = io.confirm('할까요?'); + input.write('y\n'); + await expect(yes).resolves.toBe(true); + const no = io.confirm('할까요?'); + input.write('\n'); + await expect(no).resolves.toBe(false); + io.close(); + }); + + it('입력이 끝나면 기다리던 물음과 다음 물음이 OpsInputClosed 로 끝난다', async () => { + const { input, io } = setup(); + const waiting = io.ask('이름: '); + input.end(); + await expect(waiting).rejects.toBeInstanceOf(OpsInputClosed); + await expect(io.ask('또: ')).rejects.toBeInstanceOf(OpsInputClosed); + }); + + it('보이는 줄과 물음은 출력에 쓴다', async () => { + const { input, io, written } = setup(); + io.print('안녕'); + const answer = io.ask('이름: '); + input.write('a\n'); + await answer; + expect(written()).toContain('안녕\n'); + expect(written()).toContain('이름: '); + io.close(); + }); + + it('터미널이 아니면 interactive 가 아니다 — 자동 시험만 allowNonInteractive(OPS_ALLOW_NO_TTY=1)로 연다', () => { + const { io } = setup(); + expect(io.interactive).toBe(false); + io.close(); + + const piped = new TerminalIo(new PassThrough(), new PassThrough(), { + allowNonInteractive: true, + }); + expect(piped.interactive).toBe(true); + piped.close(); + + const terminal = Object.assign(new PassThrough(), { isTTY: true }); + const tty = new TerminalIo(terminal, new PassThrough()); + expect(tty.interactive).toBe(true); + tty.close(); + }); +}); + +describe('TerminalIo — 터미널(TTY)에서 비밀번호 (리뷰 H1)', () => { + const SECRET = 'S3cretAdminPw'; + + function terminal() { + const input = Object.assign(new PassThrough(), { isTTY: true }); + const output = new PassThrough(); + let written = ''; + output.on('data', (chunk: Buffer) => { + written += chunk.toString('utf8'); + }); + const io = new TerminalIo(input, output); + return { input, io, written: () => written }; + } + + it('친 비밀번호는 화면에 되비치지 않고, 입력 기록에도 남지 않아 ↑ 로 다시 그려지지 않는다', async () => { + const { input, io, written } = terminal(); + const secret = io.askSecret('비밀번호: '); + input.write(`${SECRET}\r`); + await expect(secret).resolves.toBe(SECRET); + expect(written()).not.toContain(SECRET); + + // ↑ 다음에 친 줄 — 기록이 있었다면 ↑ 가 비밀번호를 불러와 그 뒤에 9 가 붙고 화면에도 그려졌다 + const next = io.ask('번호: '); + input.write('\x1b[A'); + input.write('9\r'); + await expect(next).resolves.toBe('9'); + expect(written()).not.toContain(SECRET); + io.close(); + }); + + it('보통 줄도 기록에 남기지 않는다 — ↑ 는 아무것도 불러오지 않는다', async () => { + const { input, io } = terminal(); + const first = io.ask('번호: '); + input.write('10\r'); + await expect(first).resolves.toBe('10'); + const second = io.ask('번호: '); + input.write('\x1b[A'); + input.write('\r'); + await expect(second).resolves.toBe(''); + io.close(); + }); +}); diff --git a/backend/src/modules/ops/ops-io.ts b/backend/src/modules/ops/ops-io.ts new file mode 100644 index 0000000..f48a04b --- /dev/null +++ b/backend/src/modules/ops/ops-io.ts @@ -0,0 +1,148 @@ +import { createInterface, type Interface } from 'readline'; +import { Writable } from 'stream'; + +/** + * 운영 도구의 묻기 · 보이기 (13단계) — 명령 로직은 이 인터페이스만 안다(시험은 흉내를 넣는다). + * 화면에 쓰는 것은 이것뿐이다 — 비밀(관리자 비밀번호)은 askSecret 으로 받아 화면에 보이지 않는다. + * 임시 비밀번호는 print 로 한 번만 보이고 어디에도 남기지 않는다 + */ +export interface OpsIo { + /** + * 사람이 터미널에서 돌리는가 — 표준 입력이 TTY 일 때만 true. 아니면 도구는 비밀번호를 묻기 전에 멈춘다 + * (파이프로 흘려 넣은 비밀번호는 셸 기록 · 프로세스 목록에 남을 수 있다). 자동 시험만 `OPS_ALLOW_NO_TTY=1` 로 연다 + */ + readonly interactive: boolean; + /** 터미널이 아닌 입력을 시험용 변수(`OPS_ALLOW_NO_TTY=1`)로 받는 중인가 — 도구가 첫 화면에 경고 한 줄을 보인다 */ + readonly pipedForTesting?: boolean; + /** 한 줄 보이기 (빈 값이면 빈 줄) */ + print(line?: string): void; + /** 한 줄 묻기 — 앞뒤 공백을 지운 값 */ + ask(question: string): Promise; + /** 화면에 보이지 않게 묻기 (관리자 비밀번호) — 지우지 않은 그대로 */ + askSecret(question: string): Promise; + /** y/N — y · yes · 예 · ㅇ 만 예, 그 밖(빈 값 포함)은 아니오 */ + confirm(question: string): Promise; + close(): void; +} + +/** 입력이 끝났다(Ctrl+D · Ctrl+C · 파이프 끝) — 도구를 끝낸다 */ +export class OpsInputClosed extends Error { + constructor() { + super('입력이 끝났습니다.'); + this.name = 'OpsInputClosed'; + } +} + +const YES = new Set(['y', 'yes', '예', 'ㅇ']); + +/** y/N 답 → 예인가 */ +export function isYes(answer: string): boolean { + return YES.has(answer.trim().toLowerCase()); +} + +interface Waiter { + readonly resolve: (line: string) => void; + readonly reject: (error: Error) => void; +} + +/** + * 터미널이 아닌 표준 입력(파이프)도 받게 하는 환경 변수 — 값이 `1` 일 때만. 자동 시험(`docker exec -i -e OPS_ALLOW_NO_TTY=1 …`)용이고 사람이 쓰지 않는다 + */ +export const OPS_ALLOW_NO_TTY_ENV = 'OPS_ALLOW_NO_TTY'; + +/** 터미널이 아닌 입력(파이프)도 받을지 — `OPS_ALLOW_NO_TTY=1` 일 때만 */ +export interface TerminalIoOptions { + readonly allowNonInteractive?: boolean; +} + +/** + * 터미널 입출력 — `docker exec -it node dist/cli/ops.js`. + * - 들어온 줄은 차례로 쌓아 두고 물을 때 하나씩 꺼낸다 — 붙여 넣거나 파이프로 한꺼번에 들어온 줄도 잃지 않는다 + * - 비밀은 출력 흐름을 잠깐 막아(muted) 친 글자가 화면에 되비치지 않게 받는다 + * - **입력 기록(history)을 두지 않는다**(`historySize: 0`) — 두면 비밀번호 줄이 기록에 남아 ↑ 한 번에 화면에 평문으로 다시 그려진다 + * (2026-11-02 리뷰). 메뉴 번호 · 주소를 다시 부를 일은 없다 + * - console 은 쓰지 않는다(린트 규칙) — 표준 출력에 직접 쓴다 + */ +export class TerminalIo implements OpsIo { + readonly interactive: boolean; + readonly pipedForTesting: boolean; + private muted = false; + private closed = false; + private readonly rl: Interface; + private readonly lines: string[] = []; + private readonly waiters: Waiter[] = []; + + constructor( + private readonly input: NodeJS.ReadableStream = process.stdin, + private readonly output: NodeJS.WritableStream = process.stdout, + options: TerminalIoOptions = {}, + ) { + const isTerminal = (this.input as { isTTY?: boolean }).isTTY === true; + this.interactive = isTerminal || options.allowNonInteractive === true; + this.pipedForTesting = !isTerminal && options.allowNonInteractive === true; + // 터미널이 되비치는 글자(친 글자)만 이 흐름을 지난다 — 비밀을 묻는 동안은 버린다 + const echo = new Writable({ + write: (chunk: Buffer | string, _encoding, callback) => { + if (!this.muted) this.output.write(chunk); + callback(); + }, + }); + this.rl = createInterface({ + input: this.input, + output: echo, + terminal: isTerminal, + // 입력 기록 없음 — 비밀번호 줄이 ↑ 로 다시 그려지지 않게 (위 머리말) + historySize: 0, + }); + this.rl.setPrompt(''); + this.rl.on('line', (line: string) => { + const waiter = this.waiters.shift(); + if (waiter) waiter.resolve(line); + else this.lines.push(line); + }); + this.rl.on('close', () => { + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter.reject(new OpsInputClosed()); + } + }); + } + + print(line = ''): void { + this.output.write(`${line}\n`); + } + + async ask(question: string): Promise { + this.output.write(question); + return (await this.nextLine()).trim(); + } + + async askSecret(question: string): Promise { + this.output.write(question); + this.muted = true; + try { + return await this.nextLine(); + } finally { + this.muted = false; + this.output.write('\n'); + } + } + + async confirm(question: string): Promise { + this.output.write(`${question} (y/N) `); + return isYes(await this.nextLine()); + } + + close(): void { + this.rl.close(); + } + + private nextLine(): Promise { + const queued = this.lines.shift(); + if (queued !== undefined) return Promise.resolve(queued); + if (this.closed) return Promise.reject(new OpsInputClosed()); + return new Promise((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } +} diff --git a/backend/src/modules/ops/ops-prompts.ts b/backend/src/modules/ops/ops-prompts.ts new file mode 100644 index 0000000..22325e4 --- /dev/null +++ b/backend/src/modules/ops/ops-prompts.ts @@ -0,0 +1,128 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + OpsEngineError, + type EngineEmployee, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { failureDetail, failureText, toAddress } from './ops-text'; + +/** 운영 도구의 공통 묻기 · 보이기 — 명령들이 함께 쓴다 */ + +const logger = new Logger('OpsTool'); + +/** + * 모르는 오류(엔진 · 업무 오류가 아닌 것 — DB · 프로그램 오류)는 오류 로그에 남긴다 — 이 창의 ERROR 줄. + * 운영자에게는 짧은 글만 보이므로 원인을 여기서 본다. 종류와 스택만이다(비밀번호는 오류에 실리지 않는다) + */ +export function logUnknownFailure(error: unknown): void { + if (error instanceof OpsEngineError || error instanceof BusinessException) { + return; + } + logger.error( + `[SYS_001] 운영 도구에서 모르는 오류: ${failureDetail(error)}`, + error instanceof Error ? error.stack : undefined, + ); +} + +/** 실패 → 운영자에게 보일 글 — 모르는 오류는 오류 로그에도 남긴다 (명령은 failureText 대신 이것을 쓴다) */ +export function failureLine(error: unknown): string { + logUnknownFailure(error); + return failureText(error); +} + +/** 구분선 */ +export const RULE = '─'.repeat(48); + +/** 주소를 묻는다 — `@도메인` 은 붙이지 않아도 된다. 모양이 틀리면 알리고 null */ +export async function askAddress( + io: OpsIo, + domain: string, + label: string, +): Promise { + const input = await io.ask( + `${label} (예: hong.gildong — @${domain} 는 붙이지 않아도 됩니다): `, + ); + const email = toAddress(input, domain); + if (email === null) { + io.print(`주소 모양이 맞지 않거나 @${domain} 주소가 아닙니다.`); + } + return email; +} + +/** 직원 주소가 아닐 때의 안내 (관리자 · 게이트웨이 · 사내 목록 · 막은 계정) */ +export const NOT_EMPLOYEE_ADDRESS = + '직원 주소가 아닙니다 — 관리자 · 게이트웨이 · 사내 목록 · 막은 계정은 이 도구로 다루지 않습니다.'; +/** 그 주소의 계정 · 목록이 직원 계정이 아닐 때의 안내 (공용 주소 · 공유 메일함) */ +export const NOT_EMPLOYEE_ACCOUNT = + '그 주소는 직원 계정이 아닙니다(공용 주소 · 공유 메일함) — 이 도구로 다루지 않습니다.'; + +/** + * 직원 주소를 묻고 엔진에서 찾는다 — 직원 주소가 아님 · 직원 계정이 아님(공용 주소 · 공유 메일함) · 엔진에 없음을 갈라 알리고 null + */ +export async function askEmployee( + io: OpsIo, + engine: OpsEngine, + domain: string, + label = '직원 주소', +): Promise { + const email = await askAddress(io, domain, label); + if (email === null) return null; + if (!engine.isEmployeeAddress(email)) { + io.print(NOT_EMPLOYEE_ADDRESS); + return null; + } + try { + const employee = await engine.findEmployee(email); + if (employee) return employee; + io.print( + (await engine.addressInUse(email)) + ? NOT_EMPLOYEE_ACCOUNT + : '엔진에 그 직원 계정이 없습니다.', + ); + return null; + } catch (error) { + io.print(failureLine(error)); + return null; + } +} + +/** 바뀔 것을 보이고 y/N 으로 묻는다 */ +export async function confirmPlan( + io: OpsIo, + title: string, + lines: readonly string[], +): Promise { + io.print(''); + io.print(RULE); + io.print(` ${title} — 바뀔 것`); + for (const line of lines) io.print(` ${line}`); + io.print(RULE); + const yes = await io.confirm('이대로 할까요?'); + if (!yes) io.print('하지 않았습니다.'); + return yes; +} + +/** 임시 비밀번호를 한 번만 보인다 — 어디에도 남기지 않는다 */ +export function showTempPassword( + io: OpsIo, + email: string, + password: string, +): void { + io.print(''); + io.print(RULE); + io.print( + ' 임시 비밀번호 — 지금 한 번만 보입니다. 직원에게 직접(말 · 쪽지) 전하세요.', + ); + io.print(` 주소 ${email}`); + io.print(` 임시 비밀번호 ${password}`); + io.print(' 직원은 첫 로그인 때 새 비밀번호를 정합니다.'); + io.print(RULE); +} + +/** 한 단계의 결과 줄 */ +export function step(io: OpsIo, ok: boolean, text: string): void { + io.print(` ${ok ? '[완료]' : '[못 함]'} ${text}`); +} diff --git a/backend/src/modules/ops/ops-text.ts b/backend/src/modules/ops/ops-text.ts new file mode 100644 index 0000000..ffbe6b8 --- /dev/null +++ b/backend/src/modules/ops/ops-text.ts @@ -0,0 +1,132 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { formatFileSize } from '@/shared/utils/format'; +import { formatKstDateTime } from '@/shared/utils/kst-date'; +import { OpsEngineError } from './ops-engine.port'; + +/** + * 운영 도구의 글 · 값 읽기 (순수 함수) — 운영자가 넣은 값의 검사와 보이는 글. + * 운영자는 비개발자일 수 있다 — 한국어로, 무엇이 왜 안 되는지와 다음에 할 일을 짧게 + */ + +/** 주소의 로컬 부분 — 소문자 · 숫자 · 점 · 밑줄 · 하이픈, 처음과 끝은 소문자 · 숫자, 64자까지 (엔진 계정 이름) */ +const LOCAL_PART = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; +/** 연속 점은 받지 않는다 (메일 주소 규칙) */ +const DOUBLE_DOT = /\.\./; +/** 이름 · 설명 — 한 줄, 1~100자 */ +export const DISPLAY_NAME_MAX_LENGTH = 100; +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; +/** 메일함 용량 상한 (GB) — 넣을 수 있는 값의 끝 (오타 막기) */ +export const QUOTA_MAX_GB = 10_000; +const BYTES_PER_GB = 1024 ** 3; + +/** + * 운영자가 넣은 주소 → 정식 주소(소문자) — `@도메인` 을 붙이지 않았으면 붙인다. 다른 도메인 · 모양이 틀리면 null + */ +export function toAddress(input: string, domain: string): string | null { + const value = input.trim().toLowerCase(); + if (value === '') return null; + const at = value.indexOf('@'); + const local = at < 0 ? value : value.slice(0, at); + const host = at < 0 ? domain : value.slice(at + 1); + if (host !== domain) return null; + if (!LOCAL_PART.test(local) || DOUBLE_DOT.test(local)) return null; + return `${local}@${domain}`; +} + +/** 표시 이름 · 공용 주소 이름 — 앞뒤 공백을 지운 한 줄 1~100자, 아니면 null */ +export function toDisplayName(input: string): string | null { + const value = input.trim(); + if (value === '' || value.length > DISPLAY_NAME_MAX_LENGTH) return null; + if (CONTROL_CHARACTER.test(value)) return null; + return value; +} + +/** 메일함 용량(GB) — 빈 값은 무제한(null). 양의 수(소수 한 자리까지) · 상한 안이 아니면 'invalid' */ +export function toQuotaBytes(input: string): number | null | 'invalid' { + const value = input.trim(); + if (value === '') return null; + if (!/^\d{1,5}(?:\.\d)?$/.test(value)) return 'invalid'; + const gigabytes = Number(value); + if (!(gigabytes > 0) || gigabytes > QUOTA_MAX_GB) return 'invalid'; + return Math.round(gigabytes * BYTES_PER_GB); +} + +/** 크기 표기 — 없으면 "—" */ +export function bytesText(bytes: number | null): string { + return bytes === null ? '—' : formatFileSize(bytes); +} + +/** 한국 시각 — 없으면 "—" */ +export function timeText(at: Date | null | undefined): string { + return at ? formatKstDateTime(at) : '—'; +} + +/** 응답을 받지 못한 엔진 실패의 까닭 — 엔진이 그 일을 했는지 모른다 */ +const UNKNOWN_OUTCOME_DETAILS: ReadonlySet = new Set([ + 'timeout', + 'network', + 'malformed-response', +]); + +/** + * 엔진이 결과를 알려 주지 않은 실패인가 — 제한 시간 · 연결 끊김 · 응답을 읽지 못함 · 5xx: 엔진이 그 일을 했는지 모른다 (재리뷰 N3). + * 명령은 "결과를 모릅니다 — 같은 명령을 다시 하세요"로 알리고, 했을 수도 있는 일의 뒷정리(재설정의 알림 메일 지우기 등)를 하지 않는다 + */ +export function isUnknownOutcome(error: unknown): boolean { + if (!(error instanceof OpsEngineError) || error.kind !== 'unavailable') { + return false; + } + const detail = error.detail ?? ''; + return UNKNOWN_OUTCOME_DETAILS.has(detail) || /^http-5\d\d$/.test(detail); +} + +/** 실패 → 운영자에게 보일 글 (엔진의 설명 문장 · 비밀은 넣지 않는다 — 종류 · 코드만) */ +export function failureText(error: unknown): string { + if (isUnknownOutcome(error)) { + return `메일 엔진의 답을 받지 못해 결과를 모릅니다 (${(error as OpsEngineError).detail ?? '까닭 모름'}) — 같은 명령을 다시 하세요.`; + } + if (error instanceof OpsEngineError) { + switch (error.kind) { + case 'weak-password': + return '엔진이 임시 비밀번호를 받지 않았습니다(강도). 다시 해 주세요.'; + case 'exists': + return '같은 이름(주소)이 엔진에 이미 있습니다.'; + case 'not-found': + return '엔진에서 대상을 찾지 못했습니다.'; + case 'custom-permissions': + return '이 계정은 권한이 직접 정해져 있어 이 도구로 바꾸지 않습니다 — 엔진 관리 화면에서 하세요.'; + case 'too-many-rules': + return '그 직원의 필터가 이미 50개라 넘기기 규칙을 더할 수 없습니다 — 필터를 줄인 뒤 다시 하세요.'; + case 'not-signed-in': + return '관리자 로그인이 끊겼습니다(비밀번호가 바뀌었을 수 있습니다). 도구를 다시 시작하세요.'; + case 'refused': + return `엔진이 받지 않았습니다 (${error.detail ?? '까닭 모름'}).`; + case 'unavailable': + return `메일 엔진에 닿지 못했습니다 (${error.detail ?? '까닭 모름'}). 잠시 뒤 다시 하세요.`; + } + } + if (error instanceof BusinessException) { + if (error.code === 'MAIL_001') { + return '메일 엔진에 닿지 못했거나 그 직원의 메일함을 열 수 없습니다(잠김 · 비밀번호 만료). 잠시 뒤 다시 하세요.'; + } + if (error.code === 'AUTH_001') { + return '그 직원의 메일함을 열 수 없습니다(계정 없음 · 잠김).'; + } + return `처리하지 못했습니다 (${error.code}).`; + } + // 모르는 오류 — 부르는 쪽(failureLine)이 오류 로그에 원인을 남긴다 + return '처리하지 못했습니다 — 바로 위의 오류 로그(ERROR 줄)를 확인하세요.'; +} + +/** 실행 기록에 남길 실패 요약 — 종류 · 코드만 */ +export function failureDetail(error: unknown): string { + if (error instanceof OpsEngineError) { + return `engine:${error.kind}${error.detail ? `/${error.detail}` : ''}`; + } + if (error instanceof BusinessException) { + const reason: unknown = error.context?.reason; + return `${error.code}${typeof reason === 'string' ? `/${reason}` : ''}`; + } + return error instanceof Error ? error.name : 'unknown'; +} diff --git a/backend/src/modules/ops/ops.module.ts b/backend/src/modules/ops/ops.module.ts new file mode 100644 index 0000000..5f16e2a --- /dev/null +++ b/backend/src/modules/ops/ops.module.ts @@ -0,0 +1,141 @@ +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AuthSessionModule } from '@/modules/auth/auth-session.module'; +import { FileSharingRepository } from '@/modules/file-sharing/file-sharing.repository'; +import { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import { + FILE_SHARING_SETTINGS, + buildFileSharingSettings, +} from '@/modules/file-sharing/file-sharing.settings'; +import { ShareLinkFileEntity } from '@/modules/file-sharing/share-link-file.entity'; +import { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity'; +import { FILE_BLOB_STORE } from '@/modules/file-storage/file-blob-store'; +import { + FILE_SCANNER, + FileScanService, +} from '@/modules/file-storage/file-scan.service'; +import { FileStorageRepository } from '@/modules/file-storage/file-storage.repository'; +import { FileStorageService } from '@/modules/file-storage/file-storage.service'; +import { + FILE_STORAGE_SETTINGS, + buildFileStorageSettings, + type FileStorageSettings, +} from '@/modules/file-storage/file-storage.settings'; +import { LocalDiskBlobStore } from '@/modules/file-storage/local-disk-blob-store'; +import { StoredFileChunkEntity } from '@/modules/file-storage/stored-file-chunk.entity'; +import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity'; +import { JmapAccountSecurityProvider } from '@/modules/mail/jmap/jmap-account-security.provider'; +import { JmapFilterScriptStore } from '@/modules/mail/jmap/jmap-filter-script'; +import { JmapOpsEngine } from '@/modules/mail/jmap/jmap-ops-engine'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { buildMailAdminAccount } from '@/modules/mail/mail.settings'; +import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage'; +import { SystemMailModule } from '@/modules/system-mail/system-mail.module'; +import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; +import { buildDatabaseOptions } from '@/shared/config/database.config'; +import { validateEnv } from '@/shared/config/env.validation'; +import { AccountCommands } from './account-commands'; +import { AdminCommands } from './admin-commands'; +import { LockCommands } from './lock-commands'; +import { OpsAuditLogEntity } from './ops-audit-log.entity'; +import { OpsAuditService } from './ops-audit.service'; +import { OpsCli } from './ops-cli'; +import { OpsDataService } from './ops-data.service'; +import { OPS_ADMIN_ACCOUNT, OPS_ENGINE } from './ops-engine.port'; + +/** 운영 도구의 DB 연결 — 한 사람이 차례로 쓰므로 작게 */ +const OPS_DB_POOL = { + max: 4, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, +} as const; + +/** + * 운영 도구 모듈 (13단계 — 계약 6절 "운영 도구") — `node dist/cli/ops.js`(src/cli/ops.ts)가 Nest 앱 컨텍스트로 띄운다. + * **서버와 다른 프로세스**다 — HTTP 서버 · cron(ScheduleModule 없음 — @Interval · @Cron 이 돌지 않는다) · 실시간 엔진 연결 · + * 악성 검사(FILE_SCANNER = null — 검사 차례가 돌지 않는다) · 마이그레이션 실행(migrationsRun false — 서버가 뜰 때 한다) 없이 필요한 것만 둔다. + * 그래서 파일 · 링크 서비스는 그 모듈을 가져오지 않고 여기서 다시 엮는다(FileStorageModule 을 가져오면 이 프로세스에서도 검사 차례가 돈다). + * 도구가 끊은 세션은 DB 에 적히고, 서버가 15초마다 DB 에서 읽어 막는다(revoked-sessions.ts) + */ +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: `.env.${process.env.NODE_ENV || 'development'}`, + validate: validateEnv, + }), + TypeOrmModule.forRootAsync({ + useFactory: () => ({ + ...buildDatabaseOptions(), + // 마이그레이션은 서버가 뜰 때 한다 — 도구는 표를 바꾸지 않는다. SQL 로그는 남기지 않는다(주소가 화면에 섞인다) + migrationsRun: false, + logging: false, + extra: { ...OPS_DB_POOL }, + autoLoadEntities: true, + }), + }), + TypeOrmModule.forFeature([ + OpsAuditLogEntity, + StoredFileEntity, + StoredFileChunkEntity, + ShareLinkEntity, + ShareLinkFileEntity, + ]), + // JmapClient 가 쓰는 캐시 (세션 기억) + CacheModule.register({ isGlobal: true }), + MailEngineModule, + AuthSessionModule, + SystemSettingsModule, + SystemMailModule, + ], + providers: [ + { + provide: OPS_ADMIN_ACCOUNT, + inject: [ConfigService], + useFactory: (config: ConfigService) => + buildMailAdminAccount((key) => config.get(key)) ?? '', + }, + JmapAccountSecurityProvider, + JmapFilterScriptStore, + { provide: OPS_ENGINE, useClass: JmapOpsEngine }, + // 파일 · 링크 — 링크 끊기 · 보관 기간 맞추기 · 퇴사 마무리의 파일 지우기 (검사 차례는 돌리지 않는다) + { + provide: FILE_STORAGE_SETTINGS, + inject: [ConfigService], + useFactory: (config: ConfigService) => + buildFileStorageSettings((key) => config.get(key)), + }, + { + provide: FILE_BLOB_STORE, + inject: [FILE_STORAGE_SETTINGS], + useFactory: (settings: FileStorageSettings) => + new LocalDiskBlobStore(settings.dir), + }, + { provide: FILE_SCANNER, useValue: null }, + FileStorageRepository, + FileScanService, + FileStorageService, + { + provide: FILE_SHARING_SETTINGS, + inject: [ConfigService], + useFactory: (config: ConfigService) => + buildFileSharingSettings((key) => config.get(key)), + }, + FileSharingRepository, + FileSharingService, + { + provide: SignatureImageStorage, + useFactory: () => new SignatureImageStorage(), + }, + OpsDataService, + OpsAuditService, + AccountCommands, + LockCommands, + AdminCommands, + OpsCli, + ], +}) +export class OpsModule {} diff --git a/backend/src/modules/ops/temp-password.ts b/backend/src/modules/ops/temp-password.ts new file mode 100644 index 0000000..2682b32 --- /dev/null +++ b/backend/src/modules/ops/temp-password.ts @@ -0,0 +1,32 @@ +import { randomInt } from 'crypto'; + +/** + * 임시 비밀번호의 글자 — 헷갈리는 글자(0 · O · o · 1 · l · I)를 뺀 영문 대 · 소문자와 숫자. + * 직원이 화면에서 옮겨 적는다. 영문 · 숫자만이라 셸 · 메신저에서 깨지지 않는다 + */ +const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; + +/** 길이 — 16자(약 93비트). 엔진의 강도 검사(passwordMinStrength)를 넉넉히 넘는다 (2026-11-02 개발 엔진에서 확인) */ +export const TEMP_PASSWORD_LENGTH = 16; + +/** + * 임시 비밀번호 (13단계 — 계약 6절 "운영 도구": 무작위 · 한 번만 보여 준다) — crypto 의 고른 난수. + * 대문자 · 소문자 · 숫자가 하나씩은 들어간다(옮겨 적을 때 섞인 것을 알아보기 쉽게). 저장하지 않고 로그에 남기지 않는다 + */ +export function generateTempPassword( + pick: (max: number) => number = randomInt, +): string { + for (;;) { + let password = ''; + for (let index = 0; index < TEMP_PASSWORD_LENGTH; index += 1) { + password += ALPHABET[pick(ALPHABET.length)]; + } + if ( + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /\d/.test(password) + ) { + return password; + } + } +} diff --git a/backend/src/modules/system-mail/system-mail.module.ts b/backend/src/modules/system-mail/system-mail.module.ts new file mode 100644 index 0000000..54729e9 --- /dev/null +++ b/backend/src/modules/system-mail/system-mail.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; + +import { JmapSystemMailbox } from '@/modules/mail/jmap/jmap-system-mailbox'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; +import { SystemMailService } from './system-mail.service'; +import { SYSTEM_MAILBOX } from './system-mailbox.port'; + +/** + * 받은편지함 알림 메일 모듈 (13단계 — 계약 6절) — 메일 모듈(위험 파일 판정)과 운영 도구(비밀번호 재설정 · 2단계 해제)가 쓴다. + * 엔진 연결은 MailEngineModule(JmapClient), 문의 연락처는 SystemSettingsModule. 메일 모듈을 가져오지 않는다(메일 → 알림 방향만). + * 엔진을 바꿀 때는 SYSTEM_MAILBOX 의 구현(useClass)만 바꾼다 + */ +@Module({ + imports: [MailEngineModule, SystemSettingsModule], + providers: [ + { provide: SYSTEM_MAILBOX, useClass: JmapSystemMailbox }, + SystemMailService, + ], + exports: [SystemMailService], +}) +export class SystemMailModule {} diff --git a/backend/src/modules/system-mail/system-mail.service.spec.ts b/backend/src/modules/system-mail/system-mail.service.spec.ts new file mode 100644 index 0000000..1cbba3b --- /dev/null +++ b/backend/src/modules/system-mail/system-mail.service.spec.ts @@ -0,0 +1,109 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { SystemMailService } from './system-mail.service'; +import type { SystemMailMessage } from './system-mailbox.port'; + +const ARA = 'ara.kim@arasystem.test'; + +describe('SystemMailService — 받은편지함 알림 메일 (13단계)', () => { + let mailbox: { + deliver: jest.Mock, [string, SystemMailMessage]>; + remove: jest.Mock; + displayNameOf: jest.Mock; + }; + let settings: { supportContact: jest.Mock }; + let service: SystemMailService; + let warns: string[]; + let infos: string[]; + + beforeEach(() => { + mailbox = { + deliver: jest + .fn, [string, SystemMailMessage]>() + .mockResolvedValue('e-1'), + remove: jest.fn().mockResolvedValue(undefined), + displayNameOf: jest.fn().mockResolvedValue('김아라'), + }; + settings = { + supportContact: jest.fn().mockResolvedValue('IT 담당 홍길동'), + }; + service = new SystemMailService( + mailbox, + settings as unknown as SystemSettingsService, + ); + warns = []; + infos = []; + jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation((m: unknown) => void warns.push(String(m))); + jest + .spyOn(Logger.prototype, 'log') + .mockImplementation((m: unknown) => void infos.push(String(m))); + }); + + afterEach(() => jest.restoreAllMocks()); + + const delivered = (): SystemMailMessage => { + const [, message] = mailbox.deliver.mock.calls[0]; + return message; + }; + + it('비밀번호 재설정 알림을 그 직원의 받은편지함에 넣는다 — 이름은 엔진에서, 문의 연락처는 서버 설정에서', async () => { + await expect(service.notifyPasswordReset(ARA)).resolves.toBe('e-1'); + + expect(mailbox.displayNameOf).toHaveBeenCalledWith(ARA); + expect(mailbox.deliver).toHaveBeenCalledWith(ARA, expect.anything()); + const message = delivered(); + expect(message.kind).toBe('password-reset'); + expect(message.to).toEqual({ email: ARA, name: '김아라' }); + expect(message.subject).toBe('관리자가 비밀번호를 재설정했습니다'); + expect(message.text).toContain('IT 담당 홍길동'); + expect(message.html).toContain('IT 담당 홍길동'); + // 로그에는 가린 주소와 종류만 — 내용은 없다 + expect(infos.join('\n')).toContain('a***@arasystem.test'); + expect(infos.join('\n')).not.toContain('재설정했습니다'); + }); + + it('이름을 알면 엔진에 묻지 않는다 (운영 도구)', async () => { + await service.notifyTwoFactorReset(ARA, { name: '김아라' }); + expect(mailbox.displayNameOf).not.toHaveBeenCalled(); + expect(delivered().kind).toBe('two-factor-reset'); + }); + + it('위험 파일 알림 — 파일 이름 · 크기 · 올린 때가 본문에 든다', async () => { + await service.notifyDangerousFile(ARA, { + name: '견적서.zip', + size: 2048, + uploadedAt: new Date('2026-10-30T06:58:00Z'), + }); + expect(delivered().kind).toBe('dangerous-file'); + expect(delivered().text).toContain('파일: 견적서.zip'); + expect(delivered().text).toContain('크기: 2 KB'); + // 파일 이름은 로그에 남기지 않는다 + expect(infos.join('\n')).not.toContain('견적서'); + }); + + it('넣지 못해도 던지지 않는다 — null 과 warn(가린 주소 · 코드)만', async () => { + mailbox.deliver.mockRejectedValueOnce( + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ); + await expect(service.notifyPasswordReset(ARA)).resolves.toBeNull(); + expect(warns.join('\n')).toContain('[MAIL_001]'); + expect(warns.join('\n')).toContain('MAIL_001/timeout'); + expect(warns.join('\n')).toContain('a***@arasystem.test'); + }); + + it('문의 연락처가 없으면 그 줄 없이 넣는다', async () => { + settings.supportContact.mockResolvedValueOnce(null); + await service.notifyPasswordReset(ARA, { name: '김아라' }); + expect(delivered().html).not.toContain(' { + await expect(service.remove(ARA, 'e-1')).resolves.toBe(true); + mailbox.remove.mockRejectedValueOnce(new BusinessException('MAIL_001')); + await expect(service.remove(ARA, 'e-1')).resolves.toBe(false); + }); +}); diff --git a/backend/src/modules/system-mail/system-mail.service.ts b/backend/src/modules/system-mail/system-mail.service.ts new file mode 100644 index 0000000..b88ea96 --- /dev/null +++ b/backend/src/modules/system-mail/system-mail.service.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html'; +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { maskEmail } from '@/shared/utils/mask'; +import { SYSTEM_MAILBOX, type SystemMailbox } from './system-mailbox.port'; +import { + dangerousFileNotice, + passwordResetNotice, + twoFactorResetNotice, + type DangerousFileInfo, + type NoticeRecipient, + type SystemNotice, +} from './system-notices'; + +function describe(error: unknown): string { + if (error instanceof BusinessException) { + const reason: unknown = error.context?.reason; + return `${error.code}${typeof reason === 'string' ? `/${reason}` : ''}`; + } + return error instanceof Error ? error.message : String(error); +} + +/** + * 받은편지함 알림 메일 (13단계 — 계약 6절 "받은편지함 알림 메일") — 서버가 그 직원의 받은편지함에 넣는다. + * - 보내는 때: 운영 도구의 비밀번호 재설정 · 2단계 해제, 위험 파일 판정(파일마다 처음 기록할 때 한 번 — 메일 모듈이 부른다) + * - **넣지 못해도 원래 일은 성공이다** — 던지지 않고 warn 로그(가린 주소 · 코드)만 남기고 null 을 돌려준다 + * - 본문은 보내는 메일 정화(허용 목록)를 거친다 — 글이 허용 목록 안이라 지워지는 것은 없다(system-notices.spec.ts 가 지킨다) + * - 문의 줄의 연락처는 서버 설정에서 읽는다(읽지 못하면 그 줄 없이) + * 메일 내용 · 파일 이름은 로그에 남기지 않는다 + */ +@Injectable() +export class SystemMailService { + private readonly logger = new Logger(SystemMailService.name); + + constructor( + @Inject(SYSTEM_MAILBOX) private readonly mailbox: SystemMailbox, + private readonly settings: SystemSettingsService, + ) {} + + /** ① 비밀번호 재설정 → 넣은 메일 id (넣지 못하면 null). `name` 을 모르면 엔진에서 읽는다 */ + async notifyPasswordReset( + email: string, + options: { readonly name?: string | null; readonly at?: Date } = {}, + ): Promise { + return this.deliver(email, options.name, (recipient) => + passwordResetNotice(recipient, options.at ?? new Date()), + ); + } + + /** ② 2단계 해제 → 넣은 메일 id (넣지 못하면 null) */ + async notifyTwoFactorReset( + email: string, + options: { readonly name?: string | null; readonly at?: Date } = {}, + ): Promise { + return this.deliver(email, options.name, (recipient) => + twoFactorResetNotice(recipient, options.at ?? new Date()), + ); + } + + /** ③ 위험 파일 판정 → 넣은 메일 id (넣지 못하면 null) */ + async notifyDangerousFile( + email: string, + file: DangerousFileInfo, + ): Promise { + return this.deliver(email, null, (recipient) => + dangerousFileNotice(recipient, file), + ); + } + + /** 넣은 알림을 지운다 (운영 도구 — 엔진 바꾸기가 실패했을 때) → 지웠으면 true. 실패해도 던지지 않는다(warn) */ + async remove(email: string, emailId: string): Promise { + try { + await this.mailbox.remove(email, emailId); + return true; + } catch (error) { + this.logger.warn( + `[MAIL_001] 넣은 알림 메일을 지우지 못했습니다: user=${maskEmail(email)}, reason=${describe(error)}`, + ); + return false; + } + } + + private async deliver( + email: string, + name: string | null | undefined, + build: (recipient: NoticeRecipient) => SystemNotice, + ): Promise { + try { + const recipient: NoticeRecipient = { + name: name?.trim() || (await this.mailbox.displayNameOf(email)), + supportContact: await this.settings.supportContact(), + }; + const notice = build(recipient); + const id = await this.mailbox.deliver(email, { + kind: notice.kind, + to: { email, name: recipient.name }, + subject: notice.subject, + html: sanitizeOutgoingHtml(notice.html), + text: notice.text, + }); + this.logger.log( + `받은편지함 알림 메일을 넣었습니다: user=${maskEmail(email)}, 종류=${notice.kind}`, + ); + return id; + } catch (error) { + this.logger.warn( + `[MAIL_001] 받은편지함 알림 메일을 넣지 못했습니다 — 원래 일은 그대로 끝냅니다: user=${maskEmail(email)}, reason=${describe(error)}`, + ); + return null; + } + } +} diff --git a/backend/src/modules/system-mail/system-mailbox.port.ts b/backend/src/modules/system-mail/system-mailbox.port.ts new file mode 100644 index 0000000..95aaa0f --- /dev/null +++ b/backend/src/modules/system-mail/system-mailbox.port.ts @@ -0,0 +1,30 @@ +import type { SystemNoticeKind } from './system-notices'; + +/** + * 받은편지함 알림 메일을 넣는 엔진 포트 (13단계 — 계약 6절 "받은편지함 알림 메일") — 서비스는 이 인터페이스만 안다. + * 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-system-mailbox.ts`(게이트웨이 대리 접근으로 원문을 올리고 Email/import). + * 엔진을 바꿀 때는 system-mail.module.ts 의 useClass 만 바꾼다. 실패는 BusinessException(MAIL_001 등)으로 던진다 + */ +export const SYSTEM_MAILBOX = Symbol('SYSTEM_MAILBOX'); + +/** 넣을 알림 한 통 — 보낸 사람은 어댑터가 정한다(ARA SYSTEM ) */ +export interface SystemMailMessage { + readonly kind: SystemNoticeKind; + /** 받는 직원 — 머리글 To 에 `이름 <주소>` 로 */ + readonly to: { readonly email: string; readonly name: string }; + readonly subject: string; + readonly html: string; + readonly text: string; +} + +export interface SystemMailbox { + /** + * 그 직원의 받은편지함에 넣는다 → 엔진의 메일 id. 안 읽음 · 받은 시각은 지금이다. + * 필터 · 스팸 판정을 거치지 않고 실시간 알림이 간다(엔진 — 2026-10-31 확인) + */ + deliver(user: string, message: SystemMailMessage): Promise; + /** 방금 넣은 알림을 지운다 (운영 도구 — 넣은 뒤 엔진 바꾸기가 실패했을 때) */ + remove(user: string, emailId: string): Promise; + /** 그 직원의 표시 이름 — 엔진에 없거나 읽지 못하면 주소의 @ 앞 */ + displayNameOf(user: string): Promise; +} diff --git a/backend/src/modules/system-mail/system-notices.spec.ts b/backend/src/modules/system-mail/system-notices.spec.ts new file mode 100644 index 0000000..6b068fa --- /dev/null +++ b/backend/src/modules/system-mail/system-notices.spec.ts @@ -0,0 +1,182 @@ +import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html'; +import { + NOTICE_FOOTER, + dangerousFileNotice, + passwordResetNotice, + twoFactorResetNotice, + type SystemNotice, +} from './system-notices'; + +const AT = new Date('2026-10-30T06:20:00Z'); // 2026년 10월 30일 (금) 오후 3:20 KST +const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'; +const ARA = { name: '김아라', supportContact: CONTACT }; + +/** style 선언을 견줄 모양으로 — 공백을 빼고 `속성:값` 들의 목록 */ +function styleDeclarations(html: string): string[] { + const declarations: string[] = []; + for (const match of html.matchAll(/style="([^"]*)"/g)) { + for (const part of match[1].split(';')) { + const [property, ...rest] = part.split(':'); + if (!property?.trim()) continue; + declarations.push( + `${property.trim().toLowerCase()}:${rest.join(':').trim().replace(/\s+/g, ' ')}`, + ); + } + } + return declarations.sort(); +} + +/** 태그를 뺀 글 (엔티티는 그대로 — 견주기만 한다) */ +function visibleText(html: string): string { + return html.replace(/<[^>]+>/g, '|'); +} + +const ALL: readonly [string, SystemNotice][] = [ + ['비밀번호 재설정', passwordResetNotice(ARA, AT)], + ['2단계 해제', twoFactorResetNotice(ARA, AT)], + [ + '위험 파일', + dangerousFileNotice(ARA, { + name: '견적_요청서.zip', + size: 50_538_291, + uploadedAt: new Date('2026-10-30T06:58:00Z'), + }), + ], +]; + +describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13단계)', () => { + it('① 비밀번호 재설정 — 제목 · 인사 · 표 · 할 일(메뉴 경로) · 확인 문장', () => { + const notice = passwordResetNotice(ARA, AT); + expect(notice.kind).toBe('password-reset'); + expect(notice.subject).toBe('관리자가 비밀번호를 재설정했습니다'); + expect(notice.text).toBe( + [ + '김아라 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.', + '', + '시각: 2026년 10월 30일 (금) 오후 3:20', + '바뀐 것: 비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다', + '로그아웃: 모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다', + '앱 비밀번호: 모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다', + '', + '할 일', + '아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.', + '', + '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.', + CONTACT, + '', + NOTICE_FOOTER, + '', + ].join('\n'), + ); + expect(notice.html).toContain( + '웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.', + ); + }); + + it('② 2단계 해제 — 그대로인 것을 함께 적는다', () => { + const notice = twoFactorResetNotice(ARA, AT); + expect(notice.subject).toBe('관리자가 2단계 인증을 껐습니다'); + expect(notice.text).toContain( + '김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.', + ); + expect(notice.text).toContain( + '그대로인 것: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저', + ); + expect(notice.html).toContain( + '웹메일 › 설정 › 계정 › 2단계 인증에서 다시 켜 주세요.', + ); + }); + + it('③ 위험 파일 — 파일 이름 그대로 · 11단계 크기 표기 · 올린 때(KST)', () => { + const [, notice] = ALL[2]; + expect(notice.subject).toBe( + '올린 대용량 파일에서 위험한 내용이 발견되었습니다', + ); + expect(notice.text).toContain('파일: 견적_요청서.zip'); + expect(notice.text).toContain('크기: 48 MB'); + expect(notice.text).toContain('올린 때: 2026년 10월 30일 (금) 오후 3:58'); + expect(notice.text).toContain( + '판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.', + ); + expect(notice.html).toContain( + '웹메일 › 설정 › 메일 › 대용량 첨부에서 볼 수 있습니다.', + ); + }); + + it.each(ALL)( + '%s — 링크가 없다 (회사 보안 알림에는 링크가 없다)', + (_label, notice) => { + expect(notice.html).not.toMatch(/]/i); + expect(notice.html).not.toMatch(/https?:|mailto:|www\./i); + expect(notice.text).not.toMatch(/https?:|www\./i); + expect(notice.text).toContain(NOTICE_FOOTER); + expect(notice.html).toContain(NOTICE_FOOTER); + }, + ); + + it.each(ALL)( + '%s — 보내는 메일 정화(허용 목록)를 거쳐도 지워지는 태그 · 스타일 · 글이 없다', + (_label, notice) => { + const sanitized = sanitizeOutgoingHtml(notice.html); + expect(styleDeclarations(sanitized)).toEqual( + styleDeclarations(notice.html), + ); + expect(visibleText(sanitized)).toEqual(visibleText(notice.html)); + for (const tag of [ + 'div', + 'p', + 'table', + 'tbody', + 'tr', + 'th', + 'td', + 'strong', + 'br', + ]) { + expect( + (sanitized.match(new RegExp(`<${tag}[\\s>]`, 'g')) ?? []).length, + ).toBe( + (notice.html.match(new RegExp(`<${tag}[\\s>]`, 'g')) ?? []).length, + ); + } + }, + ); + + it('사람이 넣은 글(이름 · 파일 이름 · 연락처)은 HTML 에서 이스케이프한다 — 텍스트는 그대로', () => { + const notice = dangerousFileNotice( + { name: '김&아라', supportContact: '"IT" ' }, + { + name: '.zip', + size: 1024, + uploadedAt: AT, + }, + ); + expect(notice.html).not.toContain(''); + expect(notice.html).not.toContain('김&아라 님,'); + expect(notice.text).toContain('파일: .zip'); + }); + + it('연락처를 정하지 않았으면 그 줄(HTML 은 앞의
와 함께)을 뺀다', () => { + const notice = passwordResetNotice( + { name: '김아라', supportContact: null }, + AT, + ); + expect(notice.html).not.toContain('
'); + expect(notice.html).toContain( + '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.

', + ); + expect(notice.text).toContain( + `직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.\n\n${NOTICE_FOOTER}`, + ); + + const blank = passwordResetNotice( + { name: '김아라', supportContact: ' ' }, + AT, + ); + expect(blank.html).not.toContain('
'); + }); +}); diff --git a/backend/src/modules/system-mail/system-notices.ts b/backend/src/modules/system-mail/system-notices.ts new file mode 100644 index 0000000..9037ca0 --- /dev/null +++ b/backend/src/modules/system-mail/system-notices.ts @@ -0,0 +1,238 @@ +import { formatFileSize } from '@/shared/utils/format'; +import { formatKstDateTime } from '@/shared/utils/kst-date'; + +/** + * 받은편지함 알림 메일의 제목 · 본문 (순수 함수) — 13단계 (계약 6절 "받은편지함 알림 메일"). + * 글은 디자인 README 13단계 "받은편지함 알림 메일"이 원본이다 — 고칠 때는 README 와 함께 고친다. + * + * - 차례: 인사 한 줄 → 표 → 할 일(메뉴 경로 글) → 확인 문장 + 문의 연락처 → 맨 아래 한 줄 + * - **링크를 넣지 않는다** — 할 일은 메뉴 경로를 굵은 글로 적는다 (회사 보안 알림에는 링크가 없다는 습관) + * - HTML 은 보내는 메일 정화(sanitize-outgoing-html.ts)의 허용 목록 안의 태그 · 스타일만 쓴다 — 서비스가 그 정화를 한 번 더 거친다 + * - 사람이 넣은 글(이름 · 파일 이름 · 연락처)은 HTML 에 넣을 때 이스케이프한다. 연락처가 없으면 그 줄(과 앞의
)을 뺀다 + */ + +/** 알림의 종류 — 메일 머리글 X-Ara-Notice 의 값이기도 하다 */ +export type SystemNoticeKind = + | 'password-reset' + | 'two-factor-reset' + | 'dangerous-file'; + +export interface SystemNotice { + readonly kind: SystemNoticeKind; + readonly subject: string; + /** 서식 본문 (정화 허용 목록 안) */ + readonly html: string; + /** 같은 글의 텍스트 본문 */ + readonly text: string; +} + +/** 세 알림이 함께 받는 것 */ +export interface NoticeRecipient { + /** 직원의 표시 이름 — 없으면 주소의 @ 앞을 넣어 부른다 */ + readonly name: string; + /** 문의 연락처 설정값 — 없으면 null (그 줄을 뺀다) */ + readonly supportContact: string | null; +} + +/** 위험 판정을 받은 파일 */ +export interface DangerousFileInfo { + /** 올린 이름 그대로 */ + readonly name: string; + readonly size: number; + /** 올린 때 */ + readonly uploadedAt: Date; +} + +/** 할 일 글의 조각 — 메뉴 경로만 굵게 */ +type TodoPart = { readonly text: string } | { readonly strong: string }; + +interface NoticeBody { + readonly kind: SystemNoticeKind; + readonly subject: string; + readonly greeting: string; + readonly rows: readonly (readonly [label: string, value: string])[]; + readonly todo: readonly TodoPart[]; + readonly confirm: string; +} + +/** 맨 아래 한 줄 (세 통 같음) */ +export const NOTICE_FOOTER = + '이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.'; + +/** 확인 문장 — 비밀번호 재설정 · 2단계 해제 */ +const CONFIRM_SELF = '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.'; + +// ── HTML 꼴 (디자인 README 13단계 "HTML 꼴" 그대로) ───────────────────────────── +const STYLE_WRAPPER = + "font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733"; +const STYLE_GREETING = 'margin: 0 0 14px'; +const STYLE_TABLE = + 'width: 100%; border-collapse: collapse; margin: 0 0 18px; font-size: 13.5px; line-height: 1.6'; +const STYLE_TH = + 'width: 104px; padding: 8px 12px; border: 1px solid #E6E8EC; vertical-align: top; background-color: #F8F9FB; color: #4A5363; font-weight: 600; text-align: left'; +const STYLE_TD = + 'padding: 8px 12px; border: 1px solid #E6E8EC; vertical-align: top'; +const STYLE_TODO_TITLE = 'margin: 0 0 2px; font-weight: 700'; +const STYLE_TODO = 'margin: 0 0 20px'; +const STYLE_CONFIRM = + 'margin: 0; padding-top: 14px; border-top: 1px solid #EEF0F3; font-size: 13px; color: #4A5363'; +const STYLE_FOOTER = 'margin: 6px 0 0; font-size: 12px; color: #667085'; + +const TODO_TITLE = '할 일'; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function todoHtml(parts: readonly TodoPart[]): string { + return parts + .map((part) => + 'strong' in part + ? `${escapeHtml(part.strong)}` + : escapeHtml(part.text), + ) + .join(''); +} + +function todoText(parts: readonly TodoPart[]): string { + return parts + .map((part) => ('strong' in part ? part.strong : part.text)) + .join(''); +} + +/** 본문을 HTML · 텍스트 두 꼴로 */ +function render(body: NoticeBody, recipient: NoticeRecipient): SystemNotice { + const contact = recipient.supportContact?.trim() || null; + const rowsHtml = body.rows + .map( + ([label, value]) => + `${escapeHtml(label)}${escapeHtml(value)}`, + ) + .join(''); + const html = + `
` + + `

${escapeHtml(body.greeting)}

` + + `${rowsHtml}
` + + `

${TODO_TITLE}

` + + `

${todoHtml(body.todo)}

` + + `

${escapeHtml(body.confirm)}${contact ? `
${escapeHtml(contact)}` : ''}

` + + `

${escapeHtml(NOTICE_FOOTER)}

` + + `
`; + + const text = [ + body.greeting, + '', + ...body.rows.map(([label, value]) => `${label}: ${value}`), + '', + TODO_TITLE, + todoText(body.todo), + '', + body.confirm, + ...(contact ? [contact] : []), + '', + NOTICE_FOOTER, + '', + ].join('\n'); + + return { kind: body.kind, subject: body.subject, html, text }; +} + +/** ① 관리자가 비밀번호를 재설정했습니다 — 운영 도구의 「비밀번호 재설정」 뒤 */ +export function passwordResetNotice( + recipient: NoticeRecipient, + at: Date, +): SystemNotice { + return render( + { + kind: 'password-reset', + subject: '관리자가 비밀번호를 재설정했습니다', + greeting: `${recipient.name} 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.`, + rows: [ + ['시각', formatKstDateTime(at)], + [ + '바뀐 것', + '비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다', + ], + ['로그아웃', '모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다'], + [ + '앱 비밀번호', + '모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다', + ], + ], + todo: [ + { + text: '아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. ', + }, + { strong: '웹메일 › 설정 › 계정 › 앱 비밀번호' }, + { text: '에서 새로 만들어 주세요.' }, + ], + confirm: CONFIRM_SELF, + }, + recipient, + ); +} + +/** ② 관리자가 2단계 인증을 껐습니다 — 운영 도구의 「2단계 해제」(휴대폰 분실) 뒤 */ +export function twoFactorResetNotice( + recipient: NoticeRecipient, + at: Date, +): SystemNotice { + return render( + { + kind: 'two-factor-reset', + subject: '관리자가 2단계 인증을 껐습니다', + greeting: `${recipient.name} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.`, + rows: [ + ['시각', formatKstDateTime(at)], + ['바뀐 것', '2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다'], + ['그대로인 것', '비밀번호 · 앱 비밀번호 · 로그인된 브라우저'], + ], + todo: [ + { text: '새 휴대폰에 인증 앱을 설치한 뒤 ' }, + { strong: '웹메일 › 설정 › 계정 › 2단계 인증' }, + { + text: '에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다.', + }, + ], + confirm: CONFIRM_SELF, + }, + recipient, + ); +} + +/** ③ 올린 대용량 파일에서 위험한 내용이 발견되었습니다 — 악성 검사가 위험으로 판정했을 때(파일마다 한 통) */ +export function dangerousFileNotice( + recipient: NoticeRecipient, + file: DangerousFileInfo, +): SystemNotice { + return render( + { + kind: 'dangerous-file', + subject: '올린 대용량 파일에서 위험한 내용이 발견되었습니다', + greeting: `${recipient.name} 님, 올린 대용량 파일을 보안 검사했더니 위험한 내용이 발견되어 보내거나 내려받을 수 없게 막았습니다.`, + rows: [ + ['파일', file.name], + ['크기', formatFileSize(file.size)], + ['올린 때', formatKstDateTime(file.uploadedAt)], + [ + '조치', + '이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다', + ], + ], + todo: [ + { + text: '직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 ', + }, + { strong: '웹메일 › 설정 › 메일 › 대용량 첨부' }, + { text: '에서 볼 수 있습니다.' }, + ], + confirm: '판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.', + }, + recipient, + ); +} diff --git a/backend/src/modules/system-settings/dto/support-contact.dto.ts b/backend/src/modules/system-settings/dto/support-contact.dto.ts new file mode 100644 index 0000000..4f0265e --- /dev/null +++ b/backend/src/modules/system-settings/dto/support-contact.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { SUPPORT_CONTACT_MAX_LENGTH } from '../system-settings.entity'; + +/** GET /api/support-contact (계약 6절 "문의 연락처") — 로그인 없이 받는다 */ +export class SupportContactDto { + @ApiProperty({ + description: `문의 연락처 — ${SUPPORT_CONTACT_MAX_LENGTH}자 이하 · 한 줄, 글자로만 보인다(링크 · HTML 없음). 운영 도구로 정한다. 정하지 않았으면 null`, + type: String, + nullable: true, + maxLength: SUPPORT_CONTACT_MAX_LENGTH, + example: 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it', + }) + text!: string | null; +} diff --git a/backend/src/modules/system-settings/support-contact.controller.ts b/backend/src/modules/system-settings/support-contact.controller.ts new file mode 100644 index 0000000..adb4cbe --- /dev/null +++ b/backend/src/modules/system-settings/support-contact.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ApiStandardResponse } from '@/common/decorators/api-standard-response.decorator'; +import { SupportContactDto } from './dto/support-contact.dto'; +import { SystemSettingsService } from './system-settings.service'; + +/** + * 문의 연락처 (계약 6절 "문의 연락처" — 13단계) — 로그인 없이 받는다(로그인 화면 · 코드 단계 · 2단계 켜기가 보인다). + * 가드가 없어 제한된 세션에서도 그대로 부른다. 요청 수는 전역 기본 한도 — 서버는 설정을 10초 기억해 DB 를 매번 묻지 않는다. + * 읽지 못하면 null 로 답한다(화면은 지금 글 그대로) + */ +@ApiTags('Support') +@Controller('support-contact') +export class SupportContactController { + constructor(private readonly settings: SystemSettingsService) {} + + @Get() + @ApiOperation({ + summary: '문의 연락처 (로그인 없음)', + description: + '운영 도구로 정한 문의 연락처 — 200자 이하 · 한 줄. 정하지 않았거나 읽지 못하면 text 가 null 이다(화면은 지금 글 그대로).', + }) + @ApiStandardResponse(SupportContactDto, { description: '문의 연락처' }) + async get(): Promise { + return { text: await this.settings.supportContact() }; + } +} diff --git a/backend/src/modules/system-settings/system-settings.entity.ts b/backend/src/modules/system-settings/system-settings.entity.ts new file mode 100644 index 0000000..6e5d036 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.entity.ts @@ -0,0 +1,41 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +/** 문의 연락처 길이 (계약 6절 "문의 연락처" — 200자 이하 · 한 줄). PostgreSQL varchar 는 코드 포인트로 센다 — 넣기 전에 서비스가 JS 길이로 본다 */ +export const SUPPORT_CONTACT_MAX_LENGTH = 200; + +/** 서버 설정 줄의 id — 표에는 이 한 줄만 있다 (마이그레이션 AddCutoverPrep 이 넣는다) */ +export const SYSTEM_SETTINGS_ROW_ID = 1; + +/** + * 서버 설정 (13단계 — 계약 6절) — 표에 한 줄(id 1)만 둔다. 운영 도구(dist/cli/ops.js)가 바꾸고 서버가 읽는다. + * - 2단계 인증 필수 정책: 켜짐 · 시작일(한국 날짜 — 그날 0시 KST 부터) · 예외 주소(소문자) + * - 문의 연락처: 로그인 화면 · 코드 단계 · 받은편지함 알림 메일의 마지막 줄 (null — 정하지 않음) + * 처음 값은 꺼짐 · 시작일 없음 · 예외 없음 · 연락처 없음이다 + */ +@Entity({ name: 'system_settings' }) +export class SystemSettingsEntity { + @PrimaryColumn({ type: 'smallint' }) + id!: number; + + @Column({ name: 'two_factor_required', type: 'boolean' }) + twoFactorRequired!: boolean; + + /** 'YYYY-MM-DD' (한국 날짜) — 켜짐인데 null 이면 바로 시작한 것으로 본다 */ + @Column({ name: 'two_factor_start_date', type: 'date', nullable: true }) + twoFactorStartDate!: string | null; + + /** 정책에서 빼는 직원 주소 (소문자) */ + @Column({ name: 'two_factor_exempt', type: 'jsonb' }) + twoFactorExempt!: string[]; + + @Column({ + name: 'support_contact', + type: 'varchar', + length: SUPPORT_CONTACT_MAX_LENGTH, + nullable: true, + }) + supportContact!: string | null; + + @Column({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/system-settings/system-settings.module.ts b/backend/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000..eb63a87 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { SupportContactController } from './support-contact.controller'; +import { SystemSettingsEntity } from './system-settings.entity'; +import { SystemSettingsService } from './system-settings.service'; + +/** + * 서버 설정 모듈 (13단계 — 계약 6절) — 2단계 인증 필수 정책 · 문의 연락처(표 system_settings 한 줄). + * 계정 보안 모듈(정책 판정) · 받은편지함 알림 메일(문의 줄) · 운영 도구(바꾸기)가 서비스를 쓴다. 이 모듈은 다른 기능 모듈을 가져오지 않는다 + */ +@Module({ + imports: [TypeOrmModule.forFeature([SystemSettingsEntity])], + controllers: [SupportContactController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/backend/src/modules/system-settings/system-settings.service.ts b/backend/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000..c15e21c --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,133 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { + SYSTEM_SETTINGS_ROW_ID, + SystemSettingsEntity, +} from './system-settings.entity'; +import { + DEFAULT_SYSTEM_SETTINGS, + type SystemSettings, + type TwoFactorPolicy, +} from './system-settings.types'; + +/** + * 설정을 기억하는 시간 — 로그인 · 갱신 · me · 문의 연락처마다 DB 를 묻지 않게. + * 운영 도구는 다른 프로세스라 이 서버의 기억을 지우지 못한다 — 바꾼 값은 이 시간 안에 보인다 + */ +export const SYSTEM_SETTINGS_CACHE_TTL_MS = 10_000; + +/** 바꿀 칸 — 보낸 칸만 바꾼다 (운영 도구) */ +export interface SystemSettingsPatch { + readonly twoFactor?: Partial; + readonly supportContact?: string | null; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 표의 줄 → 설정 — 손으로 고친 이상한 값은 그 칸만 처음 값으로 읽는다 */ +function toSettings(row: SystemSettingsEntity | null): SystemSettings { + if (row === null) return DEFAULT_SYSTEM_SETTINGS; + const exempt = Array.isArray(row.twoFactorExempt) + ? row.twoFactorExempt + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim().toLowerCase()) + : []; + return { + twoFactor: { + required: row.twoFactorRequired === true, + startDate: + typeof row.twoFactorStartDate === 'string' + ? row.twoFactorStartDate + : null, + exempt, + }, + supportContact: + typeof row.supportContact === 'string' && row.supportContact !== '' + ? row.supportContact + : null, + }; +} + +/** + * 서버 설정 (13단계 — 계약 6절) — 2단계 인증 필수 정책과 문의 연락처. 표(system_settings)에 한 줄이다. + * - 읽기는 SYSTEM_SETTINGS_CACHE_TTL_MS 동안 기억한다. 읽지 못하면 그 실패를 던진다 — 다만 기억한 값이 있으면(지났어도) 그 값을 준다 + * - 바꾸기는 운영 도구만 한다(API 없음) — 보낸 칸만, 이 프로세스의 기억은 바로 맞춘다 + * 줄이 없으면(마이그레이션 전 · 손으로 지움) 처음 값으로 읽고, 바꿀 때 만든다 + */ +@Injectable() +export class SystemSettingsService { + private readonly logger = new Logger(SystemSettingsService.name); + private cached: { value: SystemSettings; expiresAt: number } | null = null; + + constructor( + @InjectRepository(SystemSettingsEntity) + private readonly rows: Repository, + ) {} + + async get(): Promise { + const now = Date.now(); + if (this.cached && this.cached.expiresAt > now) return this.cached.value; + try { + const value = toSettings( + await this.rows.findOneBy({ id: SYSTEM_SETTINGS_ROW_ID }), + ); + this.cached = { value, expiresAt: now + SYSTEM_SETTINGS_CACHE_TTL_MS }; + return value; + } catch (error) { + if (this.cached) { + this.logger.warn( + `[SYS_001] 서버 설정을 읽지 못해 기억한 값을 씁니다: ${errorMessage(error)}`, + ); + return this.cached.value; + } + throw error; + } + } + + /** 문의 연락처 (계약 6절 — 로그인 없이 받는다). 읽지 못하면 null(화면은 지금 글 그대로) — error 로그 */ + async supportContact(): Promise { + try { + return (await this.get()).supportContact; + } catch (error) { + this.logger.error( + `[SYS_001] 문의 연락처를 읽지 못해 없는 것으로 답합니다: ${errorMessage(error)}`, + ); + return null; + } + } + + /** 기억을 건너뛰고 다시 읽는다 (운영 도구가 바꾸기 전 · 뒤에 보인다) */ + async reload(): Promise { + this.cached = null; + return this.get(); + } + + /** 보낸 칸만 바꾸고 바꾼 값을 돌려준다 (운영 도구) — 값의 검사는 부르는 쪽이 한다 */ + async update(patch: SystemSettingsPatch, at: Date): Promise { + const current = await this.reload(); + const twoFactor: TwoFactorPolicy = { + ...current.twoFactor, + ...patch.twoFactor, + }; + const supportContact = + patch.supportContact === undefined + ? current.supportContact + : patch.supportContact; + await this.rows.upsert( + { + id: SYSTEM_SETTINGS_ROW_ID, + twoFactorRequired: twoFactor.required, + twoFactorStartDate: twoFactor.startDate, + twoFactorExempt: [...new Set(twoFactor.exempt)], + supportContact, + updatedAt: at, + }, + ['id'], + ); + return this.reload(); + } +} diff --git a/backend/src/modules/system-settings/system-settings.spec.ts b/backend/src/modules/system-settings/system-settings.spec.ts new file mode 100644 index 0000000..1fb0875 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.spec.ts @@ -0,0 +1,234 @@ +import { Logger } from '@nestjs/common'; +import type { Repository } from 'typeorm'; + +import { SupportContactController } from './support-contact.controller'; +import type { SystemSettingsEntity } from './system-settings.entity'; +import { + SYSTEM_SETTINGS_CACHE_TTL_MS, + SystemSettingsService, +} from './system-settings.service'; +import { + checkSupportContact, + sessionTwoFactorState, + twoFactorRequirementFor, + type TwoFactorPolicy, +} from './system-settings.types'; + +const ARA = 'ara.kim@arasystem.test'; +const START = new Date('2026-11-30T15:00:00.000Z'); // 2026-12-01 0시 KST + +const policy = (overrides: Partial = {}): TwoFactorPolicy => ({ + required: true, + startDate: '2026-12-01', + exempt: [], + ...overrides, +}); + +describe('2단계 인증 필수 정책 판정 (13단계 — 계약 6절)', () => { + it('꺼져 있으면 아무에게도 걸리지 않는다', () => { + const requirement = twoFactorRequirementFor( + policy({ required: false }), + ARA, + new Date(), + ); + expect(requirement).toEqual({ + isRequired: false, + startsAt: START, + isStarted: false, + }); + expect(sessionTwoFactorState(requirement, false)).toEqual({ + restriction: null, + deadline: null, + }); + }); + + it('시작일 전(유예) — 꺼진 직원은 시작 시각(twoFactorDeadline), 제한 없음', () => { + const requirement = twoFactorRequirementFor( + policy(), + ARA, + new Date(START.getTime() - 1), + ); + expect(requirement).toEqual({ + isRequired: true, + startsAt: START, + isStarted: false, + }); + expect(sessionTwoFactorState(requirement, false)).toEqual({ + restriction: null, + deadline: START, + }); + // 이미 켠 직원은 안내도 없다 + expect(sessionTwoFactorState(requirement, true)).toEqual({ + restriction: null, + deadline: null, + }); + }); + + it('시작일 0시(KST)부터 — 꺼진 직원은 twoFactorSetup 제한, 켠 직원은 그대로', () => { + const requirement = twoFactorRequirementFor(policy(), ARA, START); + expect(requirement.isStarted).toBe(true); + expect(sessionTwoFactorState(requirement, false)).toEqual({ + restriction: 'twoFactorSetup', + deadline: null, + }); + expect(sessionTwoFactorState(requirement, true)).toEqual({ + restriction: null, + deadline: null, + }); + }); + + it('예외 주소는 걸리지 않는다 — 대소문자 · 앞뒤 공백을 무시한다', () => { + const requirement = twoFactorRequirementFor( + policy({ exempt: [ARA] }), + ' Ara.Kim@AraSystem.test ', + START, + ); + expect(requirement.isRequired).toBe(false); + expect(requirement.isStarted).toBe(false); + }); + + it('시작일이 없으면(또는 달력에 없는 값이면) 켠 때부터다', () => { + for (const startDate of [null, '2026-02-30']) { + const requirement = twoFactorRequirementFor( + policy({ startDate }), + ARA, + new Date('2020-01-01T00:00:00Z'), + ); + expect(requirement).toEqual({ + isRequired: true, + startsAt: null, + isStarted: true, + }); + } + }); +}); + +describe('문의 연락처 검사 — 200자 · 한 줄', () => { + it('앞뒤 공백을 지우고, 빈 글은 null(정하지 않음)', () => { + expect(checkSupportContact(' IT 담당 홍길동 ')).toEqual({ + ok: true, + value: 'IT 담당 홍길동', + }); + expect(checkSupportContact(' ')).toEqual({ ok: true, value: null }); + }); + + it('줄바꿈 · 탭 · 제어 문자는 받지 않는다', () => { + for (const text of ['a\nb', 'a\rb', 'a\tb', 'a\u0000b', 'a\u2028b']) { + expect(checkSupportContact(text)).toEqual({ + ok: false, + problem: 'not-single-line', + }); + } + }); + + it('200자(JS 문자열 길이)까지', () => { + expect(checkSupportContact('가'.repeat(200)).ok).toBe(true); + expect(checkSupportContact('가'.repeat(201))).toEqual({ + ok: false, + problem: 'too-long', + }); + }); +}); + +describe('SystemSettingsService — 서버 설정 한 줄', () => { + const NOW = Date.parse('2026-11-02T03:00:00Z'); + let row: Partial | null; + let rows: { findOneBy: jest.Mock; upsert: jest.Mock }; + let service: SystemSettingsService; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + row = { + id: 1, + twoFactorRequired: true, + twoFactorStartDate: '2026-12-01', + twoFactorExempt: ['Boss@AraSystem.test'], + supportContact: 'IT 담당', + updatedAt: new Date(NOW), + }; + rows = { + findOneBy: jest.fn(() => Promise.resolve(row)), + upsert: jest.fn((next: Partial) => { + row = { ...row, ...next }; + return Promise.resolve(); + }), + }; + service = new SystemSettingsService( + rows as unknown as Repository, + ); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('표의 줄을 읽어 기억한다 — 예외 주소는 소문자로, 10초 안에는 다시 묻지 않는다', async () => { + await expect(service.get()).resolves.toEqual({ + twoFactor: { + required: true, + startDate: '2026-12-01', + exempt: ['boss@arasystem.test'], + }, + supportContact: 'IT 담당', + }); + await service.get(); + expect(rows.findOneBy).toHaveBeenCalledTimes(1); + + jest + .spyOn(Date, 'now') + .mockReturnValue(NOW + SYSTEM_SETTINGS_CACHE_TTL_MS + 1); + await service.get(); + expect(rows.findOneBy).toHaveBeenCalledTimes(2); + }); + + it('줄이 없으면 처음 값 — 꺼짐 · 연락처 없음', async () => { + row = null; + await expect(service.get()).resolves.toEqual({ + twoFactor: { required: false, startDate: null, exempt: [] }, + supportContact: null, + }); + }); + + it('읽지 못하면 기억한 값(지났어도)을 쓰고, 기억이 없으면 던진다 — 문의 연락처는 null 로 답한다', async () => { + rows.findOneBy.mockRejectedValueOnce(new Error('db down')); + await expect(service.get()).rejects.toThrow('db down'); + rows.findOneBy.mockRejectedValueOnce(new Error('db down')); + await expect(service.supportContact()).resolves.toBeNull(); + + await service.get(); + jest + .spyOn(Date, 'now') + .mockReturnValue(NOW + SYSTEM_SETTINGS_CACHE_TTL_MS + 1); + rows.findOneBy.mockRejectedValueOnce(new Error('db down')); + await expect(service.supportContact()).resolves.toBe('IT 담당'); + }); + + it('바꾸기는 보낸 칸만 — 다른 칸은 지금 값 그대로, 예외 주소는 겹치지 않게', async () => { + const next = await service.update( + { + twoFactor: { exempt: ['a@arasystem.test', 'a@arasystem.test'] }, + }, + new Date(NOW), + ); + expect(rows.upsert).toHaveBeenCalledWith( + { + id: 1, + twoFactorRequired: true, + twoFactorStartDate: '2026-12-01', + twoFactorExempt: ['a@arasystem.test'], + supportContact: 'IT 담당', + updatedAt: new Date(NOW), + }, + ['id'], + ); + expect(next.twoFactor.exempt).toEqual(['a@arasystem.test']); + + await service.update({ supportContact: null }, new Date(NOW)); + expect((await service.get()).supportContact).toBeNull(); + }); + + it('GET /api/support-contact — { text }', async () => { + const controller = new SupportContactController(service); + await expect(controller.get()).resolves.toEqual({ text: 'IT 담당' }); + }); +}); diff --git a/backend/src/modules/system-settings/system-settings.types.ts b/backend/src/modules/system-settings/system-settings.types.ts new file mode 100644 index 0000000..f4aec37 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.types.ts @@ -0,0 +1,103 @@ +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; +import { kstStartOfDate } from '@/shared/utils/kst-date'; +import { SUPPORT_CONTACT_MAX_LENGTH } from './system-settings.entity'; + +/** + * 2단계 인증 필수 정책 (계약 6절 "2단계 인증 필수") — 운영 도구로 바꾼다. 엔진에는 강제하는 설정이 없어 서버 정책으로 둔다. + * 처음 값은 꺼짐이다 + */ +export interface TwoFactorPolicy { + readonly required: boolean; + /** 시작일 'YYYY-MM-DD'(한국 날짜 — 그날 0시 KST 부터 막는다) — null 이면 켠 때부터 */ + readonly startDate: string | null; + /** 정책에서 빼는 직원 주소 (소문자) */ + readonly exempt: readonly string[]; +} + +/** 서버 설정 (system_settings 한 줄) */ +export interface SystemSettings { + readonly twoFactor: TwoFactorPolicy; + /** 문의 연락처 (200자 이하 · 한 줄) — 정하지 않았으면 null */ + readonly supportContact: string | null; +} + +export const DEFAULT_SYSTEM_SETTINGS: SystemSettings = { + twoFactor: { required: false, startDate: null, exempt: [] }, + supportContact: null, +}; + +/** 이 직원에게 정책이 걸리는가 */ +export interface TwoFactorRequirement { + /** 정책이 켜져 있고 이 직원이 예외가 아니다 — 시작일 전(유예)에도 true (필수인 직원은 끄지 못한다) */ + readonly isRequired: boolean; + /** 시작 시각(시작일 0시 KST) — 시작일이 없으면 null(켠 때부터) */ + readonly startsAt: Date | null; + /** 시작했는가 — 필수이고 지금이 시작 시각 이후 */ + readonly isStarted: boolean; +} + +/** 세션에 붙일 2단계 인증 상태 — 제한 또는 유예 중의 시작 시각 */ +export interface SessionTwoFactorState { + readonly restriction: Extract | null; + /** 유예 중이면 시작 시각 (twoFactorDeadline) — 아니면 null */ + readonly deadline: Date | null; +} + +/** 이 직원에게 정책이 걸리는가 (순수 함수) — 주소는 소문자로 견준다. 시작일이 달력에 없는 값이면 시작일 없음(켠 때부터)으로 읽는다 */ +export function twoFactorRequirementFor( + policy: TwoFactorPolicy, + email: string, + now: Date, +): TwoFactorRequirement { + const address = email.trim().toLowerCase(); + const isRequired = policy.required && !policy.exempt.includes(address); + const startsAt = + policy.startDate === null ? null : kstStartOfDate(policy.startDate); + return { + isRequired, + startsAt, + isStarted: + isRequired && (startsAt === null || now.getTime() >= startsAt.getTime()), + }; +} + +/** + * 세션의 2단계 인증 상태 (순수 함수 — 계약 6절 "2단계 인증 필수"): + * - 필수 · 시작함 · 꺼짐 → 제한 twoFactorSetup + * - 필수 · 유예 중 · 꺼짐 → 시작 시각(twoFactorDeadline — 화면이 메일함 위에 안내 줄) + * - 그 밖(필수 아님 · 이미 켬) → 둘 다 없음 + */ +export function sessionTwoFactorState( + requirement: TwoFactorRequirement, + isEnabled: boolean, +): SessionTwoFactorState { + if (!requirement.isRequired || isEnabled) { + return { restriction: null, deadline: null }; + } + if (requirement.isStarted) { + return { restriction: 'twoFactorSetup', deadline: null }; + } + return { restriction: null, deadline: requirement.startsAt }; +} + +/** 문의 연락처 검사 결과 — 고친 값(빈 글은 null — 지우기) 또는 문제 */ +export type SupportContactCheck = + | { readonly ok: true; readonly value: string | null } + | { readonly ok: false; readonly problem: 'too-long' | 'not-single-line' }; + +/** 줄바꿈 · 탭을 포함한 제어 문자 (한 줄 · 글자로만 보인다) */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +/** 문의 연락처 (계약 6절 — 200자 이하 · 한 줄, 길이는 JS 문자열 길이) — 앞뒤 공백을 지운다. 빈 글이면 null(정하지 않음) */ +export function checkSupportContact(input: string): SupportContactCheck { + const value = input.trim(); + if (value === '') return { ok: true, value: null }; + if (CONTROL_CHARACTER.test(value)) { + return { ok: false, problem: 'not-single-line' }; + } + if (value.length > SUPPORT_CONTACT_MAX_LENGTH) { + return { ok: false, problem: 'too-long' }; + } + return { ok: true, value }; +} diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index 1604d48..4bda5f3 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -16,6 +16,7 @@ import { import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { BusinessException } from '@/common/errors/business.exception'; @@ -29,7 +30,7 @@ import { UploadService } from './upload.service'; @ApiTags('Upload') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('files') export class UploadController { constructor(private readonly uploadService: UploadService) {} diff --git a/backend/src/shared/utils/kst-date.spec.ts b/backend/src/shared/utils/kst-date.spec.ts index 2038431..c362734 100644 --- a/backend/src/shared/utils/kst-date.spec.ts +++ b/backend/src/shared/utils/kst-date.spec.ts @@ -1,4 +1,7 @@ import { + formatKstDateTime, + kstDateString, + kstStartOfDate, formatKstDate, formatKstDateEnglish, kstEndOfDayAfter, @@ -46,3 +49,53 @@ describe('formatKstDate · formatKstDateEnglish', () => { expect(formatKstDateEnglish(at)).toBe('Nov 30, 2026'); }); }); + +describe('kstStartOfDate · kstDateString — 2단계 인증 필수의 시작일 (13단계)', () => { + it('한국 날짜의 0시를 UTC 로 — 계약 6절의 예', () => { + expect(kstStartOfDate('2026-12-01')?.toISOString()).toBe( + '2026-11-30T15:00:00.000Z', + ); + expect(kstStartOfDate('2027-01-01')?.toISOString()).toBe( + '2026-12-31T15:00:00.000Z', + ); + }); + + it('달력에 없는 날짜 · 모양이 틀린 값은 null', () => { + for (const value of [ + '2026-02-30', + '2026-13-01', + '2026-1-01', + '20261201', + '', + '2026-12-01T00:00:00Z', + ]) { + expect(kstStartOfDate(value)).toBeNull(); + } + }); + + it('한국 날짜를 YYYY-MM-DD 로 — UTC 15시부터 다음 날', () => { + expect(kstDateString(new Date('2026-11-30T14:59:59.999Z'))).toBe( + '2026-11-30', + ); + expect(kstDateString(new Date('2026-11-30T15:00:00.000Z'))).toBe( + '2026-12-01', + ); + }); +}); + +describe('formatKstDateTime — 알림 메일의 시각 (디자인 README 13단계)', () => { + it('"2026년 10월 30일 (금) 오후 3:20" 꼴', () => { + expect(formatKstDateTime(new Date('2026-10-30T06:20:00Z'))).toBe( + '2026년 10월 30일 (금) 오후 3:20', + ); + }); + + it('0시는 오전 12시, 12시는 오후 12시 — 분은 두 자리', () => { + expect(formatKstDateTime(new Date('2026-10-26T15:05:00Z'))).toBe( + '2026년 10월 27일 (화) 오전 12:05', + ); + expect(formatKstDateTime(new Date('2026-10-27T03:00:00Z'))).toBe( + '2026년 10월 27일 (화) 오후 12:00', + ); + }); +}); diff --git a/backend/src/shared/utils/kst-date.ts b/backend/src/shared/utils/kst-date.ts index 1f146a7..fbd889a 100644 --- a/backend/src/shared/utils/kst-date.ts +++ b/backend/src/shared/utils/kst-date.ts @@ -61,3 +61,50 @@ export function formatKstDateEnglish(at: Date): string { const { year, month, day } = kstParts(at); return `${MONTHS_EN[month - 1]} ${day}, ${year}`; } + +/** 'YYYY-MM-DD' — 달력에 있는 날짜만 받는다 */ +const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** + * 한국 날짜('YYYY-MM-DD')의 0시(한국 시간)를 UTC 로 — 달력에 없는 날짜(2026-02-30)나 모양이 틀리면 null. + * 예) '2026-12-01' → `2026-11-30T15:00:00.000Z` (13단계 — 2단계 인증 필수의 시작일, 계약 6절의 예) + */ +export function kstStartOfDate(date: string): Date | null { + const match = DATE_ONLY.exec(date); + if (!match) return null; + const [year, month, day] = [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + ]; + const utc = Date.UTC(year, month - 1, day); + const check = new Date(utc); + if ( + check.getUTCFullYear() !== year || + check.getUTCMonth() !== month - 1 || + check.getUTCDate() !== day + ) { + return null; + } + return new Date(utc - KST_OFFSET_MS); +} + +/** `at` 의 한국 날짜를 'YYYY-MM-DD' 로 */ +export function kstDateString(at: Date): string { + const { year, month, day } = kstParts(at); + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** + * "2026년 10월 30일 (금) 오후 3:20" — 한국 시각 (13단계 받은편지함 알림 메일의 `{시각}` · `{올린 때}` — 디자인 README 13단계). + * 12시간제 — 0시는 "오전 12:00", 12시는 "오후 12:00", 분은 두 자리 + */ +export function formatKstDateTime(at: Date): string { + const shifted = new Date(at.getTime() + KST_OFFSET_MS); + const { year, month, day, weekday } = kstParts(at); + const hours = shifted.getUTCHours(); + const minutes = String(shifted.getUTCMinutes()).padStart(2, '0'); + const period = hours < 12 ? '오전' : '오후'; + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + return `${year}년 ${month}월 ${day}일 (${WEEKDAYS_KO[weekday]}) ${period} ${hour12}:${minutes}`; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 59d52f6..8d9bbdb 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -73,7 +73,11 @@ import { type MailImageResponse, type ResolvedAddress, } from '@/modules/mail/images/mail-image-fetcher'; -import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; +import { + AccountLoginDisabledException, + MAIL_PROVIDER, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { BlockedSender, @@ -117,10 +121,21 @@ import { SIGNATURE_IMAGE_MAX_BYTES, } from '@/modules/mail-signature/mail-signature.constants'; import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository'; +import { + SystemSettingsService, + type SystemSettingsPatch, +} from '@/modules/system-settings/system-settings.service'; 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 { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date'; +import { + buildOtpauthUrl, + encodeBase32, + totpCode, + totpCounterAt, + verifyTotp, +} from '@/shared/utils/totp'; import { pngBytes } from './image-fixtures'; /** @@ -172,6 +187,8 @@ function cookieValue(cookie: string | undefined): string { } const EMPLOYEE = { email: 'ara.kim@e2e.arasystem.test', name: '김아라' }; +/** 13단계 — 로그인(signedIn) · me · 갱신 응답은 세션의 제한 · 2단계 필수 유예를 늘 싣는다 (제한 없음 · 유예 아님) */ +const UNRESTRICTED = { restriction: null, twoFactorDeadline: null }; /** 대역 메일 엔진이 받아 주는 비밀번호 (그 밖의 비밀번호는 틀린 것으로 본다) */ const CORRECT_PASSWORD = 'correct-password-for-e2e'; @@ -344,6 +361,11 @@ const GATE_DEADLINE_MS = 10_000; * test/jest-e2e.json 에 testTimeout 이 없어 jest 기본값(5초)이라, 이 테스트만 따로 늘린다. */ const GATED_TEST_TIMEOUT_MS = 20_000; +/** + * 앱을 띄우는 준비(맨 바깥 beforeAll)의 시간 제한 — DB 연결 · 마이그레이션, 그리고 TypeORM 이 그때 읽는 엔티티 · 마이그레이션 파일의 ts-jest 변환이 들어 있다. + * 변환 캐시가 빈 실행을 바인드 마운트 너머(개발 컨테이너)에서 돌리면 jest 기본값(5초)을 넘어 모든 테스트가 실패했다 (2026-11-02, 13단계) + */ +const APP_SETUP_TIMEOUT_MS = 60_000; /** 열 때까지 기다리게 하는 문 — 한 번 열면 기다리던 쪽이 모두 풀린다 */ function createGate(): { readonly opened: Promise; open: () => void } { @@ -642,6 +664,10 @@ describe('응답 계약 (e2e)', () => { /** 12단계 — 대역 엔진의 2단계 인증(직원 → otpauth 주소) · 앱 비밀번호(직원마다) */ const twoFactorUrls = new Map(); + /** 13단계 — 대역 엔진에서 비밀번호가 만료된 직원 (메일 기능이 막히고, 본인이 바꾸면 엔진이 만료를 푼다) */ + const expiredPasswords = new Set(); + /** 13단계 — 대역 엔진에서 잠긴 직원 (로그인 권한을 뺐다 — 대리 접근 403, 게이트웨이는 정상) */ + const lockedAccounts = new Set(); const appPasswordsByUser = new Map(); let appPasswordSequence = 0; /** 엔진 기본 상한 (x:Authentication.maxAppPasswords) */ @@ -906,12 +932,22 @@ describe('응답 계약 (e2e)', () => { }) .overrideProvider(MAIL_PROVIDER) .useValue({ - // 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다 + // 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다. + // 13단계 — 비밀번호가 만료된 계정은 어댑터가 PasswordExpiredException 으로 알린다(메일 메서드가 forbidden) getProfile: (email: string) => - Promise.resolve({ - email, - name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name, - }), + lockedAccounts.has(email) + ? Promise.reject(new AccountLoginDisabledException(email)) + : expiredPasswords.has(email) + ? Promise.reject( + new PasswordExpiredException( + email, + PROFILE_NAMES.get(email) ?? EMPLOYEE.name, + ), + ) + : Promise.resolve({ + email, + name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name, + }), verifyCredentials: async ( email: string, password: string, @@ -1147,7 +1183,10 @@ describe('응답 계약 (e2e)', () => { [ENGINE_REFUSED_PASSWORD, 'current-password-refused'], [ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'], ]); - return Promise.resolve(outcomes.get(newPassword) ?? 'changed'); + const outcome = outcomes.get(newPassword) ?? 'changed'; + // 13단계 — 엔진은 본인이 비밀번호를 바꾸면 만료를 스스로 푼다 + if (outcome === 'changed') expiredPasswords.delete(email); + return Promise.resolve(outcome); }, createMailbox: ( user: string, @@ -1536,7 +1575,7 @@ describe('응답 계약 (e2e)', () => { server = app.getHttpServer(); jwt = app.get(JwtService, { strict: false }); authSettings = app.get(AUTH_SETTINGS, { strict: false }); - }); + }, APP_SETUP_TIMEOUT_MS); afterAll(async () => { await dataSource.query( @@ -1852,7 +1891,10 @@ describe('응답 계약 (e2e)', () => { const res = await refreshWith(refreshToken).expect(200); - expect(body(res)).toEqual({ success: true, data: EMPLOYEE }); + expect(body(res)).toEqual({ + success: true, + data: { ...EMPLOYEE, ...UNRESTRICTED }, + }); // 토큰은 본문에 없다 expect(JSON.stringify(body(res))).not.toContain('eyJ'); @@ -1872,7 +1914,7 @@ describe('응답 계약 (e2e)', () => { .get('/api/auth/me') .set('Cookie', `access_token=${cookieValue(access)}`) .expect(200); - expect(body(me).data).toEqual(EMPLOYEE); + expect(body(me).data).toEqual({ ...EMPLOYEE, ...UNRESTRICTED }); }); it('만료된 액세스 쿠키가 함께 와도 /auth/refresh 는 리프레시 쿠키로 동작한다', async () => { @@ -1917,7 +1959,7 @@ describe('응답 계약 (e2e)', () => { const second = rotated(await refreshWith(first).expect(200)); const late = await refreshWith(first).expect(200); - expect(body(late).data).toEqual(EMPLOYEE); + expect(body(late).data).toEqual({ ...EMPLOYEE, ...UNRESTRICTED }); expect(late.headers['set-cookie']).toBeUndefined(); // 이긴 쪽의 새 토큰은 그대로 쓸 수 있다 @@ -6830,6 +6872,8 @@ describe('응답 계약 (e2e)', () => { expect(body(before).data).toEqual({ isEnabled: false, enabledAt: null, + isRequired: false, + deadline: null, }); const enrolled = await asMe( @@ -6875,6 +6919,8 @@ describe('응답 계약 (e2e)', () => { expect(body(activated).data).toEqual({ isEnabled: true, enabledAt: expect.any(String) as unknown, + isRequired: false, + deadline: null, }); expect(twoFactorUrls.get(ME)).toBe(enrollment.otpauthUrl); @@ -6884,6 +6930,7 @@ describe('응답 계약 (e2e)', () => { status: 'twoFactorRequired', email: null, name: null, + ...UNRESTRICTED, }); expect(first.headers['set-cookie']).toBeUndefined(); @@ -6902,6 +6949,7 @@ describe('응답 계약 (e2e)', () => { status: 'signedIn', email: ME, name: EMPLOYEE.name, + ...UNRESTRICTED, }); expect(cookieValue(setCookie(second, 'access_token'))).not.toBe(''); @@ -6928,7 +6976,12 @@ describe('응답 계약 (e2e)', () => { otpCode: otpCodeOf(secret, -1), }) .expect(200); - expect(body(off).data).toEqual({ isEnabled: false, enabledAt: null }); + expect(body(off).data).toEqual({ + isEnabled: false, + enabledAt: null, + isRequired: false, + deadline: null, + }); expect(twoFactorUrls.has(ME)).toBe(false); // 다시 비밀번호만으로 로그인한다 @@ -7473,6 +7526,367 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => { + let systemSettings: SystemSettingsService; + /** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */ + let original: SystemSettingsPatch | undefined; + /** 처음 값 — 시험마다 이것에서 시작한다 */ + const DEFAULTS: SystemSettingsPatch = { + twoFactor: { required: false, startDate: null, exempt: [] }, + supportContact: null, + }; + + const loginAs = (email: string, from: string) => + request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', from) + .send({ email, password: CORRECT_PASSWORD }); + const cookiesOf = (res: Response) => [ + `access_token=${cookieValue(setCookie(res, 'access_token'))}`, + `refresh_token=${cookieValue(setCookie(res, 'refresh_token'))}`, + ]; + const settle = (patch: SystemSettingsPatch) => + systemSettings.update(patch, new Date()); + + beforeAll(async () => { + systemSettings = app.get(SystemSettingsService, { strict: false }); + const current = await systemSettings.reload(); + original = { + twoFactor: { ...current.twoFactor }, + supportContact: current.supportContact, + }; + }); + beforeEach(() => settle(DEFAULTS)); + afterAll(async () => { + if (original) await settle(original); + }); + + it('비밀번호가 만료된 직원 — 쿠키는 받되 passwordChange, 허용한 경로만 부르고 그 밖은 403 AUTH_010 → 바꾸면 풀린다', async () => { + const ME = 'expired.me@e2e.arasystem.test'; + expiredPasswords.add(ME); + try { + const res = await loginAs(ME, '198.18.13.1').expect(200); + expect(body(res).data).toEqual({ + status: 'signedIn', + email: ME, + name: EMPLOYEE.name, + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + const cookies = cookiesOf(res); + const as = (test: HttpTest) => test.set('Cookie', cookies); + + const me = await as(request(server).get('/api/auth/me')).expect(200); + expect(body(me).data).toEqual({ + ...EMPLOYEE, + email: ME, + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + await as(request(server).get('/api/auth/sessions')).expect(200); + await as(request(server).get('/api/two-factor')).expect(200); + await as(request(server).get('/api/support-contact')).expect(200); + for (const path of [ + '/api/mailboxes', + '/api/mail-state', + '/api/mail-events', + '/api/contacts', + '/api/app-passwords', + ]) { + const blocked = await as(request(server).get(path)).expect(403); + expect(body(blocked).error?.code).toBe('AUTH_010'); + } + // 갱신도 엔진에 다시 묻는다 — 아직 만료면 그대로 + const still = await as( + request(server).post('/api/auth/refresh'), + ).expect(200); + expect(body(still).data).toMatchObject({ + restriction: 'passwordChange', + }); + + // 새 비밀번호 — 엔진이 만료를 풀고 서버가 세션의 제한을 지운다 + const fresh = cookiesOf(still); + await request(server) + .put('/api/auth/password') + .set('X-Forwarded-For', '198.18.13.1') + .set('Cookie', fresh) + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-for-e2e-13', + }) + .expect(200); + // 제한을 푼 뒤에는 옛 액세스 토큰(rst 클레임)으로도 세션 표의 지금 값 — 마친 단계가 다시 보이지 않는다 + const after = await request(server) + .get('/api/auth/me') + .set('Cookie', fresh) + .expect(200); + expect(body(after).data).toMatchObject(UNRESTRICTED); + await request(server) + .get('/api/mailboxes') + .set('Cookie', fresh) + .expect(200); + const renewed = await request(server) + .post('/api/auth/refresh') + .set('Cookie', fresh) + .expect(200); + expect(body(renewed).data).toEqual({ + ...EMPLOYEE, + email: ME, + ...UNRESTRICTED, + }); + } finally { + expiredPasswords.delete(ME); + } + }); + + it('2단계 인증 필수 — 시작일부터 켜지 않은 직원은 twoFactorSetup · 끄기는 400 VAL_001, 유예 중에는 시작 시각만, 예외 주소는 그대로', async () => { + const ME = 'policy.me@e2e.arasystem.test'; + const today = kstDateString(new Date()); + await settle({ twoFactor: { required: true, startDate: today } }); + + const res = await loginAs(ME, '198.18.13.2').expect(200); + expect(body(res).data).toMatchObject({ + restriction: 'twoFactorSetup', + twoFactorDeadline: null, + }); + const cookies = cookiesOf(res); + const status = await request(server) + .get('/api/two-factor') + .set('Cookie', cookies) + .expect(200); + expect(body(status).data).toEqual({ + isEnabled: false, + enabledAt: null, + isRequired: true, + deadline: null, + }); + const blocked = await request(server) + .get('/api/mailboxes') + .set('Cookie', cookies) + .expect(403); + expect(body(blocked).error?.code).toBe('AUTH_010'); + + // 유예 — 시작일이 내일이면 제한 없이 시작 시각(내일 0시 KST)만 + const tomorrow = kstDateString(new Date(Date.now() + 24 * 3_600_000)); + await settle({ twoFactor: { startDate: tomorrow } }); + const grace = await loginAs(ME, '198.18.13.3').expect(200); + expect(body(grace).data).toMatchObject({ + restriction: null, + twoFactorDeadline: kstStartOfDate(tomorrow)?.toISOString(), + }); + const graceStatus = await request(server) + .get('/api/two-factor') + .set('Cookie', cookiesOf(grace)) + .expect(200); + expect(body(graceStatus).data).toMatchObject({ + isRequired: true, + deadline: kstStartOfDate(tomorrow)?.toISOString(), + }); + + // 예외 주소는 정책을 따르지 않는다 + await settle({ twoFactor: { startDate: today, exempt: [ME] } }); + const exempt = await loginAs(ME, '198.18.13.4').expect(200); + expect(body(exempt).data).toMatchObject(UNRESTRICTED); + }); + + it('만료 계정 + 2단계 필수 시작 — 비밀번호를 바꾸면 그 세션은 twoFactorSetup 으로 바뀌어 옛 토큰으로도 다음 단계 밖은 막힌다 (리뷰 M2)', async () => { + const ME = 'expired.policy@e2e.arasystem.test'; + expiredPasswords.add(ME); + try { + await settle({ + twoFactor: { required: true, startDate: kstDateString(new Date()) }, + }); + const res = await loginAs(ME, '198.18.13.6').expect(200); + // 비밀번호 만료가 먼저다 + expect(body(res).data).toMatchObject({ restriction: 'passwordChange' }); + const cookies = cookiesOf(res); + + await request(server) + .put('/api/auth/password') + .set('X-Forwarded-For', '198.18.13.6') + .set('Cookie', cookies) + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-for-e2e-13m2', + }) + .expect(200); + + // 옛 토큰(rst=passwordChange) — 가드는 세션 표의 지금 값(twoFactorSetup)을 본다 + const blocked = await request(server) + .get('/api/mailboxes') + .set('Cookie', cookies) + .expect(403); + expect(body(blocked).error?.code).toBe('AUTH_010'); + const me = await request(server) + .get('/api/auth/me') + .set('Cookie', cookies) + .expect(200); + expect(body(me).data).toMatchObject({ restriction: 'twoFactorSetup' }); + // 켜기 단계의 경로는 연다 + await request(server) + .get('/api/two-factor') + .set('Cookie', cookies) + .expect(200); + } finally { + expiredPasswords.delete(ME); + } + }); + + it('2단계를 켜면 요청 세션의 제한만 풀리고, twoFactorSetup 이 붙은 다른 세션은 끊긴다 (리뷰 M1)', async () => { + const ME = 'policy.two@e2e.arasystem.test'; + await settle({ + twoFactor: { required: true, startDate: kstDateString(new Date()) }, + }); + // 같은 직원의 두 세션 — 둘 다 2단계를 거치지 않았다(비밀번호만 아는 다른 사람의 세션일 수 있다) + const mine = cookiesOf(await loginAs(ME, '198.18.13.7').expect(200)); + const other = cookiesOf(await loginAs(ME, '198.18.13.8').expect(200)); + try { + const enrolled = await request(server) + .post('/api/two-factor/enrollments') + .set('Cookie', mine) + .expect(201); + const { enrollmentId, secret } = dataOf<{ + enrollmentId: string; + secret: string; + }>(enrolled); + const activated = await request(server) + .post('/api/two-factor/activation') + .set('Cookie', mine) + .send({ + enrollmentId, + otpCode: otpCodeOf(decodeBase32(secret)), + currentPassword: CORRECT_PASSWORD, + }) + .expect(200); + expect(body(activated).data).toMatchObject({ + isEnabled: true, + isRequired: true, + deadline: null, + }); + + // 켠 세션 — 옛 토큰으로도 제한이 풀렸다 + const me = await request(server) + .get('/api/auth/me') + .set('Cookie', mine) + .expect(200); + expect(body(me).data).toMatchObject(UNRESTRICTED); + await request(server) + .get('/api/mailboxes') + .set('Cookie', mine) + .expect(200); + + // 다른 세션 — 2단계 없이 열리지 않고 끊겼다 + const cut = await request(server) + .get('/api/mailboxes') + .set('Cookie', other) + .expect(401); + expect(body(cut).error?.code).toBe('AUTH_001'); + await request(server) + .post('/api/auth/refresh') + .set('Cookie', other) + .expect(401); + } finally { + twoFactorUrls.delete(ME); + } + }); + + it('잠긴 계정(2단계 켜짐 — 엔진이 2단계를 먼저 봐 402)의 맞는 비밀번호도 틀린 비밀번호와 같은 401 AUTH_004 (실사용 시험)', async () => { + const ME = 'locked.otp@e2e.arasystem.test'; + const secret = randomBytes(20); + twoFactorUrls.set( + ME, + buildOtpauthUrl({ + issuer: 'ARA SYSTEM', + account: ME, + secret: encodeBase32(secret), + }), + ); + lockedAccounts.add(ME); + try { + const right = await loginAs(ME, '198.18.13.9').expect(401); + const withCode = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.13.10') + .send({ + email: ME, + password: CORRECT_PASSWORD, + otpCode: otpCodeOf(secret), + }) + .expect(401); + const wrong = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.13.11') + .send({ email: ME, password: 'wrong-password-for-e2e' }) + .expect(401); + // 셋 다 같은 코드 · 문구 — 맞는 비밀번호인지 바깥에서 가를 수 없다 + for (const res of [right, withCode, wrong]) { + expect(body(res).error?.code).toBe('AUTH_004'); + expect(body(res).error?.message).toBe(body(wrong).error?.message); + expect(res.headers['set-cookie']).toBeUndefined(); + } + } finally { + lockedAccounts.delete(ME); + twoFactorUrls.delete(ME); + } + }); + + it('필수인 직원은 2단계 인증을 끌 수 없다 — 400 VAL_001 (엔진에 묻지 않는다)', async () => { + const ME = 'policy.on@e2e.arasystem.test'; + // 켠 직원으로 만든다 — 대역 엔진에 바로 넣고 비밀번호 + 코드로 로그인 + const secret = randomBytes(20); + twoFactorUrls.set( + ME, + buildOtpauthUrl({ + issuer: 'ARA SYSTEM', + account: ME, + secret: encodeBase32(secret), + }), + ); + try { + await settle({ + twoFactor: { required: true, startDate: kstDateString(new Date()) }, + }); + const res = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.13.5') + .send({ + email: ME, + password: CORRECT_PASSWORD, + otpCode: otpCodeOf(secret), + }) + .expect(200); + // 켠 직원은 제한 · 유예가 없다 + expect(body(res).data).toMatchObject({ + status: 'signedIn', + ...UNRESTRICTED, + }); + const off = await request(server) + .post('/api/two-factor/deactivation') + .set('Cookie', cookiesOf(res)) + .send({ + currentPassword: CORRECT_PASSWORD, + otpCode: otpCodeOf(secret, 1), + }) + .expect(400); + expect(body(off).error?.code).toBe('VAL_001'); + expect(twoFactorUrls.has(ME)).toBe(true); + } finally { + twoFactorUrls.delete(ME); + } + }); + + it('문의 연락처 — 로그인 없이 { text }, 정하지 않았으면 null', async () => { + const none = await request(server) + .get('/api/support-contact') + .expect(200); + expect(body(none).data).toEqual({ text: null }); + + await settle({ supportContact: 'IT 담당 홍길동 · 02-000-0000' }); + const set = await request(server).get('/api/support-contact').expect(200); + expect(body(set).data).toEqual({ text: 'IT 담당 홍길동 · 02-000-0000' }); + }); + }); + 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 4927838..d8894c4 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -12,6 +12,7 @@ const Map kErrorCodeMessages = { 'AUTH_007': '인증 앱의 코드를 입력해 주세요.', 'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', 'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', + 'AUTH_010': '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', '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 06352d8..fd92520 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -12,6 +12,7 @@ export const ERROR_CODE_MESSAGES = { AUTH_007: '인증 앱의 코드를 입력해 주세요.', AUTH_008: '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', AUTH_009: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', + AUTH_010: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.', BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', -- 2.52.0 From 1881ec7a420ec053bb6ce2a848215a3fbb5fb7e9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 15:37:14 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(frontend):=2013=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=A4=80=EB=B9=84=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20=EC=83=88=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=C2=B7=202=EB=8B=A8=EA=B3=84=20=EC=BC=9C=EA=B8=B0=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=C2=B7=20=EC=9C=A0=EC=98=88=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=C2=B7=2030=EC=9D=BC=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=C2=B7=20=EB=AC=B8=EC=9D=98=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제한된 세션: restriction 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로, AUTH_010 은 알림 없이 me 를 다시 받아 옮긴다. 제한된 동안은 나가기 확인을 묻지 않고(확인에서 이동을 취소하면 메일 화면에 갇혔다 — 리뷰) 실시간 알림을 열지 않는다. - 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분 · 세션이 끝나면 지운다. 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다. - 유예 노란 줄(시작일 전날까지), 설정 › 2단계 인증의 필수 안내, 설정 › 메일 프로그램 연결(펼침 줄 · 새 Outlook 은 권하지 않음), 휴지통 · 스팸메일함 30일 줄, 로그인 · 코드 단계의 문의 줄. 검증: lint · type-check · 단위 2,354 · 실사용 시험 89/89. Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.vue | 3 + frontend/src/components/QrCodeImage.vue | 14 +- .../src/components/auth/LoginAccountRow.vue | 97 +++ .../src/components/auth/LoginCodeStep.vue | 23 +- .../auth/LoginPasswordChangeStep.vue | 488 ++++++++++++++ .../src/components/auth/LoginSupportLine.vue | 47 ++ .../components/auth/LoginTwoFactorScan.vue | 274 ++++++++ .../auth/LoginTwoFactorSetupStep.vue | 530 +++++++++++++++ .../src/components/mail/MailListFrame.spec.ts | 72 ++ .../src/components/mail/MailListFrame.vue | 51 +- .../mail/TwoFactorGraceNotice.spec.ts | 92 +++ .../components/mail/TwoFactorGraceNotice.vue | 113 ++++ .../mail/editor/signature/SignatureForm.vue | 6 +- .../settings/MailClientGuideList.vue | 199 ++++++ .../settings/MailClientSettings.vue | 23 +- .../src/components/settings/OtpCodeField.vue | 28 +- .../src/components/settings/PasswordField.vue | 38 +- .../src/components/settings/VacationForm.vue | 5 +- frontend/src/components/shell/AppShell.vue | 2 + frontend/src/composables/useApi.spec.ts | 177 ++++- frontend/src/composables/useApi.ts | 117 +++- frontend/src/composables/useAuth.spec.ts | 56 +- frontend/src/composables/useAuth.ts | 42 +- .../src/composables/useDialogLeaveConfirm.ts | 6 +- frontend/src/composables/useLoginSecret.ts | 70 ++ .../src/composables/useMailRealtime.spec.ts | 35 + frontend/src/composables/useMailRealtime.ts | 12 +- .../composables/useSessionRestriction.spec.ts | 92 +++ .../src/composables/useSessionRestriction.ts | 36 + .../src/pages/LoginPage.restricted.spec.ts | 619 ++++++++++++++++++ frontend/src/pages/LoginPage.spec.ts | 17 +- frontend/src/pages/LoginPage.vue | 268 +++++++- frontend/src/pages/mail/ComposePage.spec.ts | 14 + frontend/src/pages/mail/ComposePage.vue | 10 +- frontend/src/pages/mail/MailLayout.vue | 5 + .../settings/AccountAppPasswordsPage.spec.ts | 9 + .../settings/AccountAppPasswordsPage.vue | 24 +- .../settings/AccountMailClientsPage.spec.ts | 141 ++++ .../pages/settings/AccountMailClientsPage.vue | 257 ++++++++ .../settings/AccountTwoFactorPage.spec.ts | 66 ++ .../pages/settings/AccountTwoFactorPage.vue | 48 +- .../pages/settings/MailSignaturePage.spec.ts | 15 + .../src/pages/settings/SettingsLayout.spec.ts | 3 +- frontend/src/router/index.spec.ts | 26 +- frontend/src/router/index.ts | 7 +- frontend/src/router/routes.ts | 9 +- .../src/shared/constants/account-security.ts | 1 - frontend/src/shared/constants/icons.ts | 7 + frontend/src/shared/constants/mail-clients.ts | 132 ++++ frontend/src/shared/constants/mail.ts | 14 +- .../shared/constants/session-restriction.ts | 89 +++ .../src/shared/constants/settings-menu.ts | 5 +- frontend/src/shared/types/account-security.ts | 4 + frontend/src/shared/types/auth.ts | 37 +- .../src/shared/utils/auth-session.spec.ts | 70 ++ frontend/src/shared/utils/auth-session.ts | 49 ++ .../src/shared/utils/support-contact.spec.ts | 21 + frontend/src/shared/utils/support-contact.ts | 12 + frontend/src/stores/auth.store.ts | 55 +- .../src/stores/support-contact.store.spec.ts | 45 ++ frontend/src/stores/support-contact.store.ts | 39 ++ frontend/src/test-utils/router.ts | 7 + 62 files changed, 4773 insertions(+), 100 deletions(-) create mode 100644 frontend/src/components/auth/LoginAccountRow.vue create mode 100644 frontend/src/components/auth/LoginPasswordChangeStep.vue create mode 100644 frontend/src/components/auth/LoginSupportLine.vue create mode 100644 frontend/src/components/auth/LoginTwoFactorScan.vue create mode 100644 frontend/src/components/auth/LoginTwoFactorSetupStep.vue create mode 100644 frontend/src/components/mail/TwoFactorGraceNotice.spec.ts create mode 100644 frontend/src/components/mail/TwoFactorGraceNotice.vue create mode 100644 frontend/src/components/settings/MailClientGuideList.vue create mode 100644 frontend/src/composables/useLoginSecret.ts create mode 100644 frontend/src/composables/useSessionRestriction.spec.ts create mode 100644 frontend/src/composables/useSessionRestriction.ts create mode 100644 frontend/src/pages/LoginPage.restricted.spec.ts create mode 100644 frontend/src/pages/settings/AccountMailClientsPage.spec.ts create mode 100644 frontend/src/pages/settings/AccountMailClientsPage.vue create mode 100644 frontend/src/shared/constants/mail-clients.ts create mode 100644 frontend/src/shared/constants/session-restriction.ts create mode 100644 frontend/src/shared/utils/auth-session.spec.ts create mode 100644 frontend/src/shared/utils/auth-session.ts create mode 100644 frontend/src/shared/utils/support-contact.spec.ts create mode 100644 frontend/src/shared/utils/support-contact.ts create mode 100644 frontend/src/stores/support-contact.store.spec.ts create mode 100644 frontend/src/stores/support-contact.store.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2683bc5..60a723f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,9 +10,12 @@ import { useLargeFileAlerts } from '@/composables/useLargeFileAlerts' import { useLogoutSync } from '@/composables/useLogoutBroadcast' import { useMailRealtime } from '@/composables/useMailRealtime' import { useNewMailNotifier } from '@/composables/useNewMailNotifier' +import { useSessionRestriction } from '@/composables/useSessionRestriction' // 다른 탭에서 로그아웃하면 이 탭도 로그인 화면으로 간다 (계약 1절 "클라이언트 처리 규칙") useLogoutSync() +// 세션에 제한(새 비밀번호 · 2단계 켜기 — 13단계)이 생기면 보호 화면을 떠나 로그인 틀의 그 단계로 간다 +useSessionRestriction() // 실시간 새 메일 — 로그인한 동안 앱 전체에서 돈다(설정 등 다른 화면에서도 바탕화면 알림 · 연결 상태가 이어지게). // 메일 화면은 이 소식을 받아 목록 · 개수를 맞춘다 (계약 2절 "실시간 알림" — MailLayout 의 useMailStatePolling). diff --git a/frontend/src/components/QrCodeImage.vue b/frontend/src/components/QrCodeImage.vue index 314bacb..6e212cc 100644 --- a/frontend/src/components/QrCodeImage.vue +++ b/frontend/src/components/QrCodeImage.vue @@ -12,9 +12,13 @@ interface Props { value: string /** 그림의 이름 (스크린리더) */ label: string + /** 148 — 로그인 틀의 2단계 인증 켜기(양식 칸 360 안에 키를 옆에 둔다 — 13단계). 기본은 160 */ + compact?: boolean } -const props = defineProps() +const props = withDefaults(defineProps(), { + compact: false, +}) /** 바깥 여백 칸 수 */ const QUIET_ZONE = 2 @@ -26,7 +30,7 @@ const viewBox = computed(() => `0 0 ${extent.value} ${extent.value}`) + + diff --git a/frontend/src/components/auth/LoginCodeStep.vue b/frontend/src/components/auth/LoginCodeStep.vue index 2698faf..ae08347 100644 --- a/frontend/src/components/auth/LoginCodeStep.vue +++ b/frontend/src/components/auth/LoginCodeStep.vue @@ -6,11 +6,14 @@ // - 코드 틀림(AUTH_006)은 칸 아래 빨간 글(부모가 칸을 비우고 초점을 돌려준다), 시도 과다(REQ_001)는 위쪽 상자 — 로그인 오류와 같은 자리 · 같은 모양 // - 확인 중: 「확인하는 중…」(로그인 단추와 같은 모양 · aria-busy), 칸은 읽기만, [다른 계정으로 로그인]도 누를 수 없다(aria-disabled). // [확인]은 disabled 가 아니라 aria-disabled — 키보드 초점을 잃지 않게(시도 과다 · 장애 뒤에도 그 자리에 남는다). 바쁜 동안의 누름은 부모가 무시한다 +// - 도움말은 문의 줄(13단계 — LoginSupportLine): 연락처가 있으면 "인증 앱을 쓸 수 없나요?" 다음 줄에 연락처, 없으면 지금 글 // 상태는 부모(LoginPage)가 가진다 — 비밀번호를 다시 실어 보내는 일이 부모 몫이라서. 고정 id: #login-code · #login-code-error · #login-code-alert import { ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' +import LoginSupportLine from '@/components/auth/LoginSupportLine.vue' import { LOGIN_TWO_FACTOR_TEXT, OTP_TEXT } from '@/shared/constants/account-security' +import { SUPPORT_CONTACT_TEXT } from '@/shared/constants/session-restriction' import { normalizeOtpInput } from '@/shared/utils/account-security' interface Props { @@ -23,6 +26,8 @@ interface Props { codeError: string | null /** 위쪽 상자 — 사전 문구(REQ_001) */ alert: string | null + /** 문의 연락처 — 없으면 지금 글 (13단계) */ + supportContact?: string | null } interface Emits { @@ -31,7 +36,9 @@ interface Emits { (e: 'other-account'): void } -const props = defineProps() +const props = withDefaults(defineProps(), { + supportContact: null, +}) const emit = defineEmits() const inputRef = ref(null) @@ -147,9 +154,11 @@ defineExpose({ -

- {{ LOGIN_TWO_FACTOR_TEXT.help }} -

+

{{ LOGIN_TWO_FACTOR_TEXT.note }}

@@ -327,12 +336,6 @@ defineExpose({ cursor: progress; } -.code-step__help { - margin: 0; - font-size: var(--font-size-label); - color: var(--color-text-muted); -} - .code-step__note { margin: 0; padding-top: var(--space-16); diff --git a/frontend/src/components/auth/LoginPasswordChangeStep.vue b/frontend/src/components/auth/LoginPasswordChangeStep.vue new file mode 100644 index 0000000..e147c99 --- /dev/null +++ b/frontend/src/components/auth/LoginPasswordChangeStep.vue @@ -0,0 +1,488 @@ + + + + + diff --git a/frontend/src/components/auth/LoginSupportLine.vue b/frontend/src/components/auth/LoginSupportLine.vue new file mode 100644 index 0000000..0a6362b --- /dev/null +++ b/frontend/src/components/auth/LoginSupportLine.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/components/auth/LoginTwoFactorScan.vue b/frontend/src/components/auth/LoginTwoFactorScan.vue new file mode 100644 index 0000000..3bb116e --- /dev/null +++ b/frontend/src/components/auth/LoginTwoFactorScan.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/components/auth/LoginTwoFactorSetupStep.vue b/frontend/src/components/auth/LoginTwoFactorSetupStep.vue new file mode 100644 index 0000000..e854de0 --- /dev/null +++ b/frontend/src/components/auth/LoginTwoFactorSetupStep.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/frontend/src/components/mail/MailListFrame.spec.ts b/frontend/src/components/mail/MailListFrame.spec.ts index 792bd97..7f841e9 100644 --- a/frontend/src/components/mail/MailListFrame.spec.ts +++ b/frontend/src/components/mail/MailListFrame.spec.ts @@ -182,3 +182,75 @@ describe('MailListFrame (목록 칸의 틀)', () => { expect(document.activeElement).toBe(section) }) }) + +// 휴지통 · 스팸메일함 — 30일 줄 (13단계 — 계약 6절 "휴지통 · 스팸 30일" · 디자인 원본 MailNoticesCutover) +describe('MailListFrame — 휴지통 · 스팸메일함의 30일 줄 (13단계)', () => { + let pinia: Pinia + + const TRASH: Mailbox = { ...INBOX, id: 'trash', name: '휴지통', role: 'trash', totalCount: 2 } + const JUNK: Mailbox = { ...INBOX, id: 'junk', name: '스팸메일함', role: 'junk', totalCount: 1 } + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' }) + installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })) + }) + + afterEach(() => { + restoreApiAdapter() + document.body.innerHTML = '' + }) + + async function mountBox(mailbox: Mailbox, messages: MessageSummary[], component: 'list' | 'table' = 'list') { + const store = useMailStore() + store.mailboxes = [INBOX, TRASH, JUNK] + store.currentMailboxId = mailbox.id + store.messages = messages + store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length } + store.listStatus = 'ready' + const router = createTestRouter() + await router.push(`/mail/${mailbox.id}`) + await router.isReady() + const page = component === 'list' ? MessageList : (await import('./MessageTable.vue')).default + return mount(page, { global: { plugins: [pinia, router] }, attachTo: document.body }) + } + + it('휴지통 — 목록 머리 바로 아래 회색 줄 「휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.」(늘 보임 · 닫기 없음)', async () => { + const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })]) + + const notice = wrapper.get('[data-retention-notice]') + expect(notice.text()).toBe('휴지통에 들어간 메일은 30일 뒤 자동으로 지워집니다.') + expect(notice.find('button').exists()).toBe(false) + }) + + it('스팸메일함도 같은 줄 — 목록 보기(표)에서는 표 위', async () => { + const wrapper = await mountBox(JUNK, [summary('j1', { mailboxIds: ['junk'] })], 'table') + + const notice = wrapper.get('[data-retention-notice]') + expect(notice.text()).toBe('스팸메일함에 들어간 메일은 30일 뒤 자동으로 지워집니다.') + // 표 머리보다 앞 + const table = wrapper.get('table').element + expect(notice.element.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('메일이 없으면 줄을 빼고 빈 상태 글에 같은 뜻을 붙인다', async () => { + const wrapper = await mountBox(TRASH, []) + + expect(wrapper.find('[data-retention-notice]').exists()).toBe(false) + expect(wrapper.text()).toContain('휴지통이 비어 있습니다') + expect(wrapper.text()).toContain('삭제한 메일이 여기에 표시됩니다. 들어간 지 30일이 지난 메일은 자동으로 지워집니다.') + }) + + it('다른 메일함 · 검색 결과에는 없다', async () => { + const inbox = await mountBox(INBOX, [summary('m1')]) + expect(inbox.find('[data-retention-notice]').exists()).toBe(false) + inbox.unmount() + + const store = useMailStore() + const wrapper = await mountBox(TRASH, [summary('t1', { mailboxIds: ['trash'] })]) + store.listSource = { kind: 'search', q: '견적', scope: 'trash' } + await flushPromises() + expect(wrapper.find('[data-retention-notice]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/components/mail/MailListFrame.vue b/frontend/src/components/mail/MailListFrame.vue index be777ef..9f8595f 100644 --- a/frontend/src/components/mail/MailListFrame.vue +++ b/frontend/src/components/mail/MailListFrame.vue @@ -1,12 +1,14 @@ + + + + diff --git a/frontend/src/components/mail/editor/signature/SignatureForm.vue b/frontend/src/components/mail/editor/signature/SignatureForm.vue index 09363b6..21ae404 100644 --- a/frontend/src/components/mail/editor/signature/SignatureForm.vue +++ b/frontend/src/components/mail/editor/signature/SignatureForm.vue @@ -92,11 +92,11 @@ const hasUnsavedChanges = computed( /** * 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창. - * 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났으면 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다. + * 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났거나 제한이 붙었으면(13단계) 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다. */ const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({ - active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.isLoggedIn, - unloadActive: () => hasUnsavedChanges.value && authStore.isLoggedIn, + active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.canUseApp, + unloadActive: () => hasUnsavedChanges.value && authStore.canUseApp, }) /** 무엇이든 바꾸면 "저장했습니다"를 내린다 */ diff --git a/frontend/src/components/settings/MailClientGuideList.vue b/frontend/src/components/settings/MailClientGuideList.vue new file mode 100644 index 0000000..d65dd9d --- /dev/null +++ b/frontend/src/components/settings/MailClientGuideList.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/frontend/src/components/settings/MailClientSettings.vue b/frontend/src/components/settings/MailClientSettings.vue index 8f0ba55..841d7a5 100644 --- a/frontend/src/components/settings/MailClientSettings.vue +++ b/frontend/src/components/settings/MailClientSettings.vue @@ -3,6 +3,7 @@ // 받는 메일(IMAP) 호스트 · 포트 993 · SSL/TLS / 보내는 메일(SMTP) 호스트 · 포트 465 · SSL/TLS / 사용자 이름 = 메일 주소 전체 / 비밀번호 = 여기서 만든 앱 비밀번호. // 호스트 · 포트는 상수 하나(MAIL_CLIENT_SETTINGS)에 둔다 — 전 직원 전환 때 다시 본다. // compact: 결과 창의 요약 — 비밀번호 줄 없이, 12.5px · 위 선 하나 (화면의 상자는 줄마다 선이 있는 테두리 상자) +// passwordNote: 비밀번호 줄의 글 — 메일 프로그램 연결 화면(13단계)은 2단계 인증 상태에 따라 바꾼다. 좁은 폭(600 이하)에서는 이름(12px 회색) 위 · 값 아래 한 칸 import { computed } from 'vue' import { APP_PASSWORD_TEXT, MAIL_CLIENT_SETTINGS } from '@/shared/constants/account-security' @@ -11,10 +12,13 @@ interface Props { /** 직원 주소 — 사용자 이름 */ email: string compact?: boolean + /** 비밀번호 줄의 글 — 없으면 앱 비밀번호 화면의 글 */ + passwordNote?: string } const props = withDefaults(defineProps(), { compact: false, + passwordNote: APP_PASSWORD_TEXT.passwordNote, }) const { host, imapPort, smtpPort, security } = MAIL_CLIENT_SETTINGS @@ -26,7 +30,7 @@ const rows = computed(() => [ { term: APP_PASSWORD_TEXT.incoming, strong: host, rest: ` · ${serverLine(imapPort)}` }, { term: APP_PASSWORD_TEXT.outgoing, strong: host, rest: ` · ${serverLine(smtpPort)}` }, { term: APP_PASSWORD_TEXT.username, strong: props.email, rest: props.compact ? '' : APP_PASSWORD_TEXT.usernameNote }, - ...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: APP_PASSWORD_TEXT.passwordNote }]), + ...(props.compact ? [] : [{ term: APP_PASSWORD_TEXT.password, strong: '', rest: props.passwordNote }]), ]) @@ -86,6 +90,23 @@ const rows = computed(() => [ font-weight: 600; } +/* 좁은 폭 — 이름(12px 회색) 위 · 값 아래 한 칸 (디자인 원본 SettingsMailClients "좁은 폭") */ +@media (max-width: 37.5rem) { + .client-settings:not(.client-settings--compact) { + grid-template-columns: minmax(0, 1fr); + } + + .client-settings:not(.client-settings--compact) .client-settings__term { + padding-bottom: 0; + border-bottom: 0; + font-size: var(--font-size-small); + } + + .client-settings:not(.client-settings--compact) .client-settings__value { + padding-top: var(--space-2); + } +} + /* 결과 창의 요약 — 이름 칸 124 · 12.5px · 위 선 하나 */ .client-settings--compact { grid-template-columns: 7.75rem minmax(0, 1fr); diff --git a/frontend/src/components/settings/OtpCodeField.vue b/frontend/src/components/settings/OtpCodeField.vue index 89d8626..e957b6d 100644 --- a/frontend/src/components/settings/OtpCodeField.vue +++ b/frontend/src/components/settings/OtpCodeField.vue @@ -4,6 +4,7 @@ // - 숫자 6자리만 — 붙여 넣은 글의 띄어쓰기 · 하이픈은 지운다(normalizeOtpInput). inputmode="numeric" · autocomplete="one-time-code" // maxlength 를 걸지 않는다 — 브라우저가 붙여 넣은 "123 456" 을 먼저 잘라 버린다 // - 오류는 칸 아래 빨간 글(alert-circle) · 칸 테두리도 빨강. 칸의 설명(aria-describedby)은 안내 → 오류 차례 (PasswordField 와 같다) +// - variant="login": 로그인 코드 칸 모양(폭 전체 · 테두리 #8A94A3 · 18px · 글자 사이 0.3em · 이름 12.5px 굵게) — 13단계 새 비밀번호 · 2단계 켜기 단계 import { computed, ref } from 'vue' import AppIcon from '@/components/AppIcon.vue' @@ -23,6 +24,8 @@ interface Props { compactLabel?: boolean /** 대화상자를 열면 이 칸에 초점 (켜기 창 — 처음 초점은 코드 칸) */ autofocus?: boolean + /** 칸 모양 — 설정 화면(기본) · 로그인 틀 */ + variant?: 'settings' | 'login' } interface Emits { @@ -35,6 +38,7 @@ const props = withDefaults(defineProps(), { readonly: false, compactLabel: false, autofocus: false, + variant: 'settings', }) const emit = defineEmits() @@ -61,7 +65,7 @@ defineExpose({