Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,21 @@ Bearer 토큰 입력 칸은 없앴다. API 키(`api-key-create`)는 REST 자동
- ⚠ **롤백 주의**: 이 변경 뒤에 새로 정하거나 다시 저장된 해시는 정규화한 값으로 만들어진다. 이전 릴리즈(v2026.10.18 이하)로 되돌리면 정규화로 모양이 바뀌는 비밀번호(전각 문자, 조합형 한글, 보이지 않는 문자 등)를 쓰는 사용자는 다시 배포할 때까지 비밀번호 로그인이 막힌다. ASCII 비밀번호는 영향이 없다.
- 로그인·MFA·비밀번호 변경 요청 본문은 64 KiB까지만 받는다(`MAX_LOGIN_BODY_BYTES`). 비밀번호 길이 제한 대신 해시 부담 공격을 막는 장치다.

### 기본 비밀번호로 메일에 접속하는 계정 찾기 (앱 비밀번호 전환 준비)

메일 프로토콜(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)

메일 사용자가 웹에서 직접 비밀번호를 바꾸고 앱 비밀번호를 발급·철회하는 화면이다. 기본으로 꺼져 있다.
Expand Down
50 changes: 50 additions & 0 deletions packages/admin-cmd/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "number", 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",
Expand Down
54 changes: 54 additions & 0 deletions packages/admin-cmd/test/mail-password-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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"]);
});
});
17 changes: 14 additions & 3 deletions packages/store/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ export async function scramKeysFor(db: DbDriver, email: string): Promise<StoredS
FROM credentials c
JOIN accounts a ON a.id = c.account_id
WHERE a.email = ? AND a.status = 1 AND c.kind = ?
ORDER BY c.created_at ASC`,
ORDER BY c.created_at ASC, c.id ASC`,
params: [email.toLowerCase(), CREDENTIAL_KIND.password],
});
const secret = rows[0]?.secret;
Expand All @@ -506,15 +506,26 @@ 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`,
ORDER BY c.created_at ASC, c.id ASC`,
params: [CREDENTIAL_KIND.password, email.toLowerCase()],
});
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) };
}

Expand Down
Loading