From 33a4e6f10a9851209d8473f8cf5856a95c9a6446 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 19 Oct 2026 10:19:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=A0=95=EB=A6=AC:=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=EB=A1=9C=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=EC=97=90=20=EC=A0=91=EC=86=8D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=EC=9D=84=20=EB=B3=B4=EC=97=AC=20=EC=A3=BC?= =?UTF-8?q?=EB=8A=94=20=EB=AA=85=EB=A0=B9=EC=9D=84=20=EB=8D=94=ED=95=98?= =?UTF-8?q?=EA=B3=A0,=20SCRAM=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EB=8F=84=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=82=AC=EC=9A=A9=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=9D=84=20=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왜(회원 공통 수치 2026-10-18, 이 창 결정 A): 메일 프로토콜은 앱 비밀번호로 옮기고, 기본 비밀번호로 접속하던 길은 2주 관측 뒤 계정별로 끊는다. 그 관측 수단이 없었다. - `mail-password-usage --days=N`(기본 14, 1~365): 최근 N일 안에 기본 비밀번호로 메일에 접속한 계정과 그 계정의 앱 비밀번호 수·최근 사용. 관리 명령이라 REST·CLI·콘솔에 함께 생긴다. 마이그레이션 없음. - 근거는 기본 비밀번호 자격증명의 `last_used_at`이다. 메일 표면의 로그인에서만 갱신되고 셀프서비스 웹 로그인은 갱신하지 않는다. 그런데 SCRAM 로그인(`scramAuthorize`)은 이 값을 갱신하지 않아서 SCRAM 클라이언트가 관측에서 빠졌다 — PLAIN 경로와 같이 갱신한다(베스트에포트). - 끊는 스위치와 날짜는 관측 뒤에 정한다(OPERATIONS에 절차 자리를 만들었다). Co-Authored-By: Claude Opus 5.5 --- docs/OPERATIONS.md | 10 ++++ packages/admin-cmd/src/accounts.ts | 50 +++++++++++++++++ .../test/mail-password-usage.test.ts | 54 +++++++++++++++++++ packages/store/src/auth.ts | 13 ++++- 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/admin-cmd/test/mail-password-usage.test.ts diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index d331954..db58cac 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -276,6 +276,16 @@ Bearer 토큰 입력 칸은 없앴다. API 키(`api-key-create`)는 REST 자동 - ⚠ **롤백 주의**: 이 변경 뒤에 새로 정하거나 다시 저장된 해시는 정규화한 값으로 만들어진다. 이전 릴리즈(v2026.10.18 이하)로 되돌리면 정규화로 모양이 바뀌는 비밀번호(전각 문자, 조합형 한글, 보이지 않는 문자 등)를 쓰는 사용자는 다시 배포할 때까지 비밀번호 로그인이 막힌다. ASCII 비밀번호는 영향이 없다. - 로그인·MFA·비밀번호 변경 요청 본문은 64 KiB까지만 받는다(`MAX_LOGIN_BODY_BYTES`). 비밀번호 길이 제한 대신 해시 부담 공격을 막는 장치다. +### 기본 비밀번호로 메일에 접속하는 계정 찾기 (앱 비밀번호 전환 준비) + +메일 프로토콜(IMAP·POP3·SMTP 제출·ManageSieve)은 앱 비밀번호로 옮겨 간다(회원 공통 수치 2026-10-18). 기본 비밀번호로 접속하던 길은 +관측 2주 뒤 계정별로 끊고, 마지막에 전역으로 끊는다(이 창 결정 A). 관측은 `mail-password-usage`로 한다. + +- `mail-password-usage --days=14`: 최근 N일 안에 기본 비밀번호로 메일에 접속한 계정, 그 계정의 앱 비밀번호 수와 최근 사용. +- 근거는 기본 비밀번호 자격증명의 마지막 사용 시각이다. 메일 표면의 로그인(PLAIN·LOGIN·SCRAM, JMAP 포함)에서만 갱신되고 + 셀프서비스 웹 로그인은 갱신하지 않는다. 어느 프로토콜인지는 접근 감사 로그(`credKind: "password"`)로 본다. +- 끊는 스위치와 날짜는 관측 뒤에 정한다(아직 없다). + ### 메일 계정 셀프서비스 (E2) 메일 사용자가 웹에서 직접 비밀번호를 바꾸고 앱 비밀번호를 발급·철회하는 화면이다. 기본으로 꺼져 있다. diff --git a/packages/admin-cmd/src/accounts.ts b/packages/admin-cmd/src/accounts.ts index d19e0f5..6e7f456 100644 --- a/packages/admin-cmd/src/accounts.ts +++ b/packages/admin-cmd/src/accounts.ts @@ -331,6 +331,56 @@ export const accountCommands: readonly Command[] = [ return { data: { ok: true }, message: `삭제 드레인 시작: ${acc.email}` }; }, }, + { + spec: { + name: "mail-password-usage", + group: "계정", + label: "기본 비밀번호 메일 접속 현황", + summary: + "최근 N일 안에 기본 비밀번호로 메일(IMAP·POP3·SMTP 제출·JMAP·ManageSieve)에 접속한 계정. 앱 비밀번호로 옮겨야 할 대상을 찾는다.", + readOnly: true, + args: [{ name: "days", label: "기간(일)", type: "string", required: false, placeholder: "14" }], + fields: [ + { key: "email", label: "계정" }, + { key: "lastUsedAt", label: "기본 비밀번호 최근 사용", format: "time" }, + { key: "appPasswords", label: "앱 비밀번호 수" }, + { key: "appPasswordLastUsedAt", label: "앱 비밀번호 최근 사용", format: "time" }, + ], + }, + /** + * 회원 공통 수치 결정(2026-10-18, 이 창 결정 A): 메일 프로토콜은 앱 비밀번호로 옮기고, 기본 비밀번호로 접속하던 길은 + * 2주 관측 뒤 계정별로 끊는다. 이 명령이 그 관측이다. + * + * 근거는 기본 비밀번호 자격증명(kind=0)의 `last_used_at`이다. 이 값은 메일 표면의 로그인(PLAIN·LOGIN·SCRAM)에서만 + * 갱신된다 — 셀프서비스 웹 로그인은 갱신하지 않는다(`verifyAccountPrimaryPassword`). 그래서 "웹에만 쓰는 계정"은 + * 여기 나오지 않는다. 어느 표면인지는 접근 감사 로그(`credKind: "password"`)로 본다. + */ + async run(ctx, args) { + const tenantId = requireTenant(ctx); + const days = args.days === undefined || args.days === "" ? 14 : Number(args.days); + if (!Number.isInteger(days) || days < 1 || days > 365) throw new CommandError("invalid", "기간은 1~365일 정수입니다"); + const since = Date.now() - days * 24 * 60 * 60 * 1000; + const { rows } = await ctx.db.query({ + sql: `SELECT a.email AS email, MAX(p.last_used_at) AS last_used_at, + (SELECT COUNT(*) FROM credentials x WHERE x.account_id = a.id AND x.kind = ?) AS app_passwords, + (SELECT MAX(x.last_used_at) FROM credentials x WHERE x.account_id = a.id AND x.kind = ?) AS app_last_used_at + FROM accounts a JOIN credentials p ON p.account_id = a.id AND p.kind = ? + WHERE a.tenant_id = ? AND a.status = ? AND p.last_used_at >= ? + GROUP BY a.id, a.email + ORDER BY last_used_at DESC`, + params: [CREDENTIAL_KIND.appPassword, CREDENTIAL_KIND.appPassword, CREDENTIAL_KIND.password, tenantId, ACCOUNT_STATUS.active, since], + }); + return { + rows: rows.map((r) => ({ + email: String(r.email), + lastUsedAt: Number(r.last_used_at), + appPasswords: Number(r.app_passwords ?? 0), + appPasswordLastUsedAt: r.app_last_used_at == null ? null : Number(r.app_last_used_at), + })), + message: `최근 ${days}일 동안 기본 비밀번호로 메일에 접속한 계정 ${rows.length}개`, + }; + }, + }, { spec: { name: "app-password-list", diff --git a/packages/admin-cmd/test/mail-password-usage.test.ts b/packages/admin-cmd/test/mail-password-usage.test.ts new file mode 100644 index 0000000..1286bbb --- /dev/null +++ b/packages/admin-cmd/test/mail-password-usage.test.ts @@ -0,0 +1,54 @@ +/** + * 기본 비밀번호 메일 접속 현황(이 창 결정 A의 관측 단계) — 앱 비밀번호로 옮겨야 할 계정을 찾는다. + */ +import { describe, expect, test } from "@ionosphere/testkit"; +import { allMigrations, migrate, openSqlite } from "@ionosphere/db"; +import { authenticate, createAppPassword, createCredential, scramAuthorize, Store } from "@ionosphere/store"; +import { createRegistry } from "../src/registry.ts"; +import { runCommand } from "../src/dispatch.ts"; +import type { CommandContext } from "../src/types.ts"; + +async function setup() { + const db = await openSqlite(); + await migrate(db, allMigrations); + const store = new Store(db); + const { tenantId } = await store.createTenant("acme"); + const make = async (email: string): Promise => { + const { accountId } = await store.createAccount({ tenantId, email }); + await createCredential(db, { accountId, password: "Primary-pw1" }); + return accountId; + }; + return { db, store, tenantId, make, ctx: { db, store, tenantId, isRoot: false } as unknown as CommandContext }; +} + +describe("mail-password-usage", () => { + test("기본 비밀번호로 메일에 접속한 계정만 나오고, 앱 비밀번호로만 쓰는 계정은 빠진다", async () => { + const s = await setup(); + await s.make("primary@acme.test"); + const appOnly = await s.make("apponly@acme.test"); + await s.make("idle@acme.test"); + expect(await authenticate(s.db, "primary@acme.test", "Primary-pw1", "imap")).not.toBeNull(); + const { password } = await createAppPassword(s.db, appOnly, "phone"); + expect(await authenticate(s.db, "apponly@acme.test", password, "imap")).not.toBeNull(); + + const r = await runCommand(createRegistry(), s.ctx, "mail-password-usage", {}); + expect(r.rows?.map((x) => x.email)).toEqual(["primary@acme.test"]); + expect(r.rows?.[0]?.appPasswords).toBe(0); + }); + + test("기간 밖의 사용은 빠지고, 기간은 1~365일 정수만 받는다", async () => { + const s = await setup(); + const id = await s.make("old@acme.test"); + await s.db.batch([{ sql: "UPDATE credentials SET last_used_at = ? WHERE account_id = ? AND kind = 0", params: [Date.now() - 30 * 86_400_000, id] }]); + expect((await runCommand(createRegistry(), s.ctx, "mail-password-usage", { days: "14" })).rows).toHaveLength(0); + expect((await runCommand(createRegistry(), s.ctx, "mail-password-usage", { days: "60" })).rows).toHaveLength(1); + await expect(runCommand(createRegistry(), s.ctx, "mail-password-usage", { days: "0" })).rejects.toThrow(/1~365/); + }); + + test("SCRAM 로그인도 관측에 잡힌다(예전엔 마지막 사용 시각을 갱신하지 않았다)", async () => { + const s = await setup(); + await s.make("scram@acme.test"); + expect(await scramAuthorize(s.db, "scram@acme.test", "imap")).not.toBeNull(); + expect((await runCommand(createRegistry(), s.ctx, "mail-password-usage", {})).rows?.map((x) => x.email)).toEqual(["scram@acme.test"]); + }); +}); diff --git a/packages/store/src/auth.ts b/packages/store/src/auth.ts index 53fcbc3..6e146c3 100644 --- a/packages/store/src/auth.ts +++ b/packages/store/src/auth.ts @@ -506,7 +506,7 @@ export async function scramAuthorize(db: DbDriver, email: string, surface: AuthS * scopes만 본다. 여러 개면 `scramKeysFor`와 같은 순서(created_at ASC)의 첫 행이다. */ const { rows } = await db.query({ - sql: `SELECT a.id AS id, c.scopes AS scopes + sql: `SELECT a.id AS id, c.id AS credential_id, c.scopes AS scopes FROM accounts a LEFT JOIN credentials c ON c.account_id = a.id AND c.kind = ? WHERE a.email = ? AND a.status = 1 ORDER BY c.created_at ASC`, @@ -515,6 +515,17 @@ export async function scramAuthorize(db: DbDriver, email: string, surface: AuthS const row = rows[0]; if (typeof row?.id !== "string") return null; if (!credentialAllowsSurface(row.scopes == null ? null : String(row.scopes), surface)) return null; + /** + * 마지막 사용 시각을 남긴다 — PLAIN 경로(`authenticate`)와 같다. 예전엔 SCRAM 로그인만 이 값을 갱신하지 않아서, + * "기본 비밀번호로 메일에 접속하는 계정" 관측(`mail-password-usage`)에서 SCRAM 클라이언트가 빠졌다. 베스트에포트. + */ + if (typeof row.credential_id === "string") { + try { + await db.batch([{ sql: "UPDATE credentials SET last_used_at = ? WHERE id = ?", params: [Date.now(), row.credential_id] }]); + } catch { + /* 무시 */ + } + } return { accountId: String(row.id) }; } From fca366cd389bcc2f4d98be7355232f2c4668079a Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Mon, 19 Oct 2026 10:23:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=A0=95=EB=A6=AC:=20=EA=B4=80=EC=B8=A1=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20=EA=B4=80=EC=B8=A1=20=EA=B8=B0=EA=B0=84=EC=9D=80=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EC=9D=BC=EB=B6=80=ED=84=B0,=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=84=B0=EB=A6=AC=20bind=EB=8A=94=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EB=B0=96=EC=9E=84=EC=9D=84=20=EC=A0=81=EA=B3=A0,?= =?UTF-8?q?=20=EA=B8=B0=EA=B0=84=20=EC=9D=B8=EC=9E=90=EB=A5=BC=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=EB=A1=9C=20=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OPERATIONS: 관측 2주는 이 릴리즈가 배포된 날부터 센다(그 전엔 SCRAM 로그인이 기록을 남기지 않아 SCRAM 전용 클라이언트가 빠진다), 비밀번호를 바꾸면 기록이 비워진다, 디렉터리(LDAP) bind 메일 로그인은 여기 나오지 않는다(감사 로그로 따로 본다). 표면 목록에 JMAP을 맞췄다. - `days` 인자를 number로(콘솔이 숫자 입력란을 그리고 검증이 먼저 거른다). - scramKeysFor·scramAuthorize의 kind=0 행 순서에 id를 붙여 같은 밀리초 동률에서도 같은 행을 고른다. Co-Authored-By: Claude Opus 5.5 --- docs/OPERATIONS.md | 7 ++++++- packages/admin-cmd/src/accounts.ts | 2 +- packages/store/src/auth.ts | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index db58cac..303d5a0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -278,12 +278,17 @@ Bearer 토큰 입력 칸은 없앴다. API 키(`api-key-create`)는 REST 자동 ### 기본 비밀번호로 메일에 접속하는 계정 찾기 (앱 비밀번호 전환 준비) -메일 프로토콜(IMAP·POP3·SMTP 제출·ManageSieve)은 앱 비밀번호로 옮겨 간다(회원 공통 수치 2026-10-18). 기본 비밀번호로 접속하던 길은 +메일 프로토콜(IMAP·POP3·SMTP 제출·JMAP·ManageSieve)은 앱 비밀번호로 옮겨 간다(회원 공통 수치 2026-10-18). 기본 비밀번호로 접속하던 길은 관측 2주 뒤 계정별로 끊고, 마지막에 전역으로 끊는다(이 창 결정 A). 관측은 `mail-password-usage`로 한다. - `mail-password-usage --days=14`: 최근 N일 안에 기본 비밀번호로 메일에 접속한 계정, 그 계정의 앱 비밀번호 수와 최근 사용. - 근거는 기본 비밀번호 자격증명의 마지막 사용 시각이다. 메일 표면의 로그인(PLAIN·LOGIN·SCRAM, JMAP 포함)에서만 갱신되고 셀프서비스 웹 로그인은 갱신하지 않는다. 어느 프로토콜인지는 접근 감사 로그(`credKind: "password"`)로 본다. +- ⚠ **관측 2주는 이 명령이 들어간 릴리즈가 배포된 날부터 센다.** 그 전에는 SCRAM 로그인이 마지막 사용 시각을 갱신하지 않아서, + SCRAM으로만 붙는 클라이언트가 목록에서 빠진다. +- 비밀번호를 바꾸면 그 계정의 기록이 비워진다(다음 메일 로그인 때 다시 나온다). +- 디렉터리(LDAP) bind로 메일에 붙는 계정은 여기 나오지 않는다(credentials를 쓰지 않는다). 감사 로그의 `credKind`로 따로 본다. + 끊는 스위치를 설계할 때 이 경로를 어떻게 할지 함께 정한다. - 끊는 스위치와 날짜는 관측 뒤에 정한다(아직 없다). ### 메일 계정 셀프서비스 (E2) diff --git a/packages/admin-cmd/src/accounts.ts b/packages/admin-cmd/src/accounts.ts index 6e7f456..ebc0772 100644 --- a/packages/admin-cmd/src/accounts.ts +++ b/packages/admin-cmd/src/accounts.ts @@ -339,7 +339,7 @@ export const accountCommands: readonly Command[] = [ summary: "최근 N일 안에 기본 비밀번호로 메일(IMAP·POP3·SMTP 제출·JMAP·ManageSieve)에 접속한 계정. 앱 비밀번호로 옮겨야 할 대상을 찾는다.", readOnly: true, - args: [{ name: "days", label: "기간(일)", type: "string", required: false, placeholder: "14" }], + args: [{ name: "days", label: "기간(일)", type: "number", required: false, placeholder: "14" }], fields: [ { key: "email", label: "계정" }, { key: "lastUsedAt", label: "기본 비밀번호 최근 사용", format: "time" }, diff --git a/packages/store/src/auth.ts b/packages/store/src/auth.ts index 6e146c3..b0f89b5 100644 --- a/packages/store/src/auth.ts +++ b/packages/store/src/auth.ts @@ -481,7 +481,7 @@ export async function scramKeysFor(db: DbDriver, email: string): Promise