feat(mfa): forced TOTP enrolment at sign-in
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user