- 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)
269 lines
9.3 KiB
Elixir
269 lines
9.3 KiB
Elixir
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
|