feat(cli): pad auth 2fa setup and disable; disable asks for the password, or a code on an account without one (TASK-403) (#1927)

This commit is contained in:
xarmian
2026-10-08 22:36:44 -04:00
committed by GitHub
parent 136fac6609
commit a896edf067
5 changed files with 740 additions and 0 deletions
+2
View File
@@ -101,6 +101,8 @@ pad auth whoami # Show current user
pad auth logout # Sign out
pad auth reset-password user@example.com # Recover a locked-out account (run ON THE SERVER HOST)
pad auth reset-password user@example.com --temp-password # ...set a temp password instead of a reset link
pad auth 2fa setup # Turn on 2FA: shows the otpauth URI + secret once, asks for a code, prints recovery codes once
pad auth 2fa disable # Turn off 2FA: asks for the password, or (no-password account) a code or recovery code; keeps this CLI signed in
# Credentials stored in ~/.pad/credentials.json (0600 permissions)
# CLI auto-attaches auth token to all API requests
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/PerpetualSoftware/pad/internal/cli"
"github.com/PerpetualSoftware/pad/internal/config"
)
// `pad auth 2fa setup|disable` (TASK-403). The server routes have existed
// since PR #77; these are the CLI doors onto them. The TOTP secret is shown
// once, in setup's own instructions, and nowhere else: it never reaches an
// error message, a log line or the credentials file.
// maxTOTPVerifyAttempts bounds setup's retry on a code that did not match
// (a typo, or a code that rolled over while it was typed).
const maxTOTPVerifyAttempts = 3
func twoFactorCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "2fa",
Short: "Manage two-factor authentication for your account",
RunE: unknownSubcommandRun,
}
cmd.AddCommand(twoFactorSetupCmd(), twoFactorDisableCmd())
return cmd
}
func twoFactorSetupCmd() *cobra.Command {
return &cobra.Command{
Use: "setup",
Short: "Turn on two-factor authentication (authenticator app)",
Long: `Turn on two-factor authentication.
Pad shows an otpauth:// URI and its secret for your authenticator app, asks
for a code from the app, and then prints your recovery codes. The recovery
codes are shown once: store them somewhere safe.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
return runTwoFactorSetup(client, bufio.NewReader(os.Stdin), cmd.OutOrStdout())
},
}
}
func twoFactorDisableCmd() *cobra.Command {
return &cobra.Command{
Use: "disable",
Short: "Turn off two-factor authentication",
Long: `Turn off two-factor authentication.
An account with a password confirms with the password. An account without
one (signed up with GitHub, Google or Apple) confirms with a code from the
authenticator app or a recovery code. Turning 2FA off signs out your other
sessions; this one is kept.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
client, cfg := getClient()
return runTwoFactorDisable(client, bufio.NewReader(os.Stdin), cmd.OutOrStdout(), func(token string) error {
return replaceSessionToken(cfg, token)
})
},
}
}
// interactiveSessionHint rewrites the server's refusal for an API token into
// what to do about it.
func interactiveSessionHint(err error) error {
var apiErr *cli.APIError
if errors.As(err, &apiErr) && apiErr.Code == "forbidden" && strings.Contains(apiErr.Message, "API token") {
return fmt.Errorf("2FA can only be changed from a signed-in session, not an API token: run 'pad auth login' (and unset PAD_TOKEN if it is set)")
}
return err
}
func runTwoFactorSetup(client *cli.Client, in *bufio.Reader, out io.Writer) error {
sec, err := client.GetAccountSecurity()
if err != nil {
return err
}
if sec.TOTPEnabled {
return fmt.Errorf("2FA is already on; to set it up again, run 'pad auth 2fa disable' first")
}
setup, err := client.TOTPSetup()
if err != nil {
return interactiveSessionHint(err)
}
fmt.Fprintln(out, "Add Pad to your authenticator app. Open this URI on a device that has the app,")
fmt.Fprintln(out, "or paste it into the app:")
fmt.Fprintf(out, "\n %s\n\n", setup.URL)
fmt.Fprintln(out, "Or enter the secret by hand:")
fmt.Fprintf(out, "\n %s\n\n", setup.Secret)
var verified *cli.TOTPVerifyResponse
for attempt := 1; ; attempt++ {
fmt.Fprint(out, "Code from the app: ")
line, _ := in.ReadString('\n')
code := strings.TrimSpace(line)
if code == "" {
return fmt.Errorf("no code entered; 2FA is still off (run 'pad auth 2fa setup' again)")
}
verified, err = client.TOTPVerify(setup.Secret, code)
if err == nil {
break
}
var apiErr *cli.APIError
if errors.As(err, &apiErr) && apiErr.Code == "invalid_code" && attempt < maxTOTPVerifyAttempts {
fmt.Fprintln(out, "That code didn't match. Enter the code the app shows now.")
continue
}
if errors.As(err, &apiErr) && apiErr.Code == "invalid_code" {
return fmt.Errorf("the code didn't match %d times; 2FA is still off. Check the device's clock, then run 'pad auth 2fa setup' again", maxTOTPVerifyAttempts)
}
return interactiveSessionHint(err)
}
green := color.New(color.FgGreen).SprintFunc()
fmt.Fprintf(out, "\n%s 2FA is on.\n\n", green("✓"))
fmt.Fprintln(out, "Recovery codes. Each one signs you in once if you lose the authenticator.")
fmt.Fprintln(out, "They are shown only now: store them somewhere safe.")
fmt.Fprintln(out)
for _, c := range verified.RecoveryCodes {
fmt.Fprintf(out, " %s\n", c)
}
fmt.Fprintln(out)
return nil
}
// runTwoFactorDisable asks for whichever proof the account takes: its
// password, or, for an account with none (#1879), a current code or a
// recovery code. saveToken stores the replacement session the server issues.
func runTwoFactorDisable(client *cli.Client, in *bufio.Reader, out io.Writer, saveToken func(string) error) error {
sec, err := client.GetAccountSecurity()
if err != nil {
return err
}
if !sec.TOTPEnabled {
return fmt.Errorf("2FA is not on for this account")
}
var password, code, recovery string
if sec.PasswordSet == nil || *sec.PasswordSet {
fmt.Fprint(out, "Password: ")
password, err = readPasswordFrom(in)
if err != nil {
return fmt.Errorf("read password: %w", err)
}
fmt.Fprintln(out)
if password == "" {
return fmt.Errorf("no password entered; 2FA is still on")
}
} else {
fmt.Fprintln(out, "This account has no password. Confirm with a code from your authenticator app,")
fmt.Fprintln(out, "or one of your recovery codes.")
fmt.Fprint(out, "Code or recovery code: ")
line, _ := in.ReadString('\n')
entered := strings.TrimSpace(line)
if entered == "" {
return fmt.Errorf("no code entered; 2FA is still on")
}
// The same split login makes: six digits is a code from the app.
if len(entered) == 6 && isAllDigits(entered) {
code = entered
} else {
recovery = entered
}
}
resp, err := client.TOTPDisable(password, code, recovery)
if err != nil {
var apiErr *cli.APIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case "invalid_password":
return fmt.Errorf("incorrect password; 2FA is still on")
case "invalid_code":
return fmt.Errorf("that code didn't work; 2FA is still on")
}
}
return interactiveSessionHint(err)
}
green := color.New(color.FgGreen).SprintFunc()
fmt.Fprintf(out, "%s 2FA is off. Your other sessions were signed out.\n", green("✓"))
if resp.Token == "" {
fmt.Fprintln(out, "Run 'pad auth login' to sign this CLI in again.")
return nil
}
if err := saveToken(resp.Token); err != nil {
fmt.Fprintf(out, "This CLI's session was replaced, and saving the new one failed (%v).\nRun 'pad auth login' to sign in again.\n", err)
}
return nil
}
// replaceSessionToken swaps the saved session for the one the server issued
// when it rotated sessions. A PAD_TOKEN session lives in the environment,
// which the CLI cannot rewrite, so that case is reported instead.
func replaceSessionToken(cfg *config.Config, token string) error {
if os.Getenv("PAD_TOKEN") != "" {
return fmt.Errorf("this session came from PAD_TOKEN, which the CLI cannot update")
}
store, err := cli.LoadStore()
if err != nil {
return err
}
creds := store.Get(cfg.BaseURL())
if creds == nil {
return fmt.Errorf("no saved credentials for %s", cfg.BaseURL())
}
creds.Token = token
store.Set(cfg.BaseURL(), creds)
return store.Save()
}
+449
View File
@@ -0,0 +1,449 @@
package main
// TASK-403: `pad auth 2fa setup|disable`, driven against a real server (the
// store, handlers and TOTP check are the production ones) through an
// httptest listener, so every branch below is the server's real answer.
import (
"bufio"
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/pquerna/otp/totp"
"github.com/PerpetualSoftware/pad/internal/cli"
"github.com/PerpetualSoftware/pad/internal/config"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/server"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
const twoFAPassword = "correct-horse-battery-staple"
type twoFAFixture struct {
store *store.Store
url string
}
func newTwoFAFixture(t *testing.T) *twoFAFixture {
t.Helper()
s := storetest.NewSQLite(t)
srv := server.New(s)
t.Cleanup(srv.Stop)
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
return &twoFAFixture{store: s, url: ts.URL}
}
// session returns a CLI client signed in as user with a fresh session.
func (f *twoFAFixture) session(t *testing.T, user *models.User) (*cli.Client, string) {
t.Helper()
tok, err := f.store.CreateSession(user.ID, "cli-test", "127.0.0.1", "", time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return f.clientWith(tok), tok
}
func (f *twoFAFixture) clientWith(token string) *cli.Client {
c := cli.NewClientFromURL(f.url)
c.SetAuthToken(token)
return c
}
func (f *twoFAFixture) passwordUser(t *testing.T, email string) *models.User {
t.Helper()
u, err := f.store.CreateUser(models.UserCreate{Email: email, Name: "Pw User", Password: twoFAPassword})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u
}
func (f *twoFAFixture) oauthUser(t *testing.T, email string) *models.User {
t.Helper()
u, err := f.store.CreateOAuthUser(email, "OAuth Only", "")
if err != nil {
t.Fatalf("CreateOAuthUser: %v", err)
}
if u.HasPassword() {
t.Fatal("fixture: an OAuth user must have no password")
}
return u
}
func (f *twoFAFixture) totpEnabled(t *testing.T, id string) bool {
t.Helper()
u, err := f.store.GetUser(id)
if err != nil || u == nil {
t.Fatalf("GetUser: %v", err)
}
return u.TOTPEnabled
}
var secretLine = regexp.MustCompile(`(?m)^ ([A-Z2-7]{16,})$`)
// enable runs setup with a code computed from the secret it printed, so the
// test types what a user would read off the app.
func enable(t *testing.T, client *cli.Client) (secret string, codes []string, out string) {
t.Helper()
pr, pw := ioPipe()
var buf bytes.Buffer
w := &codeTypingWriter{buf: &buf, pw: pw}
err := runTwoFactorSetup(client, bufio.NewReader(pr), w)
if err != nil {
t.Fatalf("setup: %v\n%s", err, buf.String())
}
out = buf.String()
m := secretLine.FindStringSubmatch(out)
if m == nil {
t.Fatalf("setup printed no secret:\n%s", out)
}
return m[1], recoveryCodesIn(out), out
}
func recoveryCodesIn(out string) []string {
_, after, ok := strings.Cut(out, "store them somewhere safe.")
if !ok {
return nil
}
var codes []string
for _, l := range strings.Split(after, "\n") {
if c := strings.TrimSpace(l); c != "" {
codes = append(codes, c)
}
}
return codes
}
func TestTASK403_SetupEnablesAndShowsRecoveryCodesOnce(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "setup@example.com")
client, _ := f.session(t, u)
secret, codes, out := enable(t, client)
if !f.totpEnabled(t, u.ID) {
t.Fatal("2FA is not enabled after setup")
}
if !strings.Contains(out, "otpauth://totp/") {
t.Fatalf("no otpauth URI in the output:\n%s", out)
}
if len(codes) == 0 {
t.Fatalf("no recovery codes printed:\n%s", out)
}
for _, c := range codes {
if n := strings.Count(out, c); n != 1 {
t.Fatalf("recovery code %q printed %d times, want once", c, n)
}
}
// The secret appears in the URI and on its own line, and nowhere else.
if n := strings.Count(out, secret); n != 2 {
t.Fatalf("secret printed %d times, want 2 (the URI and the manual line):\n%s", n, out)
}
}
func TestTASK403_SetupRetriesAWrongCode(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "retry@example.com")
client, _ := f.session(t, u)
pr, pw := ioPipe()
var buf bytes.Buffer
w := &codeTypingWriter{buf: &buf, pw: pw, wrongFirst: 1}
if err := runTwoFactorSetup(client, bufio.NewReader(pr), w); err != nil {
t.Fatalf("setup: %v\n%s", err, buf.String())
}
if !strings.Contains(buf.String(), "That code didn't match") {
t.Fatalf("no retry prompt after a wrong code:\n%s", buf.String())
}
if !f.totpEnabled(t, u.ID) {
t.Fatal("the right code on the second try did not enable 2FA")
}
}
func TestTASK403_SetupGivesUpAfterThreeWrongCodesWithoutLeakingTheSecret(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "giveup@example.com")
client, _ := f.session(t, u)
pr, pw := ioPipe()
var buf bytes.Buffer
w := &codeTypingWriter{buf: &buf, pw: pw, wrongFirst: maxTOTPVerifyAttempts}
err := runTwoFactorSetup(client, bufio.NewReader(pr), w)
if err == nil || !strings.Contains(err.Error(), "still off") {
t.Fatalf("err = %v, want a refusal saying 2FA is still off", err)
}
if f.totpEnabled(t, u.ID) {
t.Fatal("2FA was enabled by wrong codes")
}
m := secretLine.FindStringSubmatch(buf.String())
if m == nil {
t.Fatal("no secret displayed")
}
if strings.Contains(err.Error(), m[1]) {
t.Fatal("the error message carries the TOTP secret")
}
}
func TestTASK403_SetupEmptyCodeLeavesItOff(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "empty@example.com")
client, _ := f.session(t, u)
var out bytes.Buffer
err := runTwoFactorSetup(client, bufio.NewReader(strings.NewReader("\n")), &out)
if err == nil || !strings.Contains(err.Error(), "still off") {
t.Fatalf("err = %v, want 'still off'", err)
}
if f.totpEnabled(t, u.ID) {
t.Fatal("2FA enabled with no code")
}
}
func TestTASK403_SetupWhenAlreadyOnRefusesAndKeepsTheSecret(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "already@example.com")
client, _ := f.session(t, u)
enable(t, client)
before, _ := f.store.GetUser(u.ID)
var out bytes.Buffer
err := runTwoFactorSetup(client, bufio.NewReader(strings.NewReader("")), &out)
if err == nil || !strings.Contains(err.Error(), "already on") {
t.Fatalf("err = %v, want 'already on'", err)
}
if strings.Contains(out.String(), "otpauth://") {
t.Fatalf("a new secret was shown for an account that has 2FA on:\n%s", out.String())
}
after, _ := f.store.GetUser(u.ID)
if after.TOTPSecret != before.TOTPSecret {
t.Fatal("the stored secret changed")
}
}
func TestTASK403_APITokenIsToldToSignIn(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "pat@example.com")
tok, err := f.store.CreateAPIToken(u.ID, models.APITokenCreate{Name: "t"}, 30, 0)
if err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
var out bytes.Buffer
err = runTwoFactorSetup(f.clientWith(tok.Token), bufio.NewReader(strings.NewReader("")), &out)
if err == nil || !strings.Contains(err.Error(), "pad auth login") {
t.Fatalf("err = %v, want the sign-in hint", err)
}
}
func TestTASK403_DisableWithPassword(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "disable@example.com")
client, oldTok := f.session(t, u)
enable(t, client)
// A wrong password leaves it on.
var out bytes.Buffer
err := runTwoFactorDisable(client, bufio.NewReader(strings.NewReader("wrong-password\n")), &out, func(string) error {
t.Fatal("saveToken called on a refused disable")
return nil
})
if err == nil || !strings.Contains(err.Error(), "incorrect password") {
t.Fatalf("err = %v, want 'incorrect password'", err)
}
if !f.totpEnabled(t, u.ID) {
t.Fatal("a wrong password disabled 2FA")
}
if !strings.Contains(out.String(), "Password: ") {
t.Fatalf("a password account was not asked for its password:\n%s", out.String())
}
// The right one turns it off and hands back a working session.
var saved string
out.Reset()
if err := runTwoFactorDisable(client, bufio.NewReader(strings.NewReader(twoFAPassword+"\n")), &out, func(tok string) error {
saved = tok
return nil
}); err != nil {
t.Fatalf("disable: %v", err)
}
if f.totpEnabled(t, u.ID) {
t.Fatal("2FA still on")
}
if saved == "" || saved == oldTok {
t.Fatalf("saved token %q, want a new session", saved)
}
if _, err := f.clientWith(saved).GetAccountSecurity(); err != nil {
t.Fatalf("the replacement session does not work: %v", err)
}
if _, err := f.clientWith(oldTok).GetAccountSecurity(); err == nil {
t.Fatal("the old session still works after the rotation, so the saved token was never needed: this test proves nothing")
}
}
func TestTASK403_DisableWithoutPasswordTakesACode(t *testing.T) {
f := newTwoFAFixture(t)
u := f.oauthUser(t, "oauth-code@example.com")
client, _ := f.session(t, u)
secret, _, _ := enable(t, client)
// A wrong code leaves it on.
var out bytes.Buffer
err := runTwoFactorDisable(client, bufio.NewReader(strings.NewReader("000000\n")), &out, func(string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "didn't work") {
t.Fatalf("err = %v, want the code refusal", err)
}
if !f.totpEnabled(t, u.ID) {
t.Fatal("a wrong code disabled 2FA")
}
if strings.Contains(out.String(), "Password: ") || !strings.Contains(out.String(), "no password") {
t.Fatalf("an account with no password was asked for one:\n%s", out.String())
}
// A current code turns it off. Login's single-use claim refuses a code
// already spent in this window, so take the next one.
code, err := totp.GenerateCode(secret, time.Now().Add(30*time.Second))
if err != nil {
t.Fatal(err)
}
out.Reset()
if err := runTwoFactorDisable(client, bufio.NewReader(strings.NewReader(code+"\n")), &out, func(string) error { return nil }); err != nil {
t.Fatalf("disable with a code: %v\n%s", err, out.String())
}
if f.totpEnabled(t, u.ID) {
t.Fatal("2FA still on")
}
}
func TestTASK403_DisableWithoutPasswordTakesARecoveryCode(t *testing.T) {
f := newTwoFAFixture(t)
u := f.oauthUser(t, "oauth-recovery@example.com")
client, _ := f.session(t, u)
_, codes, _ := enable(t, client)
if len(codes) == 0 {
t.Fatal("no recovery codes")
}
var out bytes.Buffer
if err := runTwoFactorDisable(client, bufio.NewReader(strings.NewReader(codes[0]+"\n")), &out, func(string) error { return nil }); err != nil {
t.Fatalf("disable with a recovery code: %v\n%s", err, out.String())
}
if f.totpEnabled(t, u.ID) {
t.Fatal("2FA still on")
}
}
func TestTASK403_DisableWhenOffRefuses(t *testing.T) {
f := newTwoFAFixture(t)
u := f.passwordUser(t, "off@example.com")
client, _ := f.session(t, u)
var out bytes.Buffer
err := runTwoFactorDisable(client, bufio.NewReader(strings.NewReader("")), &out, func(string) error { return nil })
if err == nil || !strings.Contains(err.Error(), "not on") {
t.Fatalf("err = %v, want 'not on'", err)
}
}
// A server that predates `password_set` took only a password, so the CLI
// asks for one when the key is missing.
func TestTASK403_DisableAgainstAnOlderServerAsksForThePassword(t *testing.T) {
var sent map[string]string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/auth/me":
_ = json.NewEncoder(w).Encode(map[string]any{"id": "u1", "totp_enabled": true})
case "/api/v1/auth/2fa/disable":
_ = json.NewDecoder(r.Body).Decode(&sent)
_ = json.NewEncoder(w).Encode(map[string]any{"enabled": false, "token": "padsess_new"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
c := cli.NewClientFromURL(ts.URL)
c.SetAuthToken("padsess_old")
var out bytes.Buffer
if err := runTwoFactorDisable(c, bufio.NewReader(strings.NewReader("pw\n")), &out, func(string) error { return nil }); err != nil {
t.Fatalf("disable: %v", err)
}
if sent["password"] != "pw" || sent["code"] != "" || sent["recovery_code"] != "" {
t.Fatalf("sent %v, want only the password", sent)
}
}
func ioPipe() (*io.PipeReader, *io.PipeWriter) { return io.Pipe() }
// codeTypingWriter stands in for the user at setup's prompt: each time the
// prompt is written it types a code into stdin, the first wrongFirst of them
// wrong (the current code with its first digit changed, so never valid).
type codeTypingWriter struct {
buf *bytes.Buffer
pw *io.PipeWriter
wrongFirst int
prompts int
}
func (w *codeTypingWriter) Write(p []byte) (int, error) {
n, _ := w.buf.Write(p)
if strings.Contains(string(p), "Code from the app: ") {
w.prompts++
m := secretLine.FindStringSubmatch(w.buf.String())
code := "000000"
if m != nil {
if c, err := totp.GenerateCode(m[1], time.Now()); err == nil {
code = c
}
}
if w.prompts <= w.wrongFirst {
code = wrongCode(code)
}
go func() { _, _ = w.pw.Write([]byte(code + "\n")) }()
}
return n, nil
}
// wrongCode changes a code's first digit, so it can never be the valid one.
func wrongCode(code string) string {
first := (code[0]-'0'+1)%10 + '0'
return string(first) + code[1:]
}
// Disable's rotation replaces this CLI's session; the saved credential must
// follow it, and only the token changes.
func TestTASK403_ReplaceSessionTokenKeepsTheRestOfTheCredential(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("PAD_TOKEN", "")
cfg := &config.Config{URL: "http://pad.test:7777"}
st, err := cli.LoadStore()
if err != nil {
t.Fatal(err)
}
st.Set(cfg.BaseURL(), &cli.Credentials{Token: "padsess_old", UserID: "u1", Email: "a@example.com", Name: "A"})
if err := st.Save(); err != nil {
t.Fatal(err)
}
if err := replaceSessionToken(cfg, "padsess_new"); err != nil {
t.Fatalf("replace: %v", err)
}
st, _ = cli.LoadStore()
got := st.Get(cfg.BaseURL())
if got == nil || got.Token != "padsess_new" || got.UserID != "u1" || got.Email != "a@example.com" || got.Name != "A" {
t.Fatalf("saved %+v, want the new token and the rest unchanged", got)
}
// A PAD_TOKEN session is in the environment; it is reported, not written.
t.Setenv("PAD_TOKEN", "padsess_env")
if err := replaceSessionToken(cfg, "padsess_newer"); err == nil || !strings.Contains(err.Error(), "PAD_TOKEN") {
t.Fatalf("err = %v, want the PAD_TOKEN refusal", err)
}
st, _ = cli.LoadStore()
if st.Get(cfg.BaseURL()).Token != "padsess_new" {
t.Fatal("the saved credential was overwritten for a PAD_TOKEN session")
}
}
+1
View File
@@ -30,6 +30,7 @@ func authCmd() *cobra.Command {
logoutCmd(),
whoamiCmd(),
resetPasswordCmd(),
twoFactorCmd(),
)
return cmd
}
+65
View File
@@ -1546,6 +1546,71 @@ func (c *Client) LoginVerify2FA(challengeToken, code, recoveryCode string) (*Log
return &result, err
}
// AccountSecurity is the part of /auth/me that 2FA management reads.
// PasswordSet is a pointer because a server that predates it omits the key:
// nil then means "assume a password", the only answer such a server took.
type AccountSecurity struct {
TOTPEnabled bool `json:"totp_enabled"`
PasswordSet *bool `json:"password_set"`
}
// GetAccountSecurity reads whether 2FA is on and whether the account has a
// password (TASK-403).
func (c *Client) GetAccountSecurity() (*AccountSecurity, error) {
var result AccountSecurity
return &result, c.get("/auth/me", &result)
}
// TOTPSetupResponse is POST /auth/2fa/setup's answer: a fresh secret (not yet
// enabled) and its otpauth:// URI.
type TOTPSetupResponse struct {
Secret string `json:"secret"`
URL string `json:"url"`
}
// TOTPSetup starts enabling 2FA (TASK-403).
func (c *Client) TOTPSetup() (*TOTPSetupResponse, error) {
var result TOTPSetupResponse
return &result, c.post("/auth/2fa/setup", nil, &result)
}
// TOTPVerifyResponse carries the recovery codes, which the server returns
// exactly once.
type TOTPVerifyResponse struct {
Enabled bool `json:"enabled"`
RecoveryCodes []string `json:"recovery_codes"`
}
// TOTPVerify finishes enabling 2FA with a code from the authenticator app.
func (c *Client) TOTPVerify(secret, code string) (*TOTPVerifyResponse, error) {
var result TOTPVerifyResponse
return &result, c.post("/auth/2fa/verify", map[string]string{"secret": secret, "code": code}, &result)
}
// TOTPDisableResponse carries the replacement session token: disabling 2FA
// rotates the caller's sessions, so the old bearer stops working.
type TOTPDisableResponse struct {
Enabled bool `json:"enabled"`
Token string `json:"token"`
}
// TOTPDisable turns 2FA off. An account with a password proves itself with
// it; one without sends a current code or a recovery code (#1879).
func (c *Client) TOTPDisable(password, code, recoveryCode string) (*TOTPDisableResponse, error) {
body := map[string]string{}
if password != "" {
body["password"] = password
}
if code != "" {
body["code"] = code
}
if recoveryCode != "" {
body["recovery_code"] = recoveryCode
}
var result TOTPDisableResponse
return &result, c.post("/auth/2fa/disable", body, &result)
}
// Bootstrap creates the first admin account on a fresh instance.
func (c *Client) Bootstrap(email, name, password string) (*LoginResponse, error) {
return c.BootstrapWithToken(email, name, password, "")