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

+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
}