From f50659a35aa7842bd683f177d84f6a50ce8cdf34 Mon Sep 17 00:00:00 2001 From: Marcus Date: Thu, 22 Oct 2026 13:08:06 +0800 Subject: [PATCH 1/3] =?UTF-8?q?test(user-auth-api):=20=E9=A9=97=E6=94=B6?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6=EF=BC=88email=20=E7=99=BB=E5=85=A5=E3=80=81?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E7=9F=A9=E9=99=A3=E3=80=81CSRF=E3=80=81?= =?UTF-8?q?=E9=99=90=E9=80=9F=E3=80=81/users=E3=80=81V6=EF=BC=89=E4=B8=A6?= =?UTF-8?q?=E6=94=B9=E5=AF=AB=E5=AE=A2=E6=9C=8D=E5=B8=B3=E5=AF=86=E7=99=BB?= =?UTF-8?q?=E5=85=A5=E7=9A=84=E6=97=A2=E6=9C=89=E9=A9=97=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0134QMUz6NTfqU8Yrh3BJekj --- ...spatchPropertiesStartupAcceptanceTest.java | 2 - .../query/EnrollmentQueryAcceptanceTest.java | 16 +- .../RelayStartupValidationAcceptanceTest.java | 4 +- .../com/cdp/acceptance/security/AuthHttp.java | 177 +++++++++ .../AuthenticationAcceptanceTest.java | 54 ++- .../security/DefaultDenyAcceptanceTest.java | 26 +- .../EnrollmentsPathAcceptanceTest.java | 17 +- .../LogoutCookieConfigAcceptanceTest.java | 10 +- .../MeEndpointRoleAcceptanceTest.java | 17 +- .../SecretToStringAcceptanceTest.java | 33 +- .../StartupValidationAcceptanceTest.java | 16 +- .../user-auth-api/CsrfAcceptanceTest.java | 137 +++++++ .../LastAdminAcceptanceTest.java | 197 ++++++++++ .../LoginRateLimitAcceptanceTest.java | 211 +++++++++++ .../LoginSessionAcceptanceTest.java | 341 ++++++++++++++++++ .../RoleMatrixAcceptanceTest.java | 233 ++++++++++++ .../SchemaV6AppUserAcceptanceTest.java | 156 ++++++++ .../user-auth-api/UsersApiAcceptanceTest.java | 286 +++++++++++++++ 18 files changed, 1824 insertions(+), 109 deletions(-) create mode 100644 api/src/test/java/com/cdp/acceptance/security/AuthHttp.java create mode 100644 api/src/test/java/com/cdp/acceptance/user-auth-api/CsrfAcceptanceTest.java create mode 100644 api/src/test/java/com/cdp/acceptance/user-auth-api/LastAdminAcceptanceTest.java create mode 100644 api/src/test/java/com/cdp/acceptance/user-auth-api/LoginRateLimitAcceptanceTest.java create mode 100644 api/src/test/java/com/cdp/acceptance/user-auth-api/LoginSessionAcceptanceTest.java create mode 100644 api/src/test/java/com/cdp/acceptance/user-auth-api/RoleMatrixAcceptanceTest.java create mode 100644 api/src/test/java/com/cdp/acceptance/user-auth-api/SchemaV6AppUserAcceptanceTest.java create mode 100644 api/src/test/java/com/cdp/acceptance/user-auth-api/UsersApiAcceptanceTest.java diff --git a/api/src/test/java/com/cdp/acceptance/ingest/DispatchPropertiesStartupAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/ingest/DispatchPropertiesStartupAcceptanceTest.java index 3fd5298..aef9dc5 100644 --- a/api/src/test/java/com/cdp/acceptance/ingest/DispatchPropertiesStartupAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/ingest/DispatchPropertiesStartupAcceptanceTest.java @@ -31,8 +31,6 @@ private static String[] args(String... overrides) { "--cdp.relay.poll-interval=PT1H", "--cdp.security.ingest-api-key=SECRET-aaaa-1111", "--cdp.security.admin-api-key=SECRET-bbbb-2222", - "--cdp.security.support-username=agent", - "--cdp.security.support-password=SECRET-pppp-3333", P + "max-attempts=5", P + "backoff-initial=PT10S", P + "backoff-max=PT10M")) { diff --git a/api/src/test/java/com/cdp/acceptance/query/EnrollmentQueryAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/query/EnrollmentQueryAcceptanceTest.java index 1272fd0..681a476 100644 --- a/api/src/test/java/com/cdp/acceptance/query/EnrollmentQueryAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/query/EnrollmentQueryAcceptanceTest.java @@ -30,6 +30,7 @@ import com.cdp.acceptance.engine.EngineTestSupport; import com.cdp.acceptance.engine.EngineTestSupport.MutableClock; +import com.cdp.acceptance.security.AuthHttp; import com.cdp.engine.runtime.AdvanceLoop; import com.cdp.ingest.EventDispatcher; @@ -94,19 +95,8 @@ private JsonNode getOk(String path) throws Exception { } private String sessionCookie() throws Exception { - HttpResponse r = - call( - "POST", - "/api/v1/auth/login", - null, - null, - "{\"username\":\"test-support\",\"password\":\"test-support-password\"}"); - assertThat(r.statusCode()).isEqualTo(204); - return r.headers().allValues("Set-Cookie").stream() - .filter(c -> c.startsWith("CDP_SESSION=")) - .findFirst() - .orElseThrow() - .split(";")[0]; + // user-auth-api 改寫:email 登入的 VIEWER(帳號經管理金鑰建立)取代 v0.1.0 客服帳密 + return AuthHttp.loginNewUser(port, "VIEWER"); } private void assertProblem(HttpResponse r, int status, String code) throws Exception { diff --git a/api/src/test/java/com/cdp/acceptance/relay-loop-rewrite/RelayStartupValidationAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/relay-loop-rewrite/RelayStartupValidationAcceptanceTest.java index a6cb743..2d6fe8b 100644 --- a/api/src/test/java/com/cdp/acceptance/relay-loop-rewrite/RelayStartupValidationAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/relay-loop-rewrite/RelayStartupValidationAcceptanceTest.java @@ -31,9 +31,7 @@ private static String[] args(String... overrides) { "--cdp.relay.lease=PT30S", "--spring.task.scheduling.pool.size=4", "--cdp.security.ingest-api-key=SECRET-aaaa-1111", - "--cdp.security.admin-api-key=SECRET-bbbb-2222", - "--cdp.security.support-username=agent", - "--cdp.security.support-password=SECRET-pppp-3333" + "--cdp.security.admin-api-key=SECRET-bbbb-2222" }) { merged.put(a.substring(0, a.indexOf('=')), a); } diff --git a/api/src/test/java/com/cdp/acceptance/security/AuthHttp.java b/api/src/test/java/com/cdp/acceptance/security/AuthHttp.java new file mode 100644 index 0000000..d04aaa9 --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/security/AuthHttp.java @@ -0,0 +1,177 @@ +package com.cdp.acceptance.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * 驗收共用:auth.md 的 HTTP 輔助(email 登入、cookie 罐、CSRF 標頭)。使用者一律經管理金鑰 {@code POST /api/v1/users} + * 建立,不依賴任何預設帳號。cookie 以字串 {@code "CDP_SESSION=…; XSRF-TOKEN=…"} 傳遞;寫入請求由 {@link #send} 自動帶上 + * {@code X-XSRF-TOKEN}(值取自 cookie 字串當下的 XSRF-TOKEN),要測缺值或錯值時用 {@link #sendRaw}。 + */ +public final class AuthHttp { + + public static final String ADMIN_KEY = "Bearer test-admin-key"; + public static final String INGEST_KEY = "Bearer test-ingest-key"; + public static final String PASSWORD = "correct-horse-battery"; + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + private static final ObjectMapper JSON = new ObjectMapper(); + + private AuthHttp() {} + + /** 回應(status、標頭、本文)。 */ + public record Resp(int status, HttpHeaders headers, String body) { + public JsonNode json() { + try { + return JSON.readTree(body); + } catch (Exception e) { + throw new AssertionError("not JSON: " + body, e); + } + } + + public String code() { + return json().path("code").asString(); + } + + public List setCookies() { + return headers.allValues("Set-Cookie"); + } + + /** 指定名稱的 Set-Cookie 整行;沒有回 null。 */ + public String setCookie(String name) { + return setCookies().stream().filter(c -> c.startsWith(name + "=")).findFirst().orElse(null); + } + } + + public static String url(int port, String path) { + return "http://localhost:" + port + path; + } + + /** 不做任何自動處理的請求;headers 可為 null。 */ + public static Resp sendRaw(int port, String method, String path, Map headers, String body) + throws Exception { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url(port, path))); + if (headers != null) headers.forEach(b::header); + if (body != null) b.header("Content-Type", "application/json"); + b.method(method, body == null ? BodyPublishers.noBody() : BodyPublishers.ofString(body)); + HttpResponse r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()); + return new Resp(r.statusCode(), r.headers(), r.body()); + } + + /** auth(Authorization 標頭值,可 null)與 cookie(可 null);非 GET 且 cookie 內有 XSRF-TOKEN 時自動帶 CSRF 標頭。 */ + public static Resp send(int port, String method, String path, String auth, String cookie, String body) + throws Exception { + Map h = new LinkedHashMap<>(); + if (auth != null) h.put("Authorization", auth); + if (cookie != null) h.put("Cookie", cookie); + String x = xsrf(cookie); + if (x != null && !"GET".equals(method) && !"HEAD".equals(method)) h.put("X-XSRF-TOKEN", x); + return sendRaw(port, method, path, h, body); + } + + public static Resp bearer(int port, String method, String path, String auth, String body) throws Exception { + return send(port, method, path, auth, null, body); + } + + public static Resp session(int port, String method, String path, String cookie, String body) throws Exception { + return send(port, method, path, null, cookie, body); + } + + public static String xsrf(String cookie) { + return cookieValue(cookie, "XSRF-TOKEN"); + } + + public static String cookieValue(String cookie, String name) { + if (cookie == null) return null; + for (String part : cookie.split(";\\s*")) { + if (part.startsWith(name + "=")) return part.substring(name.length() + 1); + } + return null; + } + + /** 把回應的 Set-Cookie 套用到 cookie 字串(Max-Age=0 或空值 = 刪除)。 */ + public static String merge(String cookie, Resp r) { + Map jar = new LinkedHashMap<>(); + if (cookie != null && !cookie.isBlank()) { + for (String part : cookie.split(";\\s*")) { + int i = part.indexOf('='); + if (i > 0) jar.put(part.substring(0, i), part.substring(i + 1)); + } + } + for (String sc : r.setCookies()) { + String[] attrs = sc.split(";\\s*"); + int i = attrs[0].indexOf('='); + if (i <= 0) continue; + String name = attrs[0].substring(0, i); + String value = attrs[0].substring(i + 1); + boolean expired = value.isEmpty(); + for (String a : attrs) { + if (a.equalsIgnoreCase("Max-Age=0") || a.startsWith("Expires=Thu, 01 Jan 1970")) expired = true; + } + if (expired) jar.remove(name); + else jar.put(name, value); + } + StringBuilder sb = new StringBuilder(); + jar.forEach((k, v) -> sb.append(sb.length() == 0 ? "" : "; ").append(k).append('=').append(v)); + return sb.toString(); + } + + public static String uniqueEmail() { + return "u" + UUID.randomUUID().toString().replace("-", "").substring(0, 16) + "@Example.com"; + } + + public static String credentials(String email, String password) { + return "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}"; + } + + public static String userBody(String email, String name, String role, String password) { + return "{\"email\":\"" + email + "\",\"display_name\":\"" + name + "\",\"role\":\"" + role + + "\",\"password\":\"" + password + "\"}"; + } + + /** 管理金鑰建立使用者,回應原樣回傳。 */ + public static Resp createUser(int port, String email, String name, String role, String password) + throws Exception { + return bearer(port, "POST", "/api/v1/users", ADMIN_KEY, userBody(email, name, role, password)); + } + + /** 建立並確認 201,回傳 user_id。 */ + public static String createUserId(int port, String email, String role) throws Exception { + Resp r = createUser(port, email, "QA " + role, role, PASSWORD); + assertThat(r.status()).as("create user: %s", r.body()).isEqualTo(201); + return r.json().path("user_id").asString(); + } + + /** 登入並呼叫 /auth/me 取得 XSRF-TOKEN;回傳 cookie 字串。 */ + public static String login(int port, String email, String password) throws Exception { + Resp r = sendRaw(port, "POST", "/api/v1/auth/login", null, credentials(email, password)); + assertThat(r.status()).as("login: %s", r.body()).isEqualTo(204); + String cookie = merge(null, r); + assertThat(cookieValue(cookie, "CDP_SESSION")).isNotBlank(); + Resp me = send(port, "GET", "/api/v1/auth/me", null, cookie, null); + assertThat(me.status()).as("me after login: %s", me.body()).isEqualTo(200); + cookie = merge(cookie, me); + assertThat(xsrf(cookie)).as("me must set XSRF-TOKEN").isNotBlank(); + return cookie; + } + + /** 建一個指定角色的新使用者並登入;回傳 cookie 字串。 */ + public static String loginNewUser(int port, String role) throws Exception { + String email = uniqueEmail(); + createUserId(port, email, role); + return login(port, email, PASSWORD); + } +} diff --git a/api/src/test/java/com/cdp/acceptance/security/AuthenticationAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/security/AuthenticationAcceptanceTest.java index 32ed3fc..a5ae9d1 100644 --- a/api/src/test/java/com/cdp/acceptance/security/AuthenticationAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/security/AuthenticationAcceptanceTest.java @@ -9,6 +9,7 @@ import java.net.http.HttpResponse; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -19,7 +20,10 @@ import tools.jackson.databind.ObjectMapper; /** - * BE-04 驗收:契約 §1.1、§1.2、§5。只驗認證層:已通過認證的請求會進 controller(BE-01 未合併時為 500/400 等), + * user-auth-api 改寫(auth.md):登入改 email+密碼,帳號由管理金鑰經 POST /users 建立(VIEWER 取代 v0.1.0 的 SUPPORT); + * session 寫入請求自動帶 X-XSRF-TOKEN。測試意圖不變。 + * + *

BE-04 驗收:契約 §1.1、§1.2、§5。只驗認證層:已通過認證的請求會進 controller(BE-01 未合併時為 500/400 等), * 故一律只斷言「不是 401/403」。 * *

閒置逾時(CDP_SESSION_IDLE_TIMEOUT):Tomcat 逾時最小單位為分鐘,驗收測試無法快轉,改由 worker 單元測試與 verifier 手動驗證。 @@ -34,8 +38,7 @@ class AuthenticationAcceptanceTest { private static final String INGEST = "Bearer test-ingest-key"; private static final String ADMIN = "Bearer test-admin-key"; - private static final String USER = "test-support"; - private static final String PASS = "test-support-password"; + private static final String PASS = AuthHttp.PASSWORD; private static final String ID = "01JXXXXXXXXXXXXXXXXXXXXXXX"; private final HttpClient http = HttpClient.newHttpClient(); @@ -43,24 +46,34 @@ class AuthenticationAcceptanceTest { @LocalServerPort int port; + private String user; + + @BeforeEach + void createViewer() throws Exception { + user = AuthHttp.uniqueEmail(); + AuthHttp.createUserId(port, user, "VIEWER"); + } + private HttpResponse call(String method, String path, String auth, String cookie, String body) throws Exception { HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)); if (auth != null) b.header("Authorization", auth); if (cookie != null) b.header("Cookie", cookie); + if (cookie != null && !"GET".equals(method) && AuthHttp.xsrf(cookie) != null) { + b.header("X-XSRF-TOKEN", AuthHttp.xsrf(cookie)); + } if (body != null) b.header("Content-Type", "application/json"); b.method(method, body == null ? BodyPublishers.noBody() : BodyPublishers.ofString(body)); return http.send(b.build(), HttpResponse.BodyHandlers.ofString()); } - private String creds(String user, String pass) { - return "{\"username\":\"" + user + "\",\"password\":\"" + pass + "\"}"; + private String creds(String email, String pass) { + return AuthHttp.credentials(email, pass); } + /** 登入並以 /auth/me 取得 XSRF-TOKEN;回傳 "CDP_SESSION=…; XSRF-TOKEN=…"。 */ private String login() throws Exception { - HttpResponse r = call("POST", "/api/v1/auth/login", null, null, creds(USER, PASS)); - assertThat(r.statusCode()).isEqualTo(204); - return sessionCookie(r); + return AuthHttp.login(port, user, PASS); } private String setCookie(HttpResponse r) { @@ -153,7 +166,7 @@ void admin_key_for_journeys_and_queries_but_not_events() throws Exception { } @Test - void support_session_is_get_only() throws Exception { + void viewer_session_is_read_only() throws Exception { String cookie = login(); assertReachedController(call("GET", "/api/v1/enrollments?q=x", null, cookie, null)); assertReachedController(call("GET", "/api/v1/enrollments/" + ID, null, cookie, null)); @@ -173,7 +186,7 @@ void authorization_header_wins_over_cookie() throws Exception { @Test void login_success_sets_hardened_cookie() throws Exception { - HttpResponse r = call("POST", "/api/v1/auth/login", null, null, creds(USER, PASS)); + HttpResponse r = call("POST", "/api/v1/auth/login", null, null, creds(user, PASS)); assertThat(r.statusCode()).isEqualTo(204); String sc = setCookie(r); assertThat(sc).containsIgnoringCase("HttpOnly"); @@ -184,18 +197,18 @@ void login_success_sets_hardened_cookie() throws Exception { @Test void each_login_issues_a_new_session_id() throws Exception { - String a = login(); - assertThat(login()).isNotEqualTo(a); + String a = AuthHttp.cookieValue(login(), "CDP_SESSION"); + assertThat(AuthHttp.cookieValue(login(), "CDP_SESSION")).isNotEqualTo(a); // 帶著舊 cookie 再登入也要換新 id(防 session fixation) - HttpResponse r = call("POST", "/api/v1/auth/login", null, a, creds(USER, PASS)); + HttpResponse r = call("POST", "/api/v1/auth/login", null, "CDP_SESSION=" + a, creds(user, PASS)); assertThat(r.statusCode()).isEqualTo(204); - assertThat(sessionCookie(r)).isNotEqualTo(a); + assertThat(sessionCookie(r)).isNotEqualTo("CDP_SESSION=" + a); } @Test void bad_credentials_are_401_invalid_credentials_and_indistinguishable() throws Exception { - HttpResponse wrongPass = call("POST", "/api/v1/auth/login", null, null, creds(USER, "nope")); - HttpResponse wrongUser = call("POST", "/api/v1/auth/login", null, null, creds("nobody", PASS)); + HttpResponse wrongPass = call("POST", "/api/v1/auth/login", null, null, creds(user, "nope")); + HttpResponse wrongUser = call("POST", "/api/v1/auth/login", null, null, creds("nobody@example.com", PASS)); assertProblem(wrongPass, 401, "INVALID_CREDENTIALS"); assertProblem(wrongUser, 401, "INVALID_CREDENTIALS"); assertThat(json.readTree(wrongPass.body()).path("detail")) @@ -206,23 +219,24 @@ void bad_credentials_are_401_invalid_credentials_and_indistinguishable() throws @Test void login_validation_and_media_type() throws Exception { HttpResponse missing = - call("POST", "/api/v1/auth/login", null, null, "{\"username\":\"" + USER + "\"}"); + call("POST", "/api/v1/auth/login", null, null, "{\"email\":\"" + user + "\"}"); assertProblem(missing, 400, "VALIDATION_FAILED"); assertThat(json.readTree(missing.body()).path("errors").toString()).contains("password"); assertProblem(call("POST", "/api/v1/auth/login", null, null, creds("", "")), 400, "VALIDATION_FAILED"); HttpRequest req = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/api/v1/auth/login")) .header("Content-Type", "text/plain") - .POST(BodyPublishers.ofString("username=a&password=b")) + .POST(BodyPublishers.ofString("email=a&password=b")) .build(); assertProblem(http.send(req, HttpResponse.BodyHandlers.ofString()), 415, "UNSUPPORTED_MEDIA_TYPE"); } @Test - void me_returns_username_with_session() throws Exception { + void me_returns_current_user_with_session() throws Exception { HttpResponse r = call("GET", "/api/v1/auth/me", null, login(), null); assertThat(r.statusCode()).isEqualTo(200); - assertThat(json.readTree(r.body()).path("username").asText()).isEqualTo(USER); + assertThat(json.readTree(r.body()).path("email").asText()).isEqualTo(user.toLowerCase()); + assertThat(json.readTree(r.body()).path("role").asText()).isEqualTo("VIEWER"); } @Test diff --git a/api/src/test/java/com/cdp/acceptance/security/DefaultDenyAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/security/DefaultDenyAcceptanceTest.java index ccc319f..b15a15f 100644 --- a/api/src/test/java/com/cdp/acceptance/security/DefaultDenyAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/security/DefaultDenyAcceptanceTest.java @@ -24,7 +24,10 @@ import tools.jackson.databind.ObjectMapper; /** - * FIX-04 驗收:授權預設拒絕(契約 §1.1:未列於角色表的路徑,有效憑證回 403,未認證回 401)與 session 閒置逾時。 + * user-auth-api 改寫(auth.md):session 改為 email 登入的 VIEWER(取代 SUPPORT),寫入請求自動帶 CSRF 標頭; + * UNLISTED 移除 {@code GET /api/v1/journeys}(auth.md §2 已列為 VIEWER 以上可讀)。意圖不變。 + * + *

FIX-04 驗收:授權預設拒絕(契約 §1.1:未列於角色表的路徑,有效憑證回 403,未認證回 401)與 session 閒置逾時。 * 假設:ADMIN 對未知路徑的結果不在此斷言(既有 FrameworkErrorFormatAcceptanceTest 期待 ADMIN 得 404)。 */ @SpringBootTest( @@ -35,14 +38,11 @@ class DefaultDenyAcceptanceTest { private static final String INGEST = "Bearer test-ingest-key"; - private static final String USER = "test-support"; - private static final String PASS = "test-support-password"; /** 契約 §1.1 沒有列出的 method+path。 */ private static final List UNLISTED = List.of( new String[] {"GET", "/api/v1/not-in-contract"}, new String[] {"GET", "/api/v1/events"}, - new String[] {"GET", "/api/v1/journeys"}, new String[] {"GET", "/api/v1/auth"}, new String[] {"GET", "/api/unknown"}, new String[] {"GET", "/actuator"}, @@ -65,24 +65,16 @@ private HttpResponse call(String method, String path, String auth, Strin HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)); if (auth != null) b.header("Authorization", auth); if (cookie != null) b.header("Cookie", cookie); + if (cookie != null && !"GET".equals(method) && AuthHttp.xsrf(cookie) != null) { + b.header("X-XSRF-TOKEN", AuthHttp.xsrf(cookie)); + } if (body != null) b.header("Content-Type", "application/json"); b.method(method, body == null ? BodyPublishers.noBody() : BodyPublishers.ofString(body)); return http.send(b.build(), HttpResponse.BodyHandlers.ofString()); } private String login() throws Exception { - HttpResponse r = call( - "POST", - "/api/v1/auth/login", - null, - null, - "{\"username\":\"" + USER + "\",\"password\":\"" + PASS + "\"}"); - assertThat(r.statusCode()).isEqualTo(204); - return r.headers().allValues("Set-Cookie").stream() - .filter(c -> c.startsWith("CDP_SESSION=")) - .findFirst() - .orElseThrow() - .split(";")[0]; + return AuthHttp.loginNewUser(port, "VIEWER"); } private void assertProblem(HttpResponse r, int status, String code, String what) throws Exception { @@ -108,7 +100,7 @@ void unlistedPaths_ingestKey_are403() throws Exception { } @Test - void unlistedPaths_supportSession_are403() throws Exception { + void unlistedPaths_viewerSession_are403() throws Exception { String cookie = login(); for (String[] p : UNLISTED) { assertProblem(call(p[0], p[1], null, cookie, null), 403, "FORBIDDEN", p[0] + " " + p[1]); diff --git a/api/src/test/java/com/cdp/acceptance/security/EnrollmentsPathAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/security/EnrollmentsPathAcceptanceTest.java index ddd92d9..1eedb12 100644 --- a/api/src/test/java/com/cdp/acceptance/security/EnrollmentsPathAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/security/EnrollmentsPathAcceptanceTest.java @@ -5,7 +5,6 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; -import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpResponse; import java.util.List; @@ -18,7 +17,8 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; -/** FIX-04 回歸(qa FAIL 1):enrollments 只放行契約 §4 的三個樣式,其餘子路徑對 SUPPORT 與 INGEST 回 403 §5 格式。 */ +/** user-auth-api 改寫:session 改為 email 登入的 VIEWER(取代 SUPPORT)。 + * FIX-04 回歸(qa FAIL 1):enrollments 只放行契約 §4 的三個樣式,其餘子路徑對 SUPPORT 與 INGEST 回 403 §5 格式。 */ @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "spring.rabbitmq.listener.simple.auto-startup=false") @@ -46,18 +46,7 @@ private HttpResponse get(String path, String auth, String cookie) throws } private String login() throws Exception { - HttpRequest r = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/api/v1/auth/login")) - .header("Content-Type", "application/json") - .POST(BodyPublishers.ofString( - "{\"username\":\"test-support\",\"password\":\"test-support-password\"}")) - .build(); - HttpResponse res = http.send(r, HttpResponse.BodyHandlers.ofString()); - assertThat(res.statusCode()).isEqualTo(204); - return res.headers().allValues("Set-Cookie").stream() - .filter(c -> c.startsWith("CDP_SESSION=")) - .findFirst() - .orElseThrow() - .split(";")[0]; + return AuthHttp.loginNewUser(port, "VIEWER"); } private void assertForbidden(HttpResponse r, String what) throws Exception { diff --git a/api/src/test/java/com/cdp/acceptance/security/LogoutCookieConfigAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/security/LogoutCookieConfigAcceptanceTest.java index 5ebceed..1b2cb07 100644 --- a/api/src/test/java/com/cdp/acceptance/security/LogoutCookieConfigAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/security/LogoutCookieConfigAcceptanceTest.java @@ -15,7 +15,8 @@ import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -/** FIX-04 驗收:logout 清 cookie 的名稱與路徑取自 server.servlet.session.cookie.*,不寫死。 */ +/** user-auth-api 改寫:登入改 email+密碼(帳號經管理金鑰建立)。 + * FIX-04 驗收:logout 清 cookie 的名稱與路徑取自 server.servlet.session.cookie.*,不寫死。 */ @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { @@ -41,10 +42,9 @@ private HttpResponse post(String path, String cookie, String body) throw @Test void logoutClearsCookieWithConfiguredNameAndPath() throws Exception { - HttpResponse login = post( - "/api/v1/auth/login", - null, - "{\"username\":\"test-support\",\"password\":\"test-support-password\"}"); + String email = AuthHttp.uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + HttpResponse login = post("/api/v1/auth/login", null, AuthHttp.credentials(email, AuthHttp.PASSWORD)); assertThat(login.statusCode()).isEqualTo(204); String issued = login.headers().allValues("Set-Cookie").stream() .filter(c -> c.startsWith("CDP_ALT_SID=")) diff --git a/api/src/test/java/com/cdp/acceptance/security/MeEndpointRoleAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/security/MeEndpointRoleAcceptanceTest.java index 00110bb..17b9f72 100644 --- a/api/src/test/java/com/cdp/acceptance/security/MeEndpointRoleAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/security/MeEndpointRoleAcceptanceTest.java @@ -5,7 +5,6 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; -import java.net.http.HttpRequest.BodyPublishers; import java.net.http.HttpResponse; import org.junit.jupiter.api.Test; @@ -17,7 +16,8 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; -/** FIX-04 回歸(qa FAIL 1):enrollments 只放行契約 §4 的三個樣式,其餘子路徑對 SUPPORT 與 INGEST 回 403 §5 格式。 */ +/** user-auth-api 改寫:session 改為 email 登入的 VIEWER(取代 SUPPORT);me 回 CurrentUser 另見 user-auth-api 驗收。 + * FIX-04 回歸(qa FAIL 1):enrollments 只放行契約 §4 的三個樣式,其餘子路徑對 SUPPORT 與 INGEST 回 403 §5 格式。 */ @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "spring.rabbitmq.listener.simple.auto-startup=false") @@ -43,18 +43,7 @@ private HttpResponse get(String path, String auth, String cookie) throws } private String login() throws Exception { - HttpRequest r = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/api/v1/auth/login")) - .header("Content-Type", "application/json") - .POST(BodyPublishers.ofString( - "{\"username\":\"test-support\",\"password\":\"test-support-password\"}")) - .build(); - HttpResponse res = http.send(r, HttpResponse.BodyHandlers.ofString()); - assertThat(res.statusCode()).isEqualTo(204); - return res.headers().allValues("Set-Cookie").stream() - .filter(c -> c.startsWith("CDP_SESSION=")) - .findFirst() - .orElseThrow() - .split(";")[0]; + return AuthHttp.loginNewUser(port, "VIEWER"); } private void assertForbidden(HttpResponse r, String what) throws Exception { diff --git a/api/src/test/java/com/cdp/acceptance/security/SecretToStringAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/security/SecretToStringAcceptanceTest.java index af17a7d..76a6f5b 100644 --- a/api/src/test/java/com/cdp/acceptance/security/SecretToStringAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/security/SecretToStringAcceptanceTest.java @@ -2,32 +2,51 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.Map; + import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; import com.cdp.security.AuthProperties; import com.cdp.security.LoginRequest; +import tools.jackson.databind.json.JsonMapper; + /** FIX-04 驗收:機密不經 toString 外洩(日誌、例外訊息、除錯輸出)。 */ class SecretToStringAcceptanceTest { + // user-auth-api 改寫:AuthProperties/LoginRequest 的建構子簽章會隨實作改變(客服帳密移除、username → email), + // 改以 Binder/Jackson 由屬性名與 JSON 欄位建立,測試意圖(toString 不外洩機密)不變。 + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private static AuthProperties bind(Map props) { + return new Binder(new MapConfigurationPropertySource(props)) + .bind("cdp.security", AuthProperties.class) + .orElseThrow(() -> new AssertionError("cannot bind AuthProperties")); + } + @Test void authPropertiesToStringHasNoSecrets() { - AuthProperties p = new AuthProperties("ingest-SECRET-1a2b", "admin-SECRET-3c4d", "agent-name", "pw-SECRET-5e6f"); - assertThat(p.toString()).doesNotContain("ingest-SECRET-1a2b", "admin-SECRET-3c4d", "pw-SECRET-5e6f"); + AuthProperties p = bind(Map.of( + "cdp.security.ingest-api-key", "ingest-SECRET-1a2b", + "cdp.security.admin-api-key", "admin-SECRET-3c4d")); + assertThat(p.toString()).doesNotContain("ingest-SECRET-1a2b", "admin-SECRET-3c4d"); } @Test void authPropertiesToStringDoesNotNpeOnNulls() { - assertThat(new AuthProperties(null, null, null, null).toString()).isNotNull(); + assertThat(bind(Map.of("cdp.security.ingest-api-key", "x")).toString()).isNotNull(); } @Test - void loginRequestToStringHasNoPassword() { - assertThat(new LoginRequest("agent-name", "pw-SECRET-7g8h").toString()).doesNotContain("pw-SECRET-7g8h"); + void loginRequestToStringHasNoPassword() throws Exception { + LoginRequest r = JSON.readValue("{\"email\":\"a@example.com\",\"password\":\"pw-SECRET-7g8h\"}", LoginRequest.class); + assertThat(r.toString()).doesNotContain("pw-SECRET-7g8h"); } @Test - void loginRequestToStringDoesNotNpeOnNulls() { - assertThat(new LoginRequest(null, null).toString()).isNotNull(); + void loginRequestToStringDoesNotNpeOnNulls() throws Exception { + assertThat(JSON.readValue("{}", LoginRequest.class).toString()).isNotNull(); } } diff --git a/api/src/test/java/com/cdp/acceptance/security/StartupValidationAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/security/StartupValidationAcceptanceTest.java index cc854a8..f5ace69 100644 --- a/api/src/test/java/com/cdp/acceptance/security/StartupValidationAcceptanceTest.java +++ b/api/src/test/java/com/cdp/acceptance/security/StartupValidationAcceptanceTest.java @@ -14,7 +14,7 @@ import com.cdp.CdpApplication; /** - * FIX-04 驗收:金鑰或客服密碼為空、或兩把金鑰相同 → 啟動失敗,訊息不含機密值。 + * FIX-04 驗收(user-auth-api 改寫:客服帳密 CDP_SUPPORT_* 已廢除,移除對應啟動參數與兩個空值測試):金鑰為空、或兩把金鑰相同 → 啟動失敗,訊息不含機密值。 * 以完整應用程式啟動(Testcontainers 提供 DB 與 MQ),確保失敗原因來自設定驗證而非基礎設施。 * cause 鏈須含 IllegalStateException,且訊息點出 cdp.security. 開頭的屬性名。 */ @@ -31,9 +31,7 @@ private void assertStartFails(String... overrides) { for (String a : List.of( "--spring.rabbitmq.listener.simple.auto-startup=false", "--cdp.security.ingest-api-key=" + SECRET_A, - "--cdp.security.admin-api-key=" + SECRET_B, - "--cdp.security.support-username=agent", - "--cdp.security.support-password=" + SECRET_PW)) { + "--cdp.security.admin-api-key=" + SECRET_B)) { merged.put(a.substring(0, a.indexOf('=')), a); } for (String a : overrides) { @@ -78,16 +76,6 @@ void blankAdminKey_failsStartup() { assertStartFails("--cdp.security.admin-api-key= "); } - @Test - void emptySupportPassword_failsStartup() { - assertStartFails("--cdp.security.support-password="); - } - - @Test - void emptySupportUsername_failsStartup() { - assertStartFails("--cdp.security.support-username="); - } - @Test void identicalKeys_failStartup() { assertStartFails("--cdp.security.ingest-api-key=" + SAME, "--cdp.security.admin-api-key=" + SAME); diff --git a/api/src/test/java/com/cdp/acceptance/user-auth-api/CsrfAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/user-auth-api/CsrfAcceptanceTest.java new file mode 100644 index 0000000..98b76a3 --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/user-auth-api/CsrfAcceptanceTest.java @@ -0,0 +1,137 @@ +package com.cdp.acceptance.userauth; + +import static com.cdp.acceptance.security.AuthHttp.ADMIN_KEY; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import com.cdp.acceptance.security.AuthHttp; +import com.cdp.acceptance.security.AuthHttp.Resp; +import com.cdp.acceptance.security.SecurityTestSupport; + +/** user-auth-api 驗收:auth.md §1.1 CSRF(session 的非 GET 請求)。 */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.rabbitmq.listener.simple.auto-startup=false") +@ActiveProfiles("test") +@Import(SecurityTestSupport.class) +class CsrfAcceptanceTest { + + @LocalServerPort int port; + + private String newUserBody() { + return AuthHttp.userBody(AuthHttp.uniqueEmail(), "csrf target", "VIEWER", AuthHttp.PASSWORD); + } + + private Resp post(String cookie, Map extra, String body) throws Exception { + Map h = new LinkedHashMap<>(); + h.put("Cookie", cookie); + h.putAll(extra); + return AuthHttp.sendRaw(port, "POST", "/api/v1/users", h, body); + } + + private void assertCsrfInvalid(Resp r) { + assertThat(r.status()).as(r.body()).isEqualTo(403); + assertThat(r.headers().firstValue("Content-Type").orElse("")).startsWith("application/problem+json"); + assertThat(r.code()).isEqualTo("CSRF_INVALID"); + assertThat(r.json().path("request_id").asString()).isNotBlank(); + } + + @Test + void session_write_without_header_is_csrf_invalid() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + assertCsrfInvalid(post(cookie, Map.of(), newUserBody())); + } + + @Test + void session_write_with_wrong_header_is_csrf_invalid() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + assertCsrfInvalid(post(cookie, Map.of("X-XSRF-TOKEN", "not-the-token"), newUserBody())); + assertCsrfInvalid(post(cookie, Map.of("X-XSRF-TOKEN", ""), newUserBody())); + } + + @Test + void session_write_with_header_but_no_xsrf_cookie_is_csrf_invalid() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + String sessionOnly = "CDP_SESSION=" + AuthHttp.cookieValue(cookie, "CDP_SESSION"); + assertCsrfInvalid(post(sessionOnly, Map.of("X-XSRF-TOKEN", AuthHttp.xsrf(cookie)), newUserBody())); + } + + @Test + void session_write_with_matching_header_succeeds_for_all_write_methods() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + Map h = Map.of("Cookie", cookie, "X-XSRF-TOKEN", AuthHttp.xsrf(cookie)); + Resp created = AuthHttp.sendRaw(port, "POST", "/api/v1/users", h, newUserBody()); + assertThat(created.status()).as(created.body()).isEqualTo(201); + String id = created.json().path("user_id").asString(); + Resp patched = AuthHttp.sendRaw(port, "PATCH", "/api/v1/users/" + id, h, "{\"display_name\":\"x\"}"); + assertThat(patched.status()).as(patched.body()).isEqualTo(200); + Resp noHeaderPatch = AuthHttp.sendRaw( + port, "PATCH", "/api/v1/users/" + id, Map.of("Cookie", cookie), "{\"display_name\":\"y\"}"); + assertCsrfInvalid(noHeaderPatch); + } + + @Test + void get_needs_no_csrf_header() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + Resp r = AuthHttp.sendRaw(port, "GET", "/api/v1/users", Map.of("Cookie", cookie), null); + assertThat(r.status()).isEqualTo(200); + } + + @Test + void me_gives_a_fresh_token_so_retry_succeeds() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + // 模擬前端遇 CSRF_INVALID:丟掉舊值、呼叫 /auth/me 取新值後重試 + String stale = "CDP_SESSION=" + AuthHttp.cookieValue(cookie, "CDP_SESSION") + "; XSRF-TOKEN=stale"; + assertCsrfInvalid(post(stale, Map.of("X-XSRF-TOKEN", "stale"), newUserBody())); + Resp me = AuthHttp.sendRaw(port, "GET", "/api/v1/auth/me", Map.of("Cookie", stale), null); + assertThat(me.status()).isEqualTo(200); + String refreshed = AuthHttp.merge(stale, me); + assertThat(AuthHttp.xsrf(refreshed)).isNotEqualTo("stale"); + Resp retry = AuthHttp.session(port, "POST", "/api/v1/users", refreshed, newUserBody()); + assertThat(retry.status()).as(retry.body()).isEqualTo(201); + } + + @Test + void bearer_requests_skip_csrf_even_when_a_session_cookie_is_present() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "VIEWER"); + Resp r = AuthHttp.sendRaw( + port, "POST", "/api/v1/users", Map.of("Authorization", ADMIN_KEY, "Cookie", cookie), newUserBody()); + assertThat(r.status()).as(r.body()).isEqualTo(201); + } + + @Test + void login_and_logout_are_exempt() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "VIEWER"); + // 帶有效 session 但沒有 CSRF 標頭 + Resp out = AuthHttp.sendRaw(port, "POST", "/api/v1/auth/logout", Map.of("Cookie", cookie), null); + assertThat(out.status()).isEqualTo(204); + Resp login = AuthHttp.sendRaw( + port, + "POST", + "/api/v1/auth/login", + Map.of("Cookie", cookie), + AuthHttp.credentials("nobody@example.com", "whatever-password")); + assertThat(login.status()).isEqualTo(401); + assertThat(login.code()).isEqualTo("INVALID_CREDENTIALS"); + } + + @Test + void token_from_an_earlier_login_is_not_accepted_after_relogin() throws Exception { + String email = AuthHttp.uniqueEmail(); + AuthHttp.createUserId(port, email, "ADMIN"); + String first = AuthHttp.login(port, email, AuthHttp.PASSWORD); + String second = AuthHttp.login(port, email, AuthHttp.PASSWORD); + // 新 session 搭舊 token(登入使舊值失效) + String mixed = "CDP_SESSION=" + AuthHttp.cookieValue(second, "CDP_SESSION") + + "; XSRF-TOKEN=" + AuthHttp.xsrf(first); + assertCsrfInvalid(post(mixed, Map.of("X-XSRF-TOKEN", AuthHttp.xsrf(first)), newUserBody())); + } +} diff --git a/api/src/test/java/com/cdp/acceptance/user-auth-api/LastAdminAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/user-auth-api/LastAdminAcceptanceTest.java new file mode 100644 index 0000000..dc37801 --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/user-auth-api/LastAdminAcceptanceTest.java @@ -0,0 +1,197 @@ +package com.cdp.acceptance.userauth; + +import static com.cdp.acceptance.security.AuthHttp.ADMIN_KEY; +import static com.cdp.acceptance.security.AuthHttp.PASSWORD; +import static com.cdp.acceptance.security.AuthHttp.uniqueEmail; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import com.cdp.acceptance.security.AuthHttp; +import com.cdp.acceptance.security.AuthHttp.Resp; +import com.cdp.acceptance.security.SecurityTestSupport; + +/** + * user-auth-api 驗收:auth.md §4 {@code LAST_ADMIN}(含並行)。這個 context 專屬(屬性 {@code userauth.ctx} 避免與他類共用資料庫), + * 每個測試前清空 {@code app_user},才能精確控制「啟用中的 ADMIN」數量。 + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"spring.rabbitmq.listener.simple.auto-startup=false", "userauth.ctx=last-admin"}) +@ActiveProfiles("test") +@Import(SecurityTestSupport.class) +class LastAdminAcceptanceTest { + + @LocalServerPort int port; + @Autowired JdbcTemplate jdbc; + + @BeforeEach + void clean() { + jdbc.update("delete from app_user"); + } + + private String admin() throws Exception { + return AuthHttp.createUserId(port, uniqueEmail(), "ADMIN"); + } + + private Resp patch(String id, String body) throws Exception { + return AuthHttp.bearer(port, "PATCH", "/api/v1/users/" + id, ADMIN_KEY, body); + } + + private void assertLastAdmin(Resp r) { + assertThat(r.status()).as(r.body()).isEqualTo(409); + assertThat(r.headers().firstValue("Content-Type").orElse("")).startsWith("application/problem+json"); + assertThat(r.code()).isEqualTo("LAST_ADMIN"); + assertThat(r.json().path("detail").asString()).isEqualTo("至少要保留一位啟用中的管理員。"); + assertThat(r.json().path("request_id").asString()).isNotBlank(); + } + + private int activeAdmins() { + return jdbc.queryForObject( + "select count(*) from app_user where role = 'ADMIN' and status = 'ACTIVE'", Integer.class); + } + + @Test + void sole_admin_cannot_be_demoted_or_disabled() throws Exception { + String a = admin(); + assertLastAdmin(patch(a, "{\"role\":\"VIEWER\"}")); + assertLastAdmin(patch(a, "{\"role\":\"EDITOR\"}")); + assertLastAdmin(patch(a, "{\"status\":\"DISABLED\"}")); + assertLastAdmin(patch(a, "{\"role\":\"VIEWER\",\"status\":\"DISABLED\",\"display_name\":\"x\"}")); + // 被拒的更新不留下任何副作用(同一交易) + assertThat(jdbc.queryForMap("select role, status, display_name from app_user where user_id = ?", a)) + .containsEntry("role", "ADMIN") + .containsEntry("status", "ACTIVE") + .containsEntry("display_name", "QA ADMIN"); + assertThat(activeAdmins()).isEqualTo(1); + } + + @Test + void sole_admin_may_still_change_unrelated_fields_or_keep_role() throws Exception { + String a = admin(); + assertThat(patch(a, "{\"display_name\":\"renamed\"}").status()).isEqualTo(200); + assertThat(patch(a, "{\"role\":\"ADMIN\",\"status\":\"ACTIVE\"}").status()).isEqualTo(200); + assertThat(patch(a, "{\"password\":\"brand-new-password-1\"}").status()).isEqualTo(200); + } + + @Test + void self_demotion_by_session_of_sole_admin_is_rejected() throws Exception { + String email = uniqueEmail(); + String id = AuthHttp.createUserId(port, email, "ADMIN"); + String cookie = AuthHttp.login(port, email, PASSWORD); + assertLastAdmin(AuthHttp.session(port, "PATCH", "/api/v1/users/" + id, cookie, "{\"role\":\"VIEWER\"}")); + } + + @Test + void second_admin_allows_one_demotion_but_not_the_next() throws Exception { + String a = admin(); + String b = admin(); + assertThat(patch(a, "{\"role\":\"VIEWER\"}").status()).isEqualTo(200); + assertLastAdmin(patch(b, "{\"role\":\"VIEWER\"}")); + assertLastAdmin(patch(b, "{\"status\":\"DISABLED\"}")); + assertThat(activeAdmins()).isEqualTo(1); + } + + @Test + void disabled_admins_do_not_count() throws Exception { + String a = admin(); + String b = admin(); + assertThat(patch(b, "{\"status\":\"DISABLED\"}").status()).isEqualTo(200); + assertLastAdmin(patch(a, "{\"role\":\"EDITOR\"}")); + // 重新啟用 b 之後 a 才能降級 + assertThat(patch(b, "{\"status\":\"ACTIVE\"}").status()).isEqualTo(200); + assertThat(patch(a, "{\"role\":\"EDITOR\"}").status()).isEqualTo(200); + } + + @Test + void promoting_another_user_then_demoting_is_allowed() throws Exception { + String a = admin(); + String v = AuthHttp.createUserId(port, uniqueEmail(), "VIEWER"); + assertLastAdmin(patch(a, "{\"role\":\"VIEWER\"}")); + assertThat(patch(v, "{\"role\":\"ADMIN\"}").status()).isEqualTo(200); + assertThat(patch(a, "{\"role\":\"VIEWER\"}").status()).isEqualTo(200); + assertThat(activeAdmins()).isEqualTo(1); + } + + @Test + void non_admin_users_can_be_disabled_freely_even_without_admins_elsewhere() throws Exception { + admin(); + String v = AuthHttp.createUserId(port, uniqueEmail(), "VIEWER"); + assertThat(patch(v, "{\"status\":\"DISABLED\"}").status()).isEqualTo(200); + } + + // ---- 並行 ---- + + @Test + void concurrent_demotions_never_leave_zero_active_admins() throws Exception { + for (int round = 0; round < 3; round++) { + jdbc.update("delete from app_user"); + List ids = new ArrayList<>(); + for (int i = 0; i < 6; i++) ids.add(admin()); + // 對所有 ADMIN 同時降級;剛好一位必須留下 + List rs = parallel(ids.size(), i -> () -> patch(ids.get(i), "{\"role\":\"VIEWER\"}")); + long ok = rs.stream().filter(r -> r.status() == 200).count(); + long last = rs.stream().filter(r -> r.status() == 409 && "LAST_ADMIN".equals(r.code())).count(); + assertThat(ok).as("round %d: %s", round, rs.stream().map(Resp::status).toList()).isEqualTo(5); + assertThat(last).isEqualTo(1); + assertThat(activeAdmins()).isEqualTo(1); + } + } + + @Test + void concurrent_mixed_disable_and_demote_never_leave_zero_active_admins() throws Exception { + for (int round = 0; round < 3; round++) { + jdbc.update("delete from app_user"); + String a = admin(); + String b = admin(); + List rs = parallel(2, i -> () -> i == 0 + ? patch(a, "{\"status\":\"DISABLED\"}") + : patch(b, "{\"role\":\"EDITOR\"}")); + assertThat(rs.stream().filter(r -> r.status() == 200).count()).as(rs.toString()).isEqualTo(1); + assertThat(rs.stream().filter(r -> "LAST_ADMIN".equals(r.code())).count()).isEqualTo(1); + assertThat(activeAdmins()).isEqualTo(1); + } + } + + // ---- helpers ---- + + interface Task { + Callable at(int i); + } + + private List parallel(int n, Task t) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(n); + try { + CountDownLatch go = new CountDownLatch(1); + List> fs = new ArrayList<>(); + for (int i = 0; i < n; i++) { + Callable c = t.at(i); + fs.add(pool.submit(() -> { + go.await(); + return c.call(); + })); + } + go.countDown(); + List out = new ArrayList<>(); + for (Future f : fs) out.add(f.get()); + return out; + } finally { + pool.shutdownNow(); + } + } +} diff --git a/api/src/test/java/com/cdp/acceptance/user-auth-api/LoginRateLimitAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/user-auth-api/LoginRateLimitAcceptanceTest.java new file mode 100644 index 0000000..b78d2d7 --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/user-auth-api/LoginRateLimitAcceptanceTest.java @@ -0,0 +1,211 @@ +package com.cdp.acceptance.userauth; + +import static com.cdp.acceptance.security.AuthHttp.PASSWORD; +import static com.cdp.acceptance.security.AuthHttp.credentials; +import static com.cdp.acceptance.security.AuthHttp.uniqueEmail; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import com.cdp.acceptance.security.AuthHttp; +import com.cdp.acceptance.security.AuthHttp.Resp; +import com.cdp.acceptance.security.SecurityTestSupport; + +/** + * user-auth-api 驗收:auth.md §3 登入限速表格逐列。門檻 3 次、鎖定 4 秒。 + * + *

假設:CDP_LOGIN_MAX_FAILURES/CDP_LOGIN_LOCKOUT 對應屬性 {@code cdp.login.max-failures}/{@code cdp.login.lockout} + * (同 {@code CDP_LOOP_MAX_DRAIN → cdp.loop.max-drain} 的命名規則);同時也設 {@code cdp.security.login-*} 以防另取名,多設的屬性無害。 + * 「鎖定中不跑 BCrypt」屬耗時行為,不在此斷言(由單元測試/verifier 驗證)。 + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.rabbitmq.listener.simple.auto-startup=false", + "cdp.login.max-failures=3", + "cdp.login.lockout=PT4S", + "cdp.security.login-max-failures=3", + "cdp.security.login-lockout=PT4S" + }) +@ActiveProfiles("test") +@Import(SecurityTestSupport.class) +class LoginRateLimitAcceptanceTest { + + private static final String WRONG = "definitely-wrong-password"; + + @LocalServerPort int port; + + private Resp login(String email, String password) throws Exception { + return AuthHttp.sendRaw(port, "POST", "/api/v1/auth/login", null, credentials(email, password)); + } + + private void assertWrong(Resp r) { + assertThat(r.status()).as(r.body()).isEqualTo(401); + assertThat(r.code()).isEqualTo("INVALID_CREDENTIALS"); + } + + private void assertLocked(Resp r) { + assertThat(r.status()).as(r.body()).isEqualTo(429); + assertThat(r.headers().firstValue("Content-Type").orElse("")).startsWith("application/problem+json"); + assertThat(r.code()).isEqualTo("LOGIN_RATE_LIMITED"); + assertThat(r.json().path("request_id").asString()).isNotBlank(); + assertThat(r.setCookie("CDP_SESSION")).isNull(); + } + + private void lock(String email) throws Exception { + for (int i = 0; i < 3; i++) assertWrong(login(email, WRONG)); + } + + private int retryAfter(Resp r) { + return Integer.parseInt(r.headers().firstValue("Retry-After").orElseThrow()); + } + + @Test + void locks_after_max_failures_even_for_correct_password() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + lock(email); // 第 3 次仍回 401(達門檻的那次是密碼錯),之後才鎖 + assertLocked(login(email, PASSWORD)); + assertLocked(login(email, WRONG)); + } + + @Test + void retry_after_is_integer_seconds_rounded_up() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + lock(email); + // 剛鎖定:剩餘 (3, 4] 秒 → 4(無條件捨去會得到 3) + Resp first = login(email, PASSWORD); + assertLocked(first); + assertThat(retryAfter(first)).isEqualTo(4); + Thread.sleep(1500); + // 剩餘約 2.0–2.5 秒 → 3 + Resp later = login(email, PASSWORD); + assertLocked(later); + assertThat(retryAfter(later)).isEqualTo(3); + } + + @Test + void attempts_while_locked_do_not_extend_the_lock() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + lock(email); + Thread.sleep(2000); + assertLocked(login(email, WRONG)); // 鎖定中的嘗試不計數、不延長 + Thread.sleep(2600); // 距最後一次失敗約 4.6 秒 + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + } + + @Test + void unknown_email_counts_and_response_matches_existing_accounts() throws Exception { + String ghost = uniqueEmail(); + String real = uniqueEmail(); + AuthHttp.createUserId(port, real, "VIEWER"); + Resp ghostWrong = login(ghost, WRONG); + Resp realWrong = login(real, WRONG); + assertThat(ghostWrong.status()).isEqualTo(realWrong.status()); + assertThat(ghostWrong.json().path("detail")).isEqualTo(realWrong.json().path("detail")); + for (int i = 0; i < 2; i++) { + assertWrong(login(ghost, WRONG)); + assertWrong(login(real, WRONG)); + } + Resp ghostLocked = login(ghost, PASSWORD); + Resp realLocked = login(real, PASSWORD); + assertLocked(ghostLocked); + assertLocked(realLocked); + assertThat(retryAfter(ghostLocked)).isBetween(1, 4); + } + + @Test + void counting_is_per_lowercased_email() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + assertWrong(login(email.toUpperCase(), WRONG)); + assertWrong(login(email.toLowerCase(), WRONG)); + assertWrong(login(email, WRONG)); + assertLocked(login(email.toLowerCase(), PASSWORD)); + assertLocked(login(email.toUpperCase(), PASSWORD)); + } + + @Test + void other_emails_are_not_affected() throws Exception { + String a = uniqueEmail(); + String b = uniqueEmail(); + AuthHttp.createUserId(port, a, "VIEWER"); + AuthHttp.createUserId(port, b, "VIEWER"); + lock(a); + assertLocked(login(a, PASSWORD)); + assertThat(login(b, PASSWORD).status()).isEqualTo(204); + } + + @Test + void success_resets_the_counter() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + assertWrong(login(email, WRONG)); + assertWrong(login(email, WRONG)); + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + // 若沒歸零,下面第 2 次就會鎖定 + assertWrong(login(email, WRONG)); + assertWrong(login(email, WRONG)); + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + } + + @Test + void validation_errors_do_not_count() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + for (int i = 0; i < 5; i++) { + Resp r = AuthHttp.sendRaw(port, "POST", "/api/v1/auth/login", null, "{\"email\":\"" + email + "\"}"); + assertThat(r.status()).isEqualTo(400); + assertThat(r.code()).isEqualTo("VALIDATION_FAILED"); + } + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + } + + @Test + void counter_restarts_when_last_failure_is_older_than_lockout() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + assertWrong(login(email, WRONG)); + assertWrong(login(email, WRONG)); + Thread.sleep(4500); // 距上次失敗超過 CDP_LOGIN_LOCKOUT → 先歸零再計 + assertWrong(login(email, WRONG)); // 1 + assertWrong(login(email, WRONG)); // 2(沒歸零的話這裡之後就是 429) + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + } + + @Test + void lock_expires_after_the_lockout_period() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + lock(email); + assertLocked(login(email, PASSWORD)); + Thread.sleep(4500); + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + // 解鎖且成功後計數歸零,可再錯兩次不鎖 + assertWrong(login(email, WRONG)); + assertWrong(login(email, WRONG)); + } + + @Test + void password_endpoint_does_not_count() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + String cookie = AuthHttp.login(port, email, PASSWORD); + for (int i = 0; i < 5; i++) { + Resp r = AuthHttp.session( + port, + "POST", + "/api/v1/auth/password", + cookie, + "{\"current_password\":\"" + WRONG + "\",\"new_password\":\"another-long-password-9\"}"); + assertWrong(r); + } + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + } +} diff --git a/api/src/test/java/com/cdp/acceptance/user-auth-api/LoginSessionAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/user-auth-api/LoginSessionAcceptanceTest.java new file mode 100644 index 0000000..53e945b --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/user-auth-api/LoginSessionAcceptanceTest.java @@ -0,0 +1,341 @@ +package com.cdp.acceptance.userauth; + +import static com.cdp.acceptance.security.AuthHttp.ADMIN_KEY; +import static com.cdp.acceptance.security.AuthHttp.INGEST_KEY; +import static com.cdp.acceptance.security.AuthHttp.PASSWORD; +import static com.cdp.acceptance.security.AuthHttp.credentials; +import static com.cdp.acceptance.security.AuthHttp.uniqueEmail; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import com.cdp.acceptance.security.AuthHttp; +import com.cdp.acceptance.security.AuthHttp.Resp; +import com.cdp.acceptance.security.SecurityTestSupport; + +import tools.jackson.databind.JsonNode; + +/** user-auth-api 驗收:auth.md §3 登入、登出、me、改密碼與 session 行為。 */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.rabbitmq.listener.simple.auto-startup=false") +@ActiveProfiles("test") +@Import(SecurityTestSupport.class) +class LoginSessionAcceptanceTest { + + @LocalServerPort int port; + @Autowired JdbcTemplate jdbc; + + private Resp login(String email, String password) throws Exception { + return AuthHttp.sendRaw(port, "POST", "/api/v1/auth/login", null, credentials(email, password)); + } + + private void assertProblem(Resp r, int status, String code) { + assertThat(r.status()).as(r.body()).isEqualTo(status); + assertThat(r.headers().firstValue("Content-Type").orElse("")).startsWith("application/problem+json"); + JsonNode n = r.json(); + assertThat(n.path("type").asString()).isEqualTo("about:blank"); + assertThat(n.path("status").asInt()).isEqualTo(status); + assertThat(n.path("code").asString()).isEqualTo(code); + assertThat(n.path("request_id").asString()).isNotBlank(); + } + + // ---- login ---- + + @Test + void login_sets_session_and_xsrf_cookies() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "EDITOR"); + Resp r = login(email, PASSWORD); + assertThat(r.status()).isEqualTo(204); + String sc = r.setCookie("CDP_SESSION"); + assertThat(sc).isNotNull(); + assertThat(sc).containsIgnoringCase("HttpOnly").containsIgnoringCase("SameSite=Strict"); + assertThat(sc).contains("Path=/api").doesNotContain("Path=/api/"); + String x = r.setCookie("XSRF-TOKEN"); + assertThat(x).as("login must also set XSRF-TOKEN: %s", r.setCookies()).isNotNull(); + assertThat(x).contains("Path=/").containsIgnoringCase("SameSite=Strict"); + assertThat(x).doesNotContainIgnoringCase("HttpOnly"); + } + + @Test + void me_always_sets_a_fresh_xsrf_cookie() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "VIEWER"); + Resp me = AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie, null); + assertThat(me.status()).isEqualTo(200); + String x = me.setCookie("XSRF-TOKEN"); + assertThat(x).as("me must set XSRF-TOKEN").isNotNull(); + assertThat(x).contains("Path=/").containsIgnoringCase("SameSite=Strict"); + assertThat(x).doesNotContainIgnoringCase("HttpOnly"); + } + + @Test + void each_login_issues_a_new_session_id_and_xsrf_changes() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + String a = AuthHttp.login(port, email, PASSWORD); + String b = AuthHttp.login(port, email, PASSWORD); + assertThat(AuthHttp.cookieValue(b, "CDP_SESSION")).isNotEqualTo(AuthHttp.cookieValue(a, "CDP_SESSION")); + assertThat(AuthHttp.xsrf(b)).isNotEqualTo(AuthHttp.xsrf(a)); + // 帶著舊 cookie 再登入也要換新 id(防 session fixation) + Resp r = AuthHttp.sendRaw( + port, "POST", "/api/v1/auth/login", java.util.Map.of("Cookie", a), credentials(email, PASSWORD)); + assertThat(r.status()).isEqualTo(204); + assertThat(AuthHttp.cookieValue(AuthHttp.merge(null, r), "CDP_SESSION")) + .isNotEqualTo(AuthHttp.cookieValue(a, "CDP_SESSION")); + } + + @Test + void email_is_case_insensitive_and_stored_lowercase() throws Exception { + String email = "MiXeD" + System.nanoTime() + "@Example.COM"; + String id = AuthHttp.createUserId(port, email, "VIEWER"); + assertThat(jdbc.queryForObject("SELECT email FROM app_user WHERE user_id = ?", String.class, id)) + .isEqualTo(email.toLowerCase()); + assertThat(login(email.toUpperCase(), PASSWORD).status()).isEqualTo(204); + assertThat(login(email.toLowerCase(), PASSWORD).status()).isEqualTo(204); + } + + @Test + void password_is_stored_as_bcrypt_hash() throws Exception { + String id = AuthHttp.createUserId(port, uniqueEmail(), "VIEWER"); + String hash = jdbc.queryForObject("SELECT password_hash FROM app_user WHERE user_id = ?", String.class, id); + assertThat(hash).startsWith("$2").doesNotContain(PASSWORD); + } + + @Test + void bad_credentials_are_indistinguishable() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + String disabled = uniqueEmail(); + String disabledId = AuthHttp.createUserId(port, disabled, "VIEWER"); + assertThat(AuthHttp.bearer(port, "PATCH", "/api/v1/users/" + disabledId, ADMIN_KEY, "{\"status\":\"DISABLED\"}") + .status()) + .isEqualTo(200); + + Resp wrongPass = login(email, "definitely-wrong-password"); + Resp unknown = login(uniqueEmail(), PASSWORD); + Resp disabledResp = login(disabled, PASSWORD); + for (Resp r : new Resp[] {wrongPass, unknown, disabledResp}) { + assertProblem(r, 401, "INVALID_CREDENTIALS"); + assertThat(r.setCookie("CDP_SESSION")).isNull(); + } + assertThat(unknown.json().path("detail")).isEqualTo(wrongPass.json().path("detail")); + assertThat(disabledResp.json().path("detail")).isEqualTo(wrongPass.json().path("detail")); + } + + @Test + void login_validation_and_media_type() throws Exception { + String email = uniqueEmail(); + assertProblem( + AuthHttp.sendRaw(port, "POST", "/api/v1/auth/login", null, "{\"email\":\"" + email + "\"}"), + 400, + "VALIDATION_FAILED"); + assertProblem( + AuthHttp.sendRaw(port, "POST", "/api/v1/auth/login", null, "{\"password\":\"" + PASSWORD + "\"}"), + 400, + "VALIDATION_FAILED"); + assertProblem(login("", ""), 400, "VALIDATION_FAILED"); + // v0.1.0 的 username 欄位不再被接受 + assertProblem( + AuthHttp.sendRaw( + port, + "POST", + "/api/v1/auth/login", + null, + "{\"username\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"), + 400, + "VALIDATION_FAILED"); + Resp text = AuthHttp.sendRaw( + port, "POST", "/api/v1/auth/login", java.util.Map.of("Content-Type", "text/plain"), null); + assertProblem(text, 415, "UNSUPPORTED_MEDIA_TYPE"); + } + + @Test + void login_needs_no_csrf_header() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "VIEWER"); + // sendRaw 不帶 X-XSRF-TOKEN,也沒有 XSRF cookie + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + } + + // ---- me ---- + + @Test + void me_returns_current_user_for_each_role() throws Exception { + for (String role : new String[] {"VIEWER", "EDITOR", "ADMIN"}) { + String email = uniqueEmail(); + String id = AuthHttp.createUserId(port, email, role); + String cookie = AuthHttp.login(port, email, PASSWORD); + Resp r = AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie, null); + assertThat(r.status()).isEqualTo(200); + JsonNode n = r.json(); + assertThat(n.path("user_id").asString()).isEqualTo(id); + assertThat(n.path("email").asString()).isEqualTo(email.toLowerCase()); + assertThat(n.path("display_name").asString()).isEqualTo("QA " + role); + assertThat(n.path("role").asString()).isEqualTo(role); + assertThat(n.has("username")).isFalse(); + assertThat(n.has("password_hash")).isFalse(); + assertThat(n.has("password")).isFalse(); + } + } + + @Test + void me_rejects_bearer_and_missing_or_fake_session() throws Exception { + assertProblem(AuthHttp.bearer(port, "GET", "/api/v1/auth/me", ADMIN_KEY, null), 403, "FORBIDDEN"); + assertProblem(AuthHttp.bearer(port, "GET", "/api/v1/auth/me", INGEST_KEY, null), 403, "FORBIDDEN"); + assertProblem(AuthHttp.bearer(port, "GET", "/api/v1/auth/me", null, null), 401, "UNAUTHENTICATED"); + assertProblem( + AuthHttp.session(port, "GET", "/api/v1/auth/me", "CDP_SESSION=bogus", null), 401, "UNAUTHENTICATED"); + } + + // ---- logout ---- + + @Test + void logout_without_session_is_204() throws Exception { + assertThat(AuthHttp.sendRaw(port, "POST", "/api/v1/auth/logout", null, null).status()) + .isEqualTo(204); + } + + @Test + void logout_clears_cookie_and_invalidates_session_without_csrf_header() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "EDITOR"); + // 登出免 CSRF:只帶 Cookie + Resp out = AuthHttp.sendRaw(port, "POST", "/api/v1/auth/logout", java.util.Map.of("Cookie", cookie), null); + assertThat(out.status()).isEqualTo(204); + assertThat(out.setCookie("CDP_SESSION")).containsPattern("(?i)Max-Age=0|Expires=Thu, 01 Jan 1970"); + assertProblem(AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie, null), 401, "UNAUTHENTICATED"); + } + + // ---- password ---- + + private Resp changePassword(String cookie, String current, String next) throws Exception { + return AuthHttp.session( + port, + "POST", + "/api/v1/auth/password", + cookie, + "{\"current_password\":\"" + current + "\",\"new_password\":\"" + next + "\"}"); + } + + @Test + void password_change_works_and_keeps_other_sessions() throws Exception { + String email = uniqueEmail(); + AuthHttp.createUserId(port, email, "EDITOR"); + String a = AuthHttp.login(port, email, PASSWORD); + String b = AuthHttp.login(port, email, PASSWORD); + String next = "another-long-password-9"; + assertThat(changePassword(a, PASSWORD, next).status()).isEqualTo(204); + assertProblem(login(email, PASSWORD), 401, "INVALID_CREDENTIALS"); + assertThat(login(email, next).status()).isEqualTo(204); + // 其他 session 不登出,本身也還有效 + assertThat(AuthHttp.session(port, "GET", "/api/v1/auth/me", b, null).status()) + .isEqualTo(200); + assertThat(AuthHttp.session(port, "GET", "/api/v1/auth/me", a, null).status()) + .isEqualTo(200); + } + + @Test + void password_change_wrong_current_is_401_invalid_credentials() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "VIEWER"); + assertProblem(changePassword(cookie, "not-the-current-one", "another-long-password-9"), 401, "INVALID_CREDENTIALS"); + } + + @Test + void password_rules_12_to_72_bytes() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "VIEWER"); + String cur = PASSWORD; + assertProblem(changePassword(cookie, cur, "a".repeat(11)), 400, "VALIDATION_FAILED"); + assertProblem(changePassword(cookie, cur, "a".repeat(73)), 400, "VALIDATION_FAILED"); + // 25 個中文字 = 75 位元組 > 72 + assertProblem(changePassword(cookie, cur, "密".repeat(25)), 400, "VALIDATION_FAILED"); + assertProblem( + AuthHttp.session(port, "POST", "/api/v1/auth/password", cookie, "{\"current_password\":\"" + cur + "\"}"), + 400, + "VALIDATION_FAILED"); + // 邊界:12 字元、72 位元組(24 個中文字)可用;不檢查複雜度 + assertThat(changePassword(cookie, cur, "a".repeat(12)).status()).isEqualTo(204); + assertThat(changePassword(cookie, "a".repeat(12), "密".repeat(24)).status()).isEqualTo(204); + assertThat(changePassword(cookie, "密".repeat(24), "b".repeat(72)).status()).isEqualTo(204); + } + + @Test + void password_requires_csrf_and_session() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "VIEWER"); + String body = "{\"current_password\":\"" + PASSWORD + "\",\"new_password\":\"another-long-password-9\"}"; + Resp noHeader = + AuthHttp.sendRaw(port, "POST", "/api/v1/auth/password", java.util.Map.of("Cookie", cookie), body); + assertProblem(noHeader, 403, "CSRF_INVALID"); + assertProblem(AuthHttp.bearer(port, "POST", "/api/v1/auth/password", ADMIN_KEY, body), 403, "FORBIDDEN"); + assertProblem(AuthHttp.bearer(port, "POST", "/api/v1/auth/password", null, body), 401, "UNAUTHENTICATED"); + } + + // ---- 停用、改角色下一請求生效 ---- + + @Test + void disabling_user_invalidates_existing_session_on_next_request() throws Exception { + String email = uniqueEmail(); + String id = AuthHttp.createUserId(port, email, "EDITOR"); + String cookie = AuthHttp.login(port, email, PASSWORD); + assertThat(AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie, null).status()) + .isEqualTo(200); + assertThat(AuthHttp.bearer(port, "PATCH", "/api/v1/users/" + id, ADMIN_KEY, "{\"status\":\"DISABLED\"}") + .status()) + .isEqualTo(200); + assertProblem(AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie, null), 401, "UNAUTHENTICATED"); + assertProblem(AuthHttp.session(port, "GET", "/api/v1/enrollments?q=x", cookie, null), 401, "UNAUTHENTICATED"); + assertProblem(login(email, PASSWORD), 401, "INVALID_CREDENTIALS"); + // 重新啟用後可再登入 + assertThat(AuthHttp.bearer(port, "PATCH", "/api/v1/users/" + id, ADMIN_KEY, "{\"status\":\"ACTIVE\"}") + .status()) + .isEqualTo(200); + assertThat(login(email, PASSWORD).status()).isEqualTo(204); + } + + @Test + void role_change_takes_effect_on_next_request() throws Exception { + String email = uniqueEmail(); + String id = AuthHttp.createUserId(port, email, "VIEWER"); + String cookie = AuthHttp.login(port, email, PASSWORD); + assertProblem(AuthHttp.session(port, "GET", "/api/v1/users", cookie, null), 403, "FORBIDDEN"); + + assertThat(AuthHttp.bearer(port, "PATCH", "/api/v1/users/" + id, ADMIN_KEY, "{\"role\":\"ADMIN\"}") + .status()) + .isEqualTo(200); + assertThat(AuthHttp.session(port, "GET", "/api/v1/users", cookie, null).status()) + .isEqualTo(200); + assertThat(AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie, null) + .json() + .path("role") + .asString()) + .isEqualTo("ADMIN"); + + assertThat(AuthHttp.bearer(port, "PATCH", "/api/v1/users/" + id, ADMIN_KEY, "{\"role\":\"VIEWER\"}") + .status()) + .isEqualTo(200); + assertProblem(AuthHttp.session(port, "GET", "/api/v1/users", cookie, null), 403, "FORBIDDEN"); + assertThat(AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie, null) + .json() + .path("role") + .asString()) + .isEqualTo("VIEWER"); + } + + @Test + void admin_password_reset_via_patch_replaces_password() throws Exception { + String email = uniqueEmail(); + String id = AuthHttp.createUserId(port, email, "VIEWER"); + String next = "reset-by-admin-123"; + assertThat(AuthHttp.bearer( + port, "PATCH", "/api/v1/users/" + id, ADMIN_KEY, "{\"password\":\"" + next + "\"}") + .status()) + .isEqualTo(200); + assertProblem(login(email, PASSWORD), 401, "INVALID_CREDENTIALS"); + assertThat(login(email, next).status()).isEqualTo(204); + } +} diff --git a/api/src/test/java/com/cdp/acceptance/user-auth-api/RoleMatrixAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/user-auth-api/RoleMatrixAcceptanceTest.java new file mode 100644 index 0000000..f72369b --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/user-auth-api/RoleMatrixAcceptanceTest.java @@ -0,0 +1,233 @@ +package com.cdp.acceptance.userauth; + +import static com.cdp.acceptance.security.AuthHttp.ADMIN_KEY; +import static com.cdp.acceptance.security.AuthHttp.INGEST_KEY; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import com.cdp.acceptance.security.AuthHttp; +import com.cdp.acceptance.security.AuthHttp.Resp; +import com.cdp.acceptance.security.SecurityTestSupport; + +/** + * user-auth-api 驗收:auth.md §2 角色矩陣。「放行」只斷言認證與角色層通過(不是 401/403;controller 可能回 400/404/尚未實作), + * 「拒絕」斷言 403 FORBIDDEN 的 problem+json。session 的寫入請求自動帶 CSRF 標頭,所以 403 一定是角色不足。 + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.rabbitmq.listener.simple.auto-startup=false") +@ActiveProfiles("test") +@Import(SecurityTestSupport.class) +class RoleMatrixAcceptanceTest { + + private static final String ID = "01JXXXXXXXXXXXXXXXXXXXXXXX"; + + @LocalServerPort int port; + + private static final Map COOKIES = new LinkedHashMap<>(); + private static int staticPort; + + /** 憑證名稱:INGEST、ADMIN_KEY、VIEWER、EDITOR、ADMIN(session)。 */ + private static final List CREDS = List.of("INGEST", "ADMIN_KEY", "VIEWER", "EDITOR", "ADMIN"); + + private record Api(String method, String path, String body, List allowed) {} + + private static final List READERS = List.of("ADMIN_KEY", "VIEWER", "EDITOR", "ADMIN"); + private static final List WRITERS = List.of("EDITOR", "ADMIN"); + private static final List BEARER_ADMIN_ONLY = List.of("ADMIN_KEY"); + + private static final List MATRIX = List.of( + new Api("POST", "/api/v1/events", "{}", List.of("INGEST")), + new Api("POST", "/api/v1/events/batch", "{}", List.of("INGEST")), + // PUT profiles:INGEST 與管理金鑰;ADMIN session 不可 + new Api("PUT", "/api/v1/profiles/" + ID, "{}", List.of("INGEST", "ADMIN_KEY")), + new Api("GET", "/api/v1/enrollments?q=x", null, READERS), + new Api("GET", "/api/v1/enrollments/" + ID, null, READERS), + new Api("GET", "/api/v1/enrollments/" + ID + "/deliveries", null, READERS), + new Api("GET", "/api/v1/profiles/search?q=x", null, READERS), + new Api("GET", "/api/v1/profiles/" + ID, null, READERS), + new Api("GET", "/api/v1/profiles/" + ID + "/enrollments", null, READERS), + new Api("GET", "/api/v1/journeys", null, READERS), + new Api("GET", "/api/v1/journeys/" + ID, null, READERS), + new Api("GET", "/api/v1/journeys/" + ID + "/versions/1", null, READERS), + new Api("GET", "/api/v1/journeys/" + ID + "/draft", null, READERS), + new Api("GET", "/api/v1/journeys/" + ID + "/reviews", null, READERS), + // POST /journeys:只有管理金鑰,session(含 ADMIN)不可 + new Api("POST", "/api/v1/journeys", "{}", BEARER_ADMIN_ONLY), + // 草稿寫入、validate、送審:EDITOR 以上的 session + new Api("POST", "/api/v1/journeys/drafts", "{}", WRITERS), + new Api("PUT", "/api/v1/journeys/" + ID + "/draft", "{}", WRITERS), + new Api("DELETE", "/api/v1/journeys/" + ID + "/draft", null, WRITERS), + new Api("POST", "/api/v1/journeys/" + ID + "/draft/validate", null, WRITERS), + new Api("POST", "/api/v1/journeys/" + ID + "/draft/submit", "{}", WRITERS), + // 停止(D-060 不經審核):EDITOR 以上 + new Api("POST", "/api/v1/journeys/" + ID + "/stop", "{}", WRITERS), + // withdraw:EDITOR(僅自己送審的,由 journeys 單驗)與 ADMIN;此處只驗角色層 + new Api("POST", "/api/v1/journeys/" + ID + "/reviews/" + ID + "/withdraw", "{}", WRITERS), + // approve/reject:只有 ADMIN session + new Api("POST", "/api/v1/journeys/" + ID + "/reviews/" + ID + "/approve", "{}", List.of("ADMIN")), + new Api("POST", "/api/v1/journeys/" + ID + "/reviews/" + ID + "/reject", "{}", List.of("ADMIN")), + // /users:ADMIN session 與管理金鑰 + new Api("GET", "/api/v1/users", null, List.of("ADMIN_KEY", "ADMIN")), + new Api("POST", "/api/v1/users", "{}", List.of("ADMIN_KEY", "ADMIN")), + new Api("PATCH", "/api/v1/users/" + ID, "{}", List.of("ADMIN_KEY", "ADMIN"))); + + private String cookie(String role) throws Exception { + synchronized (COOKIES) { + if (staticPort != port) { + COOKIES.clear(); + staticPort = port; + } + String c = COOKIES.get(role); + if (c == null) { + c = AuthHttp.loginNewUser(port, role); + COOKIES.put(role, c); + } + return c; + } + } + + private Resp call(String cred, Api api) throws Exception { + return switch (cred) { + case "INGEST" -> AuthHttp.bearer(port, api.method(), api.path(), INGEST_KEY, api.body()); + case "ADMIN_KEY" -> AuthHttp.bearer(port, api.method(), api.path(), ADMIN_KEY, api.body()); + default -> AuthHttp.session(port, api.method(), api.path(), cookie(cred), api.body()); + }; + } + + @Test + void matrix_allows_and_denies_exactly_as_listed() throws Exception { + for (Api api : MATRIX) { + for (String cred : CREDS) { + Resp r = call(cred, api); + String what = cred + " " + api.method() + " " + api.path() + " -> " + r.status() + " " + r.body(); + if (api.allowed().contains(cred)) { + assertThat(r.status()).as(what).isNotIn(401, 403); + } else { + assertThat(r.status()).as(what).isEqualTo(403); + assertThat(r.code()).as(what).isEqualTo("FORBIDDEN"); + assertThat(r.headers().firstValue("Content-Type").orElse("")) + .as(what) + .startsWith("application/problem+json"); + } + } + } + } + + @Test + void unauthenticated_is_401_on_every_listed_api() throws Exception { + for (Api api : MATRIX) { + Resp r = AuthHttp.sendRaw(port, api.method(), api.path(), null, api.body()); + assertThat(r.status()).as(api.method() + " " + api.path()).isEqualTo(401); + assertThat(r.code()).isEqualTo("UNAUTHENTICATED"); + } + } + + @Test + void review_actions_reject_every_bearer_including_admin_key() throws Exception { + for (String action : List.of("withdraw", "approve", "reject")) { + String path = "/api/v1/journeys/" + ID + "/reviews/" + ID + "/" + action; + for (String key : List.of(ADMIN_KEY, INGEST_KEY)) { + Resp r = AuthHttp.bearer(port, "POST", path, key, "{}"); + assertThat(r.status()).as(action + " " + key).isEqualTo(403); + assertThat(r.code()).isEqualTo("FORBIDDEN"); + } + } + Resp submit = AuthHttp.bearer(port, "POST", "/api/v1/journeys/" + ID + "/draft/submit", ADMIN_KEY, "{}"); + assertThat(submit.status()).isEqualTo(403); + } + + // ---- Bearer 不看 cookie ---- + + @Test + void authorization_header_wins_over_cookie() throws Exception { + String admin = cookie("ADMIN"); + // ADMIN session + INGEST Bearer:以 INGEST 論,不得用 /users + Resp r = AuthHttp.send(port, "GET", "/api/v1/users", INGEST_KEY, admin, null); + assertThat(r.status()).isEqualTo(403); + // ADMIN session + 錯誤 Bearer:401,不 fallback 到 cookie + Resp bad = AuthHttp.send(port, "GET", "/api/v1/users", "Bearer wrong", admin, null); + assertThat(bad.status()).isEqualTo(401); + assertThat(bad.code()).isEqualTo("UNAUTHENTICATED"); + // VIEWER session + INGEST Bearer 可攝取事件(Bearer 為準) + Resp ev = AuthHttp.send(port, "POST", "/api/v1/events", INGEST_KEY, cookie("VIEWER"), "{}"); + assertThat(ev.status()).isNotIn(401, 403); + // ADMIN session + Bearer 的 events 不因 cookie 角色而放行 + Resp ev2 = AuthHttp.send(port, "POST", "/api/v1/events", ADMIN_KEY, admin, "{}"); + assertThat(ev2.status()).isEqualTo(403); + } + + @Test + void bearer_requests_need_no_csrf_header() throws Exception { + Resp r = AuthHttp.sendRaw( + port, "POST", "/api/v1/events", Map.of("Authorization", INGEST_KEY), "{}"); + assertThat(r.status()).isNotIn(401, 403); + Resp u = AuthHttp.sendRaw( + port, + "POST", + "/api/v1/users", + Map.of("Authorization", ADMIN_KEY), + AuthHttp.userBody(AuthHttp.uniqueEmail(), "no csrf", "VIEWER", AuthHttp.PASSWORD)); + assertThat(u.status()).isEqualTo(201); + } + + // ---- 未列路徑 ---- + + private static final List UNLISTED = List.of( + new String[] {"GET", "/api/v1/not-in-contract"}, + new String[] {"GET", "/api/v1/events"}, + new String[] {"GET", "/api/v1/auth"}, + new String[] {"GET", "/api/unknown"}, + new String[] {"GET", "/actuator"}, + new String[] {"GET", "/actuator/health"}, + new String[] {"GET", "/actuator/env"}, + new String[] {"GET", "/error"}, + new String[] {"GET", "/v3/api-docs"}, + new String[] {"PUT", "/api/v1/enrollments/" + ID}, + new String[] {"DELETE", "/api/v1/enrollments/" + ID}, + new String[] {"DELETE", "/api/v1/users/" + ID}, + new String[] {"GET", "/api/v1/enrollments/" + ID + "/foo"}); + + @Test + void unlisted_paths_are_401_unauthenticated_and_403_for_non_admin_credentials() throws Exception { + for (String[] p : UNLISTED) { + Resp anon = AuthHttp.sendRaw(port, p[0], p[1], null, null); + assertThat(anon.status()).as("anon " + p[0] + " " + p[1]).isEqualTo(401); + assertThat(anon.code()).isEqualTo("UNAUTHENTICATED"); + for (String cred : List.of("INGEST", "VIEWER", "EDITOR")) { + Resp r = call(cred, new Api(p[0], p[1], null, List.of())); + assertThat(r.status()).as(cred + " " + p[0] + " " + p[1]).isEqualTo(403); + assertThat(r.code()).isEqualTo("FORBIDDEN"); + } + } + } + + @Test + void login_and_logout_need_no_authentication() throws Exception { + assertThat(AuthHttp.sendRaw(port, "POST", "/api/v1/auth/logout", null, null).status()) + .isEqualTo(204); + // 登入無憑證不是 401 UNAUTHENTICATED,而是依內容回 400/401 INVALID_CREDENTIALS + Resp r = AuthHttp.sendRaw( + port, "POST", "/api/v1/auth/login", null, AuthHttp.credentials("nobody@example.com", "whatever-password")); + assertThat(r.status()).isEqualTo(401); + assertThat(r.code()).isEqualTo("INVALID_CREDENTIALS"); + } + + @Test + void auth_endpoints_are_session_only_for_all_roles() throws Exception { + for (String role : List.of("VIEWER", "EDITOR", "ADMIN")) { + assertThat(AuthHttp.session(port, "GET", "/api/v1/auth/me", cookie(role), null).status()) + .as(role) + .isEqualTo(200); + } + } +} diff --git a/api/src/test/java/com/cdp/acceptance/user-auth-api/SchemaV6AppUserAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/user-auth-api/SchemaV6AppUserAcceptanceTest.java new file mode 100644 index 0000000..8e859ae --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/user-auth-api/SchemaV6AppUserAcceptanceTest.java @@ -0,0 +1,156 @@ +package com.cdp.acceptance.userauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** + * user-auth-api 驗收:spec 07 §12 V6 只建 {@code app_user},不動既有表。獨立的 PostgreSQL 容器、直接用 Flyway,升級目標固定 6, + * 之後新增的 migration 不影響本測試。 + */ +class SchemaV6AppUserAcceptanceTest { + + private static final Timestamp T0 = Timestamp.from(Instant.parse("2026-10-22T02:00:00Z")); + + private PostgreSQLContainer pg; + private JdbcTemplate jdbc; + + @BeforeEach + void start() { + pg = new PostgreSQLContainer("postgres:18-alpine"); + pg.start(); + jdbc = new JdbcTemplate(new DriverManagerDataSource(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword())); + } + + @AfterEach + void stop() { + pg.stop(); + } + + private void migrate(String target) { + Flyway.configure() + .dataSource(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword()) + .locations("classpath:db/migration") + .target(target) + .load() + .migrate(); + } + + private Set columns() { + return new TreeSet<>(jdbc.queryForList( + "select table_name || '.' || column_name || ':' || data_type || ':' || is_nullable" + + " from information_schema.columns where table_schema = 'public'", + String.class)); + } + + private Set indexes() { + return new TreeSet<>(jdbc.queryForList( + "select indexdef from pg_indexes where schemaname = 'public'", String.class)); + } + + @Test + void v6_creates_only_app_user_and_leaves_existing_tables_untouched() { + migrate("5"); + Set colsBefore = columns(); + Set idxBefore = indexes(); + List tablesBefore = jdbc.queryForList( + "select table_name from information_schema.tables where table_schema = 'public'" + + " and table_name <> 'flyway_schema_history' order by 1", + String.class); + assertThat(tablesBefore).doesNotContain("app_user"); + + migrate("6"); + + List tablesAfter = jdbc.queryForList( + "select table_name from information_schema.tables where table_schema = 'public'" + + " and table_name <> 'flyway_schema_history' order by 1", + String.class); + assertThat(tablesAfter).containsExactlyInAnyOrderElementsOf( + java.util.stream.Stream.concat(tablesBefore.stream(), java.util.stream.Stream.of("app_user")).toList()); + + Set colsAfter = columns(); + Set nonUser = new TreeSet<>(colsAfter); + nonUser.removeIf(c -> c.startsWith("app_user.")); + assertThat(nonUser).isEqualTo(colsBefore); + + Set idxAfter = indexes(); + Set idxNonUser = new TreeSet<>(idxAfter); + idxNonUser.removeIf(d -> d.contains(" ON public.app_user ")); + assertThat(idxNonUser).isEqualTo(idxBefore); + + assertThat(jdbc.queryForObject( + "select count(*) from flyway_schema_history where version = '6' and success", Integer.class)) + .isEqualTo(1); + } + + @Test + void app_user_columns_and_constraints() { + migrate("6"); + List cols = jdbc.queryForList( + "select column_name || ':' || is_nullable from information_schema.columns" + + " where table_schema = 'public' and table_name = 'app_user' order by 1", + String.class); + assertThat(cols) + .containsExactlyInAnyOrder( + "user_id:NO", + "email:NO", + "display_name:NO", + "password_hash:NO", + "role:NO", + "status:NO", + "created_at:NO", + "updated_at:NO"); + assertThat(jdbc.queryForList( + "select a.attname from pg_index i join pg_attribute a on a.attrelid = i.indrelid" + + " and a.attnum = any(i.indkey) where i.indrelid = 'app_user'::regclass" + + " and i.indisprimary", + String.class)) + .containsExactly("user_id"); + assertThat(indexes()) + .anySatisfy(d -> assertThat(d).contains("UNIQUE").contains("uq_app_user_email").contains("(email)")); + } + + private void insert(String id, String email, String role, String status) { + jdbc.update( + "insert into app_user(user_id, email, display_name, password_hash, role, status, created_at, updated_at)" + + " values (?,?,?,?,?,?,?,?)", + id, + email, + "n", + "$2a$10$abcdefghijklmnopqrstuuabcdefghijklmnopqrstuvwxyz0123456", + role, + status, + T0, + T0); + } + + @Test + void app_user_rejects_bad_role_status_and_duplicate_email() { + migrate("6"); + insert("01JD0USER0000000000000001", "a@example.com", "ADMIN", "ACTIVE"); + insert("01JD0USER0000000000000002", "b@example.com", "VIEWER", "DISABLED"); + insert("01JD0USER0000000000000003", "c@example.com", "EDITOR", "ACTIVE"); + assertThatThrownBy(() -> insert("01JD0USER0000000000000004", "d@example.com", "ROOT", "ACTIVE")) + .isInstanceOf(DataIntegrityViolationException.class); + assertThatThrownBy(() -> insert("01JD0USER0000000000000005", "e@example.com", "VIEWER", "DELETED")) + .isInstanceOf(DataIntegrityViolationException.class); + assertThatThrownBy(() -> insert("01JD0USER0000000000000006", "a@example.com", "VIEWER", "ACTIVE")) + .isInstanceOf(DataIntegrityViolationException.class); + assertThatThrownBy(() -> insert("01JD0USER0000000000000001", "f@example.com", "VIEWER", "ACTIVE")) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/api/src/test/java/com/cdp/acceptance/user-auth-api/UsersApiAcceptanceTest.java b/api/src/test/java/com/cdp/acceptance/user-auth-api/UsersApiAcceptanceTest.java new file mode 100644 index 0000000..e9c819a --- /dev/null +++ b/api/src/test/java/com/cdp/acceptance/user-auth-api/UsersApiAcceptanceTest.java @@ -0,0 +1,286 @@ +package com.cdp.acceptance.userauth; + +import static com.cdp.acceptance.security.AuthHttp.ADMIN_KEY; +import static com.cdp.acceptance.security.AuthHttp.PASSWORD; +import static com.cdp.acceptance.security.AuthHttp.uniqueEmail; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import com.cdp.acceptance.security.AuthHttp; +import com.cdp.acceptance.security.AuthHttp.Resp; +import com.cdp.acceptance.security.SecurityTestSupport; + +import tools.jackson.databind.JsonNode; + +/** user-auth-api 驗收:auth.md §4 {@code /users} 建立、列出、修改與錯誤碼。 */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.rabbitmq.listener.simple.auto-startup=false") +@ActiveProfiles("test") +@Import(SecurityTestSupport.class) +class UsersApiAcceptanceTest { + + private static final String ULID = "[0-9A-HJKMNP-TV-Z]{26}"; + + @LocalServerPort int port; + + private Resp create(String email, String name, String role, String password) throws Exception { + return AuthHttp.createUser(port, email, name, role, password); + } + + private Resp patch(String id, String body) throws Exception { + return AuthHttp.bearer(port, "PATCH", "/api/v1/users/" + id, ADMIN_KEY, body); + } + + private void assertProblem(Resp r, int status, String code) { + assertThat(r.status()).as(r.body()).isEqualTo(status); + assertThat(r.headers().firstValue("Content-Type").orElse("")).startsWith("application/problem+json"); + assertThat(r.json().path("status").asInt()).isEqualTo(status); + assertThat(r.code()).isEqualTo(code); + assertThat(r.json().path("request_id").asString()).isNotBlank(); + } + + private void assertUserShape(JsonNode u) { + assertThat(u.path("user_id").asString()).matches(ULID); + assertThat(u.path("email").asString()).isNotBlank(); + assertThat(u.path("display_name").asString()).isNotBlank(); + assertThat(u.path("role").asString()).isIn("VIEWER", "EDITOR", "ADMIN"); + assertThat(u.path("status").asString()).isIn("ACTIVE", "DISABLED"); + Instant.parse(u.path("created_at").asString()); + Instant.parse(u.path("updated_at").asString()); + assertThat(u.has("password")).isFalse(); + assertThat(u.has("password_hash")).isFalse(); + } + + // ---- POST ---- + + @Test + void create_returns_201_user_with_lowercase_email_and_active_status() throws Exception { + String email = "Mix" + System.nanoTime() + "@Example.COM"; + Resp r = create(email, "王小明", "EDITOR", PASSWORD); + assertThat(r.status()).as(r.body()).isEqualTo(201); + JsonNode u = r.json(); + assertUserShape(u); + assertThat(u.path("email").asString()).isEqualTo(email.toLowerCase()); + assertThat(u.path("display_name").asString()).isEqualTo("王小明"); + assertThat(u.path("role").asString()).isEqualTo("EDITOR"); + assertThat(u.path("status").asString()).isEqualTo("ACTIVE"); + } + + @Test + void create_works_for_admin_session_with_csrf() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + Resp r = AuthHttp.session( + port, + "POST", + "/api/v1/users", + cookie, + AuthHttp.userBody(uniqueEmail(), "by session", "VIEWER", PASSWORD)); + assertThat(r.status()).as(r.body()).isEqualTo(201); + } + + @Test + void create_duplicate_email_is_409_email_taken_case_insensitive() throws Exception { + String email = uniqueEmail(); + assertThat(create(email, "a", "VIEWER", PASSWORD).status()).isEqualTo(201); + assertProblem(create(email, "b", "ADMIN", PASSWORD), 409, "EMAIL_TAKEN"); + assertProblem(create(email.toUpperCase(), "c", "VIEWER", PASSWORD), 409, "EMAIL_TAKEN"); + } + + @Test + void concurrent_create_of_same_email_yields_one_201_and_rest_409() throws Exception { + String email = uniqueEmail(); + List rs = parallel( + 5, i -> () -> create(email, "race " + i, "VIEWER", PASSWORD)); + assertThat(rs.stream().filter(r -> r.status() == 201).count()).isEqualTo(1); + assertThat(rs.stream().filter(r -> r.status() == 409 && "EMAIL_TAKEN".equals(r.code())).count()) + .isEqualTo(4); + } + + @Test + void create_validation() throws Exception { + String ok = uniqueEmail(); + String[] bad = { + AuthHttp.userBody("not-an-email", "n", "VIEWER", PASSWORD), + AuthHttp.userBody(ok, "n", "SUPERUSER", PASSWORD), + AuthHttp.userBody(ok, "n", "viewer", PASSWORD), + AuthHttp.userBody(ok, "", "VIEWER", PASSWORD), + AuthHttp.userBody(ok, "x".repeat(101), "VIEWER", PASSWORD), + AuthHttp.userBody(ok, "n", "VIEWER", "a".repeat(11)), + AuthHttp.userBody(ok, "n", "VIEWER", "a".repeat(73)), + AuthHttp.userBody(ok, "n", "VIEWER", "密".repeat(25)), + "{\"display_name\":\"n\",\"role\":\"VIEWER\",\"password\":\"" + PASSWORD + "\"}", + "{\"email\":\"" + ok + "\",\"role\":\"VIEWER\",\"password\":\"" + PASSWORD + "\"}", + "{\"email\":\"" + ok + "\",\"display_name\":\"n\",\"password\":\"" + PASSWORD + "\"}", + "{\"email\":\"" + ok + "\",\"display_name\":\"n\",\"role\":\"VIEWER\"}", + "{}" + }; + for (String b : bad) { + Resp r = AuthHttp.bearer(port, "POST", "/api/v1/users", ADMIN_KEY, b); + assertProblem(r, 400, "VALIDATION_FAILED"); + } + // 邊界可用:display_name 100 字、密碼 12 字元 + assertThat(create(ok, "x".repeat(100), "VIEWER", "a".repeat(12)).status()).isEqualTo(201); + // 驗證失敗不建立帳號:同 email 之後仍可建 + String ok2 = uniqueEmail(); + assertProblem(create(ok2, "n", "SUPERUSER", PASSWORD), 400, "VALIDATION_FAILED"); + assertThat(create(ok2, "n", "VIEWER", PASSWORD).status()).isEqualTo(201); + } + + @Test + void auth_is_checked_before_role_before_input() throws Exception { + String badBody = "{\"role\":\"SUPERUSER\"}"; + assertProblem(AuthHttp.sendRaw(port, "POST", "/api/v1/users", null, badBody), 401, "UNAUTHENTICATED"); + String viewer = AuthHttp.loginNewUser(port, "VIEWER"); + assertProblem(AuthHttp.session(port, "POST", "/api/v1/users", viewer, badBody), 403, "FORBIDDEN"); + assertProblem(AuthHttp.bearer(port, "POST", "/api/v1/users", AuthHttp.INGEST_KEY, badBody), 403, "FORBIDDEN"); + } + + // ---- GET ---- + + @Test + void list_is_sorted_by_email_includes_disabled_and_hides_secrets() throws Exception { + String tag = "list" + System.nanoTime(); + String c = tag + "c@example.com"; + String a = tag + "a@example.com"; + String b = tag + "b@example.com"; + create(c, "C", "VIEWER", PASSWORD); + String aId = create(a, "A", "EDITOR", PASSWORD).json().path("user_id").asString(); + create(b, "B", "ADMIN", PASSWORD); + assertThat(patch(aId, "{\"status\":\"DISABLED\"}").status()).isEqualTo(200); + + Resp r = AuthHttp.bearer(port, "GET", "/api/v1/users", ADMIN_KEY, null); + assertThat(r.status()).isEqualTo(200); + JsonNode items = r.json().path("items"); + assertThat(items.isArray()).isTrue(); + List emails = new ArrayList<>(); + for (JsonNode u : items) { + assertUserShape(u); + emails.add(u.path("email").asString()); + } + assertThat(emails).isSorted(); + assertThat(emails.stream().filter(e -> e.startsWith(tag)).toList()).containsExactly(a, b, c); + for (JsonNode u : items) { + if (u.path("email").asString().equals(a)) assertThat(u.path("status").asString()).isEqualTo("DISABLED"); + } + } + + @Test + void list_works_for_admin_session() throws Exception { + String cookie = AuthHttp.loginNewUser(port, "ADMIN"); + Resp r = AuthHttp.session(port, "GET", "/api/v1/users", cookie, null); + assertThat(r.status()).isEqualTo(200); + assertThat(r.json().path("items").isArray()).isTrue(); + } + + // ---- PATCH ---- + + @Test + void patch_updates_each_field_and_bumps_updated_at() throws Exception { + Resp created = create(uniqueEmail(), "before", "VIEWER", PASSWORD); + JsonNode u0 = created.json(); + String id = u0.path("user_id").asString(); + Thread.sleep(20); + + Resp r = patch(id, "{\"display_name\":\"after\",\"role\":\"EDITOR\",\"status\":\"DISABLED\"}"); + assertThat(r.status()).as(r.body()).isEqualTo(200); + JsonNode u1 = r.json(); + assertUserShape(u1); + assertThat(u1.path("user_id").asString()).isEqualTo(id); + assertThat(u1.path("email").asString()).isEqualTo(u0.path("email").asString()); + assertThat(u1.path("display_name").asString()).isEqualTo("after"); + assertThat(u1.path("role").asString()).isEqualTo("EDITOR"); + assertThat(u1.path("status").asString()).isEqualTo("DISABLED"); + assertThat(u1.path("created_at").asString()).isEqualTo(u0.path("created_at").asString()); + assertThat(Instant.parse(u1.path("updated_at").asString())) + .isAfter(Instant.parse(u0.path("updated_at").asString())); + + // 部分更新:只送 status 不動其他欄位 + JsonNode u2 = patch(id, "{\"status\":\"ACTIVE\"}").json(); + assertThat(u2.path("status").asString()).isEqualTo("ACTIVE"); + assertThat(u2.path("display_name").asString()).isEqualTo("after"); + assertThat(u2.path("role").asString()).isEqualTo("EDITOR"); + } + + @Test + void patch_not_found_is_404_user_not_found() throws Exception { + assertProblem(patch("01JXXXXXXXXXXXXXXXXXXXXXXX", "{\"display_name\":\"x\"}"), 404, "USER_NOT_FOUND"); + } + + @Test + void patch_validation() throws Exception { + String id = create(uniqueEmail(), "n", "VIEWER", PASSWORD).json().path("user_id").asString(); + for (String b : new String[] { + "{\"role\":\"SUPERUSER\"}", + "{\"status\":\"DELETED\"}", + "{\"display_name\":\"\"}", + "{\"display_name\":\"" + "x".repeat(101) + "\"}", + "{\"password\":\"" + "a".repeat(11) + "\"}", + "{\"password\":\"" + "a".repeat(73) + "\"}" + }) { + assertProblem(patch(id, b), 400, "VALIDATION_FAILED"); + } + } + + @Test + void patch_requires_admin_and_csrf_for_session() throws Exception { + String id = create(uniqueEmail(), "n", "VIEWER", PASSWORD).json().path("user_id").asString(); + String editor = AuthHttp.loginNewUser(port, "EDITOR"); + assertProblem( + AuthHttp.session(port, "PATCH", "/api/v1/users/" + id, editor, "{\"display_name\":\"x\"}"), + 403, + "FORBIDDEN"); + String admin = AuthHttp.loginNewUser(port, "ADMIN"); + Resp noCsrf = AuthHttp.sendRaw( + port, + "PATCH", + "/api/v1/users/" + id, + java.util.Map.of("Cookie", admin), + "{\"display_name\":\"x\"}"); + assertProblem(noCsrf, 403, "CSRF_INVALID"); + assertThat(AuthHttp.session(port, "PATCH", "/api/v1/users/" + id, admin, "{\"display_name\":\"x\"}") + .status()) + .isEqualTo(200); + } + + // ---- helpers ---- + + interface Task { + Callable at(int i); + } + + private List parallel(int n, Task t) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(n); + try { + CountDownLatch go = new CountDownLatch(1); + List> fs = new ArrayList<>(); + for (int i = 0; i < n; i++) { + Callable c = t.at(i); + fs.add(pool.submit(() -> { + go.await(); + return c.call(); + })); + } + go.countDown(); + List out = new ArrayList<>(); + for (Future f : fs) out.add(f.get()); + return out; + } finally { + pool.shutdownNow(); + } + } +} From 4dd7a44a67e1bd1609604e45df8b1ccf6bd07de3 Mon Sep 17 00:00:00 2001 From: Marcus Date: Thu, 22 Oct 2026 13:47:15 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(user-auth-api):=20email=20=E7=99=BB?= =?UTF-8?q?=E5=85=A5=E3=80=81=E8=A7=92=E8=89=B2=E7=9F=A9=E9=99=A3=E3=80=81?= =?UTF-8?q?CSRF=E3=80=81=E7=99=BB=E5=85=A5=E9=99=90=E9=80=9F=E8=88=87=20/u?= =?UTF-8?q?sers=20=E7=AE=A1=E7=90=86=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0134QMUz6NTfqU8Yrh3BJekj --- .env.example | 6 +- .../java/com/cdp/common/web/ErrorCode.java | 5 + .../java/com/cdp/security/AccessRule.java | 55 +++++ .../security/ApiKeyAuthenticationFilter.java | 6 +- .../java/com/cdp/security/AuthController.java | 106 +++++++--- .../java/com/cdp/security/AuthProperties.java | 10 +- .../cdp/security/AuthPropertiesValidator.java | 4 +- .../java/com/cdp/security/Credential.java | 13 ++ .../com/cdp/security/CurrentUserResponse.java | 6 +- .../com/cdp/security/LoginProperties.java | 9 + .../com/cdp/security/LoginRateLimiter.java | 75 +++++++ .../java/com/cdp/security/LoginRequest.java | 6 +- .../cdp/security/PasswordChangeRequest.java | 10 + api/src/main/java/com/cdp/security/Role.java | 19 +- .../java/com/cdp/security/SecurityConfig.java | 122 +++++++++-- .../security/SessionAuthenticationFilter.java | 69 ++++++ .../security/SessionCsrfTokenRepository.java | 107 ++++++++++ .../java/com/cdp/security/SessionUser.java | 12 ++ api/src/main/java/com/cdp/user/AppUser.java | 24 +++ .../main/java/com/cdp/user/AppUserStore.java | 106 ++++++++++ .../java/com/cdp/user/CreateUserRequest.java | 10 + .../java/com/cdp/user/PasswordHasher.java | 34 +++ .../java/com/cdp/user/PasswordPolicy.java | 20 ++ .../java/com/cdp/user/UpdateUserRequest.java | 10 + .../java/com/cdp/user/UserController.java | 40 ++++ .../java/com/cdp/user/UserListResponse.java | 6 + .../main/java/com/cdp/user/UserResponse.java | 19 ++ api/src/main/java/com/cdp/user/UserRole.java | 8 + .../main/java/com/cdp/user/UserService.java | 200 ++++++++++++++++++ .../main/java/com/cdp/user/UserStatus.java | 7 + api/src/main/resources/application.yml | 10 +- .../resources/db/migration/V6__app_user.sql | 16 ++ .../ApiKeyAuthenticationFilterTest.java | 2 +- .../com/cdp/security/AuthControllerTest.java | 126 ++++++++--- .../security/AuthPropertiesValidatorTest.java | 18 +- .../cdp/security/LoginRateLimiterTest.java | 102 +++++++++ .../SessionCsrfTokenRepositoryTest.java | 66 ++++++ .../java/com/cdp/user/PasswordPolicyTest.java | 32 +++ .../java/com/cdp/user/UserServiceTest.java | 124 +++++++++++ api/src/test/resources/application-test.yml | 6 +- tools/demo-seed/seed.py | 36 +++- 41 files changed, 1541 insertions(+), 121 deletions(-) create mode 100644 api/src/main/java/com/cdp/security/AccessRule.java create mode 100644 api/src/main/java/com/cdp/security/Credential.java create mode 100644 api/src/main/java/com/cdp/security/LoginProperties.java create mode 100644 api/src/main/java/com/cdp/security/LoginRateLimiter.java create mode 100644 api/src/main/java/com/cdp/security/PasswordChangeRequest.java create mode 100644 api/src/main/java/com/cdp/security/SessionAuthenticationFilter.java create mode 100644 api/src/main/java/com/cdp/security/SessionCsrfTokenRepository.java create mode 100644 api/src/main/java/com/cdp/security/SessionUser.java create mode 100644 api/src/main/java/com/cdp/user/AppUser.java create mode 100644 api/src/main/java/com/cdp/user/AppUserStore.java create mode 100644 api/src/main/java/com/cdp/user/CreateUserRequest.java create mode 100644 api/src/main/java/com/cdp/user/PasswordHasher.java create mode 100644 api/src/main/java/com/cdp/user/PasswordPolicy.java create mode 100644 api/src/main/java/com/cdp/user/UpdateUserRequest.java create mode 100644 api/src/main/java/com/cdp/user/UserController.java create mode 100644 api/src/main/java/com/cdp/user/UserListResponse.java create mode 100644 api/src/main/java/com/cdp/user/UserResponse.java create mode 100644 api/src/main/java/com/cdp/user/UserRole.java create mode 100644 api/src/main/java/com/cdp/user/UserService.java create mode 100644 api/src/main/java/com/cdp/user/UserStatus.java create mode 100644 api/src/main/resources/db/migration/V6__app_user.sql create mode 100644 api/src/test/java/com/cdp/security/LoginRateLimiterTest.java create mode 100644 api/src/test/java/com/cdp/security/SessionCsrfTokenRepositoryTest.java create mode 100644 api/src/test/java/com/cdp/user/PasswordPolicyTest.java create mode 100644 api/src/test/java/com/cdp/user/UserServiceTest.java diff --git a/.env.example b/.env.example index 556deac..8b2604e 100644 --- a/.env.example +++ b/.env.example @@ -46,11 +46,7 @@ DELIVERY_REQUEUE_DELAY=PT5S CDP_INGEST_API_KEY=CHANGE_ME # 旅程建立/查詢 API 的 Bearer 金鑰(機密,隨機產生) CDP_ADMIN_API_KEY=CHANGE_ME -# 客服登入帳號 -CDP_SUPPORT_USERNAME=support -# 客服登入密碼(機密,隨機產生) -CDP_SUPPORT_PASSWORD=CHANGE_ME -# 客服 session 閒置逾時(ISO-8601 Duration) +# 使用者 session 閒置逾時(ISO-8601 Duration) CDP_SESSION_IDLE_TIMEOUT=PT30M # 軌跡頁可明文顯示的屬性鍵(逗號分隔,例 cart_id,cart_total;填 - = 全部遮罩,規則見契約 §4.4) CDP_TRACE_PLAIN_KEYS=- diff --git a/api/src/main/java/com/cdp/common/web/ErrorCode.java b/api/src/main/java/com/cdp/common/web/ErrorCode.java index 3febd7d..1c7d771 100644 --- a/api/src/main/java/com/cdp/common/web/ErrorCode.java +++ b/api/src/main/java/com/cdp/common/web/ErrorCode.java @@ -9,6 +9,11 @@ public enum ErrorCode { UNAUTHENTICATED(HttpStatus.UNAUTHORIZED), INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED), FORBIDDEN(HttpStatus.FORBIDDEN), + CSRF_INVALID(HttpStatus.FORBIDDEN), + LOGIN_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS), + EMAIL_TAKEN(HttpStatus.CONFLICT), + LAST_ADMIN(HttpStatus.CONFLICT), + USER_NOT_FOUND(HttpStatus.NOT_FOUND), JOURNEY_NOT_FOUND(HttpStatus.NOT_FOUND), ENROLLMENT_NOT_FOUND(HttpStatus.NOT_FOUND), NOT_FOUND(HttpStatus.NOT_FOUND), diff --git a/api/src/main/java/com/cdp/security/AccessRule.java b/api/src/main/java/com/cdp/security/AccessRule.java new file mode 100644 index 0000000..5e97165 --- /dev/null +++ b/api/src/main/java/com/cdp/security/AccessRule.java @@ -0,0 +1,55 @@ +package com.cdp.security; + +import java.util.Collection; +import java.util.function.Supplier; + +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +/** + * 角色矩陣的一列(auth.md §2):需要某角色(含 RoleHierarchy 可達的),可再限定憑證種類(僅 Bearer/僅 session)。 + * 未認證一律不通過(交給 entry point 回 401)。 + */ +final class AccessRule implements AuthorizationManager { + + private final RoleHierarchy hierarchy; + private final Role role; + private final Credential credential; + + private AccessRule(RoleHierarchy hierarchy, Role role, Credential credential) { + this.hierarchy = hierarchy; + this.role = role; + this.credential = credential; + } + + /** 任何憑證,只看角色。 */ + static AccessRule role(RoleHierarchy hierarchy, Role role) { + return new AccessRule(hierarchy, role, null); + } + + /** 角色且限定憑證種類。 */ + static AccessRule role(RoleHierarchy hierarchy, Role role, Credential credential) { + return new AccessRule(hierarchy, role, credential); + } + + @Override + public AuthorizationResult authorize( + Supplier supplier, RequestAuthorizationContext context) { + Authentication auth = supplier.get(); + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + return new AuthorizationDecision(false); + } + Collection reachable = + hierarchy.getReachableGrantedAuthorities(auth.getAuthorities()); + boolean hasRole = reachable.stream().anyMatch(a -> role.authority().equals(a.getAuthority())); + boolean hasCredential = credential == null + || auth.getAuthorities().stream().anyMatch(a -> credential.authority().equals(a.getAuthority())); + return new AuthorizationDecision(hasRole && hasCredential); + } +} diff --git a/api/src/main/java/com/cdp/security/ApiKeyAuthenticationFilter.java b/api/src/main/java/com/cdp/security/ApiKeyAuthenticationFilter.java index adb5981..09d6256 100644 --- a/api/src/main/java/com/cdp/security/ApiKeyAuthenticationFilter.java +++ b/api/src/main/java/com/cdp/security/ApiKeyAuthenticationFilter.java @@ -17,7 +17,7 @@ import com.cdp.common.web.ErrorCode; /** - * 契約 §1.1:讀 Authorization: Bearer,與 ingest/admin 金鑰常數時間比對,成功則放入 Role.INGEST/ADMIN; + * auth.md §1:讀 Authorization: Bearer,與 ingest/admin 金鑰常數時間比對,成功則放入 Role.INGEST/ADMIN; * 帶了但不符 → 401 UNAUTHENTICATED(不再看 session)。不加 @Component:只由 SecurityConfig 掛進 security filter chain, * 避免被 Boot 自動註冊成一般 servlet filter。 */ @@ -57,7 +57,9 @@ protected void doFilterInternal( } SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated( - role.name().toLowerCase(), null, List.of(new SimpleGrantedAuthority("ROLE_" + role.name())))); + role.name().toLowerCase(), null, List.of( + new SimpleGrantedAuthority(role.authority()), + new SimpleGrantedAuthority(Credential.BEARER.authority())))); SecurityContextHolder.setContext(context); chain.doFilter(request, response); } diff --git a/api/src/main/java/com/cdp/security/AuthController.java b/api/src/main/java/com/cdp/security/AuthController.java index 20507c7..ccdf846 100644 --- a/api/src/main/java/com/cdp/security/AuthController.java +++ b/api/src/main/java/com/cdp/security/AuthController.java @@ -1,9 +1,13 @@ package com.cdp.security; +import java.io.IOException; +import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.springframework.boot.web.server.Cookie; @@ -12,12 +16,8 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,59 +27,91 @@ import com.cdp.common.web.ApiException; import com.cdp.common.web.ErrorCode; import com.cdp.common.web.FieldError; +import com.cdp.user.AppUser; +import com.cdp.user.PasswordHasher; +import com.cdp.user.UserService; -/** 契約 §1.2 客服登入。帳密比對用常數時間;登入成功換新 session id(防 session fixation)。 */ +/** + * auth.md §3 登入、登出、me、改密碼。登入比對 BCrypt(帳號不存在、停用也跑一次等價成本的比對),失敗一律同一個 + * 401;成功換新 session id(防 session fixation),session 只存 user_id。限速在密碼比對之前檢查,鎖定中不跑 BCrypt。 + */ @RestController @RequestMapping("/api/v1/auth") public class AuthController { - private final AuthProperties properties; - private final Cookie cookieConfig; + private static final String INVALID_CREDENTIALS_DETAIL = "帳號或密碼錯誤"; - public AuthController(AuthProperties properties, ServerProperties serverProperties) { - this.properties = properties; - this.cookieConfig = serverProperties.getServlet().getSession().getCookie(); + private final UserService users; + private final PasswordHasher hasher; + private final LoginRateLimiter limiter; + private final SessionCsrfTokenRepository csrf; + private final Cookie sessionCookie; + + public AuthController( + UserService users, + PasswordHasher hasher, + LoginRateLimiter limiter, + SessionCsrfTokenRepository csrf, + ServerProperties serverProperties) { + this.users = users; + this.hasher = hasher; + this.limiter = limiter; + this.csrf = csrf; + this.sessionCookie = serverProperties.getServlet().getSession().getCookie(); } @PostMapping(path = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity login(@RequestBody LoginRequest body, HttpServletRequest request) { + public ResponseEntity login( + @RequestBody LoginRequest body, HttpServletRequest request, HttpServletResponse response) + throws IOException { List errors = new ArrayList<>(); - if (body.username() == null || body.username().isBlank()) { - errors.add(new FieldError("username", "必填")); + if (body.email() == null || body.email().isBlank()) { + errors.add(new FieldError("email", "必填")); } - if (body.password() == null || body.password().isBlank()) { + if (body.password() == null || body.password().isEmpty()) { errors.add(new FieldError("password", "必填")); } if (!errors.isEmpty()) { throw new ApiException(ErrorCode.VALIDATION_FAILED, "請求內容有誤", errors); } - // 兩項都比、不短路:帳號錯與密碼錯耗時相同、回應相同 - boolean userOk = SecretMatcher.matches(body.username(), properties.supportUsername()); - boolean passOk = SecretMatcher.matches(body.password(), properties.supportPassword()); - if (!(userOk & passOk)) { - throw new ApiException(ErrorCode.INVALID_CREDENTIALS, "帳號或密碼錯誤"); + String key = body.email().trim().toLowerCase(Locale.ROOT); + + Duration locked = limiter.remainingLock(key); + if (!locked.isZero()) { + response.setHeader(HttpHeaders.RETRY_AFTER, Long.toString(LoginRateLimiter.retryAfterSeconds(locked))); + SecurityProblemWriter.write(response, ErrorCode.LOGIN_RATE_LIMITED, "登入嘗試過於頻繁,請稍後再試。"); + return null; + } + + AppUser user = users.findByEmail(key).orElse(null); + boolean passwordOk = user != null ? hasher.matches(body.password(), user.passwordHash()) + : hasher.matchesDummy(body.password()); + if (user == null || !user.active() || !passwordOk) { + limiter.recordFailure(key); + throw new ApiException(ErrorCode.INVALID_CREDENTIALS, INVALID_CREDENTIALS_DETAIL); } + limiter.reset(key); + HttpSession old = request.getSession(false); if (old != null) { old.invalidate(); } HttpSession session = request.getSession(true); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated( - properties.supportUsername(), null, - List.of(new SimpleGrantedAuthority("ROLE_" + Role.SUPPORT.name())))); - session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + session.setAttribute(SessionAuthenticationFilter.USER_ID_ATTRIBUTE, user.userId()); + csrf.rotate(request, response); return ResponseEntity.noContent().build(); } @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request) { + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } - ResponseCookie cleared = ResponseCookie.from(cookieConfig.getName() != null ? cookieConfig.getName() : "JSESSIONID", "") - .path(cookieConfig.getPath() != null ? cookieConfig.getPath() : "/") + csrf.clear(response); + ResponseCookie cleared = ResponseCookie.from( + sessionCookie.getName() != null ? sessionCookie.getName() : "JSESSIONID", "") + .path(sessionCookie.getPath() != null ? sessionCookie.getPath() : "/") .maxAge(0) .httpOnly(true) .secure(true) @@ -88,9 +120,25 @@ public ResponseEntity logout(HttpServletRequest request) { return ResponseEntity.noContent().header(HttpHeaders.SET_COOKIE, cleared.toString()).build(); } + /** 回應一定帶新的 XSRF-TOKEN(登入成功後前端先呼叫這支取值)。 */ @GetMapping("/me") - public CurrentUserResponse me() { + public CurrentUserResponse me(HttpServletRequest request, HttpServletResponse response) { + SessionUser user = currentUser(); + csrf.rotate(request, response); + return new CurrentUserResponse(user.userId(), user.email(), user.displayName(), user.role()); + } + + @PostMapping(path = "/password", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity changePassword(@RequestBody PasswordChangeRequest body) { + users.changePassword(currentUser().userId(), body.currentPassword(), body.newPassword()); + return ResponseEntity.noContent().build(); + } + + private static SessionUser currentUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - return new CurrentUserResponse(auth.getName()); + if (auth != null && auth.getPrincipal() instanceof SessionUser user) { + return user; + } + throw new ApiException(ErrorCode.UNAUTHENTICATED, "缺少有效的憑證"); } } diff --git a/api/src/main/java/com/cdp/security/AuthProperties.java b/api/src/main/java/com/cdp/security/AuthProperties.java index 25d3897..a2810b7 100644 --- a/api/src/main/java/com/cdp/security/AuthProperties.java +++ b/api/src/main/java/com/cdp/security/AuthProperties.java @@ -3,18 +3,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties; /** - * cdp.security.*:金鑰與客服帳密(變數見 DESIGN-01「設定」);全部只從環境變數來,不得寫預設值;session 逾時在 + * cdp.security.*:攝取金鑰與管理金鑰(auth.md §1);只從環境變數來,不得寫預設值;session 逾時在 * server.servlet.session.timeout。toString 不輸出機密;啟動驗證見 {@link AuthPropertiesValidator}。 */ @ConfigurationProperties("cdp.security") -public record AuthProperties( - String ingestApiKey, - String adminApiKey, - String supportUsername, - String supportPassword) { +public record AuthProperties(String ingestApiKey, String adminApiKey) { @Override public String toString() { - return "AuthProperties[ingestApiKey=***, adminApiKey=***, supportUsername=***, supportPassword=***]"; + return "AuthProperties[ingestApiKey=***, adminApiKey=***]"; } } diff --git a/api/src/main/java/com/cdp/security/AuthPropertiesValidator.java b/api/src/main/java/com/cdp/security/AuthPropertiesValidator.java index 3f9fba6..7fc29ee 100644 --- a/api/src/main/java/com/cdp/security/AuthPropertiesValidator.java +++ b/api/src/main/java/com/cdp/security/AuthPropertiesValidator.java @@ -3,7 +3,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.stereotype.Component; -/** 啟動時驗證 cdp.security.*:金鑰與客服帳密不得空白、兩把金鑰不得相同。訊息只講設定名稱,不含值。 */ +/** 啟動時驗證 cdp.security.*:金鑰不得空白、兩把金鑰不得相同。訊息只講設定名稱,不含值。 */ @Component class AuthPropertiesValidator implements InitializingBean { @@ -17,8 +17,6 @@ class AuthPropertiesValidator implements InitializingBean { public void afterPropertiesSet() { require(properties.ingestApiKey(), "cdp.security.ingest-api-key(CDP_INGEST_API_KEY)"); require(properties.adminApiKey(), "cdp.security.admin-api-key(CDP_ADMIN_API_KEY)"); - require(properties.supportUsername(), "cdp.security.support-username(客服帳號)"); - require(properties.supportPassword(), "cdp.security.support-password(客服密碼)"); if (properties.ingestApiKey().equals(properties.adminApiKey())) { throw new IllegalStateException( "cdp.security.ingest-api-key 與 cdp.security.admin-api-key 不可相同(兩把金鑰必須不同)"); diff --git a/api/src/main/java/com/cdp/security/Credential.java b/api/src/main/java/com/cdp/security/Credential.java new file mode 100644 index 0000000..66f2943 --- /dev/null +++ b/api/src/main/java/com/cdp/security/Credential.java @@ -0,0 +1,13 @@ +package com.cdp.security; + +/** 憑證種類,以額外的 authority 附在 Authentication 上,讓角色矩陣能區分「僅管理金鑰」「僅 session」。 */ +public enum Credential { + /** Authorization: Bearer(攝取金鑰或管理金鑰)。 */ + BEARER, + /** cookie CDP_SESSION 對應的使用者。 */ + SESSION; + + public String authority() { + return "CRED_" + name(); + } +} diff --git a/api/src/main/java/com/cdp/security/CurrentUserResponse.java b/api/src/main/java/com/cdp/security/CurrentUserResponse.java index f6451a7..dacf4db 100644 --- a/api/src/main/java/com/cdp/security/CurrentUserResponse.java +++ b/api/src/main/java/com/cdp/security/CurrentUserResponse.java @@ -1,4 +1,6 @@ package com.cdp.security; -/** 契約 §1.2 GET /api/v1/auth/me。 */ -public record CurrentUserResponse(String username) {} +import com.cdp.user.UserRole; + +/** auth.md §3 GET /api/v1/auth/me 的 CurrentUser。 */ +public record CurrentUserResponse(String userId, String email, String displayName, UserRole role) {} diff --git a/api/src/main/java/com/cdp/security/LoginProperties.java b/api/src/main/java/com/cdp/security/LoginProperties.java new file mode 100644 index 0000000..b2b143e --- /dev/null +++ b/api/src/main/java/com/cdp/security/LoginProperties.java @@ -0,0 +1,9 @@ +package com.cdp.security; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** cdp.login.*:登入限速(D-045,auth.md §3)。CDP_LOGIN_MAX_FAILURES、CDP_LOGIN_LOCKOUT;驗證見 {@link LoginRateLimiter}。 */ +@ConfigurationProperties("cdp.login") +public record LoginProperties(int maxFailures, Duration lockout) {} diff --git a/api/src/main/java/com/cdp/security/LoginRateLimiter.java b/api/src/main/java/com/cdp/security/LoginRateLimiter.java new file mode 100644 index 0000000..25412e9 --- /dev/null +++ b/api/src/main/java/com/cdp/security/LoginRateLimiter.java @@ -0,0 +1,75 @@ +package com.cdp.security; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +/** + * 登入失敗限速(D-045,auth.md §3)。維度 = 小寫 email(帳號存在與否都一樣計數);密碼錯才 +1; + * 距上次失敗超過 lockout 先歸零再計;計數達 maxFailures → 鎖到「最後一次失敗 + lockout」,鎖定中的嘗試不計數、不延長; + * 成功歸零。計數存記憶體,後端重啟歸零。 + */ +@Component +public class LoginRateLimiter { + + /** 超過這個筆數時順手清掉已過期的項目,避免亂填 email 撐爆記憶體。 */ + private static final int PURGE_THRESHOLD = 10_000; + + private record Entry(int failures, Instant lastFailure) {} + + private final int maxFailures; + private final Duration lockout; + private final Clock clock; + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + + public LoginRateLimiter(LoginProperties properties, Clock clock) { + if (properties.maxFailures() < 1) { + throw new IllegalStateException("設定 cdp.login.max-failures(CDP_LOGIN_MAX_FAILURES)須 >= 1"); + } + if (properties.lockout() == null || properties.lockout().isZero() || properties.lockout().isNegative()) { + throw new IllegalStateException("設定 cdp.login.lockout(CDP_LOGIN_LOCKOUT)須為正的 Duration"); + } + this.maxFailures = properties.maxFailures(); + this.lockout = properties.lockout(); + this.clock = clock; + } + + /** 鎖定中 → 剩餘時間;沒鎖 → {@link Duration#ZERO}。 */ + public Duration remainingLock(String key) { + Entry e = entries.get(key); + if (e == null || e.failures() < maxFailures) { + return Duration.ZERO; + } + Duration remaining = Duration.between(clock.instant(), e.lastFailure().plus(lockout)); + return remaining.isNegative() ? Duration.ZERO : remaining; + } + + /** 記一次密碼錯誤。鎖定中的嘗試呼叫端不會呼叫這個方法;這裡再保險一次,不延長鎖定。 */ + public void recordFailure(String key) { + Instant now = clock.instant(); + if (entries.size() > PURGE_THRESHOLD) { + entries.entrySet().removeIf(en -> !en.getValue().lastFailure().plus(lockout).isAfter(now)); + } + entries.compute(key, (k, e) -> { + boolean live = e != null && e.lastFailure().plus(lockout).isAfter(now); + if (live && e.failures() >= maxFailures) { + return e; + } + return new Entry(live ? e.failures() + 1 : 1, now); + }); + } + + public void reset(String key) { + entries.remove(key); + } + + /** Retry-After 秒數:整數、無條件進位、至少 1。 */ + public static long retryAfterSeconds(Duration remaining) { + long nanos = remaining.toNanos(); + long seconds = (nanos + 999_999_999L) / 1_000_000_000L; + return Math.max(1, seconds); + } +} diff --git a/api/src/main/java/com/cdp/security/LoginRequest.java b/api/src/main/java/com/cdp/security/LoginRequest.java index f58a153..15a7b27 100644 --- a/api/src/main/java/com/cdp/security/LoginRequest.java +++ b/api/src/main/java/com/cdp/security/LoginRequest.java @@ -1,10 +1,10 @@ package com.cdp.security; -/** 契約 §1.2 POST /api/v1/auth/login 的 body。toString 不輸出帳密。 */ -public record LoginRequest(String username, String password) { +/** auth.md §3 POST /api/v1/auth/login 的 body。toString 不輸出帳密。 */ +public record LoginRequest(String email, String password) { @Override public String toString() { - return "LoginRequest[username=***, password=***]"; + return "LoginRequest[email=***, password=***]"; } } diff --git a/api/src/main/java/com/cdp/security/PasswordChangeRequest.java b/api/src/main/java/com/cdp/security/PasswordChangeRequest.java new file mode 100644 index 0000000..e052615 --- /dev/null +++ b/api/src/main/java/com/cdp/security/PasswordChangeRequest.java @@ -0,0 +1,10 @@ +package com.cdp.security; + +/** auth.md §3 POST /api/v1/auth/password 的 body。toString 不輸出密碼。 */ +public record PasswordChangeRequest(String currentPassword, String newPassword) { + + @Override + public String toString() { + return "PasswordChangeRequest[currentPassword=***, newPassword=***]"; + } +} diff --git a/api/src/main/java/com/cdp/security/Role.java b/api/src/main/java/com/cdp/security/Role.java index 7ee21a1..67abaa4 100644 --- a/api/src/main/java/com/cdp/security/Role.java +++ b/api/src/main/java/com/cdp/security/Role.java @@ -1,11 +1,18 @@ package com.cdp.security; -/** 呼叫方角色與可用 API(契約 §1.1)。 */ +/** + * 呼叫方角色(auth.md §1、§2)。INGEST 與使用者角色無階層關係;使用者角色 ADMIN ⊇ EDITOR ⊇ VIEWER + * 由 {@link SecurityConfig#roleHierarchy()} 表達。管理金鑰 = ADMIN。 + */ public enum Role { - /** 外部系統:只能呼叫事件攝取 §3.2。憑證 = cdp.security.ingest-api-key。 */ + /** 外部系統:只能呼叫事件攝取等 INGEST 列出的 API。憑證 = cdp.security.ingest-api-key。 */ INGEST, - /** 管理(建旅程、腳本、測試):§3.1 與 §4。憑證 = cdp.security.admin-api-key。 */ - ADMIN, - /** 客服:登入 session,只能 GET §4 與 §1.2。 */ - SUPPORT + VIEWER, + EDITOR, + /** 憑證 = 管理金鑰(cdp.security.admin-api-key)或 role = ADMIN 的使用者 session。 */ + ADMIN; + + public String authority() { + return "ROLE_" + name(); + } } diff --git a/api/src/main/java/com/cdp/security/SecurityConfig.java b/api/src/main/java/com/cdp/security/SecurityConfig.java index 5ace928..4857b8c 100644 --- a/api/src/main/java/com/cdp/security/SecurityConfig.java +++ b/api/src/main/java/com/cdp/security/SecurityConfig.java @@ -1,26 +1,41 @@ package com.cdp.security; +import java.util.Set; + import jakarta.servlet.DispatcherType; +import org.springframework.boot.web.server.autoconfigure.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authorization.AuthorizationManagers; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.context.SecurityContextHolderFilter; +import org.springframework.security.web.csrf.CsrfException; import com.cdp.common.web.ErrorCode; +import com.cdp.user.UserService; /** - * 契約 §1.1 角色矩陣、§1.2 session cookie、§5 的 401/403 problem+json(含 request_id,RequestIdFilter 須排在 - * security filter 之前)。CSRF 關閉的前提:session 只能用於 GET §4 與 /api/v1/auth/*,寫入 API 只收 Bearer。 + * auth.md §1~§3:Bearer 金鑰與 session 兩種憑證、§2 角色矩陣(ADMIN ⊇ EDITOR ⊇ VIEWER)、§1.1 CSRF、 + * 401/403 problem+json(含 request_id,RequestIdFilter 須排在 security filter 之前)。 + * + *

順序:認證(Bearer 或 session)→ CSRF(只對 session 的非 GET 請求;Bearer、login、logout 免)→ 角色 → 輸入驗證。 */ @Configuration public class SecurityConfig { + private static final String V1 = "/api/v1"; + private static final Set SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE"); + /** 宣告空的 UserDetailsService,避免 Spring Boot 自動產生預設帳號與密碼(認證只走 Bearer 與 session)。 */ @Bean public UserDetailsService userDetailsService() { @@ -28,35 +43,104 @@ public UserDetailsService userDetailsService() { } @Bean - public SecurityFilterChain apiFilterChain(HttpSecurity http, AuthProperties properties) + public static RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.withDefaultRolePrefix() + .role(Role.ADMIN.name()) + .implies(Role.EDITOR.name()) + .role(Role.EDITOR.name()) + .implies(Role.VIEWER.name()) + .build(); + } + + /** XSRF-TOKEN 的 Secure 屬性與 session cookie 一致(server.servlet.session.cookie.secure,未設 = true)。 */ + @Bean + public SessionCsrfTokenRepository csrfTokenRepository(ServerProperties serverProperties) { + Boolean secure = serverProperties.getServlet().getSession().getCookie().getSecure(); + return new SessionCsrfTokenRepository(secure == null || secure); + } + + @Bean + public SecurityFilterChain apiFilterChain( + HttpSecurity http, + AuthProperties properties, + UserService users, + RoleHierarchy hierarchy, + SessionCsrfTokenRepository csrfRepository) throws Exception { - String ingest = Role.INGEST.name(); - String admin = Role.ADMIN.name(); - String support = Role.SUPPORT.name(); - http.csrf(AbstractHttpConfigurer::disable) + AccessRule ingest = AccessRule.role(hierarchy, Role.INGEST); + AccessRule viewer = AccessRule.role(hierarchy, Role.VIEWER); + AccessRule admin = AccessRule.role(hierarchy, Role.ADMIN); + AccessRule adminKey = AccessRule.role(hierarchy, Role.ADMIN, Credential.BEARER); + AccessRule anySession = AccessRule.role(hierarchy, Role.VIEWER, Credential.SESSION); + AccessRule editorSession = AccessRule.role(hierarchy, Role.EDITOR, Credential.SESSION); + AccessRule adminSession = AccessRule.role(hierarchy, Role.ADMIN, Credential.SESSION); + + http.csrf(csrf -> csrf.spa() + .csrfTokenRepository(csrfRepository) + .requireCsrfProtectionMatcher(SecurityConfig::sessionWriteRequest)) .cors(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .logout(AbstractHttpConfigurer::disable) .requestCache(c -> c.disable()) .addFilterAfter(new ApiKeyAuthenticationFilter(properties), SecurityContextHolderFilter.class) + .addFilterAfter(new SessionAuthenticationFilter(users), ApiKeyAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/logout").permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/events", "/api/v1/events/batch") - .hasRole(ingest) - .requestMatchers(HttpMethod.POST, "/api/v1/journeys").hasRole(admin) - .requestMatchers(HttpMethod.GET, "/api/v1/enrollments", "/api/v1/enrollments/*", - "/api/v1/enrollments/*/deliveries") - .hasAnyRole(admin, support) - .requestMatchers(HttpMethod.GET, "/api/v1/auth/me").hasRole(support) - // 預設拒絕:契約未列的路徑只有 ADMIN 放行(落到 MVC 回 404);INGEST/SUPPORT 403、未認證 401 - .anyRequest().hasRole(admin)) + .requestMatchers(HttpMethod.POST, V1 + "/auth/login", V1 + "/auth/logout").permitAll() + .requestMatchers(HttpMethod.GET, V1 + "/auth/me").access(anySession) + .requestMatchers(HttpMethod.POST, V1 + "/auth/password").access(anySession) + .requestMatchers(HttpMethod.POST, V1 + "/events", V1 + "/events/batch").access(ingest) + // PUT profiles:INGEST 與管理金鑰;ADMIN session 不可 + .requestMatchers(HttpMethod.PUT, V1 + "/profiles/*") + .access(AuthorizationManagers.anyOf(ingest, adminKey)) + .requestMatchers(HttpMethod.GET, + V1 + "/enrollments", V1 + "/enrollments/*", V1 + "/enrollments/*/deliveries", + V1 + "/profiles", V1 + "/profiles/**", + V1 + "/journeys", V1 + "/journeys/**") + .access(viewer) + // POST /journeys:只有管理金鑰(D-056 發佈須經審核;D-061) + .requestMatchers(HttpMethod.POST, V1 + "/journeys").access(adminKey) + // 草稿寫入、validate、送審、停止、撤回:EDITOR 以上的 session + .requestMatchers(HttpMethod.POST, + V1 + "/journeys/drafts", V1 + "/journeys/*/draft/validate", + V1 + "/journeys/*/draft/submit", V1 + "/journeys/*/stop", + V1 + "/journeys/*/reviews/*/withdraw") + .access(editorSession) + .requestMatchers(HttpMethod.PUT, V1 + "/journeys/*/draft").access(editorSession) + .requestMatchers(HttpMethod.DELETE, V1 + "/journeys/*/draft").access(editorSession) + // 核准、退回:只有 ADMIN 的 session(不收任何 Bearer) + .requestMatchers(HttpMethod.POST, + V1 + "/journeys/*/reviews/*/approve", V1 + "/journeys/*/reviews/*/reject") + .access(adminSession) + .requestMatchers(V1 + "/users", V1 + "/users/**").access(admin) + // 預設拒絕:契約未列的路徑只有 ADMIN 放行(落到 MVC 回 404/405);其他角色 403、未認證 401 + .anyRequest().access(admin)) .exceptionHandling(e -> e .authenticationEntryPoint((req, res, ex) -> SecurityProblemWriter.write(res, ErrorCode.UNAUTHENTICATED, "缺少有效的憑證")) - .accessDeniedHandler((req, res, ex) -> - SecurityProblemWriter.write(res, ErrorCode.FORBIDDEN, "此憑證無權使用這個 API"))); + .accessDeniedHandler((req, res, ex) -> { + if (ex instanceof CsrfException) { + SecurityProblemWriter.write( + res, ErrorCode.CSRF_INVALID, "CSRF 權杖缺少或不符,請重新呼叫 /api/v1/auth/me 取得新值。"); + } else { + SecurityProblemWriter.write(res, ErrorCode.FORBIDDEN, "此憑證無權使用這個 API"); + } + })); return http.build(); } + + /** §1.1:只有 session 認證的非 GET 請求要檢查 CSRF;Bearer 與 login、logout 不檢查。 */ + private static boolean sessionWriteRequest(jakarta.servlet.http.HttpServletRequest request) { + if (SAFE_METHODS.contains(request.getMethod())) { + return false; + } + String uri = request.getRequestURI(); + if (uri.equals(V1 + "/auth/login") || uri.equals(V1 + "/auth/logout")) { + return false; + } + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null + && auth.getAuthorities().stream().anyMatch(a -> Credential.SESSION.authority().equals(a.getAuthority())); + } } diff --git a/api/src/main/java/com/cdp/security/SessionAuthenticationFilter.java b/api/src/main/java/com/cdp/security/SessionAuthenticationFilter.java new file mode 100644 index 0000000..0dff3e4 --- /dev/null +++ b/api/src/main/java/com/cdp/security/SessionAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.cdp.security; + +import java.io.IOException; +import java.util.List; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.cdp.user.AppUser; +import com.cdp.user.UserService; + +/** + * auth.md §1:沒有 Authorization 標頭時,由 cookie 對應的 session 取出 user_id,每個請求都重新載入使用者 + * (停用、刪除 → 視為未認證並丟棄 session;角色變更下一個請求即生效)。帶了 Authorization 就完全不看 session + * ({@link ApiKeyAuthenticationFilter} 負責)。不加 @Component:只由 SecurityConfig 掛進 security filter chain。 + */ +public class SessionAuthenticationFilter extends OncePerRequestFilter { + + /** session 內只存使用者 ID。 */ + public static final String USER_ID_ATTRIBUTE = "user_id"; + + private final UserService users; + + public SessionAuthenticationFilter(UserService users) { + this.users = users; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (request.getHeader("Authorization") == null) { + HttpSession session = request.getSession(false); + if (session != null) { + authenticate(session); + } + } + chain.doFilter(request, response); + } + + private void authenticate(HttpSession session) { + Object id = session.getAttribute(USER_ID_ATTRIBUTE); + if (!(id instanceof String userId)) { + return; + } + AppUser user = users.findActiveById(userId).orElse(null); + if (user == null) { + session.invalidate(); + return; + } + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated( + SessionUser.of(user), + null, + List.of( + new SimpleGrantedAuthority("ROLE_" + user.role().name()), + new SimpleGrantedAuthority(Credential.SESSION.authority())))); + SecurityContextHolder.setContext(context); + } +} diff --git a/api/src/main/java/com/cdp/security/SessionCsrfTokenRepository.java b/api/src/main/java/com/cdp/security/SessionCsrfTokenRepository.java new file mode 100644 index 0000000..0278b0a --- /dev/null +++ b/api/src/main/java/com/cdp/security/SessionCsrfTokenRepository.java @@ -0,0 +1,107 @@ +package com.cdp.security; + +import java.security.SecureRandom; +import java.util.Base64; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRepository; +import org.springframework.security.web.csrf.DefaultCsrfToken; + +/** + * auth.md §1.1 的 CSRF 權杖:雙重提交 cookie(XSRF-TOKEN,非 HttpOnly、SameSite=Strict)加上綁定 session。 + * cookie、session 內存的值與標頭 X-XSRF-TOKEN 三者一致才放行,所以登入/登出之後舊值立即失效, + * 缺 cookie 只帶標頭也不行。權杖只在 {@link #rotate}(登入成功、GET /auth/me)發新值;CsrfFilter 驗證失敗時 + * 產生的暫時權杖不寫回({@link #saveToken} 不做事),避免被誤觸發而換掉合法值。 + */ +public class SessionCsrfTokenRepository implements CsrfTokenRepository { + + public static final String COOKIE_NAME = "XSRF-TOKEN"; + public static final String HEADER_NAME = "X-XSRF-TOKEN"; + private static final String PARAMETER_NAME = "_csrf"; + private static final String SESSION_ATTRIBUTE = SessionCsrfTokenRepository.class.getName() + ".TOKEN"; + + private final SecureRandom random = new SecureRandom(); + private final boolean secure; + + public SessionCsrfTokenRepository(boolean secure) { + this.secure = secure; + } + + @Override + public CsrfToken generateToken(HttpServletRequest request) { + return new DefaultCsrfToken(HEADER_NAME, PARAMETER_NAME, newValue()); + } + + @Override + public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { + // 刻意不做事:見類別說明 + } + + @Override + public CsrfToken loadToken(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + Object stored = session.getAttribute(SESSION_ATTRIBUTE); + String cookie = cookieValue(request); + if (stored instanceof String value && value.equals(cookie)) { + return new DefaultCsrfToken(HEADER_NAME, PARAMETER_NAME, value); + } + return null; + } + + /** 發新值:寫進目前 session 並下發 cookie。沒有 session 時不做事。 */ + public void rotate(HttpServletRequest request, HttpServletResponse response) { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + String value = newValue(); + session.setAttribute(SESSION_ATTRIBUTE, value); + response.addHeader(HttpHeaders.SET_COOKIE, cookie(value, -1).toString()); + } + + /** 清除 cookie(登出)。 */ + public void clear(HttpServletResponse response) { + response.addHeader(HttpHeaders.SET_COOKIE, cookie("", 0).toString()); + } + + private ResponseCookie cookie(String value, long maxAgeSeconds) { + ResponseCookie.ResponseCookieBuilder b = ResponseCookie.from(COOKIE_NAME, value) + .path("/") + .httpOnly(false) + .secure(secure) + .sameSite("Strict"); + if (maxAgeSeconds >= 0) { + b.maxAge(maxAgeSeconds); + } + return b.build(); + } + + private String newValue() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String cookieValue(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie c : cookies) { + if (COOKIE_NAME.equals(c.getName())) { + return c.getValue(); + } + } + return null; + } +} diff --git a/api/src/main/java/com/cdp/security/SessionUser.java b/api/src/main/java/com/cdp/security/SessionUser.java new file mode 100644 index 0000000..bdf9c89 --- /dev/null +++ b/api/src/main/java/com/cdp/security/SessionUser.java @@ -0,0 +1,12 @@ +package com.cdp.security; + +import com.cdp.user.AppUser; +import com.cdp.user.UserRole; + +/** session 認證的 principal:每個請求由資料庫重新載入,所以角色與停用即時生效。 */ +public record SessionUser(String userId, String email, String displayName, UserRole role) { + + static SessionUser of(AppUser u) { + return new SessionUser(u.userId(), u.email(), u.displayName(), u.role()); + } +} diff --git a/api/src/main/java/com/cdp/user/AppUser.java b/api/src/main/java/com/cdp/user/AppUser.java new file mode 100644 index 0000000..cb349dd --- /dev/null +++ b/api/src/main/java/com/cdp/user/AppUser.java @@ -0,0 +1,24 @@ +package com.cdp.user; + +import java.time.Instant; + +/** app_user 一列。passwordHash 不得輸出到日誌或回應,toString 不含它。 */ +public record AppUser( + String userId, + String email, + String displayName, + String passwordHash, + UserRole role, + UserStatus status, + Instant createdAt, + Instant updatedAt) { + + public boolean active() { + return status == UserStatus.ACTIVE; + } + + @Override + public String toString() { + return "AppUser[userId=" + userId + ", role=" + role + ", status=" + status + "]"; + } +} diff --git a/api/src/main/java/com/cdp/user/AppUserStore.java b/api/src/main/java/com/cdp/user/AppUserStore.java new file mode 100644 index 0000000..de0b792 --- /dev/null +++ b/api/src/main/java/com/cdp/user/AppUserStore.java @@ -0,0 +1,106 @@ +package com.cdp.user; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +/** app_user 的 JDBC 存取。鎖定查詢(FOR UPDATE)必須在呼叫端的交易內執行。 */ +@Repository +public class AppUserStore { + + private static final String COLUMNS = + "user_id, email, display_name, password_hash, role, status, created_at, updated_at"; + + private final JdbcClient jdbc; + + public AppUserStore(JdbcClient jdbc) { + this.jdbc = jdbc; + } + + /** email 重複時丟 {@link org.springframework.dao.DuplicateKeyException}。 */ + public void insert(AppUser u) { + jdbc.sql("insert into app_user (" + COLUMNS + ") values" + + " (:id, :email, :name, :hash, :role, :status, :createdAt, :updatedAt)") + .param("id", u.userId()) + .param("email", u.email()) + .param("name", u.displayName()) + .param("hash", u.passwordHash()) + .param("role", u.role().name()) + .param("status", u.status().name()) + .param("createdAt", ts(u.createdAt())) + .param("updatedAt", ts(u.updatedAt())) + .update(); + } + + public Optional findById(String userId) { + return jdbc.sql("select " + COLUMNS + " from app_user where user_id = :id") + .param("id", userId) + .query(AppUserStore::map) + .optional(); + } + + public Optional findByEmail(String email) { + return jdbc.sql("select " + COLUMNS + " from app_user where email = :email") + .param("email", email) + .query(AppUserStore::map) + .optional(); + } + + public List findAllByEmail() { + return jdbc.sql("select " + COLUMNS + " from app_user order by email") + .query(AppUserStore::map) + .list(); + } + + /** 鎖定並回傳目標列(交易內)。 */ + public Optional lockById(String userId) { + return jdbc.sql("select " + COLUMNS + " from app_user where user_id = :id for update") + .param("id", userId) + .query(AppUserStore::map) + .optional(); + } + + /** 鎖定所有啟用中的 ADMIN(固定以 user_id 排序取鎖,避免死結),回傳其 user_id(交易內)。 */ + public List lockActiveAdminIds() { + return jdbc.sql("select user_id from app_user where role = 'ADMIN' and status = 'ACTIVE'" + + " order by user_id for update") + .query(String.class) + .list(); + } + + public void update( + String userId, String displayName, UserRole role, UserStatus status, String passwordHash, Instant at) { + jdbc.sql("update app_user set display_name = :name, role = :role, status = :status," + + " password_hash = :hash, updated_at = :at where user_id = :id") + .param("name", displayName) + .param("role", role.name()) + .param("status", status.name()) + .param("hash", passwordHash) + .param("at", ts(at)) + .param("id", userId) + .update(); + } + + private static AppUser map(ResultSet rs, int row) throws SQLException { + return new AppUser( + rs.getString("user_id"), + rs.getString("email"), + rs.getString("display_name"), + rs.getString("password_hash"), + UserRole.valueOf(rs.getString("role")), + UserStatus.valueOf(rs.getString("status")), + rs.getObject("created_at", OffsetDateTime.class).toInstant(), + rs.getObject("updated_at", OffsetDateTime.class).toInstant()); + } + + private static OffsetDateTime ts(Instant instant) { + return OffsetDateTime.ofInstant(instant, ZoneOffset.UTC); + } +} diff --git a/api/src/main/java/com/cdp/user/CreateUserRequest.java b/api/src/main/java/com/cdp/user/CreateUserRequest.java new file mode 100644 index 0000000..44c7d04 --- /dev/null +++ b/api/src/main/java/com/cdp/user/CreateUserRequest.java @@ -0,0 +1,10 @@ +package com.cdp.user; + +/** POST /api/v1/users 的 body;欄位型別全用字串,由 {@link UserService} 驗證並回 400。 */ +public record CreateUserRequest(String email, String displayName, String role, String password) { + + @Override + public String toString() { + return "CreateUserRequest[email=" + email + ", role=" + role + ", password=***]"; + } +} diff --git a/api/src/main/java/com/cdp/user/PasswordHasher.java b/api/src/main/java/com/cdp/user/PasswordHasher.java new file mode 100644 index 0000000..f5a0983 --- /dev/null +++ b/api/src/main/java/com/cdp/user/PasswordHasher.java @@ -0,0 +1,34 @@ +package com.cdp.user; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +/** BCrypt 雜湊(spring-security-crypto)。帳號不存在時用 {@link #matchesDummy} 跑一次等價成本的比對,避免從耗時猜出帳號。 */ +@Component +public class PasswordHasher { + + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + private final String dummyHash = encoder.encode(UUID.randomUUID().toString()); + + public String hash(String password) { + return encoder.encode(password); + } + + /** 超過 72 位元組一律視為不符(BCrypt 只看前 72 位元組,不讓截斷造成誤判)。 */ + public boolean matches(String password, String hash) { + if (password == null || hash == null || password.getBytes(StandardCharsets.UTF_8).length > PasswordPolicy.MAX_BYTES) { + return false; + } + return encoder.matches(password, hash); + } + + /** 跑一次比對成本、結果恆為 false。 */ + public boolean matchesDummy(String password) { + boolean tooLong = password != null && password.getBytes(StandardCharsets.UTF_8).length > PasswordPolicy.MAX_BYTES; + encoder.matches(password == null || tooLong ? "x" : password, dummyHash); + return false; + } +} diff --git a/api/src/main/java/com/cdp/user/PasswordPolicy.java b/api/src/main/java/com/cdp/user/PasswordPolicy.java new file mode 100644 index 0000000..bc5d142 --- /dev/null +++ b/api/src/main/java/com/cdp/user/PasswordPolicy.java @@ -0,0 +1,20 @@ +package com.cdp.user; + +import java.nio.charset.StandardCharsets; + +/** 密碼規則(auth.md §3):12–72 字元且 UTF-8 位元組數 ≤ 72(BCrypt 上限);不檢查複雜度。 */ +public final class PasswordPolicy { + + public static final int MIN_CHARS = 12; + public static final int MAX_BYTES = 72; + + private PasswordPolicy() {} + + public static boolean valid(String password) { + return password != null + && password.codePointCount(0, password.length()) >= MIN_CHARS + && password.getBytes(StandardCharsets.UTF_8).length <= MAX_BYTES; + } + + public static final String MESSAGE = "密碼須為 12–72 字元(UTF-8 不超過 72 位元組)"; +} diff --git a/api/src/main/java/com/cdp/user/UpdateUserRequest.java b/api/src/main/java/com/cdp/user/UpdateUserRequest.java new file mode 100644 index 0000000..0269bdb --- /dev/null +++ b/api/src/main/java/com/cdp/user/UpdateUserRequest.java @@ -0,0 +1,10 @@ +package com.cdp.user; + +/** PATCH /api/v1/users/{id} 的 body;欄位為 null 代表不修改。 */ +public record UpdateUserRequest(String displayName, String role, String status, String password) { + + @Override + public String toString() { + return "UpdateUserRequest[role=" + role + ", status=" + status + ", password=***]"; + } +} diff --git a/api/src/main/java/com/cdp/user/UserController.java b/api/src/main/java/com/cdp/user/UserController.java new file mode 100644 index 0000000..753c8c0 --- /dev/null +++ b/api/src/main/java/com/cdp/user/UserController.java @@ -0,0 +1,40 @@ +package com.cdp.user; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** auth.md §4 使用者管理;角色(ADMIN)由 SecurityConfig 把關。 */ +@RestController +@RequestMapping("/api/v1/users") +public class UserController { + + private final UserService service; + + public UserController(UserService service) { + this.service = service; + } + + @GetMapping + public UserListResponse list() { + return new UserListResponse(service.list()); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public UserResponse create(@RequestBody CreateUserRequest body) { + return service.create(body); + } + + @PatchMapping(path = "/{userId}", consumes = MediaType.APPLICATION_JSON_VALUE) + public UserResponse update(@PathVariable String userId, @RequestBody UpdateUserRequest body) { + return service.update(userId, body); + } +} diff --git a/api/src/main/java/com/cdp/user/UserListResponse.java b/api/src/main/java/com/cdp/user/UserListResponse.java new file mode 100644 index 0000000..ecc0028 --- /dev/null +++ b/api/src/main/java/com/cdp/user/UserListResponse.java @@ -0,0 +1,6 @@ +package com.cdp.user; + +import java.util.List; + +/** GET /api/v1/users。 */ +public record UserListResponse(List items) {} diff --git a/api/src/main/java/com/cdp/user/UserResponse.java b/api/src/main/java/com/cdp/user/UserResponse.java new file mode 100644 index 0000000..6eafe74 --- /dev/null +++ b/api/src/main/java/com/cdp/user/UserResponse.java @@ -0,0 +1,19 @@ +package com.cdp.user; + +import java.time.Instant; + +/** auth.md §4 的 User(不含密碼與雜湊)。 */ +public record UserResponse( + String userId, + String email, + String displayName, + UserRole role, + UserStatus status, + Instant createdAt, + Instant updatedAt) { + + static UserResponse of(AppUser u) { + return new UserResponse( + u.userId(), u.email(), u.displayName(), u.role(), u.status(), u.createdAt(), u.updatedAt()); + } +} diff --git a/api/src/main/java/com/cdp/user/UserRole.java b/api/src/main/java/com/cdp/user/UserRole.java new file mode 100644 index 0000000..d148e98 --- /dev/null +++ b/api/src/main/java/com/cdp/user/UserRole.java @@ -0,0 +1,8 @@ +package com.cdp.user; + +/** 使用者角色(auth.md §2);階層 ADMIN ⊇ EDITOR ⊇ VIEWER 由 security 的 RoleHierarchy 表達。 */ +public enum UserRole { + VIEWER, + EDITOR, + ADMIN +} diff --git a/api/src/main/java/com/cdp/user/UserService.java b/api/src/main/java/com/cdp/user/UserService.java new file mode 100644 index 0000000..afd0faf --- /dev/null +++ b/api/src/main/java/com/cdp/user/UserService.java @@ -0,0 +1,200 @@ +package com.cdp.user; + +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import com.cdp.common.IdGenerator; +import com.cdp.common.web.ApiException; +import com.cdp.common.web.ErrorCode; +import com.cdp.common.web.FieldError; + +/** + * 使用者建立、查詢與修改(auth.md §4)。LAST_ADMIN 檢查與更新在同一個交易:先鎖定所有啟用中的 ADMIN(固定順序) + * 再鎖目標列,並行的降級/停用因此逐一序列化,不會把啟用中的 ADMIN 變成 0 位。BCrypt 在交易外先算好,不佔著鎖。 + */ +@Service +public class UserService { + + private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); + private static final int EMAIL_MAX = 254; + private static final int NAME_MAX = 100; + + private final AppUserStore store; + private final PasswordHasher hasher; + private final IdGenerator ids; + private final Clock clock; + private final TransactionTemplate tx; + + public UserService( + AppUserStore store, PasswordHasher hasher, IdGenerator ids, Clock clock, TransactionTemplate tx) { + this.store = store; + this.hasher = hasher; + this.ids = ids; + this.clock = clock; + this.tx = tx; + } + + /** 登入用:email 不分大小寫。 */ + public Optional findByEmail(String email) { + return store.findByEmail(normalizeEmail(email)); + } + + /** 每個請求重新載入 session 的使用者;停用者視為不存在。 */ + public Optional findActiveById(String userId) { + return store.findById(userId).filter(AppUser::active); + } + + public List list() { + return store.findAllByEmail().stream().map(UserResponse::of).toList(); + } + + public UserResponse create(CreateUserRequest req) { + List errors = new ArrayList<>(); + String email = req.email() == null ? null : normalizeEmail(req.email()); + if (email == null || email.length() > EMAIL_MAX || !EMAIL.matcher(email).matches()) { + errors.add(new FieldError("email", "須為有效的 email")); + } + validateName(req.displayName(), true, errors); + UserRole role = parseRole(req.role(), true, errors); + if (!PasswordPolicy.valid(req.password())) { + errors.add(new FieldError("password", PasswordPolicy.MESSAGE)); + } + failIfAny(errors); + + Instant now = now(); + AppUser user = new AppUser( + ids.newId(), + email, + req.displayName(), + hasher.hash(req.password()), + role, + UserStatus.ACTIVE, + now, + now); + try { + store.insert(user); + } catch (DuplicateKeyException e) { + throw new ApiException(ErrorCode.EMAIL_TAKEN, "這個 email 已被使用。"); + } + return UserResponse.of(user); + } + + public UserResponse update(String userId, UpdateUserRequest req) { + List errors = new ArrayList<>(); + validateName(req.displayName(), false, errors); + UserRole newRole = parseRole(req.role(), false, errors); + UserStatus newStatus = parseStatus(req.status(), errors); + if (req.password() != null && !PasswordPolicy.valid(req.password())) { + errors.add(new FieldError("password", PasswordPolicy.MESSAGE)); + } + failIfAny(errors); + String newHash = req.password() == null ? null : hasher.hash(req.password()); + + AppUser updated = tx.execute(status -> { + List activeAdmins = store.lockActiveAdminIds(); + AppUser current = store.lockById(userId) + .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND, "找不到這位使用者。")); + UserRole role = newRole != null ? newRole : current.role(); + UserStatus st = newStatus != null ? newStatus : current.status(); + boolean isActiveAdmin = activeAdmins.contains(userId); + boolean staysActiveAdmin = role == UserRole.ADMIN && st == UserStatus.ACTIVE; + if (isActiveAdmin && !staysActiveAdmin && activeAdmins.size() <= 1) { + throw new ApiException(ErrorCode.LAST_ADMIN, "至少要保留一位啟用中的管理員。"); + } + store.update( + userId, + req.displayName() != null ? req.displayName() : current.displayName(), + role, + st, + newHash != null ? newHash : current.passwordHash(), + now()); + return store.findById(userId).orElseThrow(); + }); + return UserResponse.of(updated); + } + + /** 使用者自己改密碼(auth.md §3):舊密碼錯 → 401 INVALID_CREDENTIALS;其他 session 不登出。 */ + public void changePassword(String userId, String currentPassword, String newPassword) { + List errors = new ArrayList<>(); + if (currentPassword == null || currentPassword.isEmpty()) { + errors.add(new FieldError("current_password", "必填")); + } + if (!PasswordPolicy.valid(newPassword)) { + errors.add(new FieldError("new_password", PasswordPolicy.MESSAGE)); + } + failIfAny(errors); + AppUser user = store.findById(userId) + .filter(AppUser::active) + .orElseThrow(() -> new ApiException(ErrorCode.UNAUTHENTICATED, "缺少有效的憑證")); + if (!hasher.matches(currentPassword, user.passwordHash())) { + throw new ApiException(ErrorCode.INVALID_CREDENTIALS, "目前的密碼錯誤"); + } + String newHash = hasher.hash(newPassword); + tx.executeWithoutResult(status -> store.lockById(userId) + .ifPresent(u -> store.update(userId, u.displayName(), u.role(), u.status(), newHash, now()))); + } + + private Instant now() { + // PostgreSQL 只存到微秒;回應與之後讀回的值必須一致 + return clock.instant().truncatedTo(ChronoUnit.MICROS); + } + + static String normalizeEmail(String email) { + return email.trim().toLowerCase(Locale.ROOT); + } + + private static void validateName(String name, boolean required, List errors) { + if (name == null) { + if (required) { + errors.add(new FieldError("display_name", "必填")); + } + return; + } + if (name.isBlank() || name.codePointCount(0, name.length()) > NAME_MAX) { + errors.add(new FieldError("display_name", "須為 1–100 字")); + } + } + + private static UserRole parseRole(String value, boolean required, List errors) { + if (value == null) { + if (required) { + errors.add(new FieldError("role", "必填")); + } + return null; + } + try { + return UserRole.valueOf(value); + } catch (IllegalArgumentException e) { + errors.add(new FieldError("role", "須為 VIEWER、EDITOR 或 ADMIN")); + return null; + } + } + + private static UserStatus parseStatus(String value, List errors) { + if (value == null) { + return null; + } + try { + return UserStatus.valueOf(value); + } catch (IllegalArgumentException e) { + errors.add(new FieldError("status", "須為 ACTIVE 或 DISABLED")); + return null; + } + } + + private static void failIfAny(List errors) { + if (!errors.isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION_FAILED, "請求內容有誤", errors); + } + } +} diff --git a/api/src/main/java/com/cdp/user/UserStatus.java b/api/src/main/java/com/cdp/user/UserStatus.java new file mode 100644 index 0000000..8dfb05e --- /dev/null +++ b/api/src/main/java/com/cdp/user/UserStatus.java @@ -0,0 +1,7 @@ +package com.cdp.user; + +/** 使用者狀態(auth.md §4);停用即不能登入、既有 session 下一個請求失效。 */ +public enum UserStatus { + ACTIVE, + DISABLED +} diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml index 1905627..259cb75 100644 --- a/api/src/main/resources/application.yml +++ b/api/src/main/resources/application.yml @@ -37,7 +37,7 @@ server: port: ${API_PORT} servlet: session: - # 客服 session(契約 §1.2);閒置逾時由甲方定 + # 使用者 session(auth.md §3);閒置逾時由甲方定 timeout: ${CDP_SESSION_IDLE_TIMEOUT} cookie: name: CDP_SESSION @@ -66,11 +66,13 @@ cdp: requeue-delay: ${DELIVERY_REQUEUE_DELAY} shutdown-timeout: ${DELIVERY_SHUTDOWN_TIMEOUT} security: - # 契約 §1.1、§1.2;機密只在 .env + # auth.md §1;機密只在 .env ingest-api-key: ${CDP_INGEST_API_KEY} admin-api-key: ${CDP_ADMIN_API_KEY} - support-username: ${CDP_SUPPORT_USERNAME} - support-password: ${CDP_SUPPORT_PASSWORD} + login: + # 登入限速(D-045,auth.md §3) + max-failures: ${CDP_LOGIN_MAX_FAILURES} + lockout: ${CDP_LOGIN_LOCKOUT} trace: # 契約 §4.4:逗號分隔、可明文顯示的屬性鍵;空 = 全部遮罩 plain-keys: ${CDP_TRACE_PLAIN_KEYS} diff --git a/api/src/main/resources/db/migration/V6__app_user.sql b/api/src/main/resources/db/migration/V6__app_user.sql new file mode 100644 index 0000000..50059b7 --- /dev/null +++ b/api/src/main/resources/db/migration/V6__app_user.sql @@ -0,0 +1,16 @@ +-- V6:登入使用者與角色(docs/spec/07-database-schema.md §12、docs/contracts/auth.md §4)。只建 app_user,不動既有表。 +-- email 一律存小寫(由應用程式正規化);密碼只存 BCrypt 雜湊。 + +CREATE TABLE app_user ( + user_id TEXT PRIMARY KEY, + email TEXT NOT NULL, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_app_user_email UNIQUE (email), + CONSTRAINT ck_app_user_role CHECK (role IN ('VIEWER', 'EDITOR', 'ADMIN')), + CONSTRAINT ck_app_user_status CHECK (status IN ('ACTIVE', 'DISABLED')) +); diff --git a/api/src/test/java/com/cdp/security/ApiKeyAuthenticationFilterTest.java b/api/src/test/java/com/cdp/security/ApiKeyAuthenticationFilterTest.java index 4cb37b7..f13b1f7 100644 --- a/api/src/test/java/com/cdp/security/ApiKeyAuthenticationFilterTest.java +++ b/api/src/test/java/com/cdp/security/ApiKeyAuthenticationFilterTest.java @@ -13,7 +13,7 @@ class ApiKeyAuthenticationFilterTest { private final ApiKeyAuthenticationFilter filter = - new ApiKeyAuthenticationFilter(new AuthProperties("ing", "adm", "u", "p")); + new ApiKeyAuthenticationFilter(new AuthProperties("ing", "adm")); @AfterEach void clear() { diff --git a/api/src/test/java/com/cdp/security/AuthControllerTest.java b/api/src/test/java/com/cdp/security/AuthControllerTest.java index c38ae90..3d36d3a 100644 --- a/api/src/test/java/com/cdp/security/AuthControllerTest.java +++ b/api/src/test/java/com/cdp/security/AuthControllerTest.java @@ -2,20 +2,42 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.boot.web.server.autoconfigure.ServerProperties; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; import com.cdp.common.web.ApiException; import com.cdp.common.web.ErrorCode; +import com.cdp.user.AppUser; +import com.cdp.user.PasswordHasher; +import com.cdp.user.UserRole; +import com.cdp.user.UserService; +import com.cdp.user.UserStatus; class AuthControllerTest { - private final AuthController controller = new AuthController( - new AuthProperties("i", "a", "agent", "secret"), serverProperties()); + private final UserService users = mock(UserService.class); + private final PasswordHasher hasher = mock(PasswordHasher.class); + private final LoginRateLimiter limiter = + new LoginRateLimiter(new LoginProperties(2, Duration.ofMinutes(5)), Clock.systemUTC()); + private final SessionCsrfTokenRepository csrf = new SessionCsrfTokenRepository(false); + private final AuthController controller = + new AuthController(users, hasher, limiter, csrf, serverProperties()); private static ServerProperties serverProperties() { ServerProperties sp = new ServerProperties(); @@ -24,49 +46,105 @@ private static ServerProperties serverProperties() { return sp; } + private static AppUser user(UserStatus status) { + Instant t = Instant.parse("2026-10-22T00:00:00Z"); + return new AppUser("01JD0USER0000000000000001", "ops@example.com", "Ops", "$2a$hash", UserRole.EDITOR, status, t, t); + } + + private HttpStatus loginStatus(String email, String password, MockHttpServletRequest req, MockHttpServletResponse res) + throws Exception { + try { + var r = controller.login(new LoginRequest(email, password), req, res); + return r == null ? HttpStatus.valueOf(res.getStatus()) : HttpStatus.valueOf(r.getStatusCode().value()); + } catch (ApiException e) { + return e.code().status(); + } + } @Test - void login_success_creates_fresh_session_with_context() { + void success_creates_fresh_session_with_only_user_id_and_new_xsrf_cookie() throws Exception { + when(users.findByEmail("ops@example.com")).thenReturn(Optional.of(user(UserStatus.ACTIVE))); + when(hasher.matches("secret-password-1", "$2a$hash")).thenReturn(true); MockHttpServletRequest req = new MockHttpServletRequest(); String oldId = req.getSession(true).getId(); - var res = controller.login(new LoginRequest("agent", "secret"), req); - assertThat(res.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + MockHttpServletResponse res = new MockHttpServletResponse(); + + var r = controller.login(new LoginRequest(" Ops@Example.com ", "secret-password-1"), req, res); + + assertThat(r.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); var session = req.getSession(false); assertThat(session.getId()).isNotEqualTo(oldId); - assertThat(session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)) - .isNotNull(); + assertThat(session.getAttribute(SessionAuthenticationFilter.USER_ID_ATTRIBUTE)) + .isEqualTo("01JD0USER0000000000000001"); + assertThat(res.getHeaders("Set-Cookie")).anyMatch(c -> c.startsWith("XSRF-TOKEN=") && c.contains("SameSite=Strict")); } @Test - void wrong_user_or_password_is_invalid_credentials() { - for (LoginRequest r : new LoginRequest[] { - new LoginRequest("agent", "x"), new LoginRequest("x", "secret")}) { - assertThatThrownBy(() -> controller.login(r, new MockHttpServletRequest())) + void unknown_disabled_and_wrong_password_are_the_same_401_and_always_run_one_comparison() throws Exception { + when(users.findByEmail("none@example.com")).thenReturn(Optional.empty()); + when(users.findByEmail("off@example.com")).thenReturn(Optional.of(user(UserStatus.DISABLED))); + when(users.findByEmail("ops@example.com")).thenReturn(Optional.of(user(UserStatus.ACTIVE))); + when(hasher.matches(anyString(), anyString())).thenReturn(true).thenReturn(false); + + for (String email : new String[] {"none@example.com", "off@example.com", "ops@example.com"}) { + assertThatThrownBy(() -> controller.login( + new LoginRequest(email, "whatever-password"), new MockHttpServletRequest(), new MockHttpServletResponse())) .isInstanceOfSatisfying(ApiException.class, e -> { assertThat(e.code()).isEqualTo(ErrorCode.INVALID_CREDENTIALS); assertThat(e.getMessage()).isEqualTo("帳號或密碼錯誤"); }); } + verify(hasher).matchesDummy("whatever-password"); } @Test - void blank_fields_are_validation_failed() { - assertThatThrownBy(() -> controller.login(new LoginRequest("agent", null), new MockHttpServletRequest())) - .isInstanceOfSatisfying(ApiException.class, e -> { - assertThat(e.code()).isEqualTo(ErrorCode.VALIDATION_FAILED); - assertThat(e.errors()).extracting("field").containsExactly("password"); - }); + void locked_email_gets_429_with_retry_after_and_never_runs_bcrypt() throws Exception { + when(users.findByEmail(anyString())).thenReturn(Optional.empty()); + for (int i = 0; i < 2; i++) { + assertThat(loginStatus("a@example.com", "x-wrong-password", new MockHttpServletRequest(), + new MockHttpServletResponse())) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + org.mockito.Mockito.clearInvocations(hasher, users); + + MockHttpServletResponse res = new MockHttpServletResponse(); + assertThat(loginStatus("A@Example.com", "correct-password-1", new MockHttpServletRequest(), res)) + .isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + assertThat(res.getHeader("Retry-After")).isEqualTo("300"); + assertThat(res.getContentAsString()).contains("LOGIN_RATE_LIMITED"); + verify(hasher, never()).matches(any(), any()); + verify(hasher, never()).matchesDummy(any()); + verify(users, never()).findByEmail(anyString()); + } + + @Test + void blank_fields_are_validation_failed_and_not_counted() { + for (int i = 0; i < 5; i++) { + assertThatThrownBy(() -> controller.login( + new LoginRequest("a@example.com", null), new MockHttpServletRequest(), new MockHttpServletResponse())) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.code()).isEqualTo(ErrorCode.VALIDATION_FAILED); + assertThat(e.errors()).extracting("field").containsExactly("password"); + }); + } + assertThatThrownBy(() -> controller.login( + new LoginRequest(null, "x"), new MockHttpServletRequest(), new MockHttpServletResponse())) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.errors()).extracting("field") + .containsExactly("email")); + assertThat(limiter.remainingLock("a@example.com")).isZero(); } @Test - void logout_invalidates_session_and_clears_cookie() { + void logout_invalidates_session_and_clears_both_cookies() { MockHttpServletRequest req = new MockHttpServletRequest(); - var session = (org.springframework.mock.web.MockHttpSession) req.getSession(true); - var res = controller.logout(req); - assertThat(res.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + var session = (MockHttpSession) req.getSession(true); + MockHttpServletResponse res = new MockHttpServletResponse(); + var r = controller.logout(req, res); + assertThat(r.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); assertThat(session.isInvalid()).isTrue(); - assertThat(res.getHeaders().getFirst("Set-Cookie")).contains("CDP_SESSION=", "Max-Age=0", "Path=/api"); - assertThat(controller.logout(new MockHttpServletRequest()).getStatusCode()) + assertThat(r.getHeaders().getFirst("Set-Cookie")).contains("CDP_SESSION=", "Max-Age=0", "Path=/api"); + assertThat(res.getHeaders("Set-Cookie")).anyMatch(c -> c.startsWith("XSRF-TOKEN=") && c.contains("Max-Age=0")); + assertThat(controller.logout(new MockHttpServletRequest(), new MockHttpServletResponse()).getStatusCode()) .isEqualTo(HttpStatus.NO_CONTENT); } } diff --git a/api/src/test/java/com/cdp/security/AuthPropertiesValidatorTest.java b/api/src/test/java/com/cdp/security/AuthPropertiesValidatorTest.java index d48e5fb..eda9a13 100644 --- a/api/src/test/java/com/cdp/security/AuthPropertiesValidatorTest.java +++ b/api/src/test/java/com/cdp/security/AuthPropertiesValidatorTest.java @@ -8,26 +8,20 @@ class AuthPropertiesValidatorTest { - private static AuthPropertiesValidator of(String i, String a, String u, String p) { - return new AuthPropertiesValidator(new AuthProperties(i, a, u, p)); + private static AuthPropertiesValidator of(String i, String a) { + return new AuthPropertiesValidator(new AuthProperties(i, a)); } @Test void valid_passes() { - assertThatCode(() -> of("i", "a", "u", "p").afterPropertiesSet()).doesNotThrowAnyException(); + assertThatCode(() -> of("i", "a").afterPropertiesSet()).doesNotThrowAnyException(); } @Test void empty_or_same_fails_without_leaking_values() { - assertThatThrownBy(() -> of(null, "a", "u", "p").afterPropertiesSet()) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> of("i", " ", "u", "p").afterPropertiesSet()) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> of("i", "a", "", "p").afterPropertiesSet()) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> of("i", "a", "u", "").afterPropertiesSet()) - .isInstanceOf(IllegalStateException.class); - assertThatThrownBy(() -> of("SAME-K", "SAME-K", "u", "p").afterPropertiesSet()) + assertThatThrownBy(() -> of(null, "a").afterPropertiesSet()).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> of("i", " ").afterPropertiesSet()).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> of("SAME-K", "SAME-K").afterPropertiesSet()) .isInstanceOf(IllegalStateException.class) .satisfies(t -> assertThat(t.getMessage()).doesNotContain("SAME-K")); } diff --git a/api/src/test/java/com/cdp/security/LoginRateLimiterTest.java b/api/src/test/java/com/cdp/security/LoginRateLimiterTest.java new file mode 100644 index 0000000..17a0cc2 --- /dev/null +++ b/api/src/test/java/com/cdp/security/LoginRateLimiterTest.java @@ -0,0 +1,102 @@ +package com.cdp.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.Test; + +class LoginRateLimiterTest { + + /** 可手動撥動的時鐘。 */ + private static final class MutableClock extends Clock { + private Instant now = Instant.parse("2026-10-22T00:00:00Z"); + + void advance(Duration d) { + now = now.plus(d); + } + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + } + + private final MutableClock clock = new MutableClock(); + private final LoginRateLimiter limiter = new LoginRateLimiter(new LoginProperties(3, Duration.ofSeconds(10)), clock); + + @Test + void locks_when_count_reaches_threshold_until_last_failure_plus_lockout() { + limiter.recordFailure("a"); + limiter.recordFailure("a"); + assertThat(limiter.remainingLock("a")).isZero(); + limiter.recordFailure("a"); + assertThat(limiter.remainingLock("a")).isEqualTo(Duration.ofSeconds(10)); + clock.advance(Duration.ofSeconds(4)); + assertThat(limiter.remainingLock("a")).isEqualTo(Duration.ofSeconds(6)); + clock.advance(Duration.ofSeconds(6)); + assertThat(limiter.remainingLock("a")).isZero(); + } + + @Test + void failures_while_locked_do_not_extend_the_lock() { + for (int i = 0; i < 3; i++) limiter.recordFailure("a"); + clock.advance(Duration.ofSeconds(5)); + limiter.recordFailure("a"); + assertThat(limiter.remainingLock("a")).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void count_restarts_when_last_failure_is_older_than_lockout() { + limiter.recordFailure("a"); + limiter.recordFailure("a"); + clock.advance(Duration.ofSeconds(11)); + limiter.recordFailure("a"); + limiter.recordFailure("a"); + assertThat(limiter.remainingLock("a")).isZero(); + limiter.recordFailure("a"); + assertThat(limiter.remainingLock("a")).isPositive(); + } + + @Test + void reset_and_keys_are_independent() { + for (int i = 0; i < 3; i++) limiter.recordFailure("a"); + assertThat(limiter.remainingLock("b")).isZero(); + limiter.reset("a"); + assertThat(limiter.remainingLock("a")).isZero(); + } + + @Test + void retry_after_rounds_up_to_whole_seconds() { + assertThat(LoginRateLimiter.retryAfterSeconds(Duration.ofMillis(3001))).isEqualTo(4); + assertThat(LoginRateLimiter.retryAfterSeconds(Duration.ofMillis(4000))).isEqualTo(4); + assertThat(LoginRateLimiter.retryAfterSeconds(Duration.ofNanos(1))).isEqualTo(1); + assertThat(LoginRateLimiter.retryAfterSeconds(Duration.ofMillis(2400))).isEqualTo(3); + } + + @Test + void invalid_settings_fail_startup_with_property_name() { + assertThatThrownBy(() -> new LoginRateLimiter(new LoginProperties(0, Duration.ofSeconds(1)), clock)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cdp.login.max-failures"); + assertThatThrownBy(() -> new LoginRateLimiter(new LoginProperties(1, Duration.ZERO), clock)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cdp.login.lockout"); + assertThatThrownBy(() -> new LoginRateLimiter(new LoginProperties(1, null), clock)) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/api/src/test/java/com/cdp/security/SessionCsrfTokenRepositoryTest.java b/api/src/test/java/com/cdp/security/SessionCsrfTokenRepositoryTest.java new file mode 100644 index 0000000..3a656d6 --- /dev/null +++ b/api/src/test/java/com/cdp/security/SessionCsrfTokenRepositoryTest.java @@ -0,0 +1,66 @@ +package com.cdp.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.servlet.http.Cookie; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class SessionCsrfTokenRepositoryTest { + + private final SessionCsrfTokenRepository repo = new SessionCsrfTokenRepository(true); + + private static String tokenOf(MockHttpServletResponse res) { + String line = res.getHeaders("Set-Cookie").get(0); + return line.substring("XSRF-TOKEN=".length(), line.indexOf(';')); + } + + @Test + void rotate_sets_non_httponly_strict_cookie_and_binds_to_session() { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.getSession(true); + MockHttpServletResponse res = new MockHttpServletResponse(); + repo.rotate(req, res); + String line = res.getHeaders("Set-Cookie").get(0); + assertThat(line).contains("Path=/", "Secure", "SameSite=Strict").doesNotContain("HttpOnly"); + + req.setCookies(new Cookie("XSRF-TOKEN", tokenOf(res))); + assertThat(repo.loadToken(req)).isNotNull(); + assertThat(repo.loadToken(req).getToken()).isEqualTo(tokenOf(res)); + } + + @Test + void missing_cookie_or_other_session_or_old_value_loads_nothing() { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.getSession(true); + MockHttpServletResponse first = new MockHttpServletResponse(); + repo.rotate(req, first); + MockHttpServletResponse second = new MockHttpServletResponse(); + repo.rotate(req, second); + + assertThat(repo.loadToken(req)).isNull(); + req.setCookies(new Cookie("XSRF-TOKEN", tokenOf(first))); + assertThat(repo.loadToken(req)).as("rotated-away value").isNull(); + req.setCookies(new Cookie("XSRF-TOKEN", tokenOf(second))); + assertThat(repo.loadToken(req)).isNotNull(); + + MockHttpServletRequest otherSession = new MockHttpServletRequest(); + otherSession.getSession(true); + otherSession.setCookies(new Cookie("XSRF-TOKEN", tokenOf(second))); + assertThat(repo.loadToken(otherSession)).isNull(); + MockHttpServletRequest noSession = new MockHttpServletRequest(); + noSession.setCookies(new Cookie("XSRF-TOKEN", tokenOf(second))); + assertThat(repo.loadToken(noSession)).isNull(); + } + + @Test + void rotate_without_session_and_save_token_do_nothing() { + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + repo.rotate(req, res); + repo.saveToken(repo.generateToken(req), req, res); + assertThat(res.getHeaders("Set-Cookie")).isEmpty(); + } +} diff --git a/api/src/test/java/com/cdp/user/PasswordPolicyTest.java b/api/src/test/java/com/cdp/user/PasswordPolicyTest.java new file mode 100644 index 0000000..1fbf36c --- /dev/null +++ b/api/src/test/java/com/cdp/user/PasswordPolicyTest.java @@ -0,0 +1,32 @@ +package com.cdp.user; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class PasswordPolicyTest { + + @Test + void length_is_12_to_72_chars_and_at_most_72_utf8_bytes() { + assertThat(PasswordPolicy.valid(null)).isFalse(); + assertThat(PasswordPolicy.valid("a".repeat(11))).isFalse(); + assertThat(PasswordPolicy.valid("a".repeat(12))).isTrue(); + assertThat(PasswordPolicy.valid("a".repeat(72))).isTrue(); + assertThat(PasswordPolicy.valid("a".repeat(73))).isFalse(); + assertThat(PasswordPolicy.valid("密".repeat(24))).as("72 bytes").isTrue(); + assertThat(PasswordPolicy.valid("密".repeat(25))).as("75 bytes").isFalse(); + assertThat(PasswordPolicy.valid("密".repeat(11))).isFalse(); + } + + @Test + void hasher_verifies_and_rejects_over_72_bytes_without_throwing() { + PasswordHasher hasher = new PasswordHasher(); + String hash = hasher.hash("correct-horse-battery"); + assertThat(hash).startsWith("$2"); + assertThat(hasher.matches("correct-horse-battery", hash)).isTrue(); + assertThat(hasher.matches("correct-horse-batterx", hash)).isFalse(); + assertThat(hasher.matches("a".repeat(100), hash)).isFalse(); + assertThat(hasher.matchesDummy("a".repeat(100))).isFalse(); + assertThat(hasher.matchesDummy(null)).isFalse(); + } +} diff --git a/api/src/test/java/com/cdp/user/UserServiceTest.java b/api/src/test/java/com/cdp/user/UserServiceTest.java new file mode 100644 index 0000000..1931848 --- /dev/null +++ b/api/src/test/java/com/cdp/user/UserServiceTest.java @@ -0,0 +1,124 @@ +package com.cdp.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import com.cdp.common.web.ApiException; +import com.cdp.common.web.ErrorCode; + +class UserServiceTest { + + private static final Instant T = Instant.parse("2026-10-22T00:00:00.123456789Z"); + + private final AppUserStore store = mock(AppUserStore.class); + private final PasswordHasher hasher = mock(PasswordHasher.class); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final UserService service = + new UserService(store, hasher, () -> "01JD0USER0000000000000009", Clock.fixed(T, ZoneOffset.UTC), tx); + + @SuppressWarnings("unchecked") + UserServiceTest() { + when(tx.execute(any())).thenAnswer(inv -> ((TransactionCallback) inv.getArgument(0)).doInTransaction(null)); + when(hasher.hash(any())).thenReturn("$2a$hashed"); + } + + private static AppUser user(String id, UserRole role, UserStatus status) { + return new AppUser(id, id + "@example.com", "n", "$2a$old", role, status, T, T); + } + + private static UpdateUserRequest patch(String role, String status) { + return new UpdateUserRequest(null, role, status, null); + } + + @Test + void create_lowercases_email_truncates_time_to_micros_and_hashes_password() { + UserResponse r = service.create(new CreateUserRequest(" Ops@Example.COM ", "Ops", "EDITOR", "correct-horse-battery")); + assertThat(r.email()).isEqualTo("ops@example.com"); + assertThat(r.status()).isEqualTo(UserStatus.ACTIVE); + assertThat(r.createdAt()).isEqualTo(Instant.parse("2026-10-22T00:00:00.123456Z")); + verify(store).insert(any()); + } + + @Test + void create_rejects_bad_input_collecting_all_fields_and_does_not_insert() { + assertThatThrownBy(() -> service.create(new CreateUserRequest("nope", "", "ROOT", "short"))) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.code()).isEqualTo(ErrorCode.VALIDATION_FAILED); + assertThat(e.errors()).extracting("field") + .containsExactly("email", "display_name", "role", "password"); + }); + verify(store, never()).insert(any()); + } + + @Test + void duplicate_email_is_email_taken() { + org.mockito.Mockito.doThrow(new DuplicateKeyException("dup")).when(store).insert(any()); + assertThatThrownBy(() -> service.create(new CreateUserRequest("a@example.com", "A", "VIEWER", "correct-horse-battery"))) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.code()).isEqualTo(ErrorCode.EMAIL_TAKEN)); + } + + @Test + void sole_active_admin_cannot_be_demoted_or_disabled_and_nothing_is_written() { + when(store.lockActiveAdminIds()).thenReturn(List.of("a")); + when(store.lockById("a")).thenReturn(Optional.of(user("a", UserRole.ADMIN, UserStatus.ACTIVE))); + for (UpdateUserRequest req : List.of(patch("VIEWER", null), patch(null, "DISABLED"), patch("EDITOR", "DISABLED"))) { + assertThatThrownBy(() -> service.update("a", req)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.code()).isEqualTo(ErrorCode.LAST_ADMIN); + assertThat(e.getMessage()).isEqualTo("至少要保留一位啟用中的管理員。"); + }); + } + verify(store, never()).update(any(), any(), any(), any(), any(), any()); + } + + @Test + void admin_may_change_when_another_admin_remains_or_when_not_losing_admin() { + when(store.lockActiveAdminIds()).thenReturn(List.of("a", "b")); + when(store.lockById("a")).thenReturn(Optional.of(user("a", UserRole.ADMIN, UserStatus.ACTIVE))); + when(store.findById("a")).thenReturn(Optional.of(user("a", UserRole.VIEWER, UserStatus.ACTIVE))); + assertThat(service.update("a", patch("VIEWER", null)).role()).isEqualTo(UserRole.VIEWER); + + when(store.lockActiveAdminIds()).thenReturn(List.of("a")); + when(store.findById("a")).thenReturn(Optional.of(user("a", UserRole.ADMIN, UserStatus.ACTIVE))); + assertThat(service.update("a", new UpdateUserRequest("renamed", "ADMIN", "ACTIVE", null)).userId()).isEqualTo("a"); + } + + @Test + void missing_user_is_404_and_bad_values_are_400_before_touching_the_database() { + when(store.lockActiveAdminIds()).thenReturn(List.of()); + when(store.lockById("x")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.update("x", patch(null, null))) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.code()).isEqualTo(ErrorCode.USER_NOT_FOUND)); + assertThatThrownBy(() -> service.update("x", patch("ROOT", "DELETED"))) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.errors()).extracting("field") + .containsExactly("role", "status")); + } + + @Test + void password_change_checks_current_password_and_rules() { + AppUser u = user("a", UserRole.EDITOR, UserStatus.ACTIVE); + when(store.findById("a")).thenReturn(Optional.of(u)); + when(hasher.matches("old-password-123", "$2a$old")).thenReturn(false); + assertThatThrownBy(() -> service.changePassword("a", "old-password-123", "brand-new-password")) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.code()).isEqualTo(ErrorCode.INVALID_CREDENTIALS)); + assertThatThrownBy(() -> service.changePassword("a", "old-password-123", "short")) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.code()).isEqualTo(ErrorCode.VALIDATION_FAILED)); + verify(store, never()).update(any(), any(), any(), any(), any(), any()); + } +} diff --git a/api/src/test/resources/application-test.yml b/api/src/test/resources/application-test.yml index a16ee65..ce284ec 100644 --- a/api/src/test/resources/application-test.yml +++ b/api/src/test/resources/application-test.yml @@ -50,7 +50,9 @@ cdp: security: ingest-api-key: test-ingest-key admin-api-key: test-admin-key - support-username: test-support - support-password: test-support-password + # 登入限速:門檻放寬,免得共用 email 的測試互相鎖住;限速驗收自己覆蓋 + login: + max-failures: 50 + lockout: PT15M trace: plain-keys: cart_id,cart_total diff --git a/tools/demo-seed/seed.py b/tools/demo-seed/seed.py index aefeb34..f8bb81c 100644 --- a/tools/demo-seed/seed.py +++ b/tools/demo-seed/seed.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 -"""Seed demo journeys and customers through the public API (contract journey-engine.md 3.1 / 3.2). +"""Seed demo journeys, customers and login accounts through the public API +(contracts journey-engine.md 3.1 / 3.2 and auth.md 4). Env (exported by the Makefile from .env): API_PORT, CDP_ADMIN_API_KEY, CDP_INGEST_API_KEY. Each run creates a NEW batch (new journeys, new customers, event names with a run tag), so reruns never collide with earlier data. Keys are never printed. +Demo accounts (one per role) are created through POST /api/v1/users with the admin key; the emails are fixed, +the passwords are random per run (a rerun resets them through PATCH /api/v1/users/{id}) and printed once. """ import json import os @@ -48,7 +51,7 @@ def env(name): OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) -def call(method, path, key, body=None, what=""): +def call(method, path, key, body=None, what="", tolerate=()): data = json.dumps(body).encode("utf-8") if body is not None else None req = urllib.request.Request(BASE + path, data=data, method=method) req.add_header("Authorization", "Bearer " + key) @@ -66,6 +69,8 @@ def call(method, path, key, body=None, what=""): extra += " [%s %s]" % (er.get("field"), er.get("message")) except Exception: extra = "" + if e.code in tolerate: + return {"_status": e.code} if e.code == 401: fail("%s returned 401 (API key rejected; check the key matches the running backend)" % what) if e.code == 403: @@ -76,6 +81,29 @@ def call(method, path, key, body=None, what=""): % (PORT, getattr(e, "reason", e))) +DEMO_USERS = [ + ("viewer", "VIEWER", "Demo viewer"), + ("editor", "EDITOR", "Demo editor"), + ("admin", "ADMIN", "Demo admin"), +] + + +def ensure_user(role_key, role, display_name): + """Create the demo account, or reset its password and re-enable it when it already exists.""" + email = "demo-%s@example.com" % role_key + password = secrets.token_urlsafe(12) + body = {"email": email, "display_name": display_name, "role": role, "password": password} + r = call("POST", "/api/v1/users", ADMIN_KEY, body, "POST /api/v1/users", tolerate=(409,)) + if r.get("_status") == 409: + users = call("GET", "/api/v1/users", ADMIN_KEY, None, "GET /api/v1/users").get("items", []) + found = [u for u in users if u["email"] == email] + if not found: + fail("POST /api/v1/users returned 409 but %s is not in the user list" % email) + patch = {"display_name": display_name, "role": role, "status": "ACTIVE", "password": password} + call("PATCH", "/api/v1/users/" + found[0]["user_id"], ADMIN_KEY, patch, "PATCH /api/v1/users") + return email, role, password + + def journey(name, event_name, wait): return { "name": name, @@ -125,6 +153,7 @@ def enrollments(profile_id): def main(): tag = secrets.token_hex(3) + accounts = [ensure_user(*u) for u in DEMO_USERS] ev_short, ev_long = "demo_short_" + tag, "demo_long_" + tag js = call("POST", "/api/v1/journeys", ADMIN_KEY, journey("Demo short wait (%ds) %s" % (SHORT_WAIT_SECONDS, tag), ev_short, @@ -160,6 +189,9 @@ def main(): print("demo-seed: batch %s created (new journeys + new customers; rerun creates another batch)" % tag) print(" journey (short): %s v%s | journey (long): %s v%s" % ( js["journey_id"], js["version"], jl["journey_id"], jl["version"])) + print("Demo login accounts (passwords are new on every run):") + for email, role, password in accounts: + print(" %-7s %s %s" % (role, email, password)) print("Paste a profile_id into the trace page search:") for name, pid, _, expect in customers: print(" %-6s %s expect: %s" % (name, pid, expect)) From b88b7e06342208ca8f5049aab0a5cb9f51619490 Mon Sep 17 00:00:00 2001 From: Marcus Date: Thu, 22 Oct 2026 14:30:44 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(user-auth-api):=20=E7=99=BB=E5=85=A5?= =?UTF-8?q?=E9=99=90=E9=80=9F=E5=8E=9F=E5=AD=90=E5=8C=96=E3=80=81=E9=81=8E?= =?UTF-8?q?=E9=95=B7=E5=AF=86=E7=A2=BC=E6=99=82=E9=96=93=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E3=80=81email=20=E9=95=B7=E5=BA=A6=E4=B8=8A=E9=99=90=E3=80=81?= =?UTF-8?q?=E7=99=BB=E5=87=BA=20cookie=20secure=20=E8=B7=9F=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0134QMUz6NTfqU8Yrh3BJekj --- .../java/com/cdp/security/AuthController.java | 35 ++++--- .../com/cdp/security/LoginRateLimiter.java | 83 ++++++++++++---- .../java/com/cdp/user/PasswordHasher.java | 21 +++- .../com/cdp/security/AuthControllerTest.java | 11 ++- .../cdp/security/LoginRateLimiterTest.java | 98 ++++++++++++++----- .../java/com/cdp/user/PasswordPolicyTest.java | 31 ++++++ 6 files changed, 221 insertions(+), 58 deletions(-) diff --git a/api/src/main/java/com/cdp/security/AuthController.java b/api/src/main/java/com/cdp/security/AuthController.java index ccdf846..6dc8a6e 100644 --- a/api/src/main/java/com/cdp/security/AuthController.java +++ b/api/src/main/java/com/cdp/security/AuthController.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Optional; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -27,6 +28,7 @@ import com.cdp.common.web.ApiException; import com.cdp.common.web.ErrorCode; import com.cdp.common.web.FieldError; +import com.cdp.security.LoginRateLimiter.Outcome; import com.cdp.user.AppUser; import com.cdp.user.PasswordHasher; import com.cdp.user.UserService; @@ -39,6 +41,7 @@ @RequestMapping("/api/v1/auth") public class AuthController { + private static final int EMAIL_MAX = 254; private static final String INVALID_CREDENTIALS_DETAIL = "帳號或密碼錯誤"; private final UserService users; @@ -67,6 +70,9 @@ public ResponseEntity login( List errors = new ArrayList<>(); if (body.email() == null || body.email().isBlank()) { errors.add(new FieldError("email", "必填")); + } else if (body.email().length() > EMAIL_MAX) { + // 不可能是真實帳號;不進限速表,免得被超長字串佔記憶體 + errors.add(new FieldError("email", "長度不可超過 " + EMAIL_MAX + " 字元")); } if (body.password() == null || body.password().isEmpty()) { errors.add(new FieldError("password", "必填")); @@ -76,21 +82,28 @@ public ResponseEntity login( } String key = body.email().trim().toLowerCase(Locale.ROOT); - Duration locked = limiter.remainingLock(key); - if (!locked.isZero()) { - response.setHeader(HttpHeaders.RETRY_AFTER, Long.toString(LoginRateLimiter.retryAfterSeconds(locked))); + // 檢查鎖定與佔用名額是原子的:並行請求不能同時越過門檻 + Optional locked = limiter.tryBegin(key); + if (locked.isPresent()) { + response.setHeader( + HttpHeaders.RETRY_AFTER, Long.toString(LoginRateLimiter.retryAfterSeconds(locked.get()))); SecurityProblemWriter.write(response, ErrorCode.LOGIN_RATE_LIMITED, "登入嘗試過於頻繁,請稍後再試。"); return null; } - - AppUser user = users.findByEmail(key).orElse(null); - boolean passwordOk = user != null ? hasher.matches(body.password(), user.passwordHash()) - : hasher.matchesDummy(body.password()); - if (user == null || !user.active() || !passwordOk) { - limiter.recordFailure(key); + AppUser user = null; + Outcome outcome = Outcome.ABORTED; + try { + user = users.findByEmail(key).orElse(null); + boolean passwordOk = user != null + ? hasher.matches(body.password(), user.passwordHash()) + : hasher.matchesDummy(body.password()); + outcome = user != null && user.active() && passwordOk ? Outcome.SUCCESS : Outcome.FAILURE; + } finally { + limiter.finish(key, outcome); + } + if (outcome != Outcome.SUCCESS) { throw new ApiException(ErrorCode.INVALID_CREDENTIALS, INVALID_CREDENTIALS_DETAIL); } - limiter.reset(key); HttpSession old = request.getSession(false); if (old != null) { @@ -114,7 +127,7 @@ public ResponseEntity logout(HttpServletRequest request, HttpServletRespon .path(sessionCookie.getPath() != null ? sessionCookie.getPath() : "/") .maxAge(0) .httpOnly(true) - .secure(true) + .secure(sessionCookie.getSecure() == null || sessionCookie.getSecure()) .sameSite("Strict") .build(); return ResponseEntity.noContent().header(HttpHeaders.SET_COOKIE, cleared.toString()).build(); diff --git a/api/src/main/java/com/cdp/security/LoginRateLimiter.java b/api/src/main/java/com/cdp/security/LoginRateLimiter.java index 25412e9..084d766 100644 --- a/api/src/main/java/com/cdp/security/LoginRateLimiter.java +++ b/api/src/main/java/com/cdp/security/LoginRateLimiter.java @@ -3,6 +3,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import org.springframework.stereotype.Component; @@ -15,10 +16,27 @@ @Component public class LoginRateLimiter { - /** 超過這個筆數時順手清掉已過期的項目,避免亂填 email 撐爆記憶體。 */ + /** 超過這個筆數時順手清掉已過期的項目,避免亂填 email 撐爆記憶體(email 長度另有上限)。 */ private static final int PURGE_THRESHOLD = 10_000; - private record Entry(int failures, Instant lastFailure) {} + /** 一次嘗試的結果:成功歸零、密碼錯計數、其他(例如資料庫例外)只釋放名額不計數。 */ + public enum Outcome { + SUCCESS, + FAILURE, + ABORTED + } + + /** failures:目前鎖定週期內的密碼錯誤數;inFlight:已通過 {@link #tryBegin} 但尚未 {@link #finish} 的嘗試數。 */ + private record Entry(int failures, int inFlight, Instant lastFailure) { + + boolean live(Instant now, Duration lockout) { + return lastFailure != null && lastFailure.plus(lockout).isAfter(now); + } + + int liveFailures(Instant now, Duration lockout) { + return live(now, lockout) ? failures : 0; + } + } private final int maxFailures; private final Duration lockout; @@ -37,33 +55,58 @@ public LoginRateLimiter(LoginProperties properties, Clock clock) { this.clock = clock; } - /** 鎖定中 → 剩餘時間;沒鎖 → {@link Duration#ZERO}。 */ - public Duration remainingLock(String key) { - Entry e = entries.get(key); - if (e == null || e.failures() < maxFailures) { - return Duration.ZERO; - } - Duration remaining = Duration.between(clock.instant(), e.lastFailure().plus(lockout)); - return remaining.isNegative() ? Duration.ZERO : remaining; - } - - /** 記一次密碼錯誤。鎖定中的嘗試呼叫端不會呼叫這個方法;這裡再保險一次,不延長鎖定。 */ - public void recordFailure(String key) { + /** + * 原子地「檢查鎖定並佔用一次嘗試名額」。回傳空 = 取得名額,呼叫端必須在做完後呼叫 {@link #finish}; + * 回傳鎖定剩餘時間 = 被擋下(不佔名額、不跑 BCrypt)。「已記錄的失敗 + 進行中的嘗試」達門檻即擋, + * 所以並行請求最多只有門檻數個能跑 BCrypt。 + */ + public Optional tryBegin(String key) { Instant now = clock.instant(); if (entries.size() > PURGE_THRESHOLD) { - entries.entrySet().removeIf(en -> !en.getValue().lastFailure().plus(lockout).isAfter(now)); + entries.entrySet().removeIf(en -> en.getValue().inFlight() == 0 && !en.getValue().live(now, lockout)); } + Duration[] blocked = new Duration[1]; entries.compute(key, (k, e) -> { - boolean live = e != null && e.lastFailure().plus(lockout).isAfter(now); - if (live && e.failures() >= maxFailures) { + int failures = e == null ? 0 : e.liveFailures(now, lockout); + int inFlight = e == null ? 0 : e.inFlight(); + if (failures + inFlight >= maxFailures) { + // 已鎖定 → 剩餘到 最後一次失敗 + lockout;只是名額被進行中的嘗試佔滿 → 先請對方等 1 秒 + blocked[0] = failures >= maxFailures + ? Duration.between(now, e.lastFailure().plus(lockout)) + : Duration.ofSeconds(1); return e; } - return new Entry(live ? e.failures() + 1 : 1, now); + return new Entry(failures, inFlight + 1, e == null ? null : e.lastFailure()); }); + return Optional.ofNullable(blocked[0]); } - public void reset(String key) { - entries.remove(key); + /** 釋放 {@link #tryBegin} 取得的名額並依結果更新計數;鎖定中的嘗試不會走到這裡。 */ + public void finish(String key, Outcome outcome) { + Instant now = clock.instant(); + entries.compute(key, (k, e) -> { + if (e == null) { + return null; + } + int inFlight = Math.max(0, e.inFlight() - 1); + int failures = e.liveFailures(now, lockout); + Instant last = e.lastFailure(); + switch (outcome) { + case SUCCESS -> { + failures = 0; + last = null; + } + case FAILURE -> { + // 距上次失敗超過 lockout 已在 liveFailures 歸零;鎖定中不延長 + if (failures < maxFailures) { + failures++; + last = now; + } + } + case ABORTED -> { } + } + return failures == 0 && inFlight == 0 ? null : new Entry(failures, inFlight, last); + }); } /** Retry-After 秒數:整數、無條件進位、至少 1。 */ diff --git a/api/src/main/java/com/cdp/user/PasswordHasher.java b/api/src/main/java/com/cdp/user/PasswordHasher.java index f5a0983..a47e706 100644 --- a/api/src/main/java/com/cdp/user/PasswordHasher.java +++ b/api/src/main/java/com/cdp/user/PasswordHasher.java @@ -4,22 +4,37 @@ import java.util.UUID; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; /** BCrypt 雜湊(spring-security-crypto)。帳號不存在時用 {@link #matchesDummy} 跑一次等價成本的比對,避免從耗時猜出帳號。 */ @Component public class PasswordHasher { - private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); - private final String dummyHash = encoder.encode(UUID.randomUUID().toString()); + private final PasswordEncoder encoder; + private final String dummyHash; + + public PasswordHasher() { + this(new BCryptPasswordEncoder()); + } + + /** 測試可注入計數用的 encoder。 */ + PasswordHasher(PasswordEncoder encoder) { + this.encoder = encoder; + this.dummyHash = encoder.encode(UUID.randomUUID().toString()); + } public String hash(String password) { return encoder.encode(password); } - /** 超過 72 位元組一律視為不符(BCrypt 只看前 72 位元組,不讓截斷造成誤判)。 */ + /** + * 超過 72 位元組或 hash 為 null 一律視為不符(BCrypt 只看前 72 位元組);這兩種情況也對 dummy hash 跑一次 + * 比對,讓回應時間和一般路徑一致,不因密碼長度洩漏帳號是否存在。 + */ public boolean matches(String password, String hash) { if (password == null || hash == null || password.getBytes(StandardCharsets.UTF_8).length > PasswordPolicy.MAX_BYTES) { + encoder.matches("x", dummyHash); return false; } return encoder.matches(password, hash); diff --git a/api/src/test/java/com/cdp/security/AuthControllerTest.java b/api/src/test/java/com/cdp/security/AuthControllerTest.java index 3d36d3a..bc9b8bd 100644 --- a/api/src/test/java/com/cdp/security/AuthControllerTest.java +++ b/api/src/test/java/com/cdp/security/AuthControllerTest.java @@ -117,6 +117,15 @@ void locked_email_gets_429_with_retry_after_and_never_runs_bcrypt() throws Excep verify(users, never()).findByEmail(anyString()); } + @Test + void over_long_email_is_400_and_never_enters_the_limiter() { + String longEmail = "a".repeat(255) + "@example.com"; + assertThatThrownBy(() -> controller.login( + new LoginRequest(longEmail, "x-password-12345"), new MockHttpServletRequest(), new MockHttpServletResponse())) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.code()).isEqualTo(ErrorCode.VALIDATION_FAILED)); + verify(users, never()).findByEmail(anyString()); + } + @Test void blank_fields_are_validation_failed_and_not_counted() { for (int i = 0; i < 5; i++) { @@ -131,7 +140,7 @@ void blank_fields_are_validation_failed_and_not_counted() { new LoginRequest(null, "x"), new MockHttpServletRequest(), new MockHttpServletResponse())) .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.errors()).extracting("field") .containsExactly("email")); - assertThat(limiter.remainingLock("a@example.com")).isZero(); + assertThat(limiter.tryBegin("a@example.com")).isEmpty(); } @Test diff --git a/api/src/test/java/com/cdp/security/LoginRateLimiterTest.java b/api/src/test/java/com/cdp/security/LoginRateLimiterTest.java index 17a0cc2..9fa8031 100644 --- a/api/src/test/java/com/cdp/security/LoginRateLimiterTest.java +++ b/api/src/test/java/com/cdp/security/LoginRateLimiterTest.java @@ -39,45 +39,97 @@ public Instant instant() { private final MutableClock clock = new MutableClock(); private final LoginRateLimiter limiter = new LoginRateLimiter(new LoginProperties(3, Duration.ofSeconds(10)), clock); + private void fail(String key) { + assertThat(limiter.tryBegin(key)).isEmpty(); + limiter.finish(key, LoginRateLimiter.Outcome.FAILURE); + } + + private Duration locked(String key) { + return limiter.tryBegin(key).orElse(Duration.ZERO); + } + @Test void locks_when_count_reaches_threshold_until_last_failure_plus_lockout() { - limiter.recordFailure("a"); - limiter.recordFailure("a"); - assertThat(limiter.remainingLock("a")).isZero(); - limiter.recordFailure("a"); - assertThat(limiter.remainingLock("a")).isEqualTo(Duration.ofSeconds(10)); + fail("a"); + fail("a"); + fail("a"); + assertThat(locked("a")).isEqualTo(Duration.ofSeconds(10)); clock.advance(Duration.ofSeconds(4)); - assertThat(limiter.remainingLock("a")).isEqualTo(Duration.ofSeconds(6)); + assertThat(locked("a")).isEqualTo(Duration.ofSeconds(6)); clock.advance(Duration.ofSeconds(6)); - assertThat(limiter.remainingLock("a")).isZero(); + assertThat(limiter.tryBegin("a")).isEmpty(); } @Test - void failures_while_locked_do_not_extend_the_lock() { - for (int i = 0; i < 3; i++) limiter.recordFailure("a"); + void attempts_while_locked_do_not_extend_the_lock() { + for (int i = 0; i < 3; i++) fail("a"); clock.advance(Duration.ofSeconds(5)); - limiter.recordFailure("a"); - assertThat(limiter.remainingLock("a")).isEqualTo(Duration.ofSeconds(5)); + assertThat(locked("a")).isEqualTo(Duration.ofSeconds(5)); + assertThat(locked("a")).isEqualTo(Duration.ofSeconds(5)); } @Test void count_restarts_when_last_failure_is_older_than_lockout() { - limiter.recordFailure("a"); - limiter.recordFailure("a"); + fail("a"); + fail("a"); clock.advance(Duration.ofSeconds(11)); - limiter.recordFailure("a"); - limiter.recordFailure("a"); - assertThat(limiter.remainingLock("a")).isZero(); - limiter.recordFailure("a"); - assertThat(limiter.remainingLock("a")).isPositive(); + fail("a"); + fail("a"); + assertThat(locked("a")).isZero(); + limiter.finish("a", LoginRateLimiter.Outcome.ABORTED); } @Test - void reset_and_keys_are_independent() { - for (int i = 0; i < 3; i++) limiter.recordFailure("a"); - assertThat(limiter.remainingLock("b")).isZero(); - limiter.reset("a"); - assertThat(limiter.remainingLock("a")).isZero(); + void success_resets_and_keys_are_independent() { + fail("a"); + fail("a"); + assertThat(limiter.tryBegin("a")).isEmpty(); + limiter.finish("a", LoginRateLimiter.Outcome.SUCCESS); + fail("a"); + fail("a"); + assertThat(limiter.tryBegin("b")).isEmpty(); + } + + @Test + void aborted_attempt_releases_slot_without_counting() { + for (int i = 0; i < 10; i++) { + assertThat(limiter.tryBegin("a")).isEmpty(); + limiter.finish("a", LoginRateLimiter.Outcome.ABORTED); + } + assertThat(limiter.tryBegin("a")).isEmpty(); + } + + @Test + void in_flight_attempts_count_toward_the_threshold() { + assertThat(limiter.tryBegin("a")).isEmpty(); + assertThat(limiter.tryBegin("a")).isEmpty(); + assertThat(limiter.tryBegin("a")).isEmpty(); + assertThat(limiter.tryBegin("a")).isPresent(); + } + + @Test + void concurrent_wrong_passwords_never_admit_more_than_the_threshold() throws Exception { + int threads = 40; + java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(threads); + java.util.concurrent.CountDownLatch go = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.atomic.AtomicInteger admitted = new java.util.concurrent.atomic.AtomicInteger(); + java.util.List> fs = new java.util.ArrayList<>(); + for (int i = 0; i < threads; i++) { + fs.add(pool.submit(() -> { + go.await(); + if (limiter.tryBegin("race").isEmpty()) { + admitted.incrementAndGet(); + Thread.sleep(50); // 模擬 BCrypt + limiter.finish("race", LoginRateLimiter.Outcome.FAILURE); + } + return null; + })); + } + go.countDown(); + for (var f : fs) f.get(); + pool.shutdown(); + assertThat(admitted.get()).isEqualTo(3); + assertThat(limiter.tryBegin("race")).isPresent(); } @Test diff --git a/api/src/test/java/com/cdp/user/PasswordPolicyTest.java b/api/src/test/java/com/cdp/user/PasswordPolicyTest.java index 1fbf36c..b947f2b 100644 --- a/api/src/test/java/com/cdp/user/PasswordPolicyTest.java +++ b/api/src/test/java/com/cdp/user/PasswordPolicyTest.java @@ -29,4 +29,35 @@ void hasher_verifies_and_rejects_over_72_bytes_without_throwing() { assertThat(hasher.matchesDummy("a".repeat(100))).isFalse(); assertThat(hasher.matchesDummy(null)).isFalse(); } + + @Test + void over_long_password_costs_one_bcrypt_comparison_on_every_path() { + java.util.concurrent.atomic.AtomicInteger matches = new java.util.concurrent.atomic.AtomicInteger(); + org.springframework.security.crypto.password.PasswordEncoder counting = + new org.springframework.security.crypto.password.PasswordEncoder() { + @Override + public String encode(CharSequence raw) { + return "h"; + } + + @Override + public boolean matches(CharSequence raw, String encoded) { + matches.incrementAndGet(); + return false; + } + }; + PasswordHasher hasher = new PasswordHasher(counting); + String tooLong = "a".repeat(73); + // 帳號存在(有 hash)與不存在(matchesDummy)、hash 為 null、正常長度:都是剛好一次 + for (java.util.function.Supplier path : java.util.List.>of( + () -> hasher.matches(tooLong, "$2a$real"), + () -> hasher.matchesDummy(tooLong), + () -> hasher.matches(tooLong, null), + () -> hasher.matches("short-but-ok-123", "$2a$real"), + () -> hasher.matchesDummy("short-but-ok-123"))) { + matches.set(0); + assertThat(path.get()).isFalse(); + assertThat(matches.get()).isEqualTo(1); + } + } }