Add two-factor login (TOTP) with recovery codes

Users can set up an authenticator app from their account page; login
then asks for a code after the password, with one-time recovery codes
as a fallback. Sensitive actions (tokens, user changes, 2FA resets)
need a code from the last 5 minutes. totp.require (none/admins/all)
forces setup, and admins can reset a user's 2FA from the UI, the jarl
CLI or jarld on the server. The dashboard and jarl status flag users
missing 2FA and a shared "admin" account still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 14:12:24 +00:00
1 parent b22ffee1bb
commit 8f9cdd0a17
35 files changed
+1965 -43

No files matched your search

+70 -3
View File
@@ -200,6 +200,14 @@ func statusCmd() *cobra.Command {
for _, t := range resp.ExpiringTokens {
fmt.Fprintf(w, "Token:\t%s/%s (id %d) expires %s\n", t.Username, t.Name, t.ID, fmtExpiry(t.ExpiresAt, nil))
}
if o := resp.TOTP; o != nil {
if len(o.Missing) > 0 {
fmt.Fprintf(w, "2FA:\trequired (%s) but not set up by %s\n", o.Policy, strings.Join(o.Missing, ", "))
}
if o.SharedAdmin != "" {
fmt.Fprintf(w, "Accounts:\tthe first account %q is still active next to personal administrator accounts; disable it if it is shared ('jarl user update %s --disabled')\n", o.SharedAdmin, o.SharedAdmin)
}
}
if len(st.Problems) > 0 {
w.(interface{ Flush() error }).Flush()
fmt.Println("Problems:")
@@ -333,6 +341,19 @@ func userID(c *client.Client, name string) (int64, error) {
return 0, fmt.Errorf("no user %q", name)
}
// twoFA describes a user's two-factor login.
func twoFA(u api.User) string {
switch {
case u.TOTPEnabledAt != nil:
return "on since " + fmtTime(u.TOTPEnabledAt)
case u.TOTPRequired:
return "REQUIRED, not set up (they can only set it up until they do)"
case !u.HasPassword:
return "-"
}
return "off"
}
func printUser(w io.Writer, u api.User) {
fmt.Fprintf(w, "User:\t%s (id %d)\n", u.Username, u.ID)
role := "user"
@@ -346,6 +367,7 @@ func printUser(w io.Writer, u api.User) {
role += ", no password (tokens only)"
}
fmt.Fprintf(w, "Role:\t%s\n", role)
fmt.Fprintf(w, "2FA:\t%s\n", twoFA(u))
for i, g := range u.GrantStrings {
label := ""
if i == 0 {
@@ -384,9 +406,18 @@ func userCmd() *cobra.Command {
return err
}
return output(users, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\tGRANTS\tLAST LOGIN")
fmt.Fprintln(w, "ID\tUSER\tADMIN\tDISABLED\t2FA\tGRANTS\tLAST LOGIN")
for _, u := range users {
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
tfa := "off"
switch {
case u.TOTPEnabledAt != nil:
tfa = "on"
case u.TOTPRequired:
tfa = "MISSING"
case !u.HasPassword:
tfa = "-"
}
fmt.Fprintf(w, "%d\t%s\t%v\t%v\t%s\t%s\t%s\n", u.ID, u.Username, u.Admin, u.Disabled, tfa, strings.Join(u.GrantStrings, "; "), fmtTime(u.LastLoginAt))
}
})
},
@@ -548,7 +579,43 @@ func userCmd() *cobra.Command {
},
}
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
cmd.AddCommand(list, show, create, update, del)
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
totpReset := &cobra.Command{
Use: "reset USER",
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
Long: `Turns off a user's two-factor login, deletes their recovery codes and logs
them out. They log in with the password alone next time and can set it up
again under Account (they must, if totp.require applies to them).
Users set up two-factor login themselves, in the web UI. Without any
administrator able to log in, use 'sudo -u jarl jarld user totp reset USER'
on the server.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Turn off two-factor login of "+args[0]+"?", yes) {
return fmt.Errorf("canceled")
}
c, err := newClient()
if err != nil {
return err
}
id, err := userID(c, args[0])
if err != nil {
return err
}
var u api.User
if err := c.Do(ctx(), "DELETE", fmt.Sprintf("/users/%d/totp", id), nil, nil, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("two-factor login of %s is off\n", u.Username)
}
return nil
},
}
totpReset.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
totpCmd.AddCommand(totpReset)
cmd.AddCommand(list, show, create, update, del, totpCmd)
return cmd
}
+40 -2
View File
@@ -297,6 +297,7 @@ func serveCmd() *cobra.Command {
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
Version: version.Version, Stats: collector, Socket: a.cfg.Socket,
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
@@ -464,7 +465,7 @@ Jarl starts empty. Either import your existing BIND configuration with
return nil
},
}
cmd.Flags().StringVarP(&username, "user", "u", "admin", "administrator username")
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the first administrator's username: best your own name, as each person should have their own account")
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
cmd.Flags().BoolVar(&withToken, "token", false, "also create an API token for the CLI")
cmd.Flags().StringSliceVar(&nameservers, "nameservers", nil, "default name servers for new zones (e.g. ns1.example.com.)")
@@ -610,6 +611,9 @@ func userCmd() *cobra.Command {
if !u.HasPassword {
flags = append(flags, "no password")
}
if u.HasTOTP() {
flags = append(flags, "2fa")
}
var gs []string
for _, g := range u.Grants {
gs = append(gs, auth.FormatGrant(g))
@@ -619,6 +623,40 @@ func userCmd() *cobra.Command {
return nil
},
}
cmd.AddCommand(add, passwd, token, list)
totpCmd := &cobra.Command{Use: "totp", Short: "Two-factor login of a user"}
totpReset := &cobra.Command{
Use: "reset NAME",
Short: "Turn off a user's two-factor login (a lost phone without recovery codes)",
Long: `Turns off a user's two-factor login and deletes their recovery codes, and
logs them out. They log in with the password alone next time; if
totp.require applies to them, they must then set it up again before doing
anything else. Works with jarld stopped too.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
a, err := openApp(true)
if err != nil {
return err
}
defer a.close()
ctx := context.Background()
u, err := a.store.GetUserByName(ctx, args[0])
if err != nil {
return fmt.Errorf("user %s: %w", args[0], err)
}
if !u.HasTOTP() && u.TOTPPending == "" {
fmt.Printf("%s doesn't use two-factor login.\n", u.Username)
return nil
}
if err := a.store.DisableTOTP(ctx, u.ID); err != nil {
return err
}
a.store.DeleteUserSessions(ctx, u.ID, "")
a.eng.Audit(ctx, &auth.Principal{User: &model.User{Username: "local"}, Method: "local"}, "user.totp_reset", "user", u.ID, u.Username, "success", "jarld user totp reset")
fmt.Printf("Two-factor login of %s is off; they log in with the password and can set it up again under Account.\n", u.Username)
return nil
},
}
totpCmd.AddCommand(totpReset)
cmd.AddCommand(add, passwd, token, list, totpCmd)
return cmd
}