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:
1 parent
b22ffee1bb
commit
8f9cdd0a17
35 files changed
+1965
-43
No files matched your search
+70
-3
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user