diff --git a/server/internal/auth/mfaticket.go b/server/internal/auth/mfaticket.go new file mode 100644 index 0000000..3fbe906 --- /dev/null +++ b/server/internal/auth/mfaticket.go @@ -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", + }) +} diff --git a/server/internal/auth/mfaticket_test.go b/server/internal/auth/mfaticket_test.go new file mode 100644 index 0000000..f608737 --- /dev/null +++ b/server/internal/auth/mfaticket_test.go @@ -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") + } +}