Files
bear-cli/lib/bear_cli/self_service.ex
T
alex a29b976f13 fix: stdin TTY 偵測改以 fd 0 實際裝置判定,escript 真終端機不再誤判(issue #19)
- 新增 BearCli.TTY:readlink /proc/self/fd/0 判定 pts/tty/console/ptmx;
  無 /proc 平台退回 :io.rows/0
- vault/self_service 的 tty_stdin?、accounts 的 tty? 改用 TTY.stdin_tty?
- 根因::io.rows/0/:io.columns/0 走 io 協定問 group leader,escript
  (noshell standard_io)不支援行列查詢,一律回 enotsup——真終端機被
  誤判成非 TTY,互動指令(vault unlock 等)無法讀 stdin 密碼
  (bear #52 驗收建議 2)
- 非 TTY(pipe/redirect)行為不變:明確提示+退出碼 2
- 新增 tty_test(4 測試);mix precommit 全綠(200 passed)
2026-09-14 19:26:23 +08:00

777 lines
23 KiB
Elixir
Raw 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.SelfService do
@moduledoc """
`bear` 個人自助指令群(docs/commands.md §3.7、issue #11)。
對應伺服器端個人自助 JSON API(alterminal/bear#35,`/api/v1/profile*`、
PAT Bearer、任何有效帳號):profile/password/email/sessions/
tokens/mfa。
- 敏感輸入(密碼、email/MFA 驗證碼)一律互動提示讀取且不回顧,
不接受命令列明文參數;非 TTY 環境 → 退出碼 2。
- 一次性明文(`tokens create` 的 PAT、`mfa` 的 recovery codes)只於
成功當下輸出於 stdout,不寫入憑證檔/log/`--verbose` 輸出。
退出碼:0 成功;1 404/422/429;2 用法錯誤(含非 TTY 的互動輸入);
3 未登入或 401;6 網路/伺服器錯誤;8 權限不足(403)。
"""
alias BearCli.{Api, Config, Credentials, TTY}
@genders ~w(male female other)
# profile set 的可更新欄位:CLI 選項名(atom)→ API 欄位名。
@profile_set_fields [
name: "name",
given_name: "given_name",
family_name: "family_name",
nickname: "nickname",
preferred_username: "preferred_username",
profile: "profile",
picture: "picture",
website: "website",
gender: "gender",
birthdate: "birthdate",
zoneinfo: "zoneinfo",
locale: "locale",
phone_number: "phone_number",
phone_verified: "phone_number_verified",
address_formatted: "address_formatted",
address_street_address: "address_street_address",
address_locality: "address_locality",
address_region: "address_region",
address_postal_code: "address_postal_code",
address_country: "address_country"
]
@url_fields ~w(profile picture website)
# -- 入口 --
@doc """
執行個人自助指令,回傳退出碼。`group` 為 :profile/:password/:email/
:sessions/:tokens/:mfa,`verb` 為子指令 atom,`opts` 為解析後選項。
`opts[:ss_api]` 可注入假 API 模組供測試;`opts[:io]` 可注入互動輸入
模組(實作 `prompt_secret/1`),`opts[:tty?]` 可覆寫 TTY 判定。
"""
def run(group, verb, opts) do
api = opts[:ss_api] || Api
with {:ok, ctx} <- context(opts) do
execute(group, verb, ctx, opts, api)
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- 各指令實作(execute/5 子句群)--
defp execute(:profile, :show, ctx, opts, api) do
case api.profile_get(ctx.issuer, ctx.token) do
{:ok, body} ->
show_profile(body["data"] || %{}, opts)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:profile, :set, ctx, opts, api) do
case build_profile_attrs(opts) do
{:ok, attrs} ->
case api.profile_update(ctx.issuer, ctx.token, attrs) do
{:ok, body} ->
show_profile(body["data"] || %{}, opts)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
{:error, message, code} ->
fail(opts, message, code)
end
end
defp execute(:password, :change, ctx, opts, api) do
with {:ok, {current, new}} <- read_password_prompts(opts) do
case api.profile_change_password(ctx.issuer, ctx.token, current, new) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true}))
else
IO.puts("密碼已變更")
IO.puts("提醒:變更密碼不自動撤銷其他 session;需要時另用 bear sessions revoke-others。")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:email, :change, ctx, opts, api) do
# OptionParser 把 --new EMAIL 正規化為 :new(底線)。
new_email = opts[:new] || opts[:new_email]
with :ok <- require_new_email(new_email),
{:ok, {current_code, new_code}} <- request_and_read_codes(ctx, opts, api, new_email) do
case api.profile_email_change(ctx.issuer, ctx.token, new_email, current_code, new_code) do
{:ok, body} ->
updated = (body["data"] || %{})["email"] || new_email
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, email: updated}))
else
IO.puts("email 已更新:#{updated}")
IO.puts("提醒:bear login 用的 PAT 不綁 email,不受影響。")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:sessions, :list, ctx, opts, api) do
case api.sessions_list(ctx.issuer, ctx.token) do
{:ok, body} ->
sessions = body["data"] || []
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, sessions: sessions}))
else
print_sessions_table(sessions)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:sessions, :revoke, ctx, opts, api) do
id = opts[:id_arg]
with :ok <- require_id(id, "sessions revoke") do
case api.sessions_revoke(ctx.issuer, ctx.token, id) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, revoked: id}))
else
IO.puts("session 已撤銷")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:sessions, :"revoke-others", ctx, opts, api) do
case api.sessions_revoke_others(ctx.issuer, ctx.token) do
{:ok, body} ->
count = (body["data"] || %{})["revoked_count"]
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, revoked: count}))
else
IO.puts("已撤銷 #{count} 個其他 session")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:tokens, :list, ctx, opts, api) do
case api.tokens_list(ctx.issuer, ctx.token) do
{:ok, body} ->
tokens = body["data"] || []
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, tokens: tokens}))
else
print_tokens_table(tokens)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:tokens, :create, ctx, opts, api) do
name = opts[:name]
with {:ok, expires_in} <- parse_expires_in(opts[:expires_in]) do
case api.tokens_create(ctx.issuer, ctx.token, name, expires_in) do
{:ok, body} ->
token = body["data"] || %{}
raw = body["token"]
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, token: token, raw: raw}))
else
expires_at = token["expires_at"]
expiry_note = if expires_at, do: "(#{expires_at} 到期)", else: "(無到期日)"
IO.puts("PAT 已建立:#{token["name"]}#{expiry_note}")
IO.puts("token(只顯示這一次,請立即保存):")
IO.puts(" #{raw}")
IO.puts("提醒:新 PAT 要使用須另以 bear login --token 登入(或設 BEAR_TOKEN)。")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:tokens, :revoke, ctx, opts, api) do
id = opts[:id_arg]
with :ok <- require_id(id, "tokens revoke") do
case api.tokens_revoke(ctx.issuer, ctx.token, id) do
{:ok, body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, revoked: (body["data"] || %{})["revoked"] || id}))
else
IO.puts("PAT 已撤銷")
end
# 撤銷目前憑證用的 PAT 時,後續指令會得 401;本機憑證檔只存 PAT
# 明文、未存 token id,CLI 無法可靠比對,一律提示重新登入的退路。
IO.puts(
:stderr,
"提醒:若撤銷的是本機憑證所用的 PAT,後續指令會得到 401;屆時重新 bear login。"
)
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:mfa, :status, ctx, opts, api) do
case api.profile_get(ctx.issuer, ctx.token) do
{:ok, body} ->
data = body["data"] || %{}
enabled = data["mfa_enabled"] || data["totp_enabled"] || false
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, totp_enabled: enabled}))
else
IO.puts("MFA(TOTP):#{if enabled, do: "已啟用", else: "未啟用"}")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
end
defp execute(:mfa, :setup, ctx, opts, api) do
with {:ok, _data} <- mfa_begin(ctx, opts, api),
{:ok, code} <- prompt_secret(opts, "6 位驗證碼(掃描 QR 後由驗證器產生)") do
case api.mfa_setup_confirm(ctx.issuer, ctx.token, code) do
{:ok, body} ->
codes = (body["data"] || %{})["recovery_codes"] || []
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, recovery_codes: codes}))
else
IO.puts("MFA 已啟用。")
print_recovery_codes(codes)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:mfa, :disable, ctx, opts, api) do
with {:ok, code} <- prompt_secret(opts, "目前的 TOTP 碼或 recovery code") do
case api.mfa_disable(ctx.issuer, ctx.token, code) do
{:ok, _body} ->
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true}))
else
IO.puts("MFA 已停用")
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
defp execute(:mfa, :"recovery-codes", ctx, opts, api) do
with {:ok, code} <- prompt_secret(opts, "目前的 TOTP 碼或 recovery code") do
case api.mfa_recovery_codes(ctx.issuer, ctx.token, code) do
{:ok, body} ->
codes = (body["data"] || %{})["recovery_codes"] || []
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, recovery_codes: codes}))
else
print_recovery_codes(codes)
end
0
{:error, _kind, _detail} = error ->
fail_api(opts, ctx, error)
end
else
{:error, message, code} -> fail(opts, message, code)
end
end
# -- 共用背景(token/issuer)--
defp context(opts) do
if token = Config.env_token() do
# BEAR_TOKEN 優先於憑證檔(不讀檔、不寫檔)。
{:ok, %{token: token, issuer: resolve_issuer(opts, nil)}}
else
case Credentials.load() do
{:ok, creds} ->
token = creds["access_token"]
if blank?(token) do
{:error, "憑證檔缺少 access_token,請重新 bear login", 7}
else
{:ok, %{token: token, issuer: resolve_issuer(opts, creds["issuer"])}}
end
:error ->
{:error, "未登入(請先執行 bear login)", 3}
end
end
end
defp resolve_issuer(opts, stored_issuer) do
opts[:issuer] || stored_issuer || Config.resolve_issuer(nil, opts[:config])
end
# -- profile set 屬性組裝與本地驗證 --
# 只送有給的欄位(§3.7.2);非法值本地檢查 → 用法錯誤 2,不發請求。
defp build_profile_attrs(opts) do
given =
@profile_set_fields
|> Enum.flat_map(fn {opt_key, api_key} ->
case Map.get(opts, opt_key) do
v when is_binary(v) and v != "" -> [{api_key, v}]
true -> [{api_key, true}]
_other -> []
end
end)
|> Map.new()
cond do
given == %{} ->
{:error, "profile set 未給任何欄位(可用選項見 bear --help)", 2}
invalid_gender?(given) ->
{:error, "--gender 不接受的值:#{given["gender"]}(可用:#{Enum.join(@genders, "、")})", 2}
true ->
check_url_fields(given)
end
end
defp invalid_gender?(%{"gender" => g}), do: g not in @genders
defp invalid_gender?(_), do: false
# URL 類欄位本地檢查 http(s)://(§3.7.2)。
defp check_url_fields(given) do
bad =
Enum.find(@url_fields, fn field ->
case given[field] do
v when is_binary(v) ->
not (String.starts_with?(v, "http://") or String.starts_with?(v, "https://"))
_ ->
false
end
end)
case bad do
nil -> {:ok, given}
field -> {:error, "--#{String.replace(field, "_", "-")} 需為 http(s):// 開頭的 URL", 2}
end
end
# -- password 互動輸入 --
defp read_password_prompts(opts) do
with {:ok, current} <- prompt_secret(opts, "目前密碼"),
{:ok, new} <- prompt_secret(opts, "新密碼"),
{:ok, confirm} <- prompt_secret(opts, "確認新密碼") do
if new == confirm do
{:ok, {current, new}}
else
# 兩次新密碼不一致 → 本地檢查,不發請求(§3.7.3)。
{:error, "兩次輸入的新密碼不一致", 2}
end
end
end
# -- email 流程輔助 --
defp require_new_email(nil), do: {:error, "email change 缺少 --new EMAIL", 2}
defp require_new_email(""), do: {:error, "email change 缺少 --new EMAIL", 2}
defp require_new_email(_), do: :ok
defp request_and_read_codes(ctx, opts, api, new_email) do
case api.profile_email_request_codes(ctx.issuer, ctx.token, new_email) do
{:ok, _body} ->
IO.puts(:stderr, "驗證碼已寄至現有信箱與 #{new_email},請查收。")
with {:ok, current_code} <- prompt_secret(opts, "現有信箱驗證碼"),
{:ok, new_code} <- prompt_secret(opts, "新信箱驗證碼") do
{:ok, {current_code, new_code}}
end
{:error, _kind, _detail} = error ->
{message, code} = format_api_error(ctx, error)
{:error, message, code}
end
end
# -- tokens create 選項 --
# --expires-in:正整數天數或 never;預設 30(§3.7.6)。
defp parse_expires_in(nil), do: {:ok, "30"}
defp parse_expires_in("never"), do: {:ok, "never"}
defp parse_expires_in(value) when is_binary(value) do
case Integer.parse(value) do
{n, ""} when n > 0 -> {:ok, value}
_ -> {:error, "--expires-in 需為正整數天數或 never", 2}
end
end
# -- mfa setup 第一步 --
defp mfa_begin(ctx, opts, api) do
case api.mfa_setup(ctx.issuer, ctx.token) do
{:ok, body} ->
data = body["data"] || %{}
secret = data["secret"]
uri = data["otpauth_uri"]
if opts[:json] do
# 兩步互動流程:第一步資訊走 stderr(stdout 保留給最終結果 JSON,
# 維持單一 JSON 物件的機器可讀輸出)。
IO.puts(
:stderr,
Jason.encode!(%{step: "setup", secret: secret, otpauth_uri: uri})
)
else
IO.puts("請用驗證器(Google Authenticator 等)掃描 QR,或手動輸入:")
IO.puts(" otpauth URI:#{uri}")
IO.puts(" secret(Base32):#{secret}")
print_qr(uri)
end
{:ok, data}
{:error, _kind, _detail} = error ->
{message, code} = format_api_error(ctx, error)
{:error, message, code}
end
end
# -- 互動輸入 --
# 敏感輸入一律互動提示讀取且不回顯;非 TTY → 退出碼 2(§3.7 群組共通)。
# 測試可注入 opts[:io](實作 prompt_secret/1)與 opts[:tty?]。
defp prompt_secret(opts, label) do
tty? = Map.get(opts, :tty?, true)
io = opts[:io]
cond do
not tty? ->
{:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2}
io ->
case io.prompt_secret(label) do
:eof -> {:error, "未讀到輸入(#{label})", 2}
{:ok, value} -> {:ok, value}
end
true ->
# 非 TTY(pipe/redirect)讀不到互動輸入 → 退出碼 2(§3.7 群組共通)。
if tty_stdin?() do
case IO.gets("#{label}:") do
:eof -> {:error, "未讀到輸入(#{label})", 2}
line -> {:ok, String.trim(line)}
end
else
{:error, "此指令需要互動輸入(#{label}),請在終端機執行", 2}
end
end
end
# stdin 是否為 TTY:優先以 fd 0 實際裝置判定(escript 環境 :io.rows/0 一律
# enotsup,真終端機會被誤判;見 BearCli.TTY,issue #19)。
defp tty_stdin? do
TTY.stdin_tty?()
end
# -- 輸出 --
@profile_fields ~w(id email role name given_name family_name nickname preferred_username profile picture website gender birthdate zoneinfo locale phone_number phone_number_verified address mfa_enabled created_at updated_at)
defp show_profile(profile, opts) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: true, profile: profile}))
else
Enum.each(@profile_fields, fn key ->
if Map.has_key?(profile, key) do
IO.puts("#{String.pad_trailing(key, 22)}: #{format_value(profile[key])}")
end
end)
end
end
defp format_value(nil), do: "(無)"
defp format_value(true), do: "true"
defp format_value(false), do: "false"
defp format_value(v) when is_binary(v), do: v
defp format_value(v) when is_number(v), do: to_string(v)
defp format_value(v), do: Jason.encode!(v)
@sessions_headers ["ID", "IP", "USER AGENT", "CREATED", "LAST SEEN"]
defp print_sessions_table(sessions) do
rows =
Enum.map(sessions, fn s ->
[
s["id"] || "",
s["ip"] || "",
truncate(s["user_agent"] || "", 28),
s["created_at"] || "",
s["last_seen_at"] || ""
]
end)
print_table(@sessions_headers, rows)
end
@tokens_headers ["ID", "NAME", "SCOPE", "CREATED", "EXPIRES", "LAST USED", "STATUS"]
defp print_tokens_table(tokens) do
rows =
Enum.map(tokens, fn t ->
[
t["id"] || "",
t["name"] || "",
t["scope"] || "",
t["created_at"] || "",
t["expires_at"] || "(無到期)",
t["last_used_at"] || "(未使用)",
token_status(t)
]
end)
print_table(@tokens_headers, rows)
end
# list 僅回未撤銷 token;STATUS 依 expires_at 本地判定(§3.7.6)。
defp token_status(%{"expires_at" => nil}), do: "active"
defp token_status(%{"expires_at" => expires_at}) when is_binary(expires_at) do
case DateTime.from_iso8601(expires_at) do
{:ok, dt, _} ->
if DateTime.compare(dt, DateTime.utc_now()) == :gt, do: "active", else: "expired"
_ ->
"active"
end
end
defp token_status(_), do: "active"
defp print_table(headers, rows) do
widths =
Enum.with_index(headers, fn _h, i ->
Enum.max([
String.length(Enum.at(headers, i))
| Enum.map(rows, &String.length(Enum.at(&1, i)))
])
end)
render_row = fn cells ->
cells
|> Enum.with_index()
|> Enum.map(fn {cell, i} -> String.pad_trailing(to_string(cell), Enum.at(widths, i)) end)
|> Enum.join(" ")
|> String.trim_trailing()
end
IO.puts(render_row.(headers))
case rows do
[] -> IO.puts("(無資料)")
_ -> Enum.each(rows, fn cells -> IO.puts(render_row.(cells)) end)
end
end
defp truncate(text, max) do
if String.length(text) > max do
String.slice(text, 0, max - 1) <> "…"
else
text
end
end
# recovery codes 只顯示一次(同 PAT 明文處理)。
defp print_recovery_codes([]), do: IO.puts("(伺服器未回傳 recovery codes)")
defp print_recovery_codes(codes) do
IO.puts("recovery codes(只顯示這一次,請立即保存;舊組全部失效):")
Enum.each(codes, fn code ->
IO.puts(" #{code}")
end)
end
# QR 由 CLI 本地生成(eqrcode),不經伺服器(§3.7.7)。產生失敗不阻斷流程,
# 只印 URI 與 secret 供手動輸入。
defp print_qr(uri) do
qr = EQRCode.encode(uri)
m = qr.matrix
IO.puts("QR(掃碼加入驗證器):")
for i <- 0..(tuple_size(m) - 1) do
row =
for j <- 0..(tuple_size(m) - 1) do
if elem(elem(m, i), j) == 1, do: "██", else: " "
end
IO.puts(Enum.join(row))
end
rescue
_ -> IO.puts(:stderr, "(QR 產生失敗,請手動輸入上列 otpauth URI/secret)")
end
# -- 錯誤處理 --
defp fail_api(opts, ctx, error) do
{message, code} = format_api_error(ctx, error)
fail(opts, message, code)
end
# API 錯誤 → 依 §3.7 群組共通退出碼輸出,回傳退出碼。
defp format_api_error(_ctx, {:error, :unauthorized, desc}) do
{"token 無效或已過期(#{desc}),請重新 bear login", 3}
end
defp format_api_error(_ctx, {:error, :forbidden, desc}) do
{"權限不足(HTTP 403:#{desc})", 8}
end
defp format_api_error(_ctx, {:error, :not_found, _desc}) do
{"找不到目標(HTTP 404)", 1}
end
# 語意錯誤(same_email/email_taken/invalid_code/already_enabled 等)。
defp format_api_error(
_ctx,
{:error, :unprocessable_entity, %{"error" => error, "error_description" => desc}}
) do
{"#{error}:#{desc}", 1}
end
# 422 逐欄位驗證錯誤(changeset errors)。
defp format_api_error(_ctx, {:error, :unprocessable_entity, %{"errors" => errors}}) do
details =
errors
|> Enum.map(fn {field, messages} ->
"#{field}: #{Enum.join(List.wrap(messages), "、")}"
end)
|> Enum.join(";")
if details == "" do
{"驗證失敗(HTTP 422)", 1}
else
{"驗證失敗(HTTP 422):#{details}", 1}
end
end
defp format_api_error(_ctx, {:error, :unprocessable_entity, _body}) do
{"驗證失敗(HTTP 422)", 1}
end
# 伺服器限流(email 驗證碼)→ 退出碼 1,提示稍後再試(§3.7.4)。
defp format_api_error(_ctx, {:error, :too_many_requests, _body}) do
{"請求過於頻繁(HTTP 429),請稍後再試", 1}
end
defp format_api_error(_ctx, {:error, :server_error, status}) do
{"伺服器回應 #{status}", 6}
end
defp format_api_error(ctx, {:error, :network, reason}) do
{"無法連線到 #{ctx.issuer}:#{reason}", 6}
end
defp fail(opts, message, code) do
if opts[:json] do
IO.puts(Jason.encode!(%{ok: false, error: message, code: code}))
else
IO.puts(:stderr, "錯誤:#{message}")
end
code
end
defp require_id(nil, command), do: {:error, "#{command} 缺少 <id>", 2}
defp require_id("", command), do: {:error, "#{command} 缺少 <id>", 2}
defp require_id(_, _command), do: :ok
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
end