Files
ziti/controller/model/mfa_manager.go
Christopher Britton c639518724 Replicate admin MFA removal across HA cluster members (#4284)
* Run admin MFA removal as a single raft command so a lagging follower cannot serve a partial delete
2026-08-24 14:46:51 -04:00

405 lines
10 KiB
Go

/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package model
import (
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"github.com/dgryski/dgoogauth"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/foundation/v2/errorz"
"github.com/openziti/ziti/v2/common/pb/cmd_pb"
"github.com/openziti/ziti/v2/controller/storage/boltz"
"github.com/openziti/ziti/v2/common/pb/edge_cmd_pb"
"github.com/openziti/ziti/v2/controller/apierror"
"github.com/openziti/ziti/v2/controller/change"
"github.com/openziti/ziti/v2/controller/command"
"github.com/openziti/ziti/v2/controller/db"
"github.com/openziti/ziti/v2/controller/fields"
"github.com/openziti/ziti/v2/controller/models"
"github.com/pkg/errors"
"github.com/skip2/go-qrcode"
"go.etcd.io/bbolt"
"google.golang.org/protobuf/proto"
)
const (
WindowSizeTOTP int = 5
)
func NewMfaManager(env Env) *MfaManager {
manager := &MfaManager{
baseEntityManager: newBaseEntityManager[*Mfa, *db.Mfa](env, env.GetStores().Mfa),
}
manager.impl = manager
RegisterManagerDecoder[*Mfa](env, manager)
RegisterCommand(env, &RemoveMfaForIdentityCmd{}, &edge_cmd_pb.RemoveMfaForIdentityCmd{})
return manager
}
type MfaManager struct {
baseEntityManager[*Mfa, *db.Mfa]
}
func (self *MfaManager) NewModelEntity() *Mfa {
return &Mfa{}
}
func (self *MfaManager) CreateForIdentityId(identityId string, ctx *change.Context) (string, error) {
identity, err := self.env.GetManagers().Identity.Read(identityId)
if err != nil {
return "", err
}
return self.CreateForIdentity(identity, ctx)
}
func (self *MfaManager) CreateForIdentity(identity *Identity, ctx *change.Context) (string, error) {
secretBytes := make([]byte, 10)
_, _ = rand.Read(secretBytes)
secret := base32.StdEncoding.EncodeToString(secretBytes)
recoveryCodes, err := self.generateRecoveryCodes()
if err != nil {
return "", err
}
mfa := &Mfa{
BaseEntity: models.BaseEntity{},
IsVerified: false,
IdentityId: identity.Id,
Identity: identity,
Secret: secret,
RecoveryCodes: recoveryCodes,
}
err = self.Create(mfa, ctx)
if err != nil {
return "", err
}
return mfa.Id, err
}
func (self *MfaManager) Create(entity *Mfa, ctx *change.Context) error {
return DispatchCreate[*Mfa](self, entity, ctx)
}
func (self *MfaManager) ApplyCreate(cmd *command.CreateEntityCommand[*Mfa], ctx boltz.MutateContext) error {
return self.GetDb().Update(ctx, func(ctx boltz.MutateContext) error {
result := &MfaListResult{manager: self}
err := self.ListWithTx(ctx.Tx(), fmt.Sprintf(`identity = "%s"`, cmd.Entity.IdentityId), result.collect)
if err != nil {
return err
}
if len(result.Mfas) > 0 {
return apierror.NewMfaExistsError()
}
_, err = self.createEntityInTx(ctx, cmd.Entity)
return err
})
}
func (self *MfaManager) Update(entity *Mfa, checker fields.UpdatedFields, ctx *change.Context) error {
return DispatchUpdate[*Mfa](self, entity, checker, ctx)
}
func (self *MfaManager) ApplyUpdate(cmd *command.UpdateEntityCommand[*Mfa], ctx boltz.MutateContext) error {
var checker boltz.FieldChecker = self
if cmd.UpdatedFields != nil {
checker = &AndFieldChecker{first: self, second: cmd.UpdatedFields}
}
return self.updateEntity(cmd.Entity, checker, ctx)
}
func (self *MfaManager) IsUpdated(field string) bool {
return field == db.FieldMfaIsVerified || field == db.FieldMfaRecoveryCodes
}
func (self *MfaManager) Query(query string) (*MfaListResult, error) {
result := &MfaListResult{manager: self}
err := self.ListWithHandler(query, result.collect)
if err != nil {
return nil, err
}
return result, nil
}
func (self *MfaManager) ReadOneByIdentityId(identityId string) (*Mfa, error) {
query := fmt.Sprintf(`identity = "%s"`, identityId)
resultList, err := self.Query(query)
if err != nil {
return nil, err
}
if resultList.Count > 1 {
return nil, fmt.Errorf("too many MFAs associated to a single identity, expected 1 got %d for identityId %s", resultList.Count, identityId)
}
if resultList.Count == 0 {
return nil, nil
}
return resultList.Mfas[0], nil
}
// Verify will attempt to check a code (recovery or totp) against the current secret.
func (self *MfaManager) Verify(mfa *Mfa, code string, ctx *change.Context) (bool, error) {
//check recovery codes
for i, recoveryCode := range mfa.RecoveryCodes {
if recoveryCode == code {
mfa.RecoveryCodes = append(mfa.RecoveryCodes[:i], mfa.RecoveryCodes[i+1:]...)
if err := self.Update(mfa, nil, ctx); err != nil {
return false, err
}
return true, nil
}
}
return self.VerifyTOTP(mfa, code)
}
// VerifyTOTP verifies TOTP values only, not recovery codes
func (self *MfaManager) VerifyTOTP(mfa *Mfa, code string) (bool, error) {
otp := dgoogauth.OTPConfig{
Secret: mfa.Secret,
WindowSize: WindowSizeTOTP,
UTC: true,
}
return otp.Authenticate(code)
}
func (self *MfaManager) DeleteForIdentity(identity *Identity, code string, ctx *change.Context) error {
mfa, err := self.ReadOneByIdentityId(identity.Id)
if err != nil {
return err
}
if mfa == nil {
return errorz.NewNotFound()
}
if mfa.IsVerified {
//if MFA is enabled require a valid code
valid, err := self.Verify(mfa, code, ctx)
if err != nil || !valid {
return apierror.NewInvalidMfaTokenError()
}
}
if err = self.Delete(mfa.Id, ctx); err != nil {
return err
}
return nil
}
func (self *MfaManager) QrCodePng(mfa *Mfa) ([]byte, error) {
if mfa.IsVerified {
return nil, fmt.Errorf("MFA is already verified")
}
url := self.GetProvisioningUrl(mfa)
return qrcode.Encode(url, qrcode.Medium, 256)
}
func (self *MfaManager) GetProvisioningUrl(mfa *Mfa) string {
otcConfig := &dgoogauth.OTPConfig{
Secret: mfa.Secret,
WindowSize: WindowSizeTOTP,
UTC: true,
}
return otcConfig.ProvisionURIWithIssuer(mfa.Identity.Name, self.env.GetConfig().Edge.Totp.Hostname)
}
func (self *MfaManager) RecreateRecoveryCodes(mfa *Mfa, ctx *change.Context) error {
newCodes, err := self.generateRecoveryCodes()
if err != nil {
return err
}
mfa.RecoveryCodes = newCodes
return self.Update(mfa, nil, ctx)
}
func (self *MfaManager) generateRecoveryCodes() ([]string, error) {
recoveryCodes := []string{}
for i := 0; i < 20; i++ {
backupBytes := make([]byte, 8)
if _, err := rand.Read(backupBytes); err != nil {
return nil, err
}
backupStr := base32.StdEncoding.EncodeToString(backupBytes)
backupCode := strings.ReplaceAll(backupStr, "=", "")[:6]
recoveryCodes = append(recoveryCodes, backupCode)
}
return recoveryCodes, nil
}
func (self *MfaManager) Marshall(entity *Mfa) ([]byte, error) {
tags, err := edge_cmd_pb.EncodeTags(entity.Tags)
if err != nil {
return nil, err
}
msg := &edge_cmd_pb.Mfa{
Id: entity.Id,
Tags: tags,
IsVerified: entity.IsVerified,
IdentityId: entity.IdentityId,
Secret: entity.Secret,
RecoveryCodes: entity.RecoveryCodes,
}
return proto.Marshal(msg)
}
func (self *MfaManager) Unmarshall(bytes []byte) (*Mfa, error) {
msg := &edge_cmd_pb.Mfa{}
if err := proto.Unmarshal(bytes, msg); err != nil {
return nil, err
}
identity, err := self.env.GetManagers().Identity.Read(msg.IdentityId)
if err != nil {
return nil, errors.Wrapf(err, "unable to lookup identity for mfa with id=[%v]", msg.Id)
}
return &Mfa{
BaseEntity: models.BaseEntity{
Id: msg.Id,
Tags: edge_cmd_pb.DecodeTags(msg.Tags),
},
IsVerified: msg.IsVerified,
IdentityId: msg.IdentityId,
Identity: identity,
Secret: msg.Secret,
RecoveryCodes: msg.RecoveryCodes,
}, nil
}
// DeleteAllForIdentity is meant for administrators to remove all MFAs (enrolled or not) from an identity
func (self *MfaManager) DeleteAllForIdentity(id string, ctx *change.Context) error {
return self.Dispatch(&RemoveMfaForIdentityCmd{
manager: self,
identityId: id,
ctx: ctx,
})
}
func (self *MfaManager) ApplyRemoveMfaForIdentityCommand(cmd *RemoveMfaForIdentityCmd, ctx boltz.MutateContext) error {
return self.GetDb().Update(ctx, func(ctx boltz.MutateContext) error {
return self.Store.DeleteWhere(ctx, fmt.Sprintf(`identity = "%s"`, cmd.identityId))
})
}
func (self *MfaManager) CompleteTotpEnrollment(identityId string, code string, changeCtx *change.Context) error {
mfa, err := self.ReadOneByIdentityId(identityId)
if err != nil {
return err
}
if mfa == nil || mfa.IsVerified {
return errorz.NewNotFound()
}
ok, err := self.VerifyTOTP(mfa, code)
if err != nil {
pfxlog.Logger().WithError(err).Error("could not verify TOTP code")
return apierror.NewInvalidMfaTokenError()
}
if !ok {
return apierror.NewInvalidMfaTokenError()
}
mfa.IsVerified = true
if err := self.Update(mfa, nil, changeCtx); err != nil {
pfxlog.Logger().Errorf("could not update MFA with new MFA status: %v", err)
return errors.New("could not update MFA status")
}
return nil
}
type MfaListResult struct {
manager *MfaManager
Mfas []*Mfa
models.QueryMetaData
}
func (result *MfaListResult) collect(tx *bbolt.Tx, ids []string, queryMetaData *models.QueryMetaData) error {
result.QueryMetaData = *queryMetaData
for _, key := range ids {
Mfa, err := result.manager.readInTx(tx, key)
if err != nil {
return err
}
result.Mfas = append(result.Mfas, Mfa)
}
return nil
}
type RemoveMfaForIdentityCmd struct {
ctx *change.Context
manager *MfaManager
identityId string
}
func (self *RemoveMfaForIdentityCmd) Apply(ctx boltz.MutateContext) error {
return self.manager.ApplyRemoveMfaForIdentityCommand(self, ctx)
}
func (self *RemoveMfaForIdentityCmd) Encode() ([]byte, error) {
cmd := &edge_cmd_pb.RemoveMfaForIdentityCmd{
Ctx: ContextToProtobuf(self.ctx),
IdentityId: self.identityId,
}
return cmd_pb.EncodeProtobuf(cmd)
}
func (self *RemoveMfaForIdentityCmd) Decode(env Env, msg *edge_cmd_pb.RemoveMfaForIdentityCmd) error {
self.ctx = ProtobufToContext(msg.Ctx)
self.manager = env.GetManagers().Mfa
self.identityId = msg.IdentityId
return nil
}
func (self *RemoveMfaForIdentityCmd) GetChangeContext() *change.Context {
return self.ctx
}