From 704619453079dbcc3335280e13cbd31ad17d4a99 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 20 Jun 2023 18:23:28 +0200 Subject: [PATCH] feat(api): list authentication method types in user api v2 (#6058) --- cmd/start/start.go | 6 + internal/api/grpc/user/v2/user.go | 38 +++++ .../api/grpc/user/v2/user_integration_test.go | 107 ++++++++++++++ internal/api/grpc/user/v2/user_test.go | 72 ++++++++++ internal/domain/permission.go | 2 +- internal/domain/user.go | 2 + internal/integration/client.go | 3 + internal/query/query.go | 4 + internal/query/user_auth_method.go | 133 ++++++++++++++++++ internal/query/user_auth_method_test.go | 106 ++++++++++++++ proto/zitadel/user/v2alpha/user_service.proto | 51 +++++++ 11 files changed, 523 insertions(+), 1 deletion(-) diff --git a/cmd/start/start.go b/cmd/start/start.go index 75516fba4..e91b628d9 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -47,6 +47,7 @@ import ( auth_es "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing" "github.com/zitadel/zitadel/internal/authz" authz_repo "github.com/zitadel/zitadel/internal/authz/repository" + authz_es "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/eventstore" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" @@ -145,6 +146,11 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error keys.SAML, config.InternalAuthZ.RolePermissionMappings, sessionTokenVerifier, + func(q *query.Queries) domain.PermissionCheck { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { + return internal_authz.CheckPermission(ctx, &authz_es.UserMembershipRepo{Queries: q}, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) + } + }, ) if err != nil { return fmt.Errorf("cannot start queries: %w", err) diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 0a2507624..9969648c1 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -221,3 +221,41 @@ func (s *Server) checkIntentToken(token string, intentID string) error { } return nil } + +func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { + authMethods, err := s.query.ListActiveUserAuthMethodTypes(ctx, req.GetUserId(), false) + if err != nil { + return nil, err + } + return &user.ListAuthenticationMethodTypesResponse{ + Details: object.ToListDetails(authMethods.SearchResponse), + AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes), + }, nil +} + +func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { + methods := make([]user.AuthenticationMethodType, len(methodTypes)) + for i, method := range methodTypes { + methods[i] = authMethodTypeToPb(method) + } + return methods +} + +func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.AuthenticationMethodType { + switch methodType { + case domain.UserAuthMethodTypeOTP: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP + case domain.UserAuthMethodTypeU2F: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F + case domain.UserAuthMethodTypePasswordless: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY + case domain.UserAuthMethodTypePassword: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD + case domain.UserAuthMethodTypeIDP: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP + case domain.UserAuthMethodTypeUnspecified: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED + default: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED + } +} diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index b78cf687c..c30057524 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -24,6 +24,7 @@ import ( openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/repository/idp" + mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" ) @@ -712,3 +713,109 @@ func TestServer_RetrieveIdentityProviderInformation(t *testing.T) { }) } } + +func TestServer_ListAuthenticationMethodTypes(t *testing.T) { + userIDWithoutAuth := Tester.CreateHumanUser(CTX).GetUserId() + + userIDWithPasskey := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userIDWithPasskey) + + userMultipleAuth := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userMultipleAuth) + provider, err := Tester.Client.Mgmt.AddGenericOIDCProvider(CTX, &mgmt.AddGenericOIDCProviderRequest{ + Name: "ListAuthenticationMethodTypes", + Issuer: "https://example.com", + ClientId: "client_id", + ClientSecret: "client_secret", + }) + require.NoError(t, err) + idpLink, err := Tester.Client.UserV2.AddIDPLink(CTX, &user.AddIDPLinkRequest{UserId: userMultipleAuth, IdpLink: &user.IDPLink{ + IdpId: provider.GetId(), + UserId: "external-id", + UserName: "displayName", + }}) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.ListAuthenticationMethodTypesRequest + } + tests := []struct { + name string + args args + want *user.ListAuthenticationMethodTypesResponse + }{ + { + name: "no auth", + args: args{ + CTX, + &user.ListAuthenticationMethodTypesRequest{ + UserId: userIDWithoutAuth, + }, + }, + want: &user.ListAuthenticationMethodTypesResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + }, + }, + }, + { + name: "with auth (passkey)", + args: args{ + CTX, + &user.ListAuthenticationMethodTypesRequest{ + UserId: userIDWithPasskey, + }, + }, + want: &user.ListAuthenticationMethodTypesResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + }, + AuthMethodTypes: []user.AuthenticationMethodType{ + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + }, + }, + }, + { + name: "multiple auth", + args: args{ + CTX, + &user.ListAuthenticationMethodTypesRequest{ + UserId: userMultipleAuth, + }, + }, + want: &user.ListAuthenticationMethodTypesResponse{ + Details: &object.ListDetails{ + TotalResult: 2, + }, + AuthMethodTypes: []user.AuthenticationMethodType{ + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got *user.ListAuthenticationMethodTypesResponse + var err error + + for { + got, err = Client.ListAuthenticationMethodTypes(tt.args.ctx, tt.args.req) + if err == nil && got.GetDetails().GetProcessedSequence() >= idpLink.GetDetails().GetSequence() { + break + } + select { + case <-CTX.Done(): + t.Fatal(CTX.Err(), err) + case <-time.After(time.Second): + t.Log("retrying ListAuthenticationMethodTypes") + continue + } + } + require.NoError(t, err) + assert.Equal(t, tt.want.GetDetails().GetTotalResult(), got.GetDetails().GetTotalResult()) + require.Equal(t, tt.want.GetAuthMethodTypes(), got.GetAuthMethodTypes()) + }) + } +} diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go index f6153f650..8332c8703 100644 --- a/internal/api/grpc/user/v2/user_test.go +++ b/internal/api/grpc/user/v2/user_test.go @@ -222,3 +222,75 @@ func Test_intentToIDPInformationPb(t *testing.T) { }) } } + +func Test_authMethodTypesToPb(t *testing.T) { + tests := []struct { + name string + methodTypes []domain.UserAuthMethodType + want []user.AuthenticationMethodType + }{ + { + "empty list", + nil, + []user.AuthenticationMethodType{}, + }, + { + "list", + []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePasswordless, + }, + []user.AuthenticationMethodType{ + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, authMethodTypesToPb(tt.methodTypes), "authMethodTypesToPb(%v)", tt.methodTypes) + }) + } +} + +func Test_authMethodTypeToPb(t *testing.T) { + tests := []struct { + name string + methodType domain.UserAuthMethodType + want user.AuthenticationMethodType + }{ + { + "uspecified", + domain.UserAuthMethodTypeUnspecified, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED, + }, + { + "(t)otp", + domain.UserAuthMethodTypeOTP, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP, + }, + { + "u2f", + domain.UserAuthMethodTypeU2F, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F, + }, + { + "passkey", + domain.UserAuthMethodTypePasswordless, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSKEY, + }, + { + "password", + domain.UserAuthMethodTypePassword, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD, + }, + { + "idp", + domain.UserAuthMethodTypeIDP, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, authMethodTypeToPb(tt.methodType), "authMethodTypeToPb(%v)", tt.methodType) + }) + } +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go index 5a51d7bdb..ebd7981ab 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -28,7 +28,7 @@ type PermissionCheck func(ctx context.Context, permission, orgID, resourceID str const ( PermissionUserWrite = "user.write" - PermissionSessionRead = "session.read" + PermissionUserRead = "user.read" PermissionSessionWrite = "session.write" PermissionSessionDelete = "session.delete" ) diff --git a/internal/domain/user.go b/internal/domain/user.go index 157edcc19..a5e01361f 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -51,6 +51,8 @@ const ( UserAuthMethodTypeOTP UserAuthMethodTypeU2F UserAuthMethodTypePasswordless + UserAuthMethodTypePassword + UserAuthMethodTypeIDP userAuthMethodTypeCount ) diff --git a/internal/integration/client.go b/internal/integration/client.go index c21b42f67..8f64936e0 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -9,6 +9,7 @@ import ( "google.golang.org/grpc" "github.com/zitadel/zitadel/pkg/grpc/admin" + mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" @@ -17,6 +18,7 @@ import ( type Client struct { CC *grpc.ClientConn Admin admin.AdminServiceClient + Mgmt mgmt.ManagementServiceClient UserV2 user.UserServiceClient SessionV2 session.SessionServiceClient } @@ -25,6 +27,7 @@ func newClient(cc *grpc.ClientConn) Client { return Client{ CC: cc, Admin: admin.NewAdminServiceClient(cc), + Mgmt: mgmt.NewManagementServiceClient(cc), UserV2: user.NewUserServiceClient(cc), SessionV2: session.NewSessionServiceClient(cc), } diff --git a/internal/query/query.go b/internal/query/query.go index 17990311b..464cf59aa 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -34,6 +34,7 @@ type Queries struct { idpConfigEncryption crypto.EncryptionAlgorithm sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) + checkPermission domain.PermissionCheck DefaultLanguage language.Tag LoginDir http.FileSystem @@ -55,6 +56,7 @@ func StartQueries( idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, zitadelRoles []authz.RoleMapping, sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), + permissionCheck func(q *Queries) domain.PermissionCheck, ) (repo *Queries, err error) { statikLoginFS, err := fs.NewWithNamespace("login") if err != nil { @@ -95,6 +97,8 @@ func StartQueries( }, } + repo.checkPermission = permissionCheck(repo) + err = projection.Create(ctx, sqlClient, es, projections, keyEncryptionAlgorithm, certEncryptionAlgorithm) if err != nil { return nil, err diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go index 0321a8750..dde1a5a38 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -6,6 +6,7 @@ import ( "time" sq "github.com/Masterminds/squirrel" + "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/call" @@ -64,12 +65,27 @@ var ( name: projection.UserAuthMethodOwnerRemovedCol, table: userAuthMethodTable, } + + authMethodTypeTable = userAuthMethodTable.setAlias("auth_method_types") + authMethodTypeUserID = UserAuthMethodColumnUserID.setTable(authMethodTypeTable) + authMethodTypeInstanceID = UserAuthMethodColumnInstanceID.setTable(authMethodTypeTable) + authMethodTypeTypes = UserAuthMethodColumnMethodType.setTable(authMethodTypeTable) + authMethodTypeState = UserAuthMethodColumnState.setTable(authMethodTypeTable) + + userIDPsCountTable = idpUserLinkTable.setAlias("user_idps_count") + userIDPsCountUserID = IDPUserLinkUserIDCol.setTable(userIDPsCountTable) + userIDPsCountInstanceID = IDPUserLinkInstanceIDCol.setTable(userIDPsCountTable) + userIDPsCountCount = Column{ + name: "count", + table: userIDPsCountTable, + } ) type AuthMethods struct { SearchResponse AuthMethods []*AuthMethod } + type AuthMethod struct { UserID string CreationDate time.Time @@ -83,6 +99,11 @@ type AuthMethod struct { Type domain.UserAuthMethodType } +type AuthMethodTypes struct { + SearchResponse + AuthMethodTypes []domain.UserAuthMethodType +} + type UserAuthMethodSearchQueries struct { SearchRequest Queries []SearchQuery @@ -114,6 +135,41 @@ func (q *Queries) SearchUserAuthMethods(ctx context.Context, queries *UserAuthMe return userAuthMethods, err } +func (q *Queries) ListActiveUserAuthMethodTypes(ctx context.Context, userID string, withOwnerRemoved bool) (userAuthMethodTypes *AuthMethodTypes, err error) { + ctxData := authz.GetCtxData(ctx) + if ctxData.UserID != userID { + if err := q.checkPermission(ctx, domain.PermissionUserRead, ctxData.OrgID, userID); err != nil { + return nil, err + } + } + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareActiveUserAuthMethodTypesQuery(ctx, q.client) + eq := sq.Eq{ + UserIDCol.identifier(): userID, + UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + } + if !withOwnerRemoved { + eq[UserOwnerRemovedCol.identifier()] = false + } + stmt, args, err := query.Where(eq).ToSql() + if err != nil { + return nil, errors.ThrowInvalidArgument(err, "QUERY-Sfdrg", "Errors.Query.InvalidRequest") + } + + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil || rows.Err() != nil { + return nil, errors.ThrowInternal(err, "QUERY-SDgr3", "Errors.Internal") + } + userAuthMethodTypes, err = scan(rows) + if err != nil { + return nil, err + } + userAuthMethodTypes.LatestSequence, err = q.latestSequence(ctx, userTable, notifyTable, userAuthMethodTable, idpUserLinkTable) + return userAuthMethodTypes, err +} + func NewUserAuthMethodUserIDSearchQuery(value string) (SearchQuery, error) { return NewTextQuery(UserAuthMethodColumnUserID, value, TextEquals) } @@ -253,3 +309,80 @@ func prepareUserAuthMethodsQuery(ctx context.Context, db prepareDatabase) (sq.Se }, nil } } + +func prepareActiveUserAuthMethodTypesQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*AuthMethodTypes, error)) { + authMethodsQuery, authMethodsArgs, err := sq.Select( + "DISTINCT("+authMethodTypeTypes.identifier()+")", + authMethodTypeUserID.identifier(), + authMethodTypeInstanceID.identifier()). + From(authMethodTypeTable.identifier()). + Where(sq.Eq{authMethodTypeState.identifier(): domain.MFAStateReady}). + ToSql() + if err != nil { + return sq.SelectBuilder{}, nil + } + idpsQuery, _, err := sq.Select( + userIDPsCountUserID.identifier(), + userIDPsCountInstanceID.identifier(), + "COUNT("+userIDPsCountUserID.identifier()+") AS "+userIDPsCountCount.name). + From(userIDPsCountTable.identifier()). + GroupBy( + userIDPsCountUserID.identifier(), + userIDPsCountInstanceID.identifier(), + ). + ToSql() + if err != nil { + return sq.SelectBuilder{}, nil + } + return sq.Select( + NotifyPasswordSetCol.identifier(), + authMethodTypeTypes.identifier(), + userIDPsCountCount.identifier()). + From(userTable.identifier()). + LeftJoin(join(NotifyUserIDCol, UserIDCol)). + LeftJoin("("+authMethodsQuery+") AS "+authMethodTypeTable.alias+" ON "+ + authMethodTypeUserID.identifier()+" = "+UserIDCol.identifier()+" AND "+ + authMethodTypeInstanceID.identifier()+" = "+UserInstanceIDCol.identifier(), + authMethodsArgs...). + LeftJoin("(" + idpsQuery + ") AS " + userIDPsCountTable.alias + " ON " + + userIDPsCountUserID.identifier() + " = " + UserIDCol.identifier() + " AND " + + userIDPsCountInstanceID.identifier() + " = " + UserInstanceIDCol.identifier() + db.Timetravel(call.Took(ctx))). + PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) (*AuthMethodTypes, error) { + userAuthMethodTypes := make([]domain.UserAuthMethodType, 0) + var passwordSet sql.NullBool + var idp sql.NullInt64 + for rows.Next() { + var authMethodType sql.NullInt16 + err := rows.Scan( + &passwordSet, + &authMethodType, + &idp, + ) + if err != nil { + return nil, err + } + if authMethodType.Valid { + userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodType(authMethodType.Int16)) + } + } + if passwordSet.Valid && passwordSet.Bool { + userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodTypePassword) + } + if idp.Valid && idp.Int64 > 0 { + logging.Error("IDP", idp.Int64) + userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodTypeIDP) + } + + if err := rows.Close(); err != nil { + return nil, errors.ThrowInternal(err, "QUERY-3n9fl", "Errors.Query.CloseRows") + } + + return &AuthMethodTypes{ + AuthMethodTypes: userAuthMethodTypes, + SearchResponse: SearchResponse{ + Count: uint64(len(userAuthMethodTypes)), + }, + }, nil + } +} diff --git a/internal/query/user_auth_method_test.go b/internal/query/user_auth_method_test.go index b09d6c9e7..da86a02aa 100644 --- a/internal/query/user_auth_method_test.go +++ b/internal/query/user_auth_method_test.go @@ -36,6 +36,23 @@ var ( "method_type", "count", } + prepareActiveAuthMethodTypesStmt = `SELECT projections.users8_notifications.password_set,` + + ` auth_method_types.method_type,` + + ` user_idps_count.count` + + ` FROM projections.users8` + + ` LEFT JOIN projections.users8_notifications ON projections.users8.id = projections.users8_notifications.user_id AND projections.users8.instance_id = projections.users8_notifications.instance_id` + + ` LEFT JOIN (SELECT DISTINCT(auth_method_types.method_type), auth_method_types.user_id, auth_method_types.instance_id FROM projections.user_auth_methods4 AS auth_method_types` + + ` WHERE auth_method_types.state = $1) AS auth_method_types` + + ` ON auth_method_types.user_id = projections.users8.id AND auth_method_types.instance_id = projections.users8.instance_id` + + ` LEFT JOIN (SELECT user_idps_count.user_id, user_idps_count.instance_id, COUNT(user_idps_count.user_id) AS count FROM projections.idp_user_links3 AS user_idps_count` + + ` GROUP BY user_idps_count.user_id, user_idps_count.instance_id) AS user_idps_count` + + ` ON user_idps_count.user_id = projections.users8.id AND user_idps_count.instance_id = projections.users8.instance_id` + + ` AS OF SYSTEM TIME '-1 ms` + prepareActiveAuthMethodTypesCols = []string{ + "password_set", + "method_type", + "idps_count", + } ) func Test_UserAuthMethodPrepares(t *testing.T) { @@ -182,6 +199,95 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, object: nil, }, + { + name: "prepareActiveUserAuthMethodTypesQuery no result", + prepare: prepareActiveUserAuthMethodTypesQuery, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt), + nil, + nil, + ), + }, + object: &AuthMethodTypes{AuthMethodTypes: []domain.UserAuthMethodType{}}, + }, + { + name: "prepareActiveUserAuthMethodTypesQuery one second factor", + prepare: prepareActiveUserAuthMethodTypesQuery, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt), + prepareActiveAuthMethodTypesCols, + [][]driver.Value{ + { + true, + domain.UserAuthMethodTypePasswordless, + 1, + }, + }, + ), + }, + object: &AuthMethodTypes{ + SearchResponse: SearchResponse{ + Count: 3, + }, + AuthMethodTypes: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePasswordless, + domain.UserAuthMethodTypePassword, + domain.UserAuthMethodTypeIDP, + }, + }, + }, + { + name: "prepareActiveUserAuthMethodTypesQuery multiple second factors", + prepare: prepareActiveUserAuthMethodTypesQuery, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt), + prepareActiveAuthMethodTypesCols, + [][]driver.Value{ + { + true, + domain.UserAuthMethodTypePasswordless, + 1, + }, + { + true, + domain.UserAuthMethodTypeOTP, + 1, + }, + }, + ), + }, + object: &AuthMethodTypes{ + SearchResponse: SearchResponse{ + Count: 4, + }, + AuthMethodTypes: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePasswordless, + domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypePassword, + domain.UserAuthMethodTypeIDP, + }, + }, + }, + { + name: "prepareActiveUserAuthMethodTypesQuery sql err", + prepare: prepareActiveUserAuthMethodTypesQuery, + want: want{ + sqlExpectations: mockQueryErr( + regexp.QuoteMeta(prepareActiveAuthMethodTypesStmt), + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index b5f776c6a..ad30412d0 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -440,6 +440,30 @@ service UserService { }; }; } + + // List all possible authentication methods of a user + rpc ListAuthenticationMethodTypes (ListAuthenticationMethodTypesRequest) returns (ListAuthenticationMethodTypesResponse) { + option (google.api.http) = { + get: "/v2alpha/users/{user_id}/authentication_methods" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "List all possible authentication methods of a user"; + description: "List all possible authentication methods of a user like password, passwordless, (T)OTP and more"; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } } message AddHumanUserRequest{ @@ -857,6 +881,7 @@ message AddIDPLinkResponse { zitadel.object.v2alpha.Details details = 1; } + message PasswordResetRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -919,3 +944,29 @@ message SetPasswordRequest{ message SetPasswordResponse{ zitadel.object.v2alpha.Details details = 1; } + +message ListAuthenticationMethodTypesRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; +} + +message ListAuthenticationMethodTypesResponse{ + zitadel.object.v2alpha.ListDetails details = 1; + repeated AuthenticationMethodType auth_method_types = 2; +} + +enum AuthenticationMethodType { + AUTHENTICATION_METHOD_TYPE_UNSPECIFIED = 0; + AUTHENTICATION_METHOD_TYPE_PASSWORD = 1; + AUTHENTICATION_METHOD_TYPE_PASSKEY = 2; + AUTHENTICATION_METHOD_TYPE_IDP = 3; + AUTHENTICATION_METHOD_TYPE_TOTP = 4; + AUTHENTICATION_METHOD_TYPE_U2F = 5; +}