Files
bear-cli/lib/bear_cli/api.ex
iris 62fd6b730e feat: 實作個人自助指令群 profile/password/email/sessions/tokens/mfa(issue #11)
- lib/bear_cli/self_service.ex:六個子群組(profile show/set、password change、
  email change、sessions list/revoke/revoke-others、tokens list/create/revoke、
  mfa status/setup/disable/recovery-codes),對接 alterminal/bear#38 的
  /api/v1/profile* JSON API(PAT Bearer、任何有效帳號)
- 敏感輸入(密碼/驗證碼)互動提示讀取,非 TTY → 退出碼 2;
  一次性明文(PAT 明文、recovery codes)僅於成功當下輸出
- mfa setup:otpauth URI 本地生成 ASCII QR(新增 eqrcode 依賴),
  確認走 /mfa/setup/confirm(依 #38 實作)
- Api 模組新增 profile 系列端點與 429 分流;CLI 分派接上六個指令群組
- docs/commands.md §3.7 對齊 #38 實際 API(sessions 欄位、tokens create
  無 --scope、mfa setup/confirm、email 需重帶 new_email);§7/§8/§9 與
  README 同步更新
- 測試 50 例(fake API 注入):解析、輸出、退出碼、互動輸入、錯誤分流;
  mix precommit 全綠(114 passed)
2026-09-09 22:50:53 +08:00

269 lines
9.3 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。
"""
@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
end