fix(oidc): IDP and passwordless user auth methods (#7998)

# Which Problems Are Solved

As already mentioned and (partially) fixed in #7992 we discovered,
issues with v2 tokens that where obtained through an IDP, with
passwordless authentication or with password authentication (wihtout any
2FA set up) using the v1 login for zitadel API calls
- (Previous) authentication through an IdP is now correctly treated as
auth method in case of a reauth even when the user is not redirected to
the IdP
- There were some cases where passwordless authentication was
successfully checked but not correctly set as auth method, which denied
access to ZITADEL API
- Users with password and passwordless, but no 2FA set up which
authenticate just wich password can access the ZITADEL API again

Additionally while testing we found out that because of #7969 the login
UI could completely break / block with the following error:
`sql: Scan error on column index 3, name "state": converting NULL to
int32 is unsupported (Internal)`
# How the Problems Are Solved

- IdP checks are treated the same way as other factors and it's ensured
that a succeeded check within the configured timeframe will always
provide the idp auth method
- `MFATypesAllowed` checks for possible passwordless authentication
- As with the v1 login, the token check now only requires MFA if the
policy is set or the user has 2FA set up
- UserAuthMethodsRequirements now always uses the correctly policy to
check for MFA enforcement
- `State` column is handled as nullable and additional events set the
state to active (as before #7969)

# Additional Changes

- Console now also checks for 403 (mfa required) errors (e.g. after
setting up the first 2FA in console) and redirects the user to the login
UI (with the current id_token as id_token_hint)
- Possible duplicates in auth methods / AMRs are removed now as well.

# Additional Context

- Bugs were introduced in #7822 and # and 7969 and only part of a
pre-release.
- partially already fixed with #7992
- Reported internally.
This commit is contained in:
Livio Spring
2024-05-28 10:59:49 +02:00
committed by GitHub
parent 4dc86c2415
commit ec222a13d7
15 changed files with 281 additions and 104 deletions

View File

@@ -2,6 +2,7 @@ package eventstore
import (
"context"
"slices"
"strings"
"time"
@@ -1030,15 +1031,11 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth
if err != nil {
return nil, err
}
if (!isInternalLogin || len(idps.Links) > 0) && len(request.LinkingUsers) == 0 && !checkVerificationTimeMaxAge(userSession.ExternalLoginVerification, request.LoginPolicy.ExternalLoginCheckLifetime, request) {
selectedIDPConfigID := request.SelectedIDPConfigID
if selectedIDPConfigID == "" {
selectedIDPConfigID = userSession.SelectedIDPConfigID
if (!isInternalLogin || len(idps.Links) > 0) && len(request.LinkingUsers) == 0 {
step := repo.idpChecked(request, idps.Links, userSession)
if step != nil {
return append(steps, step), nil
}
if selectedIDPConfigID == "" {
selectedIDPConfigID = idps.Links[0].IDPID
}
return append(steps, &domain.ExternalLoginStep{SelectedIDPConfigID: selectedIDPConfigID}), nil
}
if isInternalLogin || (!isInternalLogin && len(request.LinkingUsers) > 0) {
step := repo.firstFactorChecked(request, user, userSession)
@@ -1198,6 +1195,7 @@ func (repo *AuthRequestRepo) firstFactorChecked(request *domain.AuthRequest, use
var step domain.NextStep
if request.LoginPolicy.PasswordlessType != domain.PasswordlessTypeNotAllowed && user.IsPasswordlessReady() {
if checkVerificationTimeMaxAge(userSession.PasswordlessVerification, request.LoginPolicy.MultiFactorCheckLifetime, request) {
request.MFAsVerified = append(request.MFAsVerified, domain.MFATypeU2FUserVerification)
request.AuthTime = userSession.PasswordlessVerification
return nil
}
@@ -1225,8 +1223,27 @@ func (repo *AuthRequestRepo) firstFactorChecked(request *domain.AuthRequest, use
return &domain.PasswordStep{}
}
func (repo *AuthRequestRepo) idpChecked(request *domain.AuthRequest, idps []*query.IDPUserLink, userSession *user_model.UserSessionView) domain.NextStep {
if checkVerificationTimeMaxAge(userSession.ExternalLoginVerification, request.LoginPolicy.ExternalLoginCheckLifetime, request) {
request.IDPLoginChecked = true
request.AuthTime = userSession.ExternalLoginVerification
return nil
}
selectedIDPConfigID := request.SelectedIDPConfigID
if selectedIDPConfigID == "" {
selectedIDPConfigID = userSession.SelectedIDPConfigID
}
if selectedIDPConfigID == "" && len(idps) > 0 {
selectedIDPConfigID = idps[0].IDPID
}
return &domain.ExternalLoginStep{SelectedIDPConfigID: selectedIDPConfigID}
}
func (repo *AuthRequestRepo) mfaChecked(userSession *user_model.UserSessionView, request *domain.AuthRequest, user *user_model.UserView, isInternalAuthentication bool) (domain.NextStep, bool, error) {
mfaLevel := request.MFALevel()
if slices.Contains(request.MFAsVerified, domain.MFATypeU2FUserVerification) {
return nil, true, nil
}
allowedProviders, required := user.MFATypesAllowed(mfaLevel, request.LoginPolicy, isInternalAuthentication)
promptRequired := (user.MFAMaxSetUp < mfaLevel) || (len(allowedProviders) == 0 && required)
if promptRequired || !repo.mfaSkippedOrSetUp(user, request) {

View File

@@ -89,7 +89,7 @@ func (m *mockViewUserSession) UserSessionsByAgentID(string, string) ([]*user_vie
for i, user := range m.Users {
sessions[i] = &user_view_model.UserSessionView{
ResourceOwner: user.ResourceOwner,
State: int32(user.SessionState),
State: sql.Null[domain.UserSessionState]{V: user.SessionState},
UserID: user.UserID,
LoginName: sql.NullString{String: user.LoginName},
}
@@ -1682,11 +1682,12 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
isInternal bool
}
tests := []struct {
name string
args args
want domain.NextStep
wantChecked bool
errFunc func(err error) bool
name string
args args
want domain.NextStep
wantChecked bool
errFunc func(err error) bool
wantMFAVerified []domain.MFAType
}{
//{
// "required, prompt and false", //TODO: enable when LevelsOfAssurance is checked
@@ -1718,6 +1719,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
nil,
false,
zerrors.IsPreconditionFailed,
nil,
},
{
"not set up, no mfas configured, no prompt and true",
@@ -1737,6 +1739,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
nil,
true,
nil,
nil,
},
{
"not set up, prompt and false",
@@ -1761,6 +1764,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
},
false,
nil,
nil,
},
{
"not set up, forced by org, true",
@@ -1787,6 +1791,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
},
false,
nil,
nil,
},
{
"not set up and skipped, true",
@@ -1807,6 +1812,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
nil,
true,
nil,
nil,
},
{
"checked second factor, true",
@@ -1829,6 +1835,38 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
nil,
true,
nil,
[]domain.MFAType{domain.MFATypeTOTP},
},
{
"checked passwordless, true",
args{
request: &domain.AuthRequest{
LoginPolicy: &domain.LoginPolicy{
SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeTOTP},
SecondFactorCheckLifetime: 18 * time.Hour,
MultiFactors: []domain.MultiFactorType{domain.MultiFactorTypeU2FWithPIN},
MultiFactorCheckLifetime: 18 * time.Hour,
},
MFAsVerified: []domain.MFAType{domain.MFATypeU2FUserVerification},
},
user: &user_model.UserView{
HumanView: &user_model.HumanView{
MFAMaxSetUp: domain.MFALevelMultiFactor,
PasswordlessTokens: []*user_model.WebAuthNView{
{
TokenID: "tokenID",
State: user_model.MFAStateReady,
},
},
},
},
userSession: &user_model.UserSessionView{PasswordlessVerification: testNow.Add(-5 * time.Hour)},
isInternal: true,
},
nil,
true,
nil,
[]domain.MFAType{domain.MFATypeU2FUserVerification},
},
{
"not checked, check and false",
@@ -1854,6 +1892,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
},
false,
nil,
nil,
},
{
"external not checked or forced but set up, want step",
@@ -1878,6 +1917,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
},
false,
nil,
nil,
},
{
"external not forced but checked",
@@ -1900,6 +1940,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
nil,
true,
nil,
[]domain.MFAType{domain.MFATypeTOTP},
},
{
"external not checked but required, want step",
@@ -1927,6 +1968,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
},
false,
nil,
nil,
},
{
"external not checked but local required",
@@ -1950,6 +1992,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
nil,
true,
nil,
nil,
},
}
for _, tt := range tests {
@@ -1964,6 +2007,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) {
t.Errorf("mfaChecked() checked = %v, want %v", ok, tt.wantChecked)
}
assert.Equal(t, tt.want, got)
assert.ElementsMatch(t, tt.args.request.MFAsVerified, tt.wantMFAVerified)
})
}
}

View File

@@ -32,7 +32,7 @@ func (repo *UserRepo) UserSessionUserIDsByAgentID(ctx context.Context, agentID s
}
userIDs := make([]string, 0, len(userSessions))
for _, session := range userSessions {
if session.State == int32(domain.UserSessionStateActive) {
if session.State.V == domain.UserSessionStateActive {
userIDs = append(userIDs, session.UserID)
}
}

View File

@@ -220,6 +220,7 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err
user.HumanPasswordCheckFailedType:
columns, err := sessionColumns(event,
handler.NewCol(view_model.UserSessionKeyPasswordVerification, time.Time{}),
handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive),
)
if err != nil {
return nil, err
@@ -241,6 +242,7 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err
user.HumanU2FTokenCheckFailedType:
columns, err := sessionColumns(event,
handler.NewCol(view_model.UserSessionKeySecondFactorVerification, time.Time{}),
handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive),
)
if err != nil {
return nil, err
@@ -317,6 +319,7 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err
columns, err := sessionColumns(event,
handler.NewCol(view_model.UserSessionKeyPasswordlessVerification, time.Time{}),
handler.NewCol(view_model.UserSessionKeyMultiFactorVerification, time.Time{}),
handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive),
)
if err != nil {
return nil, err