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:
commit
a95dffb471
35 files changed
+1963
-43
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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") || "";
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user