fix(auth): final-review MFA fixes F1 F2 F3 F5 F7 and drop Ticket.Attempts
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 5m51s

- OIDC sessions minted through saveSignIn with AMR oidc and StepUpAt
- passwordless passkey uses ValidateDiscoverableLogin with owner-handle check
- TOTP replay guard burns the matched step, not the current one
- enrol-only tickets refused once the user already has a factor
- bootstrap owner session minted through mintSession
This commit is contained in:
2026-09-16 14:26:42 +00:00
parent dcdfd3ce52
commit 19383abaf8
11 changed files with 171 additions and 38 deletions
+1 -5
View File
@@ -259,14 +259,10 @@ func HandleBootstrap(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
if err := mintSession(c, u, []string{"pwd"}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{
"instance": inst,
"slug": inst.Slug,
+25 -6
View File
@@ -11,6 +11,25 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
)
// enrolTicketStillValid refuses an enrol-only ticket once the user has any
// factor. Otherwise someone holding only the password who started sign-in
// before the real user enrolled could, within the ticket's lifetime, add
// their own passkey and replace the user's recovery codes. Answered as an
// expired ticket, the single indistinguishable error, and the ticket is
// destroyed. A lookup failure is treated the same way: fail closed.
//
// It runs before the factor is written, not in finishEnrolment: by then the
// factor being enrolled already counts, so HasMFA would always be true.
func enrolTicketStillValid(c *gin.Context, t *Ticket, ticketID string) bool {
has, err := services.HasMFA(t.InstanceID, t.UserID)
if err != nil || has {
_ = DeleteTicket(c.Request.Context(), ticketID)
abortTicketExpired(c)
return false
}
return true
}
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
// from, before they hold a session. Only an enrol-only ticket reaches it.
//
@@ -21,8 +40,8 @@ import (
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/totp/setup [post]
func HandleEnrolTOTPSetup(c *gin.Context) {
t, _, ok := ticketFromRequest(c, scopeEnrol)
if !ok {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
inst, err := services.GetInstance(t.InstanceID)
@@ -50,7 +69,7 @@ func HandleEnrolTOTPSetup(c *gin.Context) {
// @Router /auth/mfa/enrol/totp/confirm [post]
func HandleEnrolTOTPConfirm(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok {
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
var body struct {
@@ -107,8 +126,8 @@ func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/passkey/begin [post]
func HandleEnrolPasskeyBegin(c *gin.Context) {
t, _, ok := ticketFromRequest(c, scopeEnrol)
if !ok {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
@@ -159,7 +178,7 @@ func HandleEnrolPasskeyBegin(c *gin.Context) {
// @Router /auth/mfa/enrol/passkey/finish [post]
func HandleEnrolPasskeyFinish(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok {
if !ok || !enrolTicketStillValid(c, t, ticketID) {
return
}
var body struct {
+26 -6
View File
@@ -21,18 +21,38 @@ func loginDecision(hasMFA, requireMFA bool) string {
}
}
// newSession builds the session a sign-in produces. Sign-in counts as a
// step-up, so StepUpAt is always the moment of sign-in.
func newSession(u *models.User, amr []string, now time.Time) *Session {
return &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
AMR: amr, StepUpAt: &now,
}
}
// oidcSession is the session an SSO callback mints. AMR "oidc" is what exempts
// it from step-up: the IdP owns authentication policy, and these users have
// no password or local factor to step up with.
func oidcSession(u *models.User, name string, now time.Time) *Session {
s := newSession(u, []string{"oidc"}, now)
s.Name = name
return s
}
// mintSession is the single place a session is created from a user, so every
// path records amr and step-up freshness the same way.
func mintSession(c *gin.Context, u *models.User, amr []string) error {
now := time.Now()
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
AMR: amr, StepUpAt: &now,
})
return saveSignIn(c, newSession(u, amr, time.Now()))
}
// saveSignIn persists a sign-in session and sets its cookie. The pending-MFA
// cookie is cleared because a completed sign-in supersedes any ticket.
func saveSignIn(c *gin.Context, sess *Session) error {
sessionID, err := SaveSession(c.Request.Context(), sess)
if err != nil {
return err
}
_ = services.TouchLastLogin(u.UserID)
_ = services.TouchLastLogin(sess.UserID)
ClearPendingCookie(c)
SetSessionCookie(c, sessionID)
return nil
-1
View File
@@ -33,7 +33,6 @@ type Ticket struct {
Email string `json:"email"`
Methods []string `json:"methods"`
EnrolOnly bool `json:"enrol_only"`
Attempts int `json:"attempts"`
}
type ticketScope int
+2 -6
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
@@ -225,15 +226,10 @@ func completeSSOLogin(c *gin.Context, instanceID, email, name string) {
}
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: name,
})
if err != nil {
if err := saveSignIn(c, oidcSession(u, name, time.Now())); err != nil {
c.Redirect(http.StatusFound, "/login?error=session_failed")
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.Redirect(http.StatusFound, "/")
}
+3 -2
View File
@@ -80,8 +80,9 @@ func HandlePasskeyLoginFinish(c *gin.Context) {
return
}
// finishAssertion is given no userID or email, so it resolves the owning
// user itself from the credential ID scoped to this instance - the same
// requirement a discoverable login has to meet.
// user from the credential ID scoped to this instance and validates with
// the library's discoverable path, refusing a user handle that is not
// that owner's.
cred, err := finishAssertion(c, instanceID, "", "")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
+22
View File
@@ -6,6 +6,8 @@ import (
"testing"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/gin-gonic/gin"
)
@@ -52,3 +54,23 @@ func TestRequireStepUpNilSessionRefusesWithoutPanic(t *testing.T) {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestOIDCSessionIsExemptFromStepUp(t *testing.T) {
now := time.Now()
u := &models.User{UserID: "u1", InstanceID: "i1", Role: "member", Email: "a@example.com"}
s := oidcSession(u, "Ann", now.Add(-24*time.Hour))
if !stepUpFresh(s, now) {
t.Fatal("an OIDC session must be exempt from step-up however old its sign-in")
}
if s.StepUpAt == nil || s.Name != "Ann" || s.UserID != "u1" || s.InstanceID != "i1" {
t.Fatalf("oidc session missing fields: %+v", s)
}
}
func TestSignInSessionIsFreshAtSignIn(t *testing.T) {
now := time.Now()
s := newSession(&models.User{UserID: "u1"}, []string{"pwd"}, now)
if !stepUpFresh(s, now) {
t.Fatal("a session is fresh at the moment of sign-in")
}
}
+22 -1
View File
@@ -3,6 +3,7 @@ package auth
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net"
@@ -429,7 +430,21 @@ func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauth
return nil, err
}
user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{toLibCredential(*stored)}}
cred, err := w.ValidateLogin(user, *sessionData, parsed)
var cred *webauthn.Credential
if userID == "" {
// Discoverable (passwordless) ceremony: the session carries no user,
// so the library asks us to resolve the authenticator's user handle.
// The only acceptable answer is the credential's own stored owner in
// this instance; any other handle is refused.
cred, err = w.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
if !discoverableHandleMatches(handle, userHandle) {
return nil, errors.New("user handle does not match the credential owner")
}
return user, nil
}, *sessionData, parsed)
} else {
cred, err = w.ValidateLogin(user, *sessionData, parsed)
}
if err != nil {
return nil, err
}
@@ -443,3 +458,9 @@ func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauth
}
return cred, nil
}
// discoverableHandleMatches reports whether the user handle an authenticator
// returned belongs to the credential's stored owner. Empty never matches.
func discoverableHandleMatches(ownerHandle, userHandle []byte) bool {
return len(ownerHandle) > 0 && subtle.ConstantTimeCompare(ownerHandle, userHandle) == 1
}
+16
View File
@@ -1,6 +1,7 @@
package auth
import (
"bytes"
"net/http/httptest"
"testing"
@@ -34,3 +35,18 @@ func TestRPConfig(t *testing.T) {
})
}
}
func TestDiscoverableHandleMatches(t *testing.T) {
owner := bytes.Repeat([]byte{7}, 64)
if !discoverableHandleMatches(owner, bytes.Repeat([]byte{7}, 64)) {
t.Fatal("the owner's own handle must match")
}
other := bytes.Repeat([]byte{7}, 64)
other[63] = 8
if discoverableHandleMatches(owner, other) {
t.Fatal("another user's handle must not match")
}
if discoverableHandleMatches(nil, nil) || discoverableHandleMatches(owner, nil) {
t.Fatal("an empty handle must never match")
}
}