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

+22 -5
View File
@@ -18,7 +18,7 @@ import (
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
se, _ := r.Context().Value(keySession).(*model.Session)
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
return nil
}
@@ -136,6 +136,11 @@ func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.
if err := decodeJSON(r, &in); err != nil {
return err
}
// A token outlives the session: with two-factor login, making one
// needs a recent code.
if err := s.stepUp(r, p); err != nil {
return err
}
if strings.TrimSpace(in.Name) == "" {
return badRequest("a token needs a name")
}
@@ -297,6 +302,9 @@ func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Pri
if t.RevokedAt != nil {
return conflict("token %d is revoked; create a new one", id)
}
if err := s.stepUp(r, p); err != nil {
return err
}
now := time.Now()
var exp *time.Time
switch {
@@ -365,7 +373,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Princ
}
out := []api.User{}
for _, u := range us {
out = append(out, presentUser(u))
out = append(out, s.userOut(u))
}
writeJSON(w, http.StatusOK, out)
return nil
@@ -401,6 +409,9 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := applyUserInput(u, &in); err != nil {
return err
}
if err := s.stepUp(r, p); err != nil {
return err
}
if err := s.store.CreateUser(r.Context(), u); err != nil {
if errors.Is(err, store.ErrConflict) {
return conflict("user %s already exists", u.Username)
@@ -408,7 +419,7 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
return err
}
s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
writeJSON(w, http.StatusCreated, presentUser(u))
writeJSON(w, http.StatusCreated, s.userOut(u))
return nil
}
@@ -467,7 +478,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p *auth.Princip
if u.ID != p.User.ID && !p.IsAdmin() {
return forbidden("only administrators can see other users")
}
writeJSON(w, http.StatusOK, presentUser(u))
writeJSON(w, http.StatusOK, s.userOut(u))
return nil
}
@@ -493,6 +504,9 @@ func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
if err := applyUserInput(u, &in); err != nil {
return err
}
if err := s.stepUp(r, p); err != nil {
return err
}
if wasAdmin && (!u.Admin || u.Disabled) {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
@@ -508,7 +522,7 @@ func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
s.store.DeleteUserSessions(r.Context(), u.ID, "")
}
s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
writeJSON(w, http.StatusOK, presentUser(u))
writeJSON(w, http.StatusOK, s.userOut(u))
return nil
}
@@ -523,6 +537,9 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Prin
if u.ID == p.User.ID {
return conflict("you can't delete yourself")
}
if err := s.stepUp(r, p); err != nil {
return err
}
if u.Admin && !u.Disabled {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator", u.Username)