feat(mfa): forced TOTP enrolment at sign-in

This commit is contained in:
2026-09-16 08:41:56 +00:00
parent 2d75832ceb
commit 3fa469c303
3 changed files with 128 additions and 0 deletions
+2
View File
@@ -45,6 +45,8 @@ func RegisterRoutes(r *gin.Engine) {
r.POST("/auth/login", auth.HandleLocalLogin)
r.POST("/auth/mfa/totp", auth.HandleMFATOTP)
r.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
r.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
+93
View File
@@ -0,0 +1,93 @@
package auth
import (
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
// from, before they hold a session. Only an enrol-only ticket reaches it.
//
// @Summary Start forced TOTP enrolment during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{secret=string,otpauth_uri=string}
// @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 {
return
}
inst, err := services.GetInstance(t.InstanceID)
issuer := "Vantage"
if err == nil && inst != nil && inst.Name != "" {
issuer = inst.Name
}
secret, uri, err := services.StartTOTPSetup(t.InstanceID, t.UserID, issuer, t.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
}
// HandleEnrolTOTPConfirm finishes forced enrolment and signs the user in.
//
// @Summary Confirm forced TOTP enrolment and sign in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/totp/confirm [post]
func HandleEnrolTOTPConfirm(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
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
}
if err := services.ConfirmTOTP(t.InstanceID, t.UserID, body.Code); err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that code is not valid", "code": "invalid_code", "attempts_left": left,
})
return
}
finishEnrolment(c, t, ticketID, services.FactorTOTP)
}
// finishEnrolment issues recovery codes, mints the session and audits, so the
// TOTP and passkey enrolment paths cannot drift apart.
func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
codes, err := services.IssueRecoveryCodes(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue recovery codes"})
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
}
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
}
+33
View File
@@ -357,3 +357,36 @@ func burnTOTPCode(userID, code string) error {
}
return nil
}
// IssueRecoveryCodes replaces the user's set and returns the plaintext once.
// Callers must not persist or log the return value.
func IssueRecoveryCodes(instanceID, userID string) ([]string, error) {
plain, stored, err := GenerateRecoveryCodes()
if err != nil {
return nil, err
}
ctx, cancel := mfaCtx()
defer cancel()
_, err = db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{"$set": bson.M{"recovery_codes": stored, "updated_at": time.Now()}},
options.UpdateOne().SetUpsert(true))
if err != nil {
return nil, err
}
return plain, nil
}
// RecoveryCodesRemaining counts unused codes for the account page.
func RecoveryCodesRemaining(m *models.UserMFA) int {
if m == nil {
return 0
}
n := 0
for _, c := range m.RecoveryCodes {
if c.UsedAt == nil {
n++
}
}
return n
}