feat(mfa): pending-login ticket store

This commit is contained in:
2026-09-16 08:34:57 +00:00
parent 330c326fb5
commit 9c60adc836
2 changed files with 199 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
ticketTTL = 5 * time.Minute
maxTicketAttempts = 5
ticketPrefix = "km:mfa:"
pendingCookieName = "km_mfa_pending"
)
// ErrTicketExpired covers every unusable ticket - missing, timed out, or
// destroyed by too many wrong codes. They are one message on purpose: which of
// the three it was tells an attacker whether the password was right.
var ErrTicketExpired = errors.New("this sign-in attempt has expired; start again")
// Ticket is a password that verified but has not yet become a session. It is
// deliberately NOT a Session: nothing half-authenticated may reach a route
// under Middleware, and the way to guarantee that is for it never to be the
// type those routes read.
type Ticket struct {
UserID string `json:"user_id"`
InstanceID string `json:"instance_id"`
Email string `json:"email"`
Methods []string `json:"methods"`
EnrolOnly bool `json:"enrol_only"`
Attempts int `json:"attempts"`
}
type ticketScope int
const (
scopeVerify ticketScope = iota
scopeEnrol
)
func (t *Ticket) allows(s ticketScope) bool {
if t.EnrolOnly {
return s == scopeEnrol
}
return s == scopeVerify
}
func attemptsLeft(attempts int) int {
if attempts >= maxTicketAttempts {
return 0
}
return maxTicketAttempts - attempts
}
func CreateTicket(ctx context.Context, t *Ticket) (string, error) {
id, err := randomHex(32)
if err != nil {
return "", err
}
data, err := json.Marshal(t)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, ticketPrefix+id, data, ticketTTL).Err(); err != nil {
return "", err
}
return id, nil
}
func LoadTicket(ctx context.Context, id string) (*Ticket, error) {
data, err := rdb.Get(ctx, ticketPrefix+id).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrTicketExpired
}
if err != nil {
return nil, err
}
var t Ticket
if err := json.Unmarshal(data, &t); err != nil {
return nil, ErrTicketExpired
}
return &t, nil
}
// FailTicket records a wrong code and returns how many attempts remain. At zero
// the ticket is destroyed rather than left to time out.
func FailTicket(ctx context.Context, id string) (int, error) {
t, err := LoadTicket(ctx, id)
if err != nil {
return 0, err
}
t.Attempts++
if attemptsLeft(t.Attempts) == 0 {
_ = DeleteTicket(ctx, id)
return 0, nil
}
data, err := json.Marshal(t)
if err != nil {
return 0, err
}
// KEEPTTL: a wrong code must not extend the five-minute window.
if err := rdb.Set(ctx, ticketPrefix+id, data, redis.KeepTTL).Err(); err != nil {
return 0, err
}
return attemptsLeft(t.Attempts), nil
}
func DeleteTicket(ctx context.Context, id string) error {
return rdb.Del(ctx, ticketPrefix+id).Err()
}
func SetPendingCookie(c *gin.Context, id string) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: pendingCookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(ticketTTL.Seconds()),
})
}
func ClearPendingCookie(c *gin.Context) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: pendingCookieName, Value: "", Path: "/",
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
}
// ticketFromRequest resolves the pending ticket and enforces its scope. It
// writes the response and returns false when the ticket is unusable.
func ticketFromRequest(c *gin.Context, scope ticketScope) (*Ticket, string, bool) {
cookie, err := c.Request.Cookie(pendingCookieName)
if err != nil || cookie.Value == "" {
abortTicketExpired(c)
return nil, "", false
}
t, err := LoadTicket(c.Request.Context(), cookie.Value)
if err != nil {
abortTicketExpired(c)
return nil, "", false
}
if !t.allows(scope) {
abortTicketExpired(c)
return nil, "", false
}
return t, cookie.Value, true
}
func abortTicketExpired(c *gin.Context) {
ClearPendingCookie(c)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": ErrTicketExpired.Error(), "code": "mfa_ticket_expired",
})
}
+36
View File
@@ -0,0 +1,36 @@
package auth
import "testing"
// The ticket's attempt cap is the only per-ticket brute-force guard: five
// wrong codes must destroy it rather than let an attacker keep guessing
// against a single stolen password.
func TestAttemptsLeftCountsDownAndHitsZero(t *testing.T) {
cases := []struct {
attempts int
want int
}{
{0, 5}, {1, 4}, {4, 1}, {5, 0}, {9, 0},
}
for _, tc := range cases {
if got := attemptsLeft(tc.attempts); got != tc.want {
t.Errorf("attemptsLeft(%d) = %d, want %d", tc.attempts, got, tc.want)
}
}
}
// An enrol-only ticket exists because the instance requires MFA the user does
// not have. It must not satisfy a verification endpoint, and a verification
// ticket must not reach the enrolment endpoints - each would skip the other's
// purpose.
func TestTicketScopeIsEnforcedInBothDirections(t *testing.T) {
verify := &Ticket{Methods: []string{"totp"}}
enrol := &Ticket{EnrolOnly: true}
if !verify.allows(scopeVerify) || verify.allows(scopeEnrol) {
t.Error("a verification ticket must allow only verification")
}
if !enrol.allows(scopeEnrol) || enrol.allows(scopeVerify) {
t.Error("an enrolment ticket must allow only enrolment")
}
}