Files
zt/controller/model/mfa_manager.go
Andrew MartinezandGitHub 87c9ce3718 fix #3178 apis missing from controllers (#3180)
* fix #3178 apis missing from controllers, fixes #3193 adds totp auth query enrollment flag

- fixed controller store to no longer blank apis on all CUD actions
- controllers now report their current config state on leader change to
  avoid stale information
- fixes peer disconnect to only set online state
- adds enrollment state flags to auth queries
- adds test for OIDC MFA enrollment/recovery/delete
- adds controller store unit tests
2025-08-05 09:15:27 -04:00

365 lines
9.2 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"
"github.com/dgryski/dgoogauth"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/foundation/v2/errorz"
"github.com/openziti/storage/boltz"
"github.com/openziti/ziti/common/pb/edge_cmd_pb"
"github.com/openziti/ziti/controller/apierror"
"github.com/openziti/ziti/controller/change"
"github.com/openziti/ziti/controller/command"
"github.com/openziti/ziti/controller/db"
"github.com/openziti/ziti/controller/fields"
"github.com/openziti/ziti/controller/models"
"github.com/pkg/errors"
"github.com/skip2/go-qrcode"
"go.etcd.io/bbolt"
"google.golang.org/protobuf/proto"
"strings"
)
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)
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.Replace(backupStr, "=", "", -1)[: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.GetDb().Update(ctx.NewMutateContext(), func(ctx boltz.MutateContext) error {
return self.Store.DeleteWhere(ctx, fmt.Sprintf("identity = \"%s\"", id))
})
}
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
}