diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index c5ffcb4..46fd684 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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) diff --git a/server/internal/auth/mfa_enrol.go b/server/internal/auth/mfa_enrol.go new file mode 100644 index 0000000..5ebbb6f --- /dev/null +++ b/server/internal/auth/mfa_enrol.go @@ -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}) +} diff --git a/server/internal/services/mfa.go b/server/internal/services/mfa.go index add9ed6..2b3ac54 100644 --- a/server/internal/services/mfa.go +++ b/server/internal/services/mfa.go @@ -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 +}