mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func authCmd() *cobra.Command {
|
||||
logoutCmd(),
|
||||
whoamiCmd(),
|
||||
resetPasswordCmd(),
|
||||
twoFactorCmd(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
Reference in New Issue
Block a user