Merge branch 'totp'

Both branches added database migration 3: DNSSEC (merged first) keeps 3
and TOTP becomes migration 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 15:17:12 +00:00
commit a95dffb471
35 files changed
+1963 -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
@@ -306,6 +306,7 @@ func serveCmd() *cobra.Command {
Version: version.Version, Stats: collector, Socket: a.cfg.Socket,
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
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,
@@ -473,7 +474,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.)")
@@ -619,6 +620,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))
@@ -628,6 +632,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
}