Files
zitadel/backend/v3/domain/session_check_password.go
T
Marco A.andGitHub 701bca6003 feat(session): TOTP Check API with relation tables (#11886)
# Which Problems Are Solved

As part of #11035 , this PR implements the TOTP check logic for session
validation

# How the Problems Are Solved

  - Implement TOTP check logic and tests
- The tarpit function has been moved to a common file so that it can be
used by both TOTP and password checks
- Manual transaction management to avoid stalling the DB while verifier
function is executed
- Update `database.Change` `Matches()` function to allow comparison of
`time.Time` values
  - A converter package from GRPC to Domain model has been added

# Additional Context

This is a cherry-picked PR + changes, coming from
https://github.com/zitadel/zitadel/pull/11164
- Relates to #11035
2026-04-01 11:34:03 +02:00

298 lines
9.2 KiB
Go

package domain
import (
"context"
"errors"
"strings"
"time"
"github.com/zitadel/passwap"
"github.com/zitadel/zitadel/backend/v3/storage/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
type CheckPasswordType struct {
Password string
}
type PasswordCheckCommand struct {
CheckPassword *CheckPasswordType
SessionID string
InstanceID string
TarpitFunc tarpitFn
VerifierFn verifierFn
FetchedUser User
UpdatedHashedPsw string
CheckTime time.Time
// IsValidated indicates if the password check has completed (no transaction errors)
IsValidated bool
// IsValidationSuccessful indicates if the completed password check is successful
// (i.e. no functional error is returned)
IsValidationSuccessful bool
// IsUserLocked indicates if the user has been locked (i.e. lockout policy check failed)
IsUserLocked bool
}
// NewPasswordCheckCommand initializes a new [PasswordCheckCommand]
//
// If tarpitFunc is nil, the default tarpit will be used.
//
// verifyFn is a function that takes as input a target encoded password
// and an input password to verify. It returns an updated hash and an error.
// It defaults to [passwap.Swapper.Verify]
//
// The command does not implement [Transactional] due to verifyFn that might take a long time to execute.
// So the DB transaction will be started only after verifyFn has been run.
//
// Moreover, the command may return a functional error so manual management of the transaction is needed
// to avoid rollbacking a transaction despite having only a functional error.
func NewPasswordCheckCommand(sessionID, instanceID string, tarpitFunc tarpitFn, verifyFn func(encoded, password string) (updated string, err error), request *CheckPasswordType) *PasswordCheckCommand {
tf := sysConfig.Tarpit.Tarpit()
if tarpitFunc != nil {
tf = tarpitFunc
}
verifierFunction := passwordHasher.Verify
if verifyFn != nil {
verifierFunction = verifyFn
}
return &PasswordCheckCommand{
CheckPassword: request,
SessionID: strings.TrimSpace(sessionID),
InstanceID: strings.TrimSpace(instanceID),
TarpitFunc: tf,
VerifierFn: verifierFunction,
}
}
// Events implements [Commander].
func (p *PasswordCheckCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) {
if p.CheckPassword == nil || !p.IsValidated {
return nil, nil
}
toReturn := make([]eventstore.Command, 1, 3)
userAgg := &user.NewAggregate(p.FetchedUser.ID, p.FetchedUser.OrganizationID).Aggregate
if p.IsValidationSuccessful {
toReturn[0] = user.NewHumanPasswordCheckSucceededEvent(ctx, userAgg, nil)
if p.UpdatedHashedPsw != "" {
toReturn = append(toReturn, user.NewHumanPasswordHashUpdatedEvent(ctx, userAgg, p.UpdatedHashedPsw))
}
} else {
toReturn[0] = user.NewHumanPasswordCheckFailedEvent(ctx, userAgg, nil)
if p.IsUserLocked {
toReturn = append(toReturn, user.NewUserLockedEvent(ctx, userAgg))
}
}
toReturn = append(toReturn, session.NewPasswordCheckedEvent(ctx, &session.NewAggregate(p.SessionID, p.InstanceID).Aggregate, p.CheckTime))
return toReturn, nil
}
// Execute implements [Commander].
func (p *PasswordCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) {
if p.CheckPassword == nil {
return nil
}
humanRepo := opts.userRepo.Human()
sessionRepo := opts.sessionRepo
updatedHash, verifyErr := p.VerifierFn(p.FetchedUser.Human.Password.Hash, p.CheckPassword.Password)
pswCheckType, err := p.GetPasswordCheckAndError(verifyErr)
changes, changesErr := p.GetPasswordCheckChanges(ctx, opts, humanRepo, updatedHash, pswCheckType)
if changesErr != nil {
return changesErr
}
tx, txErr := opts.StartTransaction(ctx, nil)
if txErr != nil {
return zerrors.ThrowInternal(txErr, "DOM-IR1vH2", "failed starting transaction")
}
defer func() {
if endErr := tx.End(ctx, txErr); endErr != nil {
err = endErr
}
}()
updateCount, updateErr := humanRepo.Update(
ctx,
tx,
humanRepo.IDCondition(p.FetchedUser.ID),
changes,
)
if updateErr != nil {
txErr = zerrors.ThrowInternal(updateErr, "DOM-netNam", "failed updating user")
return txErr
}
if updateCount == 0 {
txErr = zerrors.ThrowNotFound(nil, "DOM-8wVrNc", "user not found")
return txErr
}
if updateCount > 1 {
txErr = zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-D4hy9C", "unexpected number of rows updated")
return txErr
}
var passwordFactor SessionFactor
if err == nil {
passwordFactor = &SessionFactorPassword{LastVerifiedAt: p.CheckTime}
} else {
passwordFactor = &SessionFactorPassword{LastFailedAt: p.CheckTime}
}
updateCount, updateErr = sessionRepo.Update(ctx, tx, sessionRepo.IDCondition(p.SessionID), sessionRepo.SetFactor(passwordFactor))
if updateErr != nil {
txErr = zerrors.ThrowInternal(updateErr, "DOM-IZagay", "failed updating session")
return txErr
}
if updateCount == 0 {
txErr = zerrors.ThrowNotFound(nil, "DOM-H9Q59c", "session not found")
return txErr
}
if updateCount > 1 {
txErr = zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-Tbvpy8", "unexpected number of rows updated")
return txErr
}
if err != nil && p.TarpitFunc != nil {
p.TarpitFunc(uint64(p.FetchedUser.Human.Password.FailedAttempts + 1))
}
p.IsValidated = true
p.IsValidationSuccessful = err == nil
// This error is functional so it should be returned AND the transaction should succeed.
// Hence, do not use [Transactional] and make sure txErr = nil
return err
}
func (p *PasswordCheckCommand) GetPasswordCheckChanges(ctx context.Context, opts *InvokeOpts, humanRepo HumanUserRepository, updatedHash string, checkType VerificationType) (database.Changes, error) {
dbUpdates := make(database.Changes, 1)
switch ct := checkType.(type) {
case *VerificationTypeSucceeded:
dbUpdates[0] = humanRepo.SetLastSuccessfulPasswordCheck(ct.VerifiedAt)
if updatedHash != "" {
p.UpdatedHashedPsw = updatedHash
dbUpdates = append(dbUpdates, humanRepo.SetPassword(updatedHash))
}
case *VerificationTypeFailed:
dbUpdates[0] = humanRepo.IncrementPasswordFailedAttempts()
lockoutPolicy, err := GetLockoutPolicy(ctx, opts.DB(), opts.lockoutSettingRepo, p.InstanceID, p.FetchedUser.OrganizationID)
if err != nil {
return nil, err
}
if lockoutPolicy != nil &&
lockoutPolicy.MaxPasswordAttempts != nil && *lockoutPolicy.MaxPasswordAttempts > 0 &&
uint64(p.FetchedUser.Human.Password.FailedAttempts+1) >= *lockoutPolicy.MaxPasswordAttempts {
dbUpdates = append(dbUpdates, humanRepo.SetState(UserStateLocked))
p.IsUserLocked = true
}
}
return dbUpdates, nil
}
func (p *PasswordCheckCommand) GetPasswordCheckAndError(err error) (VerificationType, error) {
p.CheckTime = time.Now()
if err == nil {
return &VerificationTypeSucceeded{VerifiedAt: p.CheckTime}, nil
}
// TODO(IAM-Marco): Do we actually want to differentiate? I feel that it's giving away relevant info
// about the password
if errors.Is(err, passwap.ErrPasswordMismatch) {
err = zerrors.ThrowInvalidArgument(
NewPasswordVerificationError(p.FetchedUser.Human.Password.FailedAttempts+1),
"DOM-3gcfDV",
"Errors.User.Password.Invalid",
)
return &VerificationTypeFailed{FailedAt: p.CheckTime}, err
}
return &VerificationTypeFailed{FailedAt: p.CheckTime}, zerrors.ThrowInternal(err, "DOM-xceNzI", "Errors.Internal")
}
// String implements [Commander].
func (p *PasswordCheckCommand) String() string {
return "PasswordCheckCommand"
}
// Validate implements [Commander].
func (p *PasswordCheckCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
if p.CheckPassword == nil {
return nil
}
if p.SessionID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-cRKWNx", "Errors.Missing.SessionID")
}
if p.InstanceID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-JnEtcJ", "Errors.Missing.InstanceID")
}
sessionRepo := opts.sessionRepo
userRepo := opts.userRepo
session, err := sessionRepo.Get(ctx, opts.DB(), database.WithCondition(sessionRepo.IDCondition(p.SessionID)))
if err != nil {
if errors.Is(err, &database.NoRowFoundError{}) {
return zerrors.ThrowNotFound(err, "DOM-0XRmp8", "session not found")
}
return zerrors.ThrowInternal(err, "DOM-qAoQrg", "failed fetching session")
}
if session.UserID == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-hord0Z", "Errors.User.UserIDMissing")
}
user, err := userRepo.Get(ctx, opts.DB(), database.WithCondition(userRepo.IDCondition(session.UserID)))
if err != nil {
if errors.Is(err, &database.NoRowFoundError{}) {
return zerrors.ThrowNotFound(err, "DOM-zxKosn", "Errors.User.NotFound")
}
return zerrors.ThrowInternal(err, "DOM-nKD4Gq", "failed fetching user")
}
if user.Human == nil {
return zerrors.ThrowPreconditionFailed(nil, "DOM-ADhxAx", "user not human")
}
human := user.Human
if user.State == UserStateLocked {
return zerrors.ThrowPreconditionFailedf(
NewPasswordVerificationError(user.Human.Password.FailedAttempts),
"DOM-D804Sj",
"Errors.User.Locked",
)
}
if human.Password.Hash == "" {
return zerrors.ThrowPreconditionFailed(nil, "DOM-gklgos", "Errors.User.Password.NotSet")
}
p.FetchedUser = *user
return nil
}
var _ Commander = (*PasswordCheckCommand)(nil)