Add two-factor login (TOTP) with recovery codes
Users can set up an authenticator app from their account page; login then asks for a code after the password, with one-time recovery codes as a fallback. Sensitive actions (tokens, user changes, 2FA resets) need a code from the last 5 minutes. totp.require (none/admins/all) forces setup, and admins can reset a user's 2FA from the UI, the jarl CLI or jarld on the server. The dashboard and jarl status flag users missing 2FA and a shared "admin" account still in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b22ffee1bb
commit
8f9cdd0a17
35 files changed
+1965
-43
No files matched your search
@@ -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"
|
||||
@@ -131,8 +133,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))
|
||||
|
||||
@@ -47,6 +47,10 @@ type Config struct {
|
||||
// reports on (backup.dir and backup.warn_after).
|
||||
BackupDir string
|
||||
BackupWarnAfter time.Duration
|
||||
// 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.
|
||||
@@ -64,6 +68,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
|
||||
@@ -86,6 +92,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user