feat: add ldap external idp to login api (#5938)

* fix: handling of ldap login through separate endpoint

* fix: handling of ldap login through separate endpoint

* fix: handling of ldap login through separate endpoint

* fix: successful intent for ldap

* fix: successful intent for ldap

* fix: successful intent for ldap

* fix: add changes from code review

* fix: remove set intent credentials and handle ldap errors

* fix: remove set intent credentials and handle ldap errors

* refactor into separate methods and fix merge

* remove mocks

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz
2023-08-16 13:29:57 +02:00
committed by GitHub
parent 1b923425cd
commit 52f68f8db8
27 changed files with 726 additions and 149 deletions

View File

@@ -2,6 +2,7 @@ package user
import (
"context"
errs "errors"
"io"
"golang.org/x/text/language"
@@ -14,6 +15,9 @@ import (
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/idp/providers/ldap"
"github.com/zitadel/zitadel/internal/query"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
@@ -126,11 +130,22 @@ func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_
}
func (s *Server) StartIdentityProviderFlow(ctx context.Context, req *user.StartIdentityProviderFlowRequest) (_ *user.StartIdentityProviderFlowResponse, err error) {
id, details, err := s.command.CreateIntent(ctx, req.GetIdpId(), req.GetSuccessUrl(), req.GetFailureUrl(), authz.GetCtxData(ctx).OrgID)
switch t := req.GetContent().(type) {
case *user.StartIdentityProviderFlowRequest_Urls:
return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls)
case *user.StartIdentityProviderFlowRequest_Ldap:
return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap)
default:
return nil, errors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderFlow not implemented", t)
}
}
func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderFlowResponse, error) {
intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, urls.GetSuccessUrl(), urls.GetFailureUrl(), authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}
authURL, err := s.command.AuthURLFromProvider(ctx, req.GetIdpId(), id, s.idpCallback(ctx))
authURL, err := s.command.AuthURLFromProvider(ctx, idpID, intentWriteModel.AggregateID, s.idpCallback(ctx))
if err != nil {
return nil, err
}
@@ -140,6 +155,79 @@ func (s *Server) StartIdentityProviderFlow(ctx context.Context, req *user.StartI
}, nil
}
func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderFlowResponse, error) {
intentWriteModel, details, err := s.command.CreateIntent(ctx, idpID, "", "", authz.GetCtxData(ctx).OrgID)
if err != nil {
return nil, err
}
externalUser, userID, attributes, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword())
if err != nil {
if err := s.command.FailIDPIntent(ctx, intentWriteModel, err.Error()); err != nil {
return nil, err
}
return nil, err
}
token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, attributes)
if err != nil {
return nil, err
}
return &user.StartIdentityProviderFlowResponse{
Details: object.DomainToDetailsPb(details),
NextStep: &user.StartIdentityProviderFlowResponse_Intent{Intent: &user.Intent{IntentId: intentWriteModel.AggregateID, Token: token}},
}, nil
}
func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) {
idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpID)
if err != nil {
return "", err
}
externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID)
if err != nil {
return "", err
}
queries := []query.SearchQuery{
idQuery, externalIDQuery,
}
links, err := s.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, false)
if err != nil {
return "", err
}
if len(links.Links) == 1 {
return links.Links[0].UserID, nil
}
return "", nil
}
func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, map[string][]string, error) {
provider, err := s.command.GetProvider(ctx, idpID, "")
if err != nil {
return nil, "", nil, err
}
ldapProvider, ok := provider.(*ldap.Provider)
if !ok {
return nil, "", nil, errors.ThrowInvalidArgument(nil, "IDP-9a02j2n2bh", "Errors.ExternalIDP.IDPTypeNotImplemented")
}
session := ldapProvider.GetSession(username, password)
externalUser, err := session.FetchUser(ctx)
if errs.Is(err, ldap.ErrFailedLogin) || errs.Is(err, ldap.ErrNoSingleUser) {
return nil, "", nil, errors.ThrowInvalidArgument(nil, "COMMAND-nzun2i", "Errors.User.ExternalIDP.LoginFailed")
}
if err != nil {
return nil, "", nil, err
}
userID, err := s.checkLinkedExternalUser(ctx, idpID, externalUser.GetID())
if err != nil {
return nil, "", nil, err
}
attributes := make(map[string][]string, 0)
for _, item := range session.Entry.Attributes {
attributes[item.Name] = item.Values
}
return externalUser, userID, attributes, nil
}
func (s *Server) RetrieveIdentityProviderInformation(ctx context.Context, req *user.RetrieveIdentityProviderInformationRequest) (_ *user.RetrieveIdentityProviderInformationResponse, err error) {
intent, err := s.command.GetIntentWriteModel(ctx, req.GetIntentId(), authz.GetCtxData(ctx).OrgID)
if err != nil {
@@ -155,41 +243,83 @@ func (s *Server) RetrieveIdentityProviderInformation(ctx context.Context, req *u
}
func intentToIDPInformationPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *user.RetrieveIdentityProviderInformationResponse, err error) {
var idToken *string
if intent.IDPIDToken != "" {
idToken = &intent.IDPIDToken
}
var accessToken string
if intent.IDPAccessToken != nil {
accessToken, err = crypto.DecryptString(intent.IDPAccessToken, alg)
if err != nil {
return nil, err
}
}
rawInformation := new(structpb.Struct)
err = rawInformation.UnmarshalJSON(intent.IDPUser)
if err != nil {
return nil, err
}
return &user.RetrieveIdentityProviderInformationResponse{
Details: &object_pb.Details{
Sequence: intent.ProcessedSequence,
ChangeDate: timestamppb.New(intent.ChangeDate),
ResourceOwner: intent.ResourceOwner,
},
information := &user.RetrieveIdentityProviderInformationResponse{
Details: intentToDetailsPb(intent),
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: accessToken,
IdToken: idToken,
},
},
IdpId: intent.IDPID,
UserId: intent.IDPUserID,
UserName: intent.IDPUserName,
RawInformation: rawInformation,
},
}
if intent.IDPIDToken != "" || intent.IDPAccessToken != nil {
information.IdpInformation.Access, err = idpOAuthTokensToPb(intent.IDPIDToken, intent.IDPAccessToken, alg)
if err != nil {
return nil, err
}
}
if intent.IDPEntryAttributes != nil {
access, err := IDPEntryAttributesToPb(intent.IDPEntryAttributes)
if err != nil {
return nil, err
}
information.IdpInformation.Access = access
}
return information, nil
}
func idpOAuthTokensToPb(idpIDToken string, idpAccessToken *crypto.CryptoValue, alg crypto.EncryptionAlgorithm) (_ *user.IDPInformation_Oauth, err error) {
var idToken *string
if idpIDToken != "" {
idToken = &idpIDToken
}
var accessToken string
if idpAccessToken != nil {
accessToken, err = crypto.DecryptString(idpAccessToken, alg)
if err != nil {
return nil, err
}
}
return &user.IDPInformation_Oauth{
Oauth: &user.IDPOAuthAccessInformation{
AccessToken: accessToken,
IdToken: idToken,
},
}, nil
}
func intentToDetailsPb(intent *command.IDPIntentWriteModel) *object_pb.Details {
return &object_pb.Details{
Sequence: intent.ProcessedSequence,
ChangeDate: timestamppb.New(intent.ChangeDate),
ResourceOwner: intent.ResourceOwner,
}
}
func IDPEntryAttributesToPb(entryAttributes map[string][]string) (*user.IDPInformation_Ldap, error) {
values := make(map[string]interface{}, 0)
for k, v := range entryAttributes {
intValues := make([]interface{}, len(v))
for i, value := range v {
intValues[i] = value
}
values[k] = intValues
}
attributes, err := structpb.NewStruct(values)
if err != nil {
return nil, err
}
return &user.IDPInformation_Ldap{
Ldap: &user.IDPLDAPAccessInformation{
Attributes: attributes,
},
}, nil
}

View File

@@ -650,9 +650,13 @@ func TestServer_StartIdentityProviderFlow(t *testing.T) {
args: args{
CTX,
&user.StartIdentityProviderFlowRequest{
IdpId: idpID,
SuccessUrl: "https://example.com/success",
FailureUrl: "https://example.com/failure",
IdpId: idpID,
Content: &user.StartIdentityProviderFlowRequest_Urls{
Urls: &user.RedirectURLs{
SuccessUrl: "https://example.com/success",
FailureUrl: "https://example.com/failure",
},
},
},
},
want: &user.StartIdentityProviderFlowResponse{
@@ -689,7 +693,8 @@ func TestServer_StartIdentityProviderFlow(t *testing.T) {
func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
idpID := Tester.AddGenericOAuthProvider(t)
intentID := Tester.CreateIntent(t, idpID)
successfulID, token, changeDate, sequence := Tester.CreateSuccessfulIntent(t, idpID, "", "id")
successfulID, token, changeDate, sequence := Tester.CreateSuccessfulOAuthIntent(t, idpID, "", "id")
ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence := Tester.CreateSuccessfulLDAPIntent(t, idpID, "", "id")
type args struct {
ctx context.Context
req *user.RetrieveIdentityProviderInformationRequest
@@ -759,6 +764,51 @@ func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
},
wantErr: false,
},
{
name: "retrieve successful ldap intent",
args: args{
CTX,
&user.RetrieveIdentityProviderInformationRequest{
IntentId: ldapSuccessfulID,
Token: ldapToken,
},
},
want: &user.RetrieveIdentityProviderInformationResponse{
Details: &object.Details{
ChangeDate: timestamppb.New(ldapChangeDate),
ResourceOwner: Tester.Organisation.ID,
Sequence: ldapSequence,
},
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Ldap{
Ldap: &user.IDPLDAPAccessInformation{
Attributes: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"id": []interface{}{"id"},
"username": []interface{}{"username"},
"language": []interface{}{"en"},
})
require.NoError(t, err)
return s
}(),
},
},
IdpId: idpID,
UserId: "id",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"id": "id",
"preferredUsername": "username",
"preferredLanguage": "en",
})
require.NoError(t, err)
return s
}(),
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -769,7 +819,7 @@ func TestServer_RetrieveIdentityProviderInformation(t *testing.T) {
require.NoError(t, err)
}
grpc.AllFieldsEqual(t, got.ProtoReflect(), tt.want.ProtoReflect(), grpc.CustomMappers)
grpc.AllFieldsEqual(t, tt.want.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers)
})
}
}

View File

@@ -73,9 +73,10 @@ func Test_intentToIDPInformationPb(t *testing.T) {
KeyID: "id",
Crypted: []byte("accessToken"),
},
IDPIDToken: "idToken",
UserID: "userID",
State: domain.IDPIntentStateSucceeded,
IDPIDToken: "idToken",
IDPEntryAttributes: map[string][]string{},
UserID: "userID",
State: domain.IDPIntentStateSucceeded,
},
alg: decryption(caos_errs.ThrowInternal(nil, "id", "invalid key id")),
},
@@ -85,7 +86,7 @@ func Test_intentToIDPInformationPb(t *testing.T) {
},
},
{
"successful",
"successful oauth",
args{
intent: &command.IDPIntentWriteModel{
WriteModel: eventstore.WriteModel{
@@ -140,16 +141,73 @@ func Test_intentToIDPInformationPb(t *testing.T) {
},
err: nil,
},
}, {
"successful ldap",
args{
intent: &command.IDPIntentWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "intentID",
ProcessedSequence: 123,
ResourceOwner: "ro",
InstanceID: "instanceID",
ChangeDate: time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local),
},
IDPID: "idpID",
IDPUser: []byte(`{"userID": "idpUserID", "username": "username"}`),
IDPUserID: "idpUserID",
IDPUserName: "username",
IDPEntryAttributes: map[string][]string{
"id": {"idpUserID"},
"firstName": {"firstname1", "firstname2"},
"lastName": {"lastname"},
},
UserID: "userID",
State: domain.IDPIntentStateSucceeded,
},
},
res{
resp: &user.RetrieveIdentityProviderInformationResponse{
Details: &object_pb.Details{
Sequence: 123,
ChangeDate: timestamppb.New(time.Date(2019, 4, 1, 1, 1, 1, 1, time.Local)),
ResourceOwner: "ro",
},
IdpInformation: &user.IDPInformation{
Access: &user.IDPInformation_Ldap{
Ldap: &user.IDPLDAPAccessInformation{
Attributes: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"id": []interface{}{"idpUserID"},
"firstName": []interface{}{"firstname1", "firstname2"},
"lastName": []interface{}{"lastname"},
})
require.NoError(t, err)
return s
}(),
},
},
IdpId: "idpID",
UserId: "idpUserID",
UserName: "username",
RawInformation: func() *structpb.Struct {
s, err := structpb.NewStruct(map[string]interface{}{
"userID": "idpUserID",
"username": "username",
})
require.NoError(t, err)
return s
}(),
},
},
err: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := intentToIDPInformationPb(tt.args.intent, tt.args.alg)
require.ErrorIs(t, err, tt.res.err)
grpc.AllFieldsEqual(t, got.ProtoReflect(), tt.res.resp.ProtoReflect(), grpc.CustomMappers)
if tt.res.resp != nil {
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
}
grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers)
})
}
}