zitadel/internal/user/model/user_view.go
Livio Spring f20539ef8f
fix(login): make sure first email verification is done before MFA check (#9039)
# Which Problems Are Solved

During authentication in the login UI, there is a check if the user's
MFA is already checked or needs to be setup.
In cases where the user was just set up or especially, if the user was
just federated without a verified email address, this can lead to the
problem, where OTP Email cannot be setup as there's no verified email
address.

# How the Problems Are Solved

- Added a check if there's no verified email address on the user and
require a mail verification check before checking for MFA.
Note: that if the user had a verified email address, but changed it and
has not verified it, they will still be prompted with an MFA check
before the email verification. This is make sure, we don't break the
existing behavior and the user's authentication is properly checked.

# Additional Changes

None

# Additional Context

- closes https://github.com/zitadel/zitadel/issues/9035
2024-12-13 11:37:20 +00:00

311 lines
7.5 KiB
Go

package model
import (
"time"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/zerrors"
)
type UserView struct {
ID string
UserName string
CreationDate time.Time
ChangeDate time.Time
State UserState
Sequence uint64
ResourceOwner string
LastLogin time.Time
PreferredLoginName string
LoginNames []string
*MachineView
*HumanView
}
type HumanView struct {
PasswordSet bool
PasswordInitRequired bool
PasswordChangeRequired bool
UsernameChangeRequired bool
PasswordChanged time.Time
FirstName string
LastName string
NickName string
DisplayName string
AvatarKey string
PreferredLanguage string
Gender Gender
Email string
IsEmailVerified bool
VerifiedEmail string
Phone string
IsPhoneVerified bool
Country string
Locality string
PostalCode string
Region string
StreetAddress string
OTPState MFAState
OTPSMSAdded bool
OTPEmailAdded bool
U2FTokens []*WebAuthNView
PasswordlessTokens []*WebAuthNView
MFAMaxSetUp domain.MFALevel
MFAInitSkipped time.Time
InitRequired bool
PasswordlessInitRequired bool
}
type WebAuthNView struct {
TokenID string
Name string
State MFAState
}
type MachineView struct {
LastKeyAdded time.Time
Name string
Description string
}
type UserSearchRequest struct {
Offset uint64
Limit uint64
SortingColumn UserSearchKey
Asc bool
Queries []*UserSearchQuery
}
type UserSearchKey int32
const (
UserSearchKeyUnspecified UserSearchKey = iota
UserSearchKeyUserID
UserSearchKeyUserName
UserSearchKeyFirstName
UserSearchKeyLastName
UserSearchKeyNickName
UserSearchKeyDisplayName
UserSearchKeyEmail
UserSearchKeyState
UserSearchKeyResourceOwner
UserSearchKeyLoginNames
UserSearchKeyType
UserSearchKeyPreferredLoginName
UserSearchKeyInstanceID
UserSearchOwnerRemoved
)
type UserSearchQuery struct {
Key UserSearchKey
Method domain.SearchMethod
Value interface{}
}
type UserSearchResponse struct {
Offset uint64
Limit uint64
TotalResult uint64
Result []*UserView
Sequence uint64
Timestamp time.Time
}
type UserState int32
const (
UserStateUnspecified UserState = iota
UserStateActive
UserStateInactive
UserStateDeleted
UserStateLocked
UserStateSuspend
UserStateInitial
)
type Gender int32
const (
GenderUnspecified Gender = iota
GenderFemale
GenderMale
GenderDiverse
)
func (r *UserSearchRequest) EnsureLimit(limit uint64) error {
if r.Limit > limit {
return zerrors.ThrowInvalidArgument(nil, "SEARCH-zz62F", "Errors.Limit.ExceedsDefault")
}
if r.Limit == 0 {
r.Limit = limit
}
return nil
}
func (r *UserSearchRequest) AppendMyOrgQuery(orgID string) {
r.Queries = append(r.Queries, &UserSearchQuery{Key: UserSearchKeyResourceOwner, Method: domain.SearchMethodEquals, Value: orgID})
}
func (u *UserView) MFATypesSetupPossible(level domain.MFALevel, policy *domain.LoginPolicy) []domain.MFAType {
types := make([]domain.MFAType, 0)
switch level {
default:
fallthrough
case domain.MFALevelSecondFactor:
if policy.HasSecondFactors() {
for _, mfaType := range policy.SecondFactors {
switch mfaType {
case domain.SecondFactorTypeTOTP:
if u.OTPState != MFAStateReady {
types = append(types, domain.MFATypeTOTP)
}
case domain.SecondFactorTypeU2F:
types = append(types, domain.MFATypeU2F)
case domain.SecondFactorTypeOTPSMS:
if !u.OTPSMSAdded {
types = append(types, domain.MFATypeOTPSMS)
}
case domain.SecondFactorTypeOTPEmail:
if !u.OTPEmailAdded {
types = append(types, domain.MFATypeOTPEmail)
}
}
}
}
}
return types
}
func (u *UserView) MFATypesAllowed(level domain.MFALevel, policy *domain.LoginPolicy, isInternalAuthentication bool) ([]domain.MFAType, bool) {
types := make([]domain.MFAType, 0)
required := true
switch level {
default:
required = domain.RequiresMFA(policy.ForceMFA, policy.ForceMFALocalOnly, isInternalAuthentication)
fallthrough
case domain.MFALevelSecondFactor:
if policy.HasSecondFactors() {
for _, mfaType := range policy.SecondFactors {
switch mfaType {
case domain.SecondFactorTypeTOTP:
if u.OTPState == MFAStateReady {
types = append(types, domain.MFATypeTOTP)
}
case domain.SecondFactorTypeU2F:
if u.IsU2FReady() {
types = append(types, domain.MFATypeU2F)
}
case domain.SecondFactorTypeOTPSMS:
if u.OTPSMSAdded {
types = append(types, domain.MFATypeOTPSMS)
}
case domain.SecondFactorTypeOTPEmail:
if u.OTPEmailAdded {
types = append(types, domain.MFATypeOTPEmail)
}
}
}
}
}
return types, required
}
func (u *UserView) IsU2FReady() bool {
for _, token := range u.U2FTokens {
if token.State == MFAStateReady {
return true
}
}
return false
}
func (u *UserView) IsPasswordlessReady() bool {
for _, token := range u.PasswordlessTokens {
if token.State == MFAStateReady {
return true
}
}
return false
}
func (u *UserView) GetProfile() (*Profile, error) {
if u.HumanView == nil {
return nil, zerrors.ThrowPreconditionFailed(nil, "MODEL-WLTce", "Errors.User.NotHuman")
}
return &Profile{
ObjectRoot: models.ObjectRoot{
AggregateID: u.ID,
Sequence: u.Sequence,
ResourceOwner: u.ResourceOwner,
CreationDate: u.CreationDate,
ChangeDate: u.ChangeDate,
},
FirstName: u.FirstName,
LastName: u.LastName,
NickName: u.NickName,
DisplayName: u.DisplayName,
PreferredLanguage: language.Make(u.PreferredLanguage),
Gender: u.Gender,
PreferredLoginName: u.PreferredLoginName,
LoginNames: u.LoginNames,
AvatarKey: u.AvatarKey,
}, nil
}
func (u *UserView) GetPhone() (*Phone, error) {
if u.HumanView == nil {
return nil, zerrors.ThrowPreconditionFailed(nil, "MODEL-him4a", "Errors.User.NotHuman")
}
return &Phone{
ObjectRoot: models.ObjectRoot{
AggregateID: u.ID,
Sequence: u.Sequence,
ResourceOwner: u.ResourceOwner,
CreationDate: u.CreationDate,
ChangeDate: u.ChangeDate,
},
PhoneNumber: u.Phone,
IsPhoneVerified: u.IsPhoneVerified,
}, nil
}
func (u *UserView) GetEmail() (*Email, error) {
if u.HumanView == nil {
return nil, zerrors.ThrowPreconditionFailed(nil, "MODEL-PWd6K", "Errors.User.NotHuman")
}
return &Email{
ObjectRoot: models.ObjectRoot{
AggregateID: u.ID,
Sequence: u.Sequence,
ResourceOwner: u.ResourceOwner,
CreationDate: u.CreationDate,
ChangeDate: u.ChangeDate,
},
EmailAddress: u.Email,
IsEmailVerified: u.IsEmailVerified,
}, nil
}
func (u *UserView) GetAddress() (*Address, error) {
if u.HumanView == nil {
return nil, zerrors.ThrowPreconditionFailed(nil, "MODEL-DN61m", "Errors.User.NotHuman")
}
return &Address{
ObjectRoot: models.ObjectRoot{
AggregateID: u.ID,
Sequence: u.Sequence,
ResourceOwner: u.ResourceOwner,
CreationDate: u.CreationDate,
ChangeDate: u.ChangeDate,
},
Country: u.Country,
Locality: u.Locality,
PostalCode: u.PostalCode,
Region: u.Region,
StreetAddress: u.StreetAddress,
}, nil
}