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

+79
View File
@@ -73,6 +73,9 @@ type Option struct {
type User struct {
model.User
GrantStrings []string `json:"grant_strings"`
// TOTPRequired says that totp.require in jarl.yaml makes two-factor
// login compulsory for the user.
TOTPRequired bool `json:"totp_required,omitempty"`
}
// Token is an API token. Secret is only set when the token is created.
@@ -91,6 +94,67 @@ type Whoami struct {
Permissions []string `json:"permissions"`
Admin bool `json:"admin"`
Version string `json:"version"`
// TOTP is the user's two-factor login (sessions only).
TOTP *TOTPStatus `json:"totp,omitempty"`
// TOTPSetupRequired: the user must set up two-factor login before
// doing anything else (totp.require).
TOTPSetupRequired bool `json:"totp_setup_required,omitempty"`
}
// LoginChallenge answers a correct password of a user with two-factor
// login: send a code with the challenge to POST /auth/login/totp.
type LoginChallenge struct {
TOTPRequired bool `json:"totp_required"`
Challenge string `json:"challenge"`
}
// LoginTOTP is the second login step.
type LoginTOTP struct {
Challenge string `json:"challenge"`
// Code is the current code from the authenticator app, or a recovery
// code.
Code string `json:"code"`
}
// TOTPStatus is a user's two-factor login (GET /me/totp).
type TOTPStatus struct {
Enabled bool `json:"enabled"`
EnabledAt *time.Time `json:"enabled_at,omitempty"`
Required bool `json:"required"`
RecoveryCodesLeft int `json:"recovery_codes_left"`
// Policy is totp.require: none, admins or all.
Policy string `json:"policy"`
}
// TOTPSetupRequest starts setting up two-factor login.
type TOTPSetupRequest struct {
Password string `json:"password"`
}
// TOTPSetup is a new secret to put into an authenticator app: QR is the
// QR code of URI, as an image data: URI.
type TOTPSetup struct {
Secret string `json:"secret"`
URI string `json:"uri"`
QR string `json:"qr"`
Issuer string `json:"issuer"`
Account string `json:"account"`
}
// TOTPCode confirms a setup.
type TOTPCode struct {
Code string `json:"code"`
}
// TOTPDisable turns two-factor login off.
type TOTPDisable struct {
Password string `json:"password"`
Code string `json:"code"`
}
// RecoveryCodes are new recovery codes, shown once.
type RecoveryCodes struct {
Codes []string `json:"recovery_codes"`
}
// LoginRequest logs in.
@@ -163,6 +227,21 @@ type StatusResponse struct {
ExpiringTokens []Token `json:"expiring_tokens"`
// Backup is the state of database backups (administrators only).
Backup *BackupStatus `json:"backup,omitempty"`
// TOTP is about two-factor login across users (administrators only).
TOTP *TOTPOverview `json:"totp,omitempty"`
}
// TOTPOverview is what the dashboard reports about two-factor login.
type TOTPOverview struct {
// Policy is totp.require: none, admins or all.
Policy string `json:"policy"`
// Missing are enabled users who must use two-factor login and
// haven't set it up (they can do nothing else until they do).
Missing []string `json:"missing"`
// SharedAdmin is set when an enabled administrator called "admin"
// (the usual first account) exists next to other enabled
// administrators: it is probably shared, and worth disabling.
SharedAdmin string `json:"shared_admin,omitempty"`
}
// TokenExpiresHeader is sent with responses to a token that expires
+41
View File
@@ -30,6 +30,7 @@ type Config struct {
StagingDir string `yaml:"staging_dir"`
Log Log `yaml:"log"`
Session Session `yaml:"session"`
TOTP TOTP `yaml:"totp"`
BIND BIND `yaml:"bind"`
Audit Audit `yaml:"audit"`
Backup Backup `yaml:"backup"`
@@ -150,6 +151,28 @@ type Session struct {
}
// BIND says where BIND's files and tools are.
// TOTP configures two-factor login to the web UI (docs/permissions.md).
type TOTP struct {
// Require is who must use it: none (each user decides), admins or
// all (every user who logs in with a password).
Require string `yaml:"require"`
// Issuer is the name authenticator apps show for the account ("" is
// "Jarl <hostname>").
Issuer string `yaml:"issuer"`
}
// IssuerName is Issuer, or "Jarl <short hostname>".
func (t TOTP) IssuerName() string {
if t.Issuer != "" {
return t.Issuer
}
h, _ := os.Hostname()
if h, _, _ = strings.Cut(h, "."); h == "" {
return "Jarl"
}
return "Jarl " + h
}
type BIND struct {
MainConfig string `yaml:"main_config"`
ConfigDir string `yaml:"config_dir"`
@@ -254,6 +277,7 @@ func Default() *Config {
StagingDir: "/var/lib/jarl/staging",
Log: Log{Level: "info", Format: "text"},
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
TOTP: TOTP{Require: "none"},
BIND: BIND{
MainConfig: "/etc/bind/named.conf",
ConfigDir: "/etc/bind/jarl",
@@ -328,6 +352,14 @@ func (c *Config) check() error {
default:
return fmt.Errorf("session.cookie_secure must be auto, always or never")
}
switch c.TOTP.Require {
case "none", "admins", "all":
default:
return fmt.Errorf("totp.require must be none, admins or all")
}
if len(c.TOTP.Issuer) > 64 || strings.ContainsAny(c.TOTP.Issuer, ":") {
return fmt.Errorf("totp.issuer must be at most 64 characters, without ':'")
}
if err := c.Stats.check(); err != nil {
return fmt.Errorf("stats: %w", err)
}
@@ -468,6 +500,15 @@ session:
idle_timeout: 2h # ...or after this long without activity
cookie_secure: auto # auto, always or never
# Two-factor login to the web UI with an authenticator app (TOTP). Each
# user can turn it on under Account. require makes it compulsory: none,
# admins, or all (every user with a password). A user who must use it and
# hasn't set it up can only set it up after logging in. API tokens and
# the local socket are not affected. See docs/permissions.md.
totp:
require: none
issuer: "" # the name in the app; "" is "Jarl <hostname>"
bind:
# The file named loads. 'jarld adopt' makes it include Jarl's config.
main_config: /etc/bind/named.conf
+12
View File
@@ -70,8 +70,17 @@ type User struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// TOTPEnabledAt is when the user turned on two-factor login (nil:
// off). The secrets never leave the server.
TOTPEnabledAt *time.Time `json:"totp_enabled_at,omitempty"`
TOTPSecret string `json:"-"`
TOTPPending string `json:"-"`
TOTPLastStep int64 `json:"-"`
}
// HasTOTP reports whether the user logs in with a TOTP code.
func (u *User) HasTOTP() bool { return u.TOTPSecret != "" }
// Token is an API key. The secret is shown once at creation and only its
// hash is stored.
type Token struct {
@@ -125,6 +134,9 @@ type Session struct {
LastSeenAt time.Time `json:"last_seen_at"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
// TOTPAt is when the session last proved a TOTP code (at login or
// since); nil for a session without two-factor login.
TOTPAt *time.Time `json:"totp_at,omitempty"`
}
// AuditEvent records an action.
+11 -1
View File
@@ -266,6 +266,10 @@ func (s *Server) authed(h handler) http.HandlerFunc {
// The CLI warns its user when it sees this.
w.Header().Set(api.TokenExpiresHeader, p.Token.ExpiresAt.UTC().Format(time.RFC3339))
}
if s.setupOnly(p) && !setupOnlyAllowed(r) {
s.writeErr(w, r, errSetupOnly, nil)
return
}
ctx := context.WithValue(r.Context(), keyPrincipal, p)
if se != nil {
ctx = context.WithValue(ctx, keySession, se)
@@ -327,6 +331,12 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason)
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
}
if u.HasTOTP() {
// The password was right; the session comes with the code
// (POST /auth/login/totp).
s.loginChallenge(w, u)
return nil
}
s.limiter.reset(userKey)
id, csrf := auth.NewSessionID()
now := time.Now()
@@ -338,7 +348,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
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", "")
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
return nil
}
+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)
+1
View File
@@ -276,6 +276,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
if p.IsAdmin() {
resp.Backup = s.backupStatus(r.Context(), now)
resp.TOTP = s.totpOverview(r.Context())
}
writeJSON(w, http.StatusOK, resp)
return nil
+507
View File
@@ -0,0 +1,507 @@
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
}
+16 -2
View File
@@ -1,6 +1,8 @@
package server
import (
"context"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/catalog"
@@ -132,8 +134,20 @@ func presentToken(t *model.Token) api.Token {
return out
}
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
// userOut is presentUser with what depends on the server's settings.
func (s *Server) userOut(u *model.User) api.User {
out := presentUser(u)
out.TOTPRequired = s.totpRequired(u)
return out
}
func (s *Server) whoamiOf(ctx context.Context, p *auth.Principal, se *model.Session) api.Whoami {
out := api.Whoami{User: s.userOut(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
if p.Method == "session" {
st := s.totpStatus(ctx, p.User)
out.TOTP = &st
out.TOTPSetupRequired = s.setupOnly(p)
}
if p.Token != nil {
t := presentToken(p.Token)
out.Token = &t
+7
View File
@@ -24,9 +24,15 @@ func (s *Server) routes() {
})
// Authentication
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/login/totp", s.public(s.loginTOTP))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
m.HandleFunc("GET "+p+"/me/totp", s.authed(s.getOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/setup", s.authed(s.setupOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/enable", s.authed(s.enableOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/disable", s.authed(s.disableOwnTOTP))
m.HandleFunc("POST "+p+"/me/totp/recovery-codes", s.authed(s.newRecoveryCodes))
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
@@ -40,6 +46,7 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
m.HandleFunc("DELETE "+p+"/users/{id}/totp", s.authed(s.resetUserTOTP))
// Views
m.HandleFunc("GET "+p+"/views", s.authed(s.listViews))
m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
+12
View File
@@ -52,6 +52,10 @@ type Config struct {
// without an API key); PorkbunAllows says which domains it may touch.
Porkbun *porkbun.Client
PorkbunAllows func(domain string) bool
// TOTPRequire is totp.require (none, admins or all) and TOTPIssuer the
// name authenticator apps show (h_totp.go).
TOTPRequire string
TOTPIssuer string
}
// Server is the HTTP server.
@@ -69,6 +73,8 @@ type Server struct {
oldSecretWarn throttle
// expiryWarn limits warnings about expiring tokens to one a day each.
expiryWarn throttle
// pendingLogins are logins waiting for their TOTP code.
pendingLogins pendingLogins
// nsChecker, when set, replaces the default name server checker
// (tests point it at local DNS servers).
nsChecker *nscheck.Checker
@@ -91,6 +97,12 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
if cfg.CookieSecure == "" {
cfg.CookieSecure = "auto"
}
if cfg.TOTPRequire == "" {
cfg.TOTPRequire = "none"
}
if cfg.TOTPIssuer == "" {
cfg.TOTPIssuer = "Jarl"
}
if log == nil {
log = slog.Default()
}
+273
View File
@@ -0,0 +1,273 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"testing"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/totp"
)
// browser is a web UI session: cookies plus the CSRF token.
type browser struct {
t *testing.T
e *apiEnv
c *http.Client
csrf string
}
func newBrowser(e *apiEnv) *browser {
jar, _ := cookiejar.New(nil)
return &browser{t: e.t, e: e, c: &http.Client{Jar: jar}}
}
// call sends a request; header is an optional X-Jarl-TOTP code.
func (b *browser) call(method, path string, body any, code string) (map[string]any, int) {
b.t.Helper()
var rd io.Reader
if body != nil {
j, _ := json.Marshal(body)
rd = bytes.NewReader(j)
}
r, _ := http.NewRequest(method, b.e.ts.URL+api.Prefix+path, rd)
r.Header.Set("Content-Type", "application/json")
if b.csrf != "" {
r.Header.Set(csrfHeader, b.csrf)
}
if code != "" {
r.Header.Set(TOTPHeader, code)
}
resp, err := b.c.Do(r)
if err != nil {
b.t.Fatal(err)
}
defer resp.Body.Close()
var m map[string]any
json.NewDecoder(resp.Body).Decode(&m)
if c, ok := m["csrf_token"].(string); ok && c != "" {
b.csrf = c
}
return m, resp.StatusCode
}
func (b *browser) must(method, path string, body any, code string, want int) map[string]any {
b.t.Helper()
m, status := b.call(method, path, body, code)
if status != want {
b.t.Fatalf("%s %s = %d, want %d: %v", method, path, status, want, m)
}
return m
}
// sessionHash is the stored hash of the browser's session cookie.
func (b *browser) sessionHash() string {
u, _ := url.Parse(b.e.ts.URL)
for _, c := range b.c.Jar.Cookies(u) {
if c.Name == sessionCookie {
return auth.HashSecret(c.Value)
}
}
b.t.Fatal("no session cookie")
return ""
}
// codeAt is secret's code for a step. Tests count steps from a base
// taken at their start, so crossing a step boundary mid-test changes
// nothing (the server accepts one step either side).
func codeAt(secret string, step int64) string {
c, _ := totp.Code(secret, step)
return c
}
func bodyErrCode(m map[string]any) string {
if e, ok := m["error"].(map[string]any); ok {
s, _ := e["code"].(string)
return s
}
return ""
}
func TestTOTPLogin(t *testing.T) {
base := totp.Step(time.Now())
e := newAPIEnv(t)
const pw = "correct horse battery"
b := newBrowser(e)
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if st := b.must("GET", "/me/totp", nil, "", 200); st["enabled"] != false {
t.Fatalf("status = %v", st)
}
// Setting up needs the password; the secret only counts once a code
// confirms it.
if m, status := b.call("POST", "/me/totp/setup", map[string]string{"password": "wrong"}, ""); status != 403 {
t.Errorf("setup with a wrong password = %d %v", status, m)
}
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
secret := setup["secret"].(string)
if setup["qr"] == "" || setup["account"] != "admin" {
t.Errorf("setup = %v", setup)
}
if m, status := b.call("POST", "/me/totp/enable", map[string]string{"code": "000000"}, ""); status != 400 || bodyErrCode(m) != "totp_invalid" {
t.Errorf("enable with a wrong code = %d %v", status, m)
}
// The codes of three steps are accepted (the clock may be off by one);
// each works once. Enable with the previous step's, log in with the
// current one's, and step up with the next one's.
en := b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base-1)}, "", 200)
recovery := en["recovery_codes"].([]any)
if len(recovery) != totp.RecoveryCodes {
t.Fatalf("recovery codes = %v", en)
}
// Turning it on kept this session and recorded the proved code: a
// token can be made right away.
b.must("POST", "/me/tokens", map[string]any{"name": "laptop"}, "", 201)
// The password alone is not a session any more.
b2 := newBrowser(e)
ch := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if ch["totp_required"] != true || ch["challenge"] == "" {
t.Fatalf("login = %v", ch)
}
if _, status := b2.call("GET", "/auth/whoami", nil, ""); status != 401 {
t.Errorf("whoami after the password only = %d", status)
}
// The code used to turn it on can't be used again.
if m, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base-1)}, ""); status != 401 || bodyErrCode(m) != "totp_invalid" {
t.Errorf("reused code = %d %v", status, m)
}
who := b2.must("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, "", 200)
if who["totp"].(map[string]any)["enabled"] != true {
t.Errorf("whoami = %v", who)
}
if _, status := b2.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": codeAt(secret, base)}, ""); status != 401 {
t.Errorf("challenge used twice = %d", status)
}
// Step-up: once the proved code is older than the window, making a
// token needs a fresh one.
old := time.Now().Add(-stepUpWindow - time.Minute)
e.st.SetSessionTOTP(context.Background(), b2.sessionHash(), old)
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, ""); status != 403 || bodyErrCode(m) != "totp_step_up" {
t.Errorf("token without a recent code = %d %v", status, m)
}
if m, status := b2.call("POST", "/me/tokens", map[string]any{"name": "ci"}, "123456"); status != 403 || bodyErrCode(m) != "totp_invalid" {
t.Errorf("token with a wrong code = %d %v", status, m)
}
b2.must("POST", "/me/tokens", map[string]any{"name": "ci"}, codeAt(secret, base+1), 201)
// That proved a code again: the next action needs none.
b2.must("POST", "/me/tokens", map[string]any{"name": "ci2"}, "", 201)
// API tokens never need a code.
e.do("POST", "/me/tokens", e.token, map[string]any{"name": "script"}, 201)
// A recovery code logs in once.
rc := recovery[0].(string)
for i, want := range []int{200, 401} {
b3 := newBrowser(e)
ch := b3.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if _, status := b3.call("POST", "/auth/login/totp", map[string]string{"challenge": ch["challenge"].(string), "code": " " + rc + " "}, ""); status != want {
t.Errorf("recovery code use %d = %d, want %d", i+1, status, want)
}
}
if st := b2.must("GET", "/me/totp", nil, "", 200); st["recovery_codes_left"] != float64(totp.RecoveryCodes-1) {
t.Errorf("recovery codes left = %v", st["recovery_codes_left"])
}
evs := e.list("/audit?action=user.totp", e.token)
if len(evs) == 0 {
t.Error("no user.totp audit events")
}
}
func TestTOTPRequiredAndReset(t *testing.T) {
base := totp.Step(time.Now())
e := newAPIEnvConfig(t, Config{Version: "test", TOTPRequire: "admins", TOTPIssuer: "Jarl test"})
const pw = "correct horse battery"
// An administrator without TOTP gets a session that can only set it up.
b := newBrowser(e)
who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
if who["totp_setup_required"] != true {
t.Fatalf("whoami = %v", who)
}
if m, status := b.call("GET", "/zones", nil, ""); status != 403 || bodyErrCode(m) != "totp_setup_required" {
t.Errorf("zones before setup = %d %v", status, m)
}
// Tokens are not affected.
e.do("GET", "/zones", e.token, nil, 200)
setup := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)
if setup["issuer"] != "Jarl test" {
t.Errorf("issuer = %v", setup["issuer"])
}
secret := setup["secret"].(string)
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
b.must("GET", "/zones", nil, "", 200)
// It can't be turned off while required.
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, ""); status != 409 {
t.Errorf("disable while required = %d", status)
}
// Users that aren't administrators may go without.
e.do("POST", "/users", e.token, map[string]any{"username": "bob", "password": "bob-password-123", "role": "readonly"}, 201)
bob := newBrowser(e)
if who := bob.must("POST", "/auth/login", map[string]string{"username": "bob", "password": "bob-password-123"}, "", 200); who["totp_setup_required"] == true {
t.Error("bob must set up TOTP")
}
bob.must("GET", "/zones", nil, "", 200)
// The dashboard: another administrator without two-factor login is
// listed, and the first "admin" account looks shared.
e.do("POST", "/users", e.token, map[string]any{"username": "carol", "password": "carol-password-123", "admin": true}, 201)
st := e.do("GET", "/status", e.token, nil, 200)["totp"].(map[string]any)
if missing := st["missing"].([]any); len(missing) != 1 || missing[0] != "carol" || st["shared_admin"] != "admin" || st["policy"] != "admins" {
t.Errorf("status totp = %v", st)
}
// An administrator resets someone's TOTP (with a token: no code).
users := e.list("/users", e.token)
var adminID string
for _, u := range users {
if u["username"] == "admin" {
adminID = itoa(int64(u["id"].(float64)))
if u["totp_required"] != true || u["totp_enabled_at"] == nil {
t.Errorf("admin = %v", u)
}
}
}
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 200)
if _, status := b.call("GET", "/zones", nil, ""); status != 401 {
t.Errorf("session after reset = %d", status)
}
e.do("DELETE", "/users/"+adminID+"/totp", e.token, nil, 409)
// Next login: password only, and setup again.
b = newBrowser(e)
if who := b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["totp_setup_required"] != true {
t.Errorf("after reset = %v", who)
}
}
func TestTOTPDisable(t *testing.T) {
base := totp.Step(time.Now())
e := newAPIEnv(t)
const pw = "correct horse battery"
b := newBrowser(e)
b.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200)
secret := b.must("POST", "/me/totp/setup", map[string]string{"password": pw}, "", 200)["secret"].(string)
b.must("POST", "/me/totp/enable", map[string]string{"code": codeAt(secret, base)}, "", 200)
if _, status := b.call("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base)}, ""); status != 403 {
t.Errorf("disable with a used code = %d", status)
}
b.must("POST", "/me/totp/disable", map[string]string{"password": pw, "code": codeAt(secret, base+1)}, "", 204)
// The password alone logs in again.
b2 := newBrowser(e)
if who := b2.must("POST", "/auth/login", map[string]string{"username": "admin", "password": pw}, "", 200); who["csrf_token"] == "" {
t.Errorf("login = %v", who)
}
// A token can't set up TOTP.
if _, status := e.req("POST", "/me/totp/setup", e.token, map[string]string{"password": pw}); status != 403 {
t.Errorf("setup with a token = %d", status)
}
}
+17
View File
@@ -242,6 +242,23 @@ ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
// 3: DNSSEC signing (the zone's dnssec-policy).
`
ALTER TABLE zones ADD COLUMN dnssec_policy TEXT NOT NULL DEFAULT '';
`,
// 4: two-factor login with TOTP. totp_pending holds a secret being set
// up until a code confirms it; totp_last_step is the newest time step
// used, so a code works only once. sessions.totp_at is when the
// session last proved a code (for actions that ask for a recent one).
`
ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN totp_pending TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN totp_enabled_at TEXT;
ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sessions ADD COLUMN totp_at TEXT;
CREATE TABLE recovery_codes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TEXT,
PRIMARY KEY (user_id, code_hash)
);
`,
}
+126
View File
@@ -0,0 +1,126 @@
package store
import (
"context"
"database/sql"
"time"
)
// SetTOTPPending stores a secret being set up (or clears it with ""). It
// becomes the user's secret once a code confirms it (EnableTOTP).
func (s *Store) SetTOTPPending(ctx context.Context, userID int64, secret string) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_pending = ? WHERE id = ?`, secret, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// EnableTOTP turns two-factor login on: the secret, the step of the code
// that confirmed it (so that code can't be used again), and the hashes of
// new recovery codes, replacing any old ones.
func (s *Store) EnableTOTP(ctx context.Context, userID int64, secret string, step int64, codeHashes []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := ts(time.Now())
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_pending = '', totp_enabled_at = ?, totp_last_step = ?, updated_at = ? WHERE id = ?`,
secret, now, step, now, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
return err
}
return tx.Commit()
}
// DisableTOTP turns two-factor login off and deletes the recovery codes.
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `UPDATE users SET totp_secret = '', totp_pending = '', totp_enabled_at = NULL, totp_last_step = 0, updated_at = ? WHERE id = ?`, ts(time.Now()), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
return tx.Commit()
}
// UseTOTPStep records that the code of step was used. It returns false if
// that step (or a later one) was used already: each code works once, even
// with two requests at the same moment.
func (s *Store) UseTOTPStep(ctx context.Context, userID, step int64) (bool, error) {
res, err := s.db.ExecContext(ctx, `UPDATE users SET totp_last_step = ? WHERE id = ? AND totp_last_step < ? AND totp_secret != ''`, step, userID, step)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// ReplaceRecoveryCodes gives a user new recovery codes (their hashes),
// making the old ones useless.
func (s *Store) ReplaceRecoveryCodes(ctx context.Context, userID int64, codeHashes []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := replaceRecoveryCodes(ctx, tx, userID, codeHashes); err != nil {
return err
}
return tx.Commit()
}
func replaceRecoveryCodes(ctx context.Context, tx *sql.Tx, userID int64, hashes []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, userID); err != nil {
return err
}
for _, h := range hashes {
if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)`, userID, h); err != nil {
return err
}
}
return nil
}
// UseRecoveryCode marks an unused recovery code (by its hash) used. It
// returns false if the user has no such unused code.
func (s *Store) UseRecoveryCode(ctx context.Context, userID int64, codeHash string) (bool, error) {
res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, ts(time.Now()), userID, codeHash)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// RecoveryCodesLeft counts a user's unused recovery codes.
func (s *Store) RecoveryCodesLeft(ctx context.Context, userID int64) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID).Scan(&n)
return n, err
}
// SetSessionTOTP records that a session proved a TOTP code at at.
func (s *Store) SetSessionTOTP(ctx context.Context, idHash string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_at = ? WHERE id_hash = ?`, ts(at), idHash)
return err
}
+10 -7
View File
@@ -18,14 +18,14 @@ func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at`
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at, totp_secret, totp_pending, totp_enabled_at, totp_last_step`
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u := &model.User{}
var grants, ca, ua string
var admin, disabled int
var ll sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil {
var ll, te sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll, &u.TOTPSecret, &u.TOTPPending, &te, &u.TOTPLastStep); err != nil {
return nil, err
}
u.Admin, u.Disabled = admin != 0, disabled != 0
@@ -37,6 +37,7 @@ func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u.Grants = []model.Grant{}
}
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
u.TOTPEnabledAt = parseTSPtr(te)
return u, nil
}
@@ -263,8 +264,8 @@ func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
// CreateSession stores a session.
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`,
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent)
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at) VALUES (?,?,?,?,?,?,?,?,?)`,
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent, tsPtr(se.TOTPAt))
return err
}
@@ -272,8 +273,9 @@ func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
se := &model.Session{}
var ca, ea, ls string
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash).
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent)
var ta sql.NullString
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent, totp_at FROM sessions WHERE id_hash = ?`, idHash).
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent, &ta)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -281,6 +283,7 @@ func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session,
return nil, err
}
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
se.TOTPAt = parseTSPtr(ta)
return se, nil
}
+193
View File
@@ -0,0 +1,193 @@
// Package totp implements time-based one-time passwords (RFC 6238) for
// two-factor login, with the settings every authenticator app supports:
// HMAC-SHA1, 6 digits, a 30-second step. It also makes the one-time
// recovery codes and the QR code that sets up an app.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"math/big"
"net/url"
"strings"
"time"
"rsc.io/qr"
)
const (
// Digits is the length of a code.
Digits = 6
// Period is the length of a time step.
Period = 30 * time.Second
// Skew is how many steps before and after the current one are
// accepted, for clocks that are a little off.
Skew = 1
// SecretBytes is the length of a secret (160 bits, as RFC 4226
// recommends).
SecretBytes = 20
// RecoveryCodes is how many recovery codes a user gets.
RecoveryCodes = 10
)
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
// NewSecret returns a new random secret, base32 encoded (the form
// authenticator apps take).
func NewSecret() string {
b := make([]byte, SecretBytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
func decode(secret string) ([]byte, error) {
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
k, err := b32.DecodeString(strings.TrimRight(s, "="))
if err != nil || len(k) == 0 {
return nil, errors.New("invalid TOTP secret")
}
return k, nil
}
// Step returns the time step t falls in.
func Step(t time.Time) int64 { return t.Unix() / int64(Period/time.Second) }
// Code returns the code for a step.
func Code(secret string, step int64) (string, error) {
k, err := decode(secret)
if err != nil {
return "", err
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(step))
m := hmac.New(sha1.New, k)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
return fmt.Sprintf("%0*d", Digits, v%1_000_000), nil
}
// Normalize removes the spaces and dashes people type in codes.
func Normalize(code string) string {
return strings.Map(func(r rune) rune {
if r == ' ' || r == '-' || r == '\t' {
return -1
}
return r
}, strings.TrimSpace(code))
}
// IsCode reports whether s looks like a TOTP code (as opposed to a
// recovery code).
func IsCode(s string) bool {
s = Normalize(s)
if len(s) != Digits {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// Verify checks a code against the steps around now and returns the step
// it matched. Steps up to and including last (the newest step already
// used) are refused, so a code works only once.
func Verify(secret, code string, now time.Time, last int64) (int64, bool) {
code = Normalize(code)
if !IsCode(code) {
return 0, false
}
cur := Step(now)
for d := -Skew; d <= Skew; d++ {
step := cur + int64(d)
if step <= last {
continue
}
want, err := Code(secret, step)
if err != nil {
return 0, false
}
if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 {
return step, true
}
}
return 0, false
}
// URI is the otpauth:// URI that authenticator apps read from the QR code.
func URI(issuer, account, secret string) string {
label := url.PathEscape(issuer) + ":" + url.PathEscape(account)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", issuer)
q.Set("algorithm", "SHA1")
q.Set("digits", fmt.Sprint(Digits))
q.Set("period", fmt.Sprint(int(Period/time.Second)))
return "otpauth://totp/" + label + "?" + q.Encode()
}
// QRDataURI renders text as a QR code: an SVG image as a data: URI, ready
// for an <img> tag.
func QRDataURI(text string) (string, error) {
c, err := qr.Encode(text, qr.M)
if err != nil {
return "", err
}
const quiet = 4 // the white border scanners need, in modules
n := c.Size + 2*quiet
var b strings.Builder
fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, n, n)
fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#fff"/><path fill="#000" d="`, n, n)
for y := 0; y < c.Size; y++ {
for x := 0; x < c.Size; x++ {
if c.Black(x, y) {
fmt.Fprintf(&b, "M%d %dh1v1h-1z", x+quiet, y+quiet)
}
}
}
b.WriteString(`"/></svg>`)
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())), nil
}
// recoveryAlphabet has no 0/o, 1/l/i, so codes are easy to read and type.
const recoveryAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
// NewRecoveryCodes returns n random recovery codes like "k7dm2-xq9pw"
// (about 49 bits each).
func NewRecoveryCodes(n int) []string {
out := make([]string, n)
for i := range out {
var s strings.Builder
for j := range 10 {
if j == 5 {
s.WriteByte('-')
}
v, err := rand.Int(rand.Reader, big.NewInt(int64(len(recoveryAlphabet))))
if err != nil {
panic(err)
}
s.WriteByte(recoveryAlphabet[v.Int64()])
}
out[i] = s.String()
}
return out
}
// NormalizeRecovery puts a recovery code as typed into the form it is
// stored in (lower case, no spaces or dashes).
func NormalizeRecovery(code string) string {
return strings.ToLower(Normalize(code))
}
+82
View File
@@ -0,0 +1,82 @@
package totp
import (
"strings"
"testing"
"time"
)
// The SHA-1 test vectors of RFC 6238 appendix B, cut to 6 digits.
func TestCodeRFC6238(t *testing.T) {
secret := b32.EncodeToString([]byte("12345678901234567890"))
for _, c := range []struct {
unix int64
want string
}{
{59, "287082"},
{1111111109, "081804"},
{1111111111, "050471"},
{1234567890, "005924"},
{2000000000, "279037"},
{20000000000, "353130"},
} {
got, err := Code(secret, Step(time.Unix(c.unix, 0)))
if err != nil || got != c.want {
t.Errorf("T=%d: %q, %v; want %q", c.unix, got, err, c.want)
}
}
}
func TestVerify(t *testing.T) {
secret := NewSecret()
now := time.Unix(1_800_000_000, 0)
cur := Step(now)
code := func(step int64) string { c, _ := Code(secret, step); return c }
if step, ok := Verify(secret, code(cur), now, 0); !ok || step != cur {
t.Fatal("current code refused")
}
if _, ok := Verify(secret, code(cur - 1)[:3]+" "+code(cur - 1)[3:], now, 0); !ok {
t.Error("previous step (with a space) refused")
}
if _, ok := Verify(secret, code(cur+1), now, 0); !ok {
t.Error("next step refused")
}
if _, ok := Verify(secret, code(cur-2), now, 0); ok {
t.Error("code from a minute ago accepted")
}
// Replay: a step at or before the last used one is refused.
if _, ok := Verify(secret, code(cur), now, cur); ok {
t.Error("reused code accepted")
}
if _, ok := Verify(secret, code(cur+1), now, cur); !ok {
t.Error("newer code refused after a used one")
}
if _, ok := Verify(secret, "abcdef", now, 0); ok {
t.Error("letters accepted")
}
}
func TestURIAndQR(t *testing.T) {
u := URI("Jarl ns1", "adam", "JBSWY3DPEHPK3PXP")
if !strings.HasPrefix(u, "otpauth://totp/Jarl%20ns1:adam?") || !strings.Contains(u, "secret=JBSWY3DPEHPK3PXP") || !strings.Contains(u, "issuer=Jarl+ns1") {
t.Errorf("uri = %s", u)
}
img, err := QRDataURI(u)
if err != nil || !strings.HasPrefix(img, "data:image/svg+xml;base64,") {
t.Errorf("qr = %.60s, %v", img, err)
}
}
func TestRecoveryCodes(t *testing.T) {
codes := NewRecoveryCodes(RecoveryCodes)
seen := map[string]bool{}
for _, c := range codes {
if len(c) != 11 || c[5] != '-' || seen[c] || IsCode(c) {
t.Errorf("bad code %q", c)
}
seen[c] = true
}
if NormalizeRecovery(" K7DM2-XQ9PW ") != "k7dm2xq9pw" {
t.Error("normalize")
}
}
+21 -1
View File
@@ -23,8 +23,27 @@ export class APIError extends Error {
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
// api calls the REST API. opts: {body, stage (bool), query}.
// With two-factor login, some actions (API tokens, users) need a code
// from the last few minutes. The server then answers totp_step_up (or
// totp_invalid for a wrong code); askCode asks for one, and the request is
// sent again with it. It returns null when the user cancels.
let askCode = null;
export function setCodePrompt(fn) { askCode = fn; }
const stepUpCodes = new Set(["totp_step_up", "totp_invalid"]);
// api calls the REST API. opts: {body, stage (bool), query, code}.
export async function api(method, path, opts = {}) {
try {
return await call(method, path, opts);
} catch (e) {
if (!askCode || e.status !== 403 || !stepUpCodes.has(e.code) || path.startsWith("/me/totp/disable")) throw e;
const code = await askCode(e.code === "totp_invalid" ? e.message : "");
if (!code) throw e;
return api(method, path, { ...opts, code });
}
}
async function call(method, path, opts) {
const headers = { "Accept": "application/json" };
let body;
if (opts.body !== undefined) {
@@ -40,6 +59,7 @@ export async function api(method, path, opts = {}) {
if (s) url += (url.includes("?") ? "&" : "?") + s;
}
if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage);
if (opts.code) headers["X-Jarl-TOTP"] = opts.code;
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
if (resp.status === 204) return null;
const ct = resp.headers.get("Content-Type") || "";
+71 -12
View File
@@ -1,7 +1,8 @@
// Jarl web UI: router, layout and login.
import { h, mount, toast, errorBox, plural } from "./ui.js";
import { wordmark, logo } from "./brand.js";
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate } from "./api.js";
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
import * as totp from "./totp.js";
import * as dashboard from "./pages/dashboard.js";
import * as zones from "./pages/zones.js";
import * as zone from "./pages/zone.js";
@@ -65,22 +66,81 @@ function renderLogin(message) {
e.preventDefault();
mount(err);
try {
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
state.me = me;
state.csrf = me.csrf_token;
await start();
const r = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
if (r.totp_required) { renderCodeStep(r.challenge); return; }
await loggedIn(r);
} catch (ex) {
mount(err, errorBox(ex));
pass.select();
}
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
mount(app, h("div", { class: "login panel" },
mount(app, loginPanel(message, form));
user.focus();
}
function loginPanel(message, ...body) {
return h("div", { class: "login panel" },
logo(104, "login-logo"),
h("h1", null, wordmark()),
h("div", { class: "tag" }, "Rule your DNS holds"),
message ? h("div", { class: "msg warn" }, message) : null,
form));
user.focus();
...body);
}
// renderCodeStep is the second login step for users with two-factor login.
function renderCodeStep(challenge) {
const code = h("input", { name: "code", autocomplete: "one-time-code", inputmode: "numeric", placeholder: "6-digit code", required: true, class: "totp-code" });
const err = h("div");
let recovery = false;
const hint = h("div", { class: "small muted" });
const toggle = h("a", { href: "#", class: "small", onclick: (e) => {
e.preventDefault();
recovery = !recovery;
code.placeholder = recovery ? "Recovery code, e.g. k7dm2-xq9pw" : "6-digit code";
code.inputMode = recovery ? "text" : "numeric";
toggle.textContent = recovery ? "Use the authenticator app" : "Lost your phone? Use a recovery code";
code.value = "";
code.focus();
} }, "Lost your phone? Use a recovery code");
mount(hint, "Enter the code from your authenticator app.");
const form = h("form", { onsubmit: async (e) => {
e.preventDefault();
mount(err);
try {
const me = await api("POST", "/auth/login/totp", { body: { challenge, code: code.value } });
await loggedIn(me);
} catch (ex) {
if (ex.code === "login_expired") { renderLogin(ex.message); return; }
mount(err, errorBox(ex));
code.select();
}
} }, hint, code, h("button", { class: "primary", type: "submit" }, "Verify"), toggle, err);
mount(app, loginPanel(null, form));
code.focus();
}
async function loggedIn(me) {
state.me = me;
state.csrf = me.csrf_token || "";
if (me.totp_setup_required) renderSetupRequired();
else await start();
}
// renderSetupRequired: the server requires two-factor login for this
// user (totp.require), and it isn't set up yet. Nothing else works until
// it is.
function renderSetupRequired() {
const box = h("div");
mount(app, h("div", { class: "login panel wide" },
logo(64, "login-logo"),
h("h1", null, "Set up two-factor login"),
h("p", null, "This server requires two-factor login for your account. Set up an authenticator app to continue."),
box,
h("div", { class: "small", style: { marginTop: "12px" } }, h("a", { href: "#", onclick: (e) => { e.preventDefault(); logout(); } }, "Log out"))));
totp.setup(box, state.me, async () => {
const me = await get("/auth/whoami");
await loggedIn(me);
});
}
function layout() {
@@ -183,12 +243,11 @@ setUnauthorizedHandler(() => {
}
});
setCodePrompt(totp.askCode);
(async function boot() {
try {
const me = await get("/auth/whoami");
state.me = me;
state.csrf = me.csrf_token || "";
await start();
await loggedIn(await get("/auth/whoami"));
} catch (e) {
if (e.status === 401) renderLogin();
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Jarl"), errorBox(e)));
+64 -1
View File
@@ -1,5 +1,6 @@
import { h, mount, field, input, toast, errorBox } from "../ui.js";
import { h, mount, field, input, toast, errorBox, time, modal, badge } from "../ui.js";
import { api, get } from "../api.js";
import * as totp from "../totp.js";
import { tokenTable, newTokenForm, grantHelp } from "./users.js";
export async function render(el) {
@@ -18,6 +19,7 @@ export async function render(el) {
h("h1", null, "Account: " + me.user.username),
h("div", { class: "panel" }, h("h2", null, "Permissions"),
h("div", { class: "grants" }, (me.permissions || []).map((p) => h("div", null, p)))),
me.method === "session" ? twoFactorPanel(me) : null,
h("div", { class: "panel" }, h("h2", null, "Change password"),
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw1, "At least 8 characters."), field("Repeat", pw2)),
err,
@@ -36,3 +38,64 @@ export async function render(el) {
grantHelp(grants));
await drawTokens();
}
// twoFactorPanel shows and changes the user's two-factor login.
function twoFactorPanel(me) {
const el = h("div", { class: "panel" });
const draw = async () => {
const st = await get("/me/totp");
const body = h("div");
const redraw = () => draw();
if (!st.enabled) {
mount(el, h("h2", null, "Two-factor login"),
h("p", null, "Log in with your password and a code from an authenticator app on your phone, so a stolen password alone isn't enough.",
st.required ? h("strong", null, " This server requires it for your account.") : null),
body);
totp.setup(body, me, redraw);
return;
}
const actions = h("div", { class: "actions" });
mount(el, h("h2", null, "Two-factor login"),
h("p", null, badge("on", "ok"), " since ", time(st.enabled_at), ". ",
st.recovery_codes_left + " of 10 recovery codes left.",
st.required ? h("span", { class: "muted" }, " Required on this server.") : null),
st.recovery_codes_left <= 3 ? h("div", { class: "msg warn" }, "Only " + st.recovery_codes_left + " recovery codes are left: make new ones.") : null,
body, actions);
mount(actions,
h("button", { onclick: () => {
mount(actions);
totp.setup(body, me, redraw, { again: true, cancel: redraw });
} }, "Move to a new phone"),
h("button", { onclick: async () => {
try {
const r = await api("POST", "/me/totp/recovery-codes");
mount(actions);
mount(body, totp.recoveryBox(r.recovery_codes, me.user.username),
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: redraw }, "I have saved the codes")));
} catch (e) { toast(e.message, "err", 8000); }
} }, "New recovery codes"),
st.required ? null : h("button", { class: "danger", onclick: () => disable(me, redraw) }, "Turn off"));
};
draw();
return el;
}
async function disable(me, done) {
const pw = input({ type: "password", autocomplete: "current-password" });
const code = input({ autocomplete: "one-time-code", placeholder: "123456 or a recovery code" });
const err = h("div");
const ok = await modal("Turn off two-factor login", h("div", null,
h("p", null, "You will log in with your password alone."),
h("div", { class: "form-grid" },
me.user.has_password ? field("Password", pw) : null,
field("Code", code, "From your authenticator app, or a recovery code.")), err), [
{ label: "Cancel", value: null },
{ label: "Turn off", kind: "danger", onclick: async () => {
try {
await api("POST", "/me/totp/disable", { body: { password: pw.value, code: code.value } });
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
]);
if (ok) { toast("Two-factor login is off"); done(); }
}
@@ -40,6 +40,17 @@ export async function render(el) {
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
}
const tf = st.totp;
if (tf && tf.missing && tf.missing.length) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Two-factor login is required (" + tf.policy + ") but not set up by: "), tf.missing.join(", "), ". ",
"They can only set it up when they next log in. ", h("a", { href: "#/users" }, "Users")));
}
if (tf && tf.shared_admin) {
parts.push(h("div", { class: "msg info" },
h("strong", null, "The first account, " + tf.shared_admin + ", is still active "), "next to personal administrator accounts. If it is shared, disable it under ",
h("a", { href: "#/users" }, "Users"), ": everyone then logs in as themselves, with their own two-factor login, and the audit log shows who did what. Root on the server can always get back in with 'jarld user passwd'."));
}
const exp = st.expiring_tokens || [];
if (exp.length) {
const now = new Date();
+11
View File
@@ -16,11 +16,13 @@ export async function render(el) {
h("div", { class: "panel" }, table([
{ label: "User", render: (u) => h("strong", null, u.username) },
{ label: "Role", render: (u) => [u.admin ? badge("admin", "accent") : null, u.disabled ? badge("disabled", "err") : null, !u.has_password ? badge("tokens only") : null] },
{ label: "2FA", render: (u) => u.totp_enabled_at ? badge("on", "ok") : u.totp_required ? badge("not set up", "warn") : !u.has_password ? h("span", { class: "muted" }, "–") : h("span", { class: "muted" }, "off") },
{ label: "Permissions", render: (u) => u.admin ? h("span", { class: "muted" }, "everything") : h("div", { class: "grants" }, (u.grant_strings || []).map((g) => h("div", null, g))), tdClass: "small" },
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : h("span", { class: "muted" }, "never") },
{ label: "", render: (u) => h("span", null,
h("button", { class: "small", onclick: () => tokensDialog(u, grants) }, "Tokens"), " ",
h("button", { class: "small", onclick: () => userDialog(u, grants) }, "Edit"), " ",
u.totp_enabled_at ? [h("button", { class: "small", title: "Turn off their two-factor login (a lost phone without recovery codes)", onclick: () => resetTOTP(u) }, "Reset 2FA"), " "] : null,
h("button", { class: "small danger", disabled: u.id === state.me.user.id, onclick: () => del(u) }, "Delete")), tdClass: "actions-cell" },
], users)),
grantHelp(grants));
@@ -78,6 +80,15 @@ async function userDialog(u, grants) {
], { wide: true, sticky: true });
}
async function resetTOTP(u) {
if (!(await confirmDialog("Reset two-factor login", "Turn off two-factor login for " + u.username + "? Their recovery codes stop working and they are logged out. They log in with the password next time" + (u.totp_required ? " and must set it up again." : ", and can set it up again under Account."), "Reset", "danger"))) return;
try {
await api("DELETE", "/users/" + u.id + "/totp");
toast("Two-factor login of " + u.username + " is off");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
async function del(u) {
if (!(await confirmDialog("Delete user", "Delete " + u.username + " and all their API tokens?", "Delete", "danger"))) return;
try {
+8
View File
@@ -242,6 +242,14 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.kv dd { margin: 0; }
.grants { font-family: var(--mono); font-size: 12px; }
.copy-box { display: flex; gap: 6px; align-items: center; }
.login.wide { max-width: 520px; }
.totp-steps { padding-left: 20px; }
.totp-steps li { margin-bottom: 12px; }
.totp-qr { margin: 10px 0; }
.totp-qr img { display: block; width: 200px; height: 200px; background: #fff; border-radius: var(--radius); }
.totp-confirm { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
input.totp-code { font-family: var(--mono); font-size: 18px; letter-spacing: 0.15em; max-width: 14em; }
.recovery-codes { font-family: var(--mono); font-size: 15px; columns: 2; max-width: 360px; }
.copy-box input { flex: 1; font-family: var(--mono); }
.ds-record { margin: 12px 0 16px; }
.ds-record > .copy-box { max-width: 900px; }
+91
View File
@@ -0,0 +1,91 @@
// Two-factor login (TOTP): the code prompt, setting up an authenticator
// app, and recovery codes.
import { h, mount, modal, input, field, toast, errorBox, copyBox } from "./ui.js";
import { api } from "./api.js";
function codeInput(attrs = {}) {
return input({ inputmode: "numeric", autocomplete: "one-time-code", maxlength: 6, pattern: "[0-9]*", placeholder: "123456", class: "totp-code", ...attrs });
}
// askCode asks for a current code (actions that need a recent one).
// It returns the code, or null when canceled.
export async function askCode(message) {
const code = codeInput();
const r = await modal("Confirm with your authenticator", h("div", null,
message ? h("div", { class: "msg err" }, message) : null,
h("p", null, "This action needs a current code from your authenticator app."),
code), [
{ label: "Cancel", value: null },
{ label: "Confirm", kind: "primary", onclick: () => code.value.trim().length > 0 },
]);
return r === "Confirm" ? code.value.trim() : null;
}
// recoveryBox shows new recovery codes, to copy or download.
export function recoveryBox(codes, username) {
const text = "Jarl recovery codes for " + username + " (each works once)\n\n" + codes.join("\n") + "\n";
const download = () => {
const a = h("a", { href: URL.createObjectURL(new Blob([text], { type: "text/plain" })), download: "jarl-recovery-codes-" + username + ".txt" });
document.body.append(a);
a.click();
a.remove();
};
return h("div", null,
h("div", { class: "msg warn" }, "Save these recovery codes somewhere safe (a password manager, or printed). Each logs you in once if you lose your phone. They are shown only now."),
h("pre", { class: "recovery-codes" }, codes.join("\n")),
h("div", { class: "actions" },
h("button", { type: "button", onclick: async () => {
try { await navigator.clipboard.writeText(text); toast("Copied"); } catch { toast("Copy failed; select the codes instead", "err"); }
} }, "Copy"),
h("button", { type: "button", onclick: download }, "Download")));
}
// setup walks through setting up an authenticator app in el: password,
// QR code, confirming code, recovery codes. onDone runs when finished.
export function setup(el, me, onDone, opts = {}) {
const hasPassword = me.user.has_password !== false;
const pw = input({ type: "password", autocomplete: "current-password" });
const err = h("div");
const start = async () => {
mount(err);
let s;
try {
s = await api("POST", "/me/totp/setup", { body: { password: pw.value } });
} catch (e) { mount(err, errorBox(e)); return; }
showQR(s);
};
const showQR = (s) => {
const code = codeInput();
const err2 = h("div");
const confirm = async () => {
mount(err2);
try {
const r = await api("POST", "/me/totp/enable", { body: { code: code.value } });
mount(el,
h("div", { class: "msg ok" }, "Two-factor login is on. From now on you log in with your password and a code from the app."),
recoveryBox(r.recovery_codes, me.user.username),
h("div", { class: "actions", style: { marginTop: "8px" } }, h("button", { class: "primary", onclick: onDone }, "I have saved the codes")));
} catch (e) { mount(err2, errorBox(e)); code.select(); }
};
code.addEventListener("keydown", (e) => { if (e.key === "Enter") confirm(); });
mount(el,
h("ol", { class: "totp-steps" },
h("li", null, "Scan this QR code with an authenticator app (Aegis, Google Authenticator, 1Password, Bitwarden, …).",
h("div", { class: "totp-qr" }, h("img", { src: s.qr, alt: "QR code for " + s.issuer + ": " + s.account, width: 200, height: 200 })),
h("details", null, h("summary", { class: "small" }, "Can't scan? Enter the key by hand"),
h("div", { class: "small muted" }, "Account: " + s.issuer + ": " + s.account + " · time-based, 6 digits, 30 seconds"),
copyBox(s.secret))),
h("li", null, "Enter the 6-digit code the app shows:", h("div", { class: "totp-confirm" }, code,
h("button", { class: "primary", onclick: confirm }, "Turn on")))),
err2);
code.focus();
};
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") start(); });
mount(el,
hasPassword ? h("div", { class: "form-grid" }, field("Your password", pw, "To make sure it's you.")) : null,
err,
h("div", { class: "actions", style: { marginTop: "8px" } },
opts.cancel ? h("button", { onclick: opts.cancel }, "Cancel") : null,
h("button", { class: "primary", onclick: start }, opts.again ? "Set up a new app" : "Set up")));
if (hasPassword) pw.focus();
}