Files
bear-cli/lib/bear_cli/api.ex
queena d70aa9e541 feat: 實作管理端指令群 jwks/accounts/audit-logs(issue #12)
- docs/commands.md 新增 §3.8 jwks、§3.9 accounts、§3.10 audit-logs 規格
- Api 新增 jwks/accounts/audit-logs 端點(共用 api_request)
- 新增 Jwks/Accounts/AuditLogs/Admin 模組;CLI 接線三個指令群
- 403 → 退出碼 8 提示需 admin;一次性密碼只在成功當下輸出
- 測試 100 例全綠(fake API 注入,比照 cli_test.exs);基於含 PR #15 的最新 main
2026-09-10 03:15:53 +08:00

352 lines
12 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule BearCli.Api do
@moduledoc """
Bear 伺服器 HTTP 介面(一律使用 `Req`)。
- `GET /userinfo`:PAT 模式身分驗證(issue #6)。
- App 管理 API `/api/v1/apps`(alterminal/bear#28;PAT Bearer、admin 限定)。
- 個人自助 API `/api/v1/profile*`(alterminal/bear#35;PAT Bearer、
任何有效帳號):profile/密碼/email/sessions/PAT/MFA。
- 管理端 API `/api/v1/jwks`/`/api/v1/accounts`/`/api/v1/audit-logs`
(alterminal/bear#36; PAT Bearer, admin only).
"""
@finch BearCli.Finch
@doc """
呼叫 `GET {issuer}/userinfo`(Bearer token)。
回傳:
- `{:ok, claims}` — 200,claims 為 JSON map
- `{:error, :unauthorized, description}` — 401
- `{:error, :server_error, status}` — 其他非 2xx
- `{:error, :network, reason}` — 傳輸層錯誤
"""
def userinfo(issuer, token) do
url = String.trim_trailing(issuer, "/") <> "/userinfo"
case Req.get(url,
headers: [authorization: "Bearer " <> token, accept: "application/json"],
retry: false,
finch: [name: @finch]
) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, decode_body(body)}
{:ok, %Req.Response{status: 401, body: body}} ->
{:error, :unauthorized, error_description(body)}
{:ok, %Req.Response{status: status}} ->
{:error, :server_error, status}
{:error, exception} ->
{:error, :network, Exception.message(exception)}
end
end
defp decode_body(%{} = body), do: body
defp decode_body(body) when is_binary(body), do: Jason.decode!(body)
defp decode_body(_), do: %{}
defp error_description(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, %{"error_description" => desc}} when is_binary(desc) -> desc
{:ok, %{"error" => err}} when is_binary(err) -> err
_ -> "invalid_token"
end
end
defp error_description(_), do: "invalid_token"
# -- App 管理 API(alterminal/bear#28;PAT Bearer、admin 限定)--
@doc """
`GET /api/v1/apps`:列出 App(分頁)。
`params` 為 keyword(`page:`/`per_page:`,可加 `visibility:`/`status:` 過濾)。
成功回 `{:ok, %{"data" => [apps], "page" => n, "per_page" => n, "total" => n}}`。
"""
def apps_list(issuer, token, params \\ []) do
api_request(issuer, token, :get, "/api/v1/apps", params: params)
end
@doc "`GET /api/v1/apps/{id}`:單一 App(`client_secret` 永不回傳)。"
def apps_get(issuer, token, id) do
api_request(issuer, token, :get, "/api/v1/apps/" <> URI.encode(id))
end
@doc """
`POST /api/v1/apps`:建立 App。`method=client_secret` 且未給
`client_secret` 時由伺服器產生,成功回應內含一次性明文
(`%{"data" => app, "client_secret" => secret}`)。
"""
def apps_create(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/apps", json: attrs)
end
@doc "`PUT /api/v1/apps/{id}`:部分更新(只送有給的欄位)。"
def apps_update(issuer, token, id, attrs) do
api_request(issuer, token, :put, "/api/v1/apps/" <> URI.encode(id), json: attrs)
end
@doc """
`POST /api/v1/apps/{id}/rotate-secret`:輪轉 client secret,回應內含
一次性明文。PKCE App 回 422 `%{"error" => "pkce_app"}`。
"""
def apps_rotate_secret(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/rotate-secret",
json: %{}
)
end
@doc "`POST /api/v1/apps/{id}/toggle`:切換 active ↔ inactive。"
def apps_toggle(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/apps/" <> URI.encode(id) <> "/toggle", json: %{})
end
# -- 個人自助 API(alterminal/bear#35;PAT Bearer、任何有效帳號)--
@doc "`GET /api/v1/profile`:完整個人資料(含 MFA 狀態)。"
def profile_get(issuer, token) do
api_request(issuer, token, :get, "/api/v1/profile")
end
@doc "`PUT /api/v1/profile`:更新個人資料欄位(只送有給的欄位)。"
def profile_update(issuer, token, attrs) do
api_request(issuer, token, :put, "/api/v1/profile", json: attrs)
end
@doc """
`POST /api/v1/profile/password`:變更密碼(body:`current_password`、
`new_password`、`password_confirmation`;伺服器驗舊密碼,不因 CLI 放寬)。
"""
def profile_change_password(issuer, token, current_password, new_password) do
api_request(issuer, token, :post, "/api/v1/profile/password",
json: %{
current_password: current_password,
new_password: new_password,
password_confirmation: new_password
}
)
end
@doc "`POST /api/v1/profile/email/request-codes`:寄發 email 變更雙向驗證碼。"
def profile_email_request_codes(issuer, token, new_email) do
api_request(issuer, token, :post, "/api/v1/profile/email/request-codes",
json: %{new_email: new_email}
)
end
@doc """
`POST /api/v1/profile/email`:確認變更 email(body:`new_email`、
`current_code`、`new_code`)。
"""
def profile_email_change(issuer, token, new_email, current_code, new_code) do
api_request(issuer, token, :post, "/api/v1/profile/email",
json: %{new_email: new_email, current_code: current_code, new_code: new_code}
)
end
@doc "`GET /api/v1/profile/sessions`:列出作用中 session(新到舊)。"
def sessions_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/profile/sessions")
end
@doc "`POST /api/v1/profile/sessions/{id}/revoke`:撤銷單一 session。"
def sessions_revoke(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/profile/sessions/" <> URI.encode(id) <> "/revoke",
json: %{}
)
end
@doc """
`POST /api/v1/profile/sessions/revoke-others`:撤銷其他 session
(body `keep` 可省略=全部撤銷)。
"""
def sessions_revoke_others(issuer, token, keep \\ nil) do
api_request(issuer, token, :post, "/api/v1/profile/sessions/revoke-others",
json: %{keep: keep} |> Map.reject(fn {_k, v} -> is_nil(v) end)
)
end
@doc "`GET /api/v1/profile/tokens`:列出未撤銷的 PAT(新到舊)。"
def tokens_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/profile/tokens")
end
@doc """
`POST /api/v1/profile/tokens`:建立 PAT;明文 `token` 只在成功回應
出現一次(body:`name`、`expires_in`=天數或 `never`)。
"""
def tokens_create(issuer, token, name, expires_in) do
api_request(issuer, token, :post, "/api/v1/profile/tokens",
json: %{name: name, expires_in: expires_in}
)
end
@doc "`POST /api/v1/profile/tokens/{id}/revoke`:撤銷單一 PAT。"
def tokens_revoke(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/profile/tokens/" <> URI.encode(id) <> "/revoke",
json: %{}
)
end
@doc """
`POST /api/v1/profile/mfa/setup`(無 body):開始 TOTP 設定,回
`%{"data" => %{"secret" => ..., "otpauth_uri" => ...}}`。已啟用 MFA 回
422 `already_enabled`。
"""
def mfa_setup(issuer, token) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/setup", json: %{})
end
@doc """
`POST /api/v1/profile/mfa/setup/confirm`(body:`code`):確認設定並
啟用 MFA,回一次性 recovery codes。
"""
def mfa_setup_confirm(issuer, token, code) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/setup/confirm", json: %{code: code})
end
@doc """
`POST /api/v1/profile/mfa/disable`(body:`code`=目前 TOTP 或
recovery code):停用 MFA。
"""
def mfa_disable(issuer, token, code) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/disable", json: %{code: code})
end
@doc """
`POST /api/v1/profile/mfa/recovery-codes`(body:`code`):重產一組
一次性 recovery codes(舊組全部失效)。
"""
def mfa_recovery_codes(issuer, token, code) do
api_request(issuer, token, :post, "/api/v1/profile/mfa/recovery-codes", json: %{code: code})
end
# -- 共用請求輔助 --
# 回傳:
# {:ok, body} | {:error, :unauthorized, desc} | {:error, :forbidden, desc}
# | {:error, :not_found, desc} | {:error, :unprocessable_entity, body}
# | {:error, :too_many_requests, body}
# | {:error, :server_error, status} | {:error, :network, reason}
defp api_request(issuer, token, method, path, extra \\ []) do
url = String.trim_trailing(issuer, "/") <> path
opts =
[
headers: [authorization: "Bearer " <> token, accept: "application/json"],
retry: false,
finch: [name: @finch]
]
|> Keyword.merge(extra)
case apply(Req, method, [url, opts]) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, decode_body(body)}
{:ok, %Req.Response{status: 401, body: body}} ->
{:error, :unauthorized, error_description(decode_body(body))}
{:ok, %Req.Response{status: 403, body: body}} ->
{:error, :forbidden, error_description(decode_body(body))}
{:ok, %Req.Response{status: 404, body: body}} ->
{:error, :not_found, error_description(decode_body(body))}
{:ok, %Req.Response{status: 422, body: body}} ->
{:error, :unprocessable_entity, decode_body(body)}
{:ok, %Req.Response{status: 429, body: body}} ->
{:error, :too_many_requests, decode_body(body)}
{:ok, %Req.Response{status: status}} ->
{:error, :server_error, status}
{:error, exception} ->
{:error, :network, Exception.message(exception)}
end
end
@doc "`GET /api/v1/jwks`: list all keys (active and inactive), newest first (no pagination)."
def jwks_list(issuer, token) do
api_request(issuer, token, :get, "/api/v1/jwks")
end
@doc """
`POST /api/v1/jwks`: create a new signing key. attrs: `%{"kid" => "...", "alg" => "RS256"}`
(`alg` optional; RS256/384/512, ES256/384/512). Key material is never serialized.
"""
def jwks_create(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/jwks", json: attrs)
end
@doc "`GET /api/v1/jwks/{id}`: single key (public metadata only)."
def jwks_get(issuer, token, id) do
api_request(issuer, token, :get, "/api/v1/jwks/" <> URI.encode(id))
end
@doc "`POST /api/v1/jwks/{id}/toggle`: switch key between active and inactive."
def jwks_toggle(issuer, token, id) do
api_request(issuer, token, :post, "/api/v1/jwks/" <> URI.encode(id) <> "/toggle", json: %{})
end
# -- Accounts API (alterminal/bear#46; PAT Bearer, admin only) --
@doc """
`GET /api/v1/accounts`: paginated list (newest first; per_page max 100).
Returns `{:ok, %{"data" => [accounts], "page" => n, "per_page" => n, "total" => n}}`.
"""
def accounts_list(issuer, token, params \\ []) do
api_request(issuer, token, :get, "/api/v1/accounts", params: params)
end
@doc """
`POST /api/v1/accounts`: create an account directly (no email verification).
attrs: `%{"email" => "...", "hash_password" => "<plaintext>", "role" => "user|admin"}`
(`role` optional, default `user`).
"""
def accounts_create(issuer, token, attrs) do
api_request(issuer, token, :post, "/api/v1/accounts", json: attrs)
end
@doc "`GET /api/v1/accounts/{id}`: single account (no credential fields)."
def accounts_get(issuer, token, id) do
api_request(issuer, token, :get, "/api/v1/accounts/" <> URI.encode(id))
end
@doc """
`PUT /api/v1/accounts/{id}`: update role; attrs: `%{"role" => "user|admin"}`.
"""
def accounts_update_role(issuer, token, id, attrs) do
api_request(issuer, token, :put, "/api/v1/accounts/" <> URI.encode(id), json: attrs)
end
@doc """
`PUT /api/v1/accounts/{id}/password`: admin sets a new password (no old password
needed); attrs: `%{"hash_password" => "<plaintext>"}`.
"""
def accounts_set_password(issuer, token, id, attrs) do
api_request(issuer, token, :put, "/api/v1/accounts/" <> URI.encode(id) <> "/password",
json: attrs
)
end
@doc "`DELETE /api/v1/accounts/{id}`: hard-delete an account (admin path). Returns `{:ok, %{}}` (204 empty)."
def accounts_delete(issuer, token, id) do
api_request(issuer, token, :delete, "/api/v1/accounts/" <> URI.encode(id))
end
# -- Audit logs API (alterminal/bear#46; PAT Bearer, admin only) --
@doc """
`GET /api/v1/audit-logs`: paginated list (newest first). params: `page:` /
`per_page:` (max 200) / `category:` filter. Returns `{:ok, %{"data" => [entries],
"page" => n, "per_page" => n, "total" => n, "total_pages" => n,
"emails" => %{id => email}}}`.
"""
def audit_logs_list(issuer, token, params \\ []) do
api_request(issuer, token, :get, "/api/v1/audit-logs", params: params)
end
end