Skip to content

feat(user-auth-api): 依 auth.md 實作使用者登入(email+密碼,BCrypt)、角色(VIEWER/EDITOR/ADMIN)、CSRF、登入限速與 /users 管理 API,取代 v0.1.0 的共用客服帳密 - #104

Merged
Marcus778899 merged 4 commits into
developfrom
feat/user-auth-api
Oct 23, 2026
Merged

Marcus778899 merged 4 commits into
developfrom
feat/user-auth-api

Conversation

@Marcus778899

Copy link
Copy Markdown
Owner

Closes #103

派工單:tasks/user-auth-api.md(負責:backend)

Worker 自驗:docker compose --env-file .env -f .worktrees/user-auth-api/compose.yaml run --rm test;另在 .worktrees/user-auth-api 跑 make check → exit 0

變更檔案:

  • .env.example
  • api/src/main/java/com/cdp/security/ (改寫 AuthController、SecurityConfig、AuthProperties、AuthPropertiesValidator、Role、LoginRequest、CurrentUserResponse、ApiKeyAuthenticationFilter;新增 AccessRule、Credential、LoginProperties、LoginRateLimiter、PasswordChangeRequest、SessionAuthenticationFilter、SessionCsrfTokenRepository、SessionUser)
  • api/src/main/java/com/cdp/user/ (新:AppUser、AppUserStore、UserService、UserController、PasswordHasher、PasswordPolicy 與請求/回應 record)
  • api/src/main/java/com/cdp/common/web/ErrorCode.java (超出負責檔案,見 open_questions)
  • api/src/main/resources/db/migration/V6__app_user.sql
  • api/src/main/resources/application.yml
  • api/src/test/resources/application-test.yml
  • tools/demo-seed/seed.py
  • api/src/test/java/com/cdp/security/ (ApiKeyAuthenticationFilterTest、AuthControllerTest、AuthPropertiesValidatorTest 改寫;LoginRateLimiterTest、SessionCsrfTokenRepositoryTest 新增)
  • api/src/test/java/com/cdp/user/ (新:PasswordPolicyTest、UserServiceTest)

@Marcus778899

Copy link
Copy Markdown
Owner Author

QA:PASS(commit 4dd7a44)

PASS 4dd7a44

user-auth-api 驗證(HEAD 4dd7a44,PR #104)

實際執行

  • docker compose --env-file .env -f .worktrees/user-auth-api/compose.yaml run --rm test:Tests run: 292, Failures: 0, Errors: 0, Skipped: 0、BUILD SUCCESS(含 user-auth-api、security、query、ingest、relay-loop-rewrite 驗收與新增單元測試 LoginRateLimiterTest 6、UserServiceTest 7、PasswordPolicyTest 2、AuthControllerTest 5)。
  • make check(在 worktree):spotless/package 成功,web Tests 196 passed (196),check passed。

逐條檢查

  • 驗收測試未被改:git diff --stat f50659a HEAD -- api/src/test/java/com/cdp/acceptance 輸出為空。
  • ErrorCode.java diff 只新增 CSRF_INVALID、LOGIN_RATE_LIMITED、EMAIL_TAKEN、LAST_ADMIN、USER_NOT_FOUND 五項,皆見於 auth.md;既有項目未動。
  • CDP_SUPPORT_*:程式、.env.example、application*.yml 皆無(grep 只剩 docs 與一則測試註解);.env.example 保留 CDP_LOGIN_MAX_FAILURES/CDP_LOGIN_LOCKOUT。
  • demo-seed(tools/demo-seed/seed.py):以 CDP_ADMIN_API_KEY 呼叫 POST /api/v1/users 建 viewer/editor/admin 三個帳號,409 時改 PATCH 重設;密碼用 secrets.token_urlsafe 每次隨機並印出。僅看程式,未啟動服務實跑;其呼叫的 /users 契約已由 UsersApiAcceptanceTest 覆蓋。
  • 「鎖定中不跑 BCrypt」:AuthControllerTest.locked_email_gets_429_with_retry_after_and_never_runs_bcrypt 驗證 hasher.matches/matchesDummy 皆 never、也不查使用者;通過。帳號不存在跑 dummy BCrypt 亦有測試(verify(hasher).matchesDummy)。
  • 寫死機密:搜尋 api/src/main、tools/demo-seed、.env.example 無寫死密碼或金鑰;application.yml 皆為 ${...}。

判斷:auth.md §2「未列路徑已認證一律 403」vs FrameworkErrorFormatAcceptanceTest(ADMIN 打未知路徑 404)

  • 實際行為(SecurityConfig .anyRequest().access(admin)):未認證 401;INGEST/VIEWER/EDITOR 403 FORBIDDEN;ADMIN 通過授權層,落到 MVC 回 404 NOT_FOUND/405。
  • 不違反驗收條件:RoleMatrixAcceptanceTest 的未列路徑測試只斷言 401 與「非 ADMIN 憑證」403(並刻意排除 ADMIN);DefaultDenyAcceptanceTest 只測 INGEST、VIEWER;FrameworkErrorFormatAcceptanceTest 要求 ADMIN 得 404,全部通過。
  • 但契約字面(auth.md L43「已認證一律 403」)與實作/既有驗收對 ADMIN 不一致,屬文件措辭問題,非本單缺陷。建議 manager 另開文件修正,把 L43 改為「非 ADMIN 已認證 403;ADMIN 依 MVC 回 404/405」,或決定 ADMIN 也要 403(那會需改 FrameworkErrorFormat 測試,須甲方/架構確認)。

其他觀察(不構成 FAIL)

  • CSRF 只檢查 session 認證的非 GET 請求(worker open_questions 已說明);匿名請求仍回 401,與 auth.md 流程(先驗認證)一致,CsrfAcceptanceTest 全綠。
  • compose 警告 volume cdp-test-m2 建於別的 project,無影響。

問題數量:0(僅 1 項文件措辭建議)

@Marcus778899

Copy link
Copy Markdown
Owner Author

Code review(architect):CHANGES(commit 4dd7a44)

REQUEST_CHANGES 4dd7a44

user-auth-api code review(PR #104,HEAD 4dd7a44)

對照:派工單(含兩則追加)、docs/contracts/auth.md、docs/design/DESIGN-03.md 做法 #2、worker 回報、qa 證據(PASS)。以 gh pr diff 104 與 worktree 原始碼檢查;驗收測試目錄只看不判(qa 已確認 f50659a 之後沒有變更)。

要改的問題(2 項)

# 檔案:行號 問題 要怎麼改
1 api/src/main/java/com/cdp/user/PasswordHasher.java:21-25(搭配 AuthController.java:86-88) 用時間差就能判斷帳號是否存在。密碼超過 72 位元組時,matches() 直接回 false、不跑 BCrypt;但帳號不存在時走 matchesDummy(),仍會跑一次 BCrypt(用 "x" 比對)。攻擊者送 73 位元組的密碼:回應快代表帳號存在,回應慢(約一次 BCrypt 的時間)代表不存在。這違反派工單「帳號不存在也跑一次 BCrypt」的目的(兩條路徑的成本要一致),停用帳號也受影響。 matches() 在密碼過長或 hash 為 null 時,也要對 dummy hash 跑一次 encoder.matches 再回 false(和 matchesDummy 同成本)。補單元測試:密碼過長時,存在與不存在兩條路徑都要呼叫 BCrypt 一次(例如 encoder 用可注入的 spy,或計數)。
2 api/src/main/java/com/cdp/security/AuthController.java:79-91、LoginRateLimiter.java:36-64 並行請求可以繞過限速。目前是先查鎖定(remainingLock),跑完 BCrypt 之後才計數(recordFailure),兩步不是原子的。同一個 email 同時送 N 個請求,全部都會通過鎖定檢查、全部跑 BCrypt,所以每個鎖定週期能試的次數約等於並行數(Tomcat 預設 200),不是 CDP_LOGIN_MAX_FAILURES。D-045 的限速因此形同虛設,還順便放大了 BCrypt 造成的 CPU 消耗。 「檢查鎖定」與「佔用一次嘗試名額」要在同一個 ConcurrentHashMap.compute 裡原子完成,例如 tryBegin(key):失敗數 + 進行中數 >= 門檻 就回鎖定剩餘時間(不跑 BCrypt),否則「進行中」+1。結束時呼叫 finish(key, success):「進行中」-1;失敗就照現行規則計數並記錄失敗時間;成功就歸零。對外行為不變(第 N 次失敗仍回 401,之後回 429;鎖定中不計數、不延長)。補並行單元測試:門檻 N、同時 M>N 個錯誤密碼請求,跑 BCrypt 的次數必須 ≤ N。

建議(不擋合併,可同一輪順手處理)

檔案:行號 觀察 建議
AuthController.java:68-77、LoginRateLimiter.java:52-55 登入時 email 沒有長度上限,限速表會用整串 email 當 key 存在記憶體裡 CDP_LOGIN_LOCKOUT 那麼久。依 D-023 請求大小不設限,所以送超長 email 可以佔用大量記憶體;另外存活項目超過 10,000 筆時,每次失敗都會整張表掃一次(removeIf)。 超過 254 字元(與 UserService.EMAIL_MAX 相同)直接回 400 VALIDATION_FAILED(契約已列 400,且不計數);或者照常回 401,但不寫進限速表(這種 email 不可能是真實帳號)。
AuthController.java:111-115 登出清 CDP_SESSION cookie 時把 secure(true) 寫死,但 XSRF cookie 會跟著 server.servlet.session.cookie.secure 設定。 改成同一個來源(sessionCookie.getSecure()),兩個 cookie 的行為才一致。

逐項檢查(通過)

項目 結果
session 固定攻擊 登入成功時先 invalidate 舊 session 再建新的(AuthController.java:94-99);session 只存 user_id,CSRF 值也隨之換新
每個請求重新載入使用者 SessionAuthenticationFilter.authenticate 每次都呼叫 findActiveById;使用者被停用或刪除時清掉 session,結果是 401
帶了 Authorization 就只看它 ApiKeyAuthenticationFilter:任何 Authorization 標頭(包含非 Bearer)不符就 401;SessionAuthenticationFilter 只在沒有這個標頭時才讀 session
CSRF 自訂 SessionCsrfTokenRepository:cookie、session 裡存的值、標頭三者都要一致;比對用的是 Spring CsrfFilter 內建的常數時間比較;saveToken 不做事,所以驗證失敗時不會換掉合法值;只有 session 的非安全方法才檢查(Bearer、login、logout 免檢查);filter 順序正確(認證 filter 掛在 SecurityContextHolderFilter 之後,在 CsrfFilter 之前)。和設計 #2 寫的 CookieCsrfTokenRepository 不同,但設計本來就寫「行為以 auth.md §1.1 為準」,而且這個做法更嚴格(見「需要改的設計文件」)
角色矩陣 SecurityConfig 逐列對照 §2:PUT profiles=INGEST 或管理金鑰(ADMIN 的 session 被拒);POST journeys 只收管理金鑰;approve/reject 只收 ADMIN 的 session;送審類只收 EDITOR 以上的 session;/users* 要 ADMIN;/auth/me、/auth/password 只收 session(Bearer 得 403);RoleHierarchy 是 ADMIN > EDITOR > VIEWER,INGEST 不在階層裡
LAST_ADMIN 並行 同一個交易裡先依 user_id 順序 FOR UPDATE 鎖住所有啟用中的 ADMIN,再鎖目標列。在 READ COMMITTED 下,後到的交易會重新評估 WHERE 條件,看到的是對方已提交的停用或降級,所以兩位 ADMIN 同時互相降級時,只有一個會成功。BCrypt 在交易外先算好,不會佔著鎖
使用者列舉(除了 #1) 帳號不存在、停用、密碼錯誤都回同一個 401 碼與同一段訊息;限速對存在與不存在的帳號一樣計數;EMAIL_TAKEN 只有 ADMIN 看得到
錯誤訊息洩漏 都經過 ApiException/SecurityProblemWriter 統一格式,不帶堆疊、不帶 SQL
密碼不進 log LoginRequest、PasswordChangeRequest、CreateUserRequest、UpdateUserRequest、AppUser、AuthProperties 的 toString 都已遮罩;UserResponse 不含 hash;demo-seed 只印出示範帳號的隨機密碼(設計就是要這樣),不印金鑰
Retry-After 無條件進位,最少 1 秒;鎖定中不查使用者、不跑 BCrypt(有單元測試)
SQL 全部用 JdbcClient 參數綁定;V6 只建 app_user,有 UNIQUE(email) 與 CHECK 約束
設定 沒有寫死的值;cdp.login.* 從 CDP_LOGIN_* 讀,啟動時會驗證;CDP_SUPPORT_* 已從程式、yml、.env.example 移除
負責檔案 只改了追加後的負責檔案(ErrorCode.java 只新增 5 個碼,manager 已追認)

qa 提出的 §2「未列路徑」問題:改契約,不改實作

  • 理由:(1) 「ADMIN 打未列路徑就放行」是 v0.1.0 就有的行為(原本是 anyRequest().hasRole(ADMIN)),而且鎖住的 FrameworkErrorFormatAcceptanceTest 要求回 404;(2) 現有的 controller 端點都已在 SecurityConfig 明列,掉到預設規則的只剩不存在的路由,所以 ADMIN 只會拿到 MVC 的 404/405,沒有資料外洩;(3) 若要讓 ADMIN 也回 403,得改成 denyAll,會破壞 404/405 的語意與既有驗收。
  • 對未來的風險:新端點如果忘了加進 SecurityConfig,ADMIN 預設就能用(對其他角色仍然是預設拒絕)。這點靠契約規則管住(見下)。

需要改的設計文件(併入設計文件更新單)

文件 改法
docs/contracts/auth.md §2 最後一條 改成「其他 /api/** 未列者:未認證 401;INGEST/VIEWER/EDITOR 403 FORBIDDEN;ADMIN(含管理金鑰)不另擋,依路由回 404 NOT_FOUND/405。新增端點必須列進本表,並在 SecurityConfig 明列規則」
docs/design/DESIGN-03.md 做法 #2 的 CSRF 那段 改成實際做法:csrf.spa()+自訂 SessionCsrfTokenRepository(cookie、session 存值、標頭三者一致;只有 /auth/me 與登入成功會發新值);CSRF 只檢查 session 認證的非安全方法,所以匿名的寫入請求回 401,不回 CSRF_INVALID

契約問題(不擋本單,供 manager 判斷是否列待決定)

  • §4 管理員用 PATCH 重設某人密碼或改角色時,對方現有的 session 不會失效。角色改變會在下一個請求生效,但「帳號被盜、由管理員重設密碼」這種情況,盜用者的 session 會一直有效,直到閒置逾時。契約沒寫,屬於安全政策,建議列為待決定。
  • §3 /auth/password 依契約不限速:拿到 session 的人可以用 BCrypt 的速度猜目前密碼。目前有 CSRF 加上「必須已有 session」兩道限制,風險不高,記錄下來即可。

@Marcus778899

Copy link
Copy Markdown
Owner Author

QA:PASS(commit b88b7e0)

PASS b88b7e0

user-auth-api QA(PR #104,commit b88b7e0)

測試

指令:docker compose --env-file .env -f .worktrees/user-auth-api/compose.yaml run --rm test(Docker 正常,Testcontainers 可用)

結果摘要:[INFO] Tests run: 297, Failures: 0, Errors: 0, Skipped: 0 / BUILD SUCCESS

驗收測試目錄全綠(Failures 0、Errors 0):

  • userauth:LoginRateLimit 11、Csrf 9、SchemaV6AppUser 3、UsersApi 12、RoleMatrix 8、LoginSession 19、LastAdmin 9
  • security:Authentication 16、EnrollmentsPath 3、SecretToString 4、LogoutCookieConfig 1、StartupValidation 3、NoGeneratedPasswordLog 1、MeEndpointRole 3、DefaultDeny 6
  • query:EnrollmentQuery 23、NulParameter 7、PlainKeys 3 檔
  • ingest:DispatchPropertiesStartup 6、NulCharacterValidation 4、EnrollmentWakeMissingData 2、EventDispatchRetry 5
  • relayloop:RelayStartupValidation 5、RelayStuckTimers 1、RelayRewrite 7

輸出中的 APPLICATION FAILED TO START(DispatchProperties 綁定)是 DispatchPropertiesStartupAcceptanceTest 預期的負面案例,測試本身通過。

Lint/前端

指令:make check -> check passed(web vitest 16 檔 196 測試通過)。

驗收條件對照

  • V6/login/session/每請求重載、角色矩陣、CSRF、限速、/users 與 LAST_ADMIN:對應 userauth、security 驗收全綠。
  • 既有測試全綠:297 通過。
  • demo-seed/CDP_SUPPORT_*:grep SUPPORT_ 在 api/src/main、tools、.env.example 無結果。
  • 無寫死的 port/token/密碼(grep 無結果)。
  • 驗收測試目錄自 f50659a 後無變更(git diff --stat 空);worktree 乾淨。

review 修正(b88b7e0)確認

  • 登入 email 長度上限 254:AuthController 有 EMAIL_MAX 檢查,超過回 400 VALIDATION_FAILED。
  • 登出 cookie secure 跟設定:.secure(sessionCookie.getSecure() == null || ...);LogoutCookieConfigAcceptanceTest 通過。
  • 過長密碼時間一致、限速原子化:對外行為由 LoginRateLimitAcceptanceTest/AuthenticationAcceptanceTest 驗證通過;原子化與 BCrypt 次數屬單元測試範圍(含於 297 測試中,全綠)。

備註(非 FAIL)

  • 未實際啟動服務手動打 API(本單非整合單,驗收測試已以 SpringBootTest/Testcontainers 打真實 HTTP 與 DB)。
  • D-071 不在本輪範圍。

@Marcus778899

Copy link
Copy Markdown
Owner Author

Code review(architect):APPROVE(commit b88b7e0)

APPROVE b88b7e0

user-auth-api code review 第 2 輪(PR #104,HEAD b88b7e0)

對照:派工單(範圍以「範圍修正」為準)、docs/contracts/auth.md、上一輪 review(4dd7a44)、worker 回報、qa 證據(PASS b88b7e0,297 測試)。用 gh pr diff 104 看全部變更,用 git show b88b7e0 看本輪修正。上一輪第 1 輪已逐項檢查過的部分(session 固定攻擊、每請求重載、Bearer 優先、CSRF、角色矩陣、LAST_ADMIN、SQL、設定、log 遮罩)在 b88b7e0 沒有變動,維持通過。

上一輪問題的處理

# 上一輪問題 修正 結果
1 密碼過長時用時間差能判斷帳號是否存在 PasswordHasher.java:35-41:密碼過長或 hash 為 null 時也對 dummy hash 跑一次 encoder.matches;matchesDummy 遇到過長密碼改用 "x"(:44-48)。encoder 改成可注入;PasswordPolicyTest.over_long_password_costs_one_bcrypt_comparison_on_every_path 驗證 5 條路徑(存在/不存在 × 過長/正常、hash null)都剛好跑一次比對 已處理
2 並行請求可以繞過限速 LoginRateLimiter.tryBegin/finish:在同一個 compute 裡原子地判斷「有效失敗數 + 進行中數 ≥ 門檻」並佔用名額;finish 依 SUCCESS/FAILURE/ABORTED 更新。AuthController.java:86-103 用 try/finally 確保名額一定會釋放(DB 例外記成 ABORTED,不計數)。不變式「失敗數 + 進行中數 ≤ 門檻」成立,所以 finish(FAILURE) 不會讓失敗數超過門檻,「鎖定中不延長」仍成立。concurrent_wrong_passwords_never_admit_more_than_the_threshold(40 執行緒、門檻 3)驗證只有 3 個請求進得去 已處理
建議 email 沒有長度上限 AuthController.java:73-76:超過 254 字元回 400 VALIDATION_FAILED,不查使用者也不進限速表;有單元測試 已處理
建議 登出時 CDP_SESSION 的 secure 寫死 AuthController.java:130 改成跟 server.servlet.session.cookie.secure,和 SecurityConfig.java:57-60 的 XSRF cookie 同一個來源 已處理

本輪變更的其他檢查

項目 結果
負責檔案 b88b7e0 只改了 security/、user/ 與對應的單元測試;驗收測試目錄從 f50659a 之後沒有變動(git diff 4dd7a44 b88b7e0 -- api/src/test/java/com/cdp/acceptance 是空的)
限速計時 鎖定剩餘時間 = 最後一次失敗 + lockout;超過 lockout 的失敗由 liveFailures 歸零;成功時清空;finish 在失敗數與進行中數都是 0 時會刪掉該筆,表不會無限長大
寫死的值 沒有新增

不擋合併的觀察(併進設計文件更新單)

位置 觀察 建議
LoginRateLimiter.java:73-77 對照 auth.md §3「超限回應」 名額被進行中的嘗試佔滿時(還沒鎖定),回 429、Retry-After: 1。契約只寫「鎖定中」才回 429。這是上一輪 review 要求的做法,只有同一個 email 同時有 ≥ 門檻數個請求時才會發生,屬於合理的延伸 在 auth.md §3 的「超限回應」列加一句:「同一個 email 同時進行中的嘗試加上失敗計數達到門檻時,也回 429(Retry-After 至少 1)」
上一輪「需要改的設計文件」 auth.md §2 最後一條(ADMIN 打未列路徑回 404/405)、DESIGN-03.md 做法 #2 的 CSRF 寫法 還沒改,照上一輪的寫法併入同一張設計文件更新單

給下一張後端單:配合 D-071 要怎麼改 auth.md(本次不改)

位置 現行 改成
§4 PATCH /api/v1/users/{user_id} 表格下方 沒有寫 新增一條:「請求帶 password 時,該使用者所有現有 session 立即失效,下一個請求回 401 UNAUTHENTICATED(D-071);管理金鑰呼叫時也一樣。只改 display_name/role/status 時 session 不失效(停用與改角色照 §1 在下一個請求生效)」
§3 POST /auth/password 的「成功」欄 204;其他 session 不登出 維持不變,在後面加註「(與 §4 管理員重設不同,見 D-071)」,避免被誤讀成兩者一致
§1 最後一條(session 每個請求都以當下使用者資料判斷) 停用、改角色 加上「管理員重設密碼 → 下一個請求 401(§4)」
待確認(屬安全政策,交 manager 判斷要不要問甲方) — 管理員用 PATCH 重設自己的密碼時,D-071 寫的是「該使用者所有 session 失效」,這樣呼叫者自己目前的 session 也會跟著失效(下一個請求就要重新登入)。建議照字面執行並寫進契約;前端只需要在 PATCH 自己的密碼成功後導向登入頁

實作提示(給下一張單的設計參考,非契約內容):session 存在 Tomcat 記憶體裡,沒辦法依使用者列出所有 session。最簡單的做法是在 app_user 加一個 session_epoch(或 password_changed_at)欄位(V7,expand 型的 migration)。登入時把這個值存進 session;SessionAuthenticationFilter.authenticate 本來就會在每個請求重新載入使用者,這時順便比對,值不一致就清掉 session 並回 401。管理員重設密碼時把這個值 +1,而且要跟密碼更新放在同一個交易裡。/auth/password 不動這個值。

@Marcus778899

Copy link
Copy Markdown
Owner Author

併入最新 develop(b88b7e0 → 4cab803):任務 diff 沒變,沿用 qa、review;等 CI 綠再 merge

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant