feat(mfa): pending-login ticket store
This commit is contained in:
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user