feat(mfa): second-factor sign-in with TOTP and recovery codes

This commit is contained in:
2026-09-16 08:39:48 +00:00
parent dfcfd1d3e2
commit 2d75832ceb
6 changed files with 254 additions and 7 deletions
+39 -7
View File
@@ -102,16 +102,48 @@ func HandleLocalLogin(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
requireMFA := services.RequireMFAForInstance(instanceID)
hasMFA, err := services.HasMFA(u.InstanceID, u.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.JSON(http.StatusOK, gin.H{"ok": true})
switch loginDecision(hasMFA, requireMFA) {
case "session":
if err := mintSession(c, u, []string{"pwd"}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
case "verify":
methods, err := services.MFAMethods(u.InstanceID, u.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"})
return
}
id, err := CreateTicket(c.Request.Context(), &Ticket{
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, Methods: methods,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetPendingCookie(c, id)
c.JSON(http.StatusOK, gin.H{"mfa_required": true, "methods": methods})
case "enrol":
id, err := CreateTicket(c.Request.Context(), &Ticket{
UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, EnrolOnly: true,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetPendingCookie(c, id)
c.JSON(http.StatusOK, gin.H{"enrol_required": true})
}
}
// HandleListPublicProviders is unauthenticated: it is what the login page reads
+121
View File
@@ -0,0 +1,121 @@
package auth
import (
"errors"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// loginDecision is the branch a verified password takes.
func loginDecision(hasMFA, requireMFA bool) string {
switch {
case hasMFA:
return "verify"
case requireMFA:
return "enrol"
default:
return "session"
}
}
// 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,
})
if err != nil {
return err
}
_ = services.TouchLastLogin(u.UserID)
ClearPendingCookie(c)
SetSessionCookie(c, sessionID)
return nil
}
// HandleMFATOTP completes a sign-in with a TOTP code.
//
// @Summary Complete sign-in with a TOTP code
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/totp [post]
func HandleMFATOTP(c *gin.Context) {
handleMFAVerify(c, services.FactorTOTP)
}
// HandleMFARecovery completes a sign-in with a recovery code.
//
// @Summary Complete sign-in with a recovery code
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Recovery code"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/recovery [post]
func HandleMFARecovery(c *gin.Context) {
handleMFAVerify(c, services.FactorRecovery)
}
func handleMFAVerify(c *gin.Context, factor string) {
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
var err error
switch factor {
case services.FactorTOTP:
err = services.VerifyTOTPCode(t.InstanceID, t.UserID, body.Code)
case services.FactorRecovery:
err = services.UseRecoveryCode(t.InstanceID, t.UserID, body.Code)
}
if err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor="+factor)
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
code := "invalid_code"
if errors.Is(err, services.ErrCodeReplayed) {
code = "invalid_code"
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that code is not valid", "code": code, "attempts_left": left,
})
return
}
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
if err := mintSession(c, u, []string{"pwd", factor}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
if factor == services.FactorRecovery {
services.LogEvent(t.InstanceID, "mfa.recovery_used", u.Email, "", "", "")
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+27
View File
@@ -0,0 +1,27 @@
package auth
import "testing"
// The whole point of the feature is in this table: a user with MFA is never
// handed a session by a password alone, and require_mfa turns "no factor" into
// forced enrolment rather than a free pass.
func TestLoginDecision(t *testing.T) {
cases := []struct {
name string
hasMFA bool
requireMFA bool
want string
}{
{"no mfa, not required", false, false, "session"},
{"no mfa, required", false, true, "enrol"},
{"has mfa, not required", true, false, "verify"},
{"has mfa, required", true, true, "verify"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := loginDecision(tc.hasMFA, tc.requireMFA); got != tc.want {
t.Fatalf("loginDecision(%v,%v) = %q, want %q", tc.hasMFA, tc.requireMFA, got, tc.want)
}
})
}
}
+20
View File
@@ -31,6 +31,14 @@ type Session struct {
TokenName string `json:"-"`
Scopes []string `json:"-"`
TokenScope map[string]string `json:"-"`
// AMR records how this session authenticated: pwd, otp, webauthn,
// recovery, oidc. Step-up reads it to exempt OIDC sessions, whose IdP owns
// authentication policy.
AMR []string `json:"amr,omitempty"`
// StepUpAt is the last successful re-authentication. Sign-in counts as one.
StepUpAt *time.Time `json:"step_up_at,omitempty"`
}
var rdb *redis.Client
@@ -103,3 +111,15 @@ func GetSession(ctx context.Context, id string) (*Session, error) {
func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
// TouchStepUp records a fresh re-authentication without disturbing the
// session's remaining lifetime.
func TouchStepUp(ctx context.Context, id string, sess *Session) error {
now := time.Now()
sess.StepUpAt = &now
data, err := json.Marshal(sess)
if err != nil {
return err
}
return rdb.Set(ctx, sessionPrefix+id, data, redis.KeepTTL).Err()
}