feat(mfa): account MFA management, passkey registration and step-up
This commit is contained in:
@@ -51,6 +51,8 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish)
|
||||
r.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
|
||||
r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
|
||||
r.POST("/auth/mfa/enrol/passkey/begin", auth.HandleEnrolPasskeyBegin)
|
||||
r.POST("/auth/mfa/enrol/passkey/finish", auth.HandleEnrolPasskeyFinish)
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
r.GET("/auth/me", auth.HandleMe)
|
||||
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
|
||||
@@ -99,6 +101,18 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/tokens", createToken)
|
||||
apiGroup.DELETE("/tokens/:id", revokeToken)
|
||||
|
||||
apiGroup.GET("/me/mfa", getMyMFA)
|
||||
apiGroup.POST("/me/mfa/totp/setup", auth.RequireStepUp(), setupTOTP)
|
||||
apiGroup.POST("/me/mfa/totp/confirm", confirmTOTP)
|
||||
apiGroup.DELETE("/me/mfa/totp", auth.RequireStepUp(), removeTOTP)
|
||||
apiGroup.POST("/me/mfa/recovery/regenerate", auth.RequireStepUp(), regenerateRecoveryCodes)
|
||||
apiGroup.POST("/me/passkeys/begin", auth.RequireStepUp(), auth.HandleRegisterPasskeyBegin)
|
||||
apiGroup.POST("/me/passkeys/finish", auth.RequireStepUp(), auth.HandleRegisterPasskeyFinish)
|
||||
apiGroup.PATCH("/me/passkeys/:id", renamePasskey)
|
||||
apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey)
|
||||
apiGroup.POST("/me/step-up", stepUp)
|
||||
apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA)
|
||||
|
||||
apiGroup.GET("/openapi.json", getOpenAPI)
|
||||
apiGroup.GET("/docs", getAPIDocs)
|
||||
apiGroup.GET("/docs/scalar.js", getScalarJS)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// oidcUser refuses MFA management for a user whose IdP owns authentication.
|
||||
func oidcUser(c *gin.Context) bool {
|
||||
u, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
|
||||
return err == nil && u.AuthSource == models.AuthOIDC
|
||||
}
|
||||
|
||||
// getMyMFA reports this user's factors.
|
||||
//
|
||||
// @Summary Get my MFA status
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{totp_enabled=bool,passkeys=[]models.WebAuthnCredential,recovery_remaining=int,require_mfa=bool,applicable=bool}
|
||||
// @Router /me/mfa [get]
|
||||
func getMyMFA(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
m, err := services.GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
passkeys, err := services.ListPasskeys(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"totp_enabled": m != nil && m.TOTPConfirmedAt != nil,
|
||||
"passkeys": passkeys,
|
||||
"recovery_remaining": services.RecoveryCodesRemaining(m),
|
||||
"require_mfa": services.RequireMFAForInstance(instanceID),
|
||||
"applicable": !oidcUser(c),
|
||||
})
|
||||
}
|
||||
|
||||
// setupTOTP issues a new unconfirmed secret.
|
||||
//
|
||||
// @Summary Start TOTP setup
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{secret=string,otpauth_uri=string}
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp/setup [post]
|
||||
func setupTOTP(c *gin.Context) {
|
||||
if oidcUser(c) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "your identity provider manages sign-in", "code": "mfa_not_applicable"})
|
||||
return
|
||||
}
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
issuer := "Vantage"
|
||||
if inst, err := services.GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
|
||||
issuer = inst.Name
|
||||
}
|
||||
secret, uri, err := services.StartTOTPSetup(instanceID, userID, issuer, auth.GetSessionFromContext(c).Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
|
||||
}
|
||||
|
||||
// confirmTOTP activates the pending secret and issues recovery codes if this
|
||||
// is the user's first factor.
|
||||
//
|
||||
// @Summary Confirm TOTP setup
|
||||
// @Tags mfa
|
||||
// @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 /me/mfa/totp/confirm [post]
|
||||
func confirmTOTP(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
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(instanceID, userID, body.Code); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that code is not valid", "code": "invalid_code"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.enrolled", actorFromCtx(c), "", "", "factor=totp")
|
||||
|
||||
m, _ := services.GetUserMFA(instanceID, userID)
|
||||
if services.RecoveryCodesRemaining(m) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
codes, err := services.IssueRecoveryCodes(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
}
|
||||
|
||||
// removeTOTP drops the TOTP factor. Step-up guarded at the route.
|
||||
//
|
||||
// @Summary Remove TOTP
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 204
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/mfa/totp [delete]
|
||||
func removeTOTP(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorTOTP); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
|
||||
return
|
||||
}
|
||||
if err := services.RemoveTOTP(instanceID, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=totp")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// regenerateRecoveryCodes invalidates the old set. Step-up guarded.
|
||||
//
|
||||
// @Summary Regenerate recovery codes
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{recovery_codes=[]string}
|
||||
// @Router /me/mfa/recovery/regenerate [post]
|
||||
func regenerateRecoveryCodes(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
codes, err := services.IssueRecoveryCodes(instanceID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.recovery_regenerated", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
||||
}
|
||||
|
||||
// renamePasskey and deletePasskey work on the hex credential ID.
|
||||
//
|
||||
// @Summary Rename a passkey
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Credential ID"
|
||||
// @Param body body object{name=string} true "New name"
|
||||
// @Success 204
|
||||
// @Router /me/passkeys/{id} [patch]
|
||||
func renamePasskey(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||||
return
|
||||
}
|
||||
err := services.RenamePasskey(auth.InstanceID(c), auth.UserID(c), c.Param("id"), body.Name)
|
||||
if errors.Is(err, services.ErrNoPasskey) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Delete a passkey
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Param id path string true "Credential ID"
|
||||
// @Success 204
|
||||
// @Failure 409 {object} object{error=string,code=string}
|
||||
// @Router /me/passkeys/{id} [delete]
|
||||
func deletePasskey(c *gin.Context) {
|
||||
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
|
||||
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorWebAuthn); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
|
||||
return
|
||||
}
|
||||
err := services.DeletePasskey(instanceID, userID, c.Param("id"))
|
||||
if errors.Is(err, services.ErrNoPasskey) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=webauthn")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// resetUserMFA lets an owner or admin clear somebody else's factors.
|
||||
//
|
||||
// @Summary Reset another member's MFA
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204
|
||||
// @Failure 403 {object} object{error=string}
|
||||
// @Router /org/users/{id}/mfa [delete]
|
||||
func resetUserMFA(c *gin.Context) {
|
||||
instanceID := auth.InstanceID(c)
|
||||
target, err := services.GetUserInInstance(instanceID, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no such member"})
|
||||
return
|
||||
}
|
||||
// An admin resetting an owner's MFA would be a promotion path: clear the
|
||||
// factor, phish the password, hold the instance.
|
||||
if auth.Role(c) != models.RoleOwner && target.Role == models.RoleOwner {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can reset an owner's MFA"})
|
||||
return
|
||||
}
|
||||
if err := services.ClearMFA(instanceID, target.UserID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent(instanceID, "mfa.reset", actorFromCtx(c), "", "", "target="+target.Email)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// stepUp re-authenticates the current session.
|
||||
//
|
||||
// @Summary Re-authenticate before a sensitive action
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{totp=string,recovery=string,password=string} true "One factor"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/step-up [post]
|
||||
func stepUp(c *gin.Context) {
|
||||
sess := auth.GetSessionFromContext(c)
|
||||
var body struct {
|
||||
TOTP string `json:"totp"`
|
||||
Recovery string `json:"recovery"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case body.TOTP != "":
|
||||
err = services.VerifyTOTPCode(sess.InstanceID, sess.UserID, body.TOTP)
|
||||
case body.Recovery != "":
|
||||
err = services.UseRecoveryCode(sess.InstanceID, sess.UserID, body.Recovery)
|
||||
case body.Password != "":
|
||||
// Password is offered only to a user with no MFA at all; accepting it
|
||||
// from an enrolled user would demote step-up to what they already did.
|
||||
has, herr := services.HasMFA(sess.InstanceID, sess.UserID)
|
||||
if herr != nil || has {
|
||||
err = services.ErrBadCode
|
||||
} else {
|
||||
u, uerr := services.GetUserInInstance(sess.InstanceID, sess.UserID)
|
||||
if uerr != nil || !services.VerifyPassword(u, body.Password) {
|
||||
err = services.ErrBadCode
|
||||
}
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
services.LogEvent(sess.InstanceID, "step_up.failed", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that did not verify", "code": "invalid_code"})
|
||||
return
|
||||
}
|
||||
if err := auth.TouchStepUpFromRequest(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "step_up.ok", actorFromCtx(c), "", "", "")
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
|
||||
@@ -91,3 +95,120 @@ func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
|
||||
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyBegin starts forced passkey enrolment during sign-in.
|
||||
// Identical to HandleRegisterPasskeyBegin except the user comes from the
|
||||
// enrol-only ticket rather than a session, since none exists yet.
|
||||
//
|
||||
// @Summary Begin forced passkey enrolment during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/begin [post]
|
||||
func HandleEnrolPasskeyBegin(c *gin.Context) {
|
||||
t, _, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: t.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyFinish stores the new credential and finishes forced
|
||||
// enrolment, minting the session that HandleEnrolTOTPConfirm also produces.
|
||||
//
|
||||
// @Summary Complete forced passkey enrolment and sign in
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/finish [post]
|
||||
func HandleEnrolPasskeyFinish(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that enrolment expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
if !cred.Flags.UserVerified {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, tr := range parsed.Response.Transports {
|
||||
transports = append(transports, string(tr))
|
||||
}
|
||||
if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
finishEnrolment(c, t, ticketID, services.FactorWebAuthn)
|
||||
}
|
||||
|
||||
@@ -56,3 +56,15 @@ func RequireStepUp() gin.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TouchStepUpFromRequest records a fresh step-up against the session the
|
||||
// request arrived on. It lives in auth because the cookie name and the Redis
|
||||
// key are this package's business.
|
||||
func TouchStepUpFromRequest(c *gin.Context) error {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := GetSessionFromContext(c)
|
||||
return TouchStepUp(c.Request.Context(), cookie.Value, sess)
|
||||
}
|
||||
|
||||
@@ -200,6 +200,123 @@ func HandleMFAWebAuthnFinish(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyBegin starts registration for the signed-in user.
|
||||
//
|
||||
// @Summary Begin passkey registration
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Router /me/passkeys/begin [post]
|
||||
func HandleRegisterPasskeyBegin(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: sess.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyFinish stores the new credential.
|
||||
//
|
||||
// @Summary Complete passkey registration
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Router /me/passkeys/finish [post]
|
||||
func HandleRegisterPasskeyFinish(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
if !cred.Flags.UserVerified {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, t := range parsed.Response.Transports {
|
||||
transports = append(transports, string(t))
|
||||
}
|
||||
if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn")
|
||||
|
||||
// A first factor earns recovery codes; later ones do not reissue them.
|
||||
m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID)
|
||||
if services.RecoveryCodesRemaining(m) == 0 {
|
||||
codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
|
||||
// step-up, so the verification rules (user verification, clone detection,
|
||||
// instance scope) exist once.
|
||||
|
||||
@@ -377,6 +377,57 @@ func IssueRecoveryCodes(instanceID, userID string) ([]string, error) {
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
var ErrMFARequiredByPolicy = errors.New("this instance requires multi-factor authentication; add another factor before removing this one")
|
||||
|
||||
// canRemoveFactor is pure so the rule is testable without a database.
|
||||
// removing is FactorTOTP or FactorWebAuthn, and for a passkey it means one of
|
||||
// the counted passkeys.
|
||||
func canRemoveFactor(requireMFA, hasTOTP bool, passkeys int, removing string) error {
|
||||
if !requireMFA {
|
||||
return nil
|
||||
}
|
||||
remaining := 0
|
||||
if hasTOTP && removing != FactorTOTP {
|
||||
remaining++
|
||||
}
|
||||
switch removing {
|
||||
case FactorWebAuthn:
|
||||
remaining += passkeys - 1
|
||||
default:
|
||||
remaining += passkeys
|
||||
}
|
||||
if remaining > 0 {
|
||||
return nil
|
||||
}
|
||||
return ErrMFARequiredByPolicy
|
||||
}
|
||||
|
||||
// CheckCanRemoveFactor reads the current state and applies the rule.
|
||||
func CheckCanRemoveFactor(instanceID, userID, removing string) error {
|
||||
m, err := GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
passkeys, err := CountPasskeys(instanceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return canRemoveFactor(RequireMFAForInstance(instanceID), m != nil && m.TOTPConfirmedAt != nil, int(passkeys), removing)
|
||||
}
|
||||
|
||||
// RemoveTOTP clears only the TOTP factor, leaving passkeys and recovery codes.
|
||||
func RemoveTOTP(instanceID, userID string) error {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("user_mfa").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID},
|
||||
bson.M{
|
||||
"$unset": bson.M{"totp_secret_enc": "", "totp_confirmed_at": "", "totp_pending_enc": ""},
|
||||
"$set": bson.M{"updated_at": time.Now()},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RecoveryCodesRemaining counts unused codes for the account page.
|
||||
func RecoveryCodesRemaining(m *models.UserMFA) int {
|
||||
if m == nil {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// Removing a factor under an MFA policy must leave at least one behind, or the
|
||||
// user locks themselves out of an instance that will then demand enrolment
|
||||
// they cannot complete without signing in.
|
||||
func TestCanRemoveFactor(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requireMFA bool
|
||||
totp bool
|
||||
passkeys int
|
||||
removing string
|
||||
wantRefusal bool
|
||||
}{
|
||||
{"policy off, last factor", false, true, 0, FactorTOTP, false},
|
||||
{"policy on, totp plus passkey, drop totp", true, true, 1, FactorTOTP, false},
|
||||
{"policy on, last totp", true, true, 0, FactorTOTP, true},
|
||||
{"policy on, last passkey", true, false, 1, FactorWebAuthn, true},
|
||||
{"policy on, two passkeys, drop one", true, false, 2, FactorWebAuthn, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := canRemoveFactor(tc.requireMFA, tc.totp, tc.passkeys, tc.removing)
|
||||
if tc.wantRefusal != (err != nil) {
|
||||
t.Fatalf("canRemoveFactor refusal = %v, want %v", err != nil, tc.wantRefusal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user