Files
abrown38andClaude Opus 5.5 8f9cdd0a17 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>
2026-10-09 14:12:24 +00:00

508 lines
17 KiB
Go

package server
// Two-factor login with TOTP (docs/permissions.md#two-factor-login).
//
// Logging in with a password is the first step for a user with TOTP: it
// returns a short-lived challenge instead of a session, and
// POST /auth/login/totp with the challenge and a code (or a recovery code)
// creates the session. A session remembers when it last proved a code;
// actions that would let a stolen password outlive the second factor
// (creating or rotating API tokens, changing users, resetting someone's
// TOTP) need one from the last stepUpWindow, or a code in the X-Jarl-TOTP
// header. API tokens and the local socket are not affected: a token is
// its own secret, and can't answer a prompt.
//
// totp.require in jarl.yaml makes TOTP compulsory for administrators or
// for everyone. A user who must use it but hasn't set it up gets a session
// that can only set it up (setupOnly).
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/totp"
)
const (
// challengeLifetime is how long the second login step may take.
challengeLifetime = 5 * time.Minute
// challengeTries is how many wrong codes one challenge allows.
challengeTries = 5
// stepUpWindow is how long a proved code counts for actions that
// ask for a recent one.
stepUpWindow = 5 * time.Minute
// TOTPHeader carries a code for an action that asks for a recent one.
TOTPHeader = "X-Jarl-TOTP"
)
// pendingLogins are logins waiting for their second step, by the hash of
// the challenge.
type pendingLogins struct {
mu sync.Mutex
m map[string]*pendingLogin
}
type pendingLogin struct {
userID int64
expires time.Time
tries int
}
func (c *pendingLogins) add(userID int64, now time.Time) string {
id, _ := auth.NewSessionID()
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil {
c.m = map[string]*pendingLogin{}
}
for k, v := range c.m {
if now.After(v.expires) {
delete(c.m, k)
}
}
c.m[auth.HashSecret(id)] = &pendingLogin{userID: userID, expires: now.Add(challengeLifetime)}
return id
}
func (c *pendingLogins) get(id string, now time.Time) *pendingLogin {
c.mu.Lock()
defer c.mu.Unlock()
ch := c.m[auth.HashSecret(id)]
if ch == nil || now.After(ch.expires) {
return nil
}
return ch
}
// fail counts a wrong code; the challenge ends after challengeTries.
func (c *pendingLogins) fail(id string) (left int) {
c.mu.Lock()
defer c.mu.Unlock()
h := auth.HashSecret(id)
ch := c.m[h]
if ch == nil {
return 0
}
ch.tries++
if ch.tries >= challengeTries {
delete(c.m, h)
return 0
}
return challengeTries - ch.tries
}
func (c *pendingLogins) done(id string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.m, auth.HashSecret(id))
}
// totpRequired reports whether totp.require makes TOTP compulsory for u.
// Service accounts (no password) can't log in, so it never applies to
// them.
func (s *Server) totpRequired(u *model.User) bool {
if u == nil || u.ID == 0 || u.PasswordHash == "" {
return false
}
switch s.cfg.TOTPRequire {
case "all":
return true
case "admins":
return u.Admin
}
return false
}
// setupOnly reports whether a session may only set up TOTP: the user
// must use it and hasn't set it up yet.
func (s *Server) setupOnly(p *auth.Principal) bool {
return p.Method == "session" && s.totpRequired(p.User) && !p.User.HasTOTP()
}
// setupOnlyAllowed are the requests a setup-only session may make.
func setupOnlyAllowed(r *http.Request) bool {
path := strings.TrimPrefix(r.URL.Path, api.Prefix)
switch {
case path == "/auth/whoami", path == "/auth/logout", path == "/me/password":
return true
case path == "/me/totp" || strings.HasPrefix(path, "/me/totp/"):
return true
}
return false
}
var errSetupOnly = &httpError{http.StatusForbidden, "totp_setup_required", "this server requires two-factor login for your account: set it up under Account first"}
// checkCode checks a TOTP code, or with recovery set also a recovery
// code, for u, and uses it up. It returns how it was proved ("totp" or
// "recovery code").
func (s *Server) checkCode(ctx context.Context, u *model.User, code string, recovery bool) (string, error) {
if !u.HasTOTP() {
return "", errors.New("two-factor login is not on")
}
if totp.IsCode(code) {
step, ok := totp.Verify(u.TOTPSecret, code, time.Now(), u.TOTPLastStep)
if ok {
if ok, err := s.store.UseTOTPStep(ctx, u.ID, step); err != nil {
return "", err
} else if ok {
u.TOTPLastStep = step
return "totp", nil
}
}
return "", errors.New("wrong or already used code")
}
if recovery && code != "" {
ok, err := s.store.UseRecoveryCode(ctx, u.ID, auth.HashSecret(totp.NormalizeRecovery(code)))
if err != nil {
return "", err
}
if ok {
return "recovery code", nil
}
return "", errors.New("wrong or already used recovery code")
}
return "", errors.New("wrong code")
}
// loginChallenge answers a correct password of a user with TOTP: the
// challenge for the second step.
func (s *Server) loginChallenge(w http.ResponseWriter, u *model.User) {
writeJSON(w, http.StatusOK, api.LoginChallenge{TOTPRequired: true, Challenge: s.pendingLogins.add(u.ID, time.Now())})
}
// loginTOTP is the second login step: POST /auth/login/totp.
func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) error {
var in api.LoginTOTP
if err := decodeJSON(r, &in); err != nil {
return err
}
ip := s.clientIP(r)
now := time.Now()
ch := s.pendingLogins.get(in.Challenge, now)
if ch == nil {
return &httpError{http.StatusUnauthorized, "login_expired", "the login took too long or had too many wrong codes; log in again"}
}
u, err := s.store.GetUser(r.Context(), ch.userID)
if err != nil || u.Disabled {
s.pendingLogins.done(in.Challenge)
return &httpError{http.StatusUnauthorized, "login_failed", "your account is not available"}
}
userKey := "user:" + strings.ToLower(u.Username)
who := &auth.Principal{User: u, IP: ip, Agent: r.UserAgent(), Method: "session"}
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user", u.ID, u.Username, "denied", "too many failed attempts")
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
}
how, err := s.checkCode(r.Context(), u, in.Code, true)
if err != nil {
s.limiter.fail("ip:"+ip, userKey)
left := s.pendingLogins.fail(in.Challenge)
s.eng.Audit(r.Context(), who, "auth.totp_failed", "user", u.ID, u.Username, "failure", err.Error())
msg := "wrong code: use the current code from your authenticator app, or a recovery code"
if left == 0 {
return &httpError{http.StatusUnauthorized, "login_expired", "too many wrong codes; log in again"}
}
return &httpError{http.StatusUnauthorized, "totp_invalid", msg}
}
s.pendingLogins.done(in.Challenge)
s.limiter.reset(userKey)
detail := "with an authenticator code"
if how == "recovery code" {
n, _ := s.store.RecoveryCodesLeft(r.Context(), u.ID)
detail = fmt.Sprintf("with a recovery code (%d left)", n)
}
return s.startSession(w, r, u, &now, detail)
}
// startSession creates a session for u after a successful login.
// totpAt is when it proved a code (nil without TOTP).
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *model.User, totpAt *time.Time, detail string) error {
ip := s.clientIP(r)
id, csrf := auth.NewSessionID()
now := time.Now()
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent(), TOTPAt: totpAt}
if err := s.store.CreateSession(r.Context(), se); err != nil {
return err
}
s.store.TouchLogin(r.Context(), u.ID)
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", detail)
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
return nil
}
// stepUp makes sure a session of a user with TOTP proved a code within
// stepUpWindow, or proves one now from the X-Jarl-TOTP header. Tokens,
// the local socket and users without TOTP pass.
func (s *Server) stepUp(r *http.Request, p *auth.Principal) error {
if p.Method != "session" || !p.User.HasTOTP() {
return nil
}
se, _ := r.Context().Value(keySession).(*model.Session)
if se == nil {
return nil
}
now := time.Now()
if se.TOTPAt != nil && now.Sub(*se.TOTPAt) < stepUpWindow {
return nil
}
code := r.Header.Get(TOTPHeader)
if code == "" {
return &httpError{http.StatusForbidden, "totp_step_up", "this needs a current code from your authenticator app"}
}
ip := s.clientIP(r)
userKey := "user:" + strings.ToLower(p.User.Username)
if s.limiter.blocked(userKey, failsPerKey) {
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many wrong codes; wait a few minutes and try again"}
}
if _, err := s.checkCode(r.Context(), p.User, code, false); err != nil {
s.limiter.fail("ip:"+ip, userKey)
s.eng.Audit(r.Context(), p, "auth.totp_failed", "user", p.User.ID, p.User.Username, "failure", "step-up: "+err.Error())
return &httpError{http.StatusForbidden, "totp_invalid", "wrong or already used code; enter the current code from your authenticator app"}
}
s.store.SetSessionTOTP(r.Context(), se.IDHash, now)
se.TOTPAt = &now
return nil
}
func (s *Server) totpStatus(ctx context.Context, u *model.User) api.TOTPStatus {
st := api.TOTPStatus{Enabled: u.HasTOTP(), EnabledAt: u.TOTPEnabledAt, Required: s.totpRequired(u), Policy: s.cfg.TOTPRequire}
if st.Enabled {
st.RecoveryCodesLeft, _ = s.store.RecoveryCodesLeft(ctx, u.ID)
}
return st
}
// needSession refuses TOTP changes made with an API token or on the
// local socket: they belong to a person in the web UI.
func needSession(p *auth.Principal) error {
if p.Method != "session" {
return forbidden("two-factor login is set up in the web UI (Account), not with an API token")
}
return nil
}
// GET /me/totp
func (s *Server) getOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if p.User.ID == 0 {
return badRequest("%s on the local socket isn't a Jarl user", p.Name())
}
writeJSON(w, http.StatusOK, s.totpStatus(r.Context(), p.User))
return nil
}
// POST /me/totp/setup starts setting up (or moving to a new app): a new
// secret, shown as a QR code, that becomes active once a code from it is
// confirmed. It needs the password, and with TOTP already on a recent
// code too.
func (s *Server) setupOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
var in api.TOTPSetupRequest
if err := decodeJSON(r, &in); err != nil {
return err
}
u := p.User
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Password) {
s.limiter.fail("user:" + strings.ToLower(u.Username))
s.eng.Audit(r.Context(), p, "user.totp_setup", "user", u.ID, u.Username, "failure", "wrong password")
return &httpError{http.StatusForbidden, "wrong_password", "the password is wrong"}
}
if err := s.stepUp(r, p); err != nil {
return err
}
secret := totp.NewSecret()
if err := s.store.SetTOTPPending(r.Context(), u.ID, secret); err != nil {
return err
}
issuer := s.cfg.TOTPIssuer
uri := totp.URI(issuer, u.Username, secret)
qr, err := totp.QRDataURI(uri)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, api.TOTPSetup{Secret: secret, URI: uri, QR: qr, Issuer: issuer, Account: u.Username})
return nil
}
// POST /me/totp/enable confirms the secret from setup with a code from
// the app. It returns the recovery codes (shown once) and logs out the
// user's other sessions.
func (s *Server) enableOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
var in api.TOTPCode
if err := decodeJSON(r, &in); err != nil {
return err
}
u, err := s.store.GetUser(r.Context(), p.User.ID)
if err != nil {
return err
}
if u.TOTPPending == "" {
return conflict("start the setup first (POST /me/totp/setup)")
}
userKey := "user:" + strings.ToLower(u.Username)
if s.limiter.blocked(userKey, failsPerKey) {
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many wrong codes; wait a few minutes and try again"}
}
step, ok := totp.Verify(u.TOTPPending, in.Code, time.Now(), 0)
if !ok {
s.limiter.fail(userKey)
return &httpError{http.StatusBadRequest, "totp_invalid", "the code doesn't match: check that the app shows a 6-digit code for " + s.cfg.TOTPIssuer + " and that this device's clock is right, then try the current code"}
}
codes := totp.NewRecoveryCodes(totp.RecoveryCodes)
var hashes []string
for _, c := range codes {
hashes = append(hashes, auth.HashSecret(totp.NormalizeRecovery(c)))
}
replaced := u.HasTOTP()
if err := s.store.EnableTOTP(r.Context(), u.ID, u.TOTPPending, step, hashes); err != nil {
return err
}
se, _ := r.Context().Value(keySession).(*model.Session)
keep := ""
if se != nil {
keep = se.IDHash
s.store.SetSessionTOTP(r.Context(), se.IDHash, time.Now())
}
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
detail := "turned on"
if replaced {
detail = "moved to a new authenticator"
}
s.eng.Audit(r.Context(), p, "user.totp_enable", "user", u.ID, u.Username, "success", detail)
writeJSON(w, http.StatusOK, api.RecoveryCodes{Codes: codes})
return nil
}
// POST /me/totp/disable turns TOTP off, with the password and a code (or
// a recovery code). Not when totp.require applies to the user.
func (s *Server) disableOwnTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
var in api.TOTPDisable
if err := decodeJSON(r, &in); err != nil {
return err
}
u := p.User
if !u.HasTOTP() {
return conflict("two-factor login is not on")
}
if s.totpRequired(u) {
return conflict("this server requires two-factor login for your account (totp.require in jarl.yaml); to move to a new phone, set it up again instead")
}
userKey := "user:" + strings.ToLower(u.Username)
if s.limiter.blocked(userKey, failsPerKey) {
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; wait a few minutes and try again"}
}
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Password) {
s.limiter.fail(userKey)
return &httpError{http.StatusForbidden, "wrong_password", "the password is wrong"}
}
if _, err := s.checkCode(r.Context(), u, in.Code, true); err != nil {
s.limiter.fail(userKey)
return &httpError{http.StatusForbidden, "totp_invalid", err.Error()}
}
if err := s.store.DisableTOTP(r.Context(), u.ID); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "user.totp_disable", "user", u.ID, u.Username, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
// POST /me/totp/recovery-codes replaces the recovery codes (needs a
// recent code).
func (s *Server) newRecoveryCodes(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := needSession(p); err != nil {
return err
}
if !p.User.HasTOTP() {
return conflict("two-factor login is not on")
}
if err := s.stepUp(r, p); err != nil {
return err
}
codes := totp.NewRecoveryCodes(totp.RecoveryCodes)
var hashes []string
for _, c := range codes {
hashes = append(hashes, auth.HashSecret(totp.NormalizeRecovery(c)))
}
if err := s.store.ReplaceRecoveryCodes(r.Context(), p.User.ID, hashes); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "user.recovery_codes", "user", p.User.ID, p.User.Username, "success", "new recovery codes")
writeJSON(w, http.StatusOK, api.RecoveryCodes{Codes: codes})
return nil
}
// DELETE /users/{id}/totp: an administrator turns off someone's TOTP (a
// lost phone without recovery codes). Their sessions end; at the next
// login they log in with the password and, if totp.require applies, must
// set it up again.
func (s *Server) resetUserTOTP(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
u, err := s.userByPath(r)
if err != nil {
return err
}
if err := s.stepUp(r, p); err != nil {
return err
}
if !u.HasTOTP() && u.TOTPPending == "" {
return conflict("%s doesn't use two-factor login", u.Username)
}
if err := s.store.DisableTOTP(r.Context(), u.ID); err != nil {
return err
}
s.store.DeleteUserSessions(r.Context(), u.ID, "")
s.eng.Audit(r.Context(), p, "user.totp_reset", "user", u.ID, u.Username, "success", "")
u.TOTPSecret, u.TOTPEnabledAt = "", nil
writeJSON(w, http.StatusOK, s.userOut(u))
return nil
}
// totpOverview is the dashboard's view of two-factor login.
func (s *Server) totpOverview(ctx context.Context) *api.TOTPOverview {
us, err := s.store.ListUsers(ctx)
if err != nil {
return nil
}
o := &api.TOTPOverview{Policy: s.cfg.TOTPRequire, Missing: []string{}}
admins, shared := 0, ""
for _, u := range us {
if u.Disabled {
continue
}
if s.totpRequired(u) && !u.HasTOTP() {
o.Missing = append(o.Missing, u.Username)
}
if u.Admin {
admins++
if strings.EqualFold(u.Username, "admin") && u.PasswordHash != "" {
shared = u.Username
}
}
}
if shared != "" && admins > 1 {
o.SharedAdmin = shared
}
return o
}