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} 缺少 ", 2} defp require_id("", command), do: {:error, "#{command} 缺少 ", 2} defp require_id(_, _command), do: :ok defp blank?(nil), do: true defp blank?(""), do: true defp blank?(_), do: false end