feat: ResetPassword endpoint

This commit is contained in:
Stefan Benz 2024-09-24 14:26:48 +02:00
parent 1e9d58c924
commit 04f5ed8d1c
No known key found for this signature in database
GPG Key ID: 071AA751ED4F9D31
9 changed files with 418 additions and 46 deletions

View File

@ -31,3 +31,31 @@ func setPasswordRequestToSetSchemaUserPassword(req *user.SetPasswordRequest) *co
ChangeRequired: req.GetNewPassword().GetChangeRequired(),
}
}
/*
func (s *Server) RemovePassword(ctx context.Context, req *user.RemovePasswordRequest) (_ *user.RemovePasswordResponse, err error) {
if err := checkUserSchemaEnabled(ctx); err != nil {
return nil, err
}
details, err := s.command.DeleteSchemaUserPassword(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId())
if err != nil {
return nil, err
}
return &user.RemovePasswordResponse{
Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner),
}, nil
}
func (s *Server) RemovePassword(ctx context.Context, req *user.RemovePasswordRequest) (_ *user.RemovePasswordResponse, err error) {
if err := checkUserSchemaEnabled(ctx); err != nil {
return nil, err
}
details, err := s.command.DeleteSchemaUserPassword(ctx, organizationToUpdateResourceOwner(req.Organization), req.GetId())
if err != nil {
return nil, err
}
return &user.RemovePasswordResponse{
Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_ORG, details.ResourceOwner),
}, nil
}
*/

View File

@ -2,10 +2,12 @@ package command
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/repository/user/authenticator"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
@ -16,6 +18,9 @@ type SetSchemaUserPassword struct {
Password string
EncodedPasswordHash string
ChangeRequired bool
CurrentPassword string
VerificationCode string
}
func (p *SetSchemaUserPassword) Validate(hasher *crypto.Hasher) (err error) {
@ -35,38 +40,47 @@ func (p *SetSchemaUserPassword) Validate(hasher *crypto.Hasher) (err error) {
return nil
}
func (c *Commands) SetSchemaUserPassword(ctx context.Context, username *SetSchemaUserPassword) (*domain.ObjectDetails, error) {
if err := username.Validate(c.userPasswordHasher); err != nil {
func (c *Commands) SetSchemaUserPassword(ctx context.Context, user *SetSchemaUserPassword) (*domain.ObjectDetails, error) {
if err := user.Validate(c.userPasswordHasher); err != nil {
return nil, err
}
existing, err := c.getPasswordExistsWithVerification(ctx, username.ResourceOwner, username.UserID)
schemaUser := &schemaUserPassword{
ResourceOwner: user.ResourceOwner,
UserID: user.UserID,
VerificationCode: user.VerificationCode,
CurrentPassword: user.CurrentPassword,
Password: user.Password,
EncodedPasswordHash: user.EncodedPasswordHash,
}
existing, err := c.getSchemaUserPasswordWithVerification(ctx, schemaUser)
if err != nil {
return nil, err
}
resourceOwner := existing.ResourceOwner
if existing.EncodedHash == "" {
existingUser, err := c.getSchemaUserExists(ctx, username.ResourceOwner, username.UserID)
existingUser, err := c.getSchemaUserExists(ctx, user.ResourceOwner, user.UserID)
if err != nil {
return nil, err
}
if !existingUser.Exists() {
return nil, zerrors.ThrowNotFound(nil, "TODO", "TODO")
return nil, zerrors.ThrowNotFound(nil, "COMMAND-TODO", "Errors.User.Password.NotFound")
}
resourceOwner = existingUser.ResourceOwner
}
// If password is provided, let's check if is compliant with the policy.
// If only a encodedPassword is passed, we can skip this.
if username.Password != "" {
if err = c.checkPasswordComplexity(ctx, username.Password, resourceOwner); err != nil {
if user.Password != "" {
if err = c.checkPasswordComplexity(ctx, user.Password, resourceOwner); err != nil {
return nil, err
}
}
encodedPassword := username.EncodedPasswordHash
if username.Password != "" {
encodedPassword, err = c.userPasswordHasher.Hash(username.Password)
encodedPassword := schemaUser.EncodedPasswordHash
if user.Password != "" {
encodedPassword, err = c.userPasswordHasher.Hash(user.Password)
if err = convertPasswapErr(err); err != nil {
return nil, err
}
@ -74,10 +88,10 @@ func (c *Commands) SetSchemaUserPassword(ctx context.Context, username *SetSchem
events, err := c.eventstore.Push(ctx,
authenticator.NewPasswordCreatedEvent(ctx,
&authenticator.NewAggregate(username.UserID, resourceOwner).Aggregate,
&authenticator.NewAggregate(user.UserID, resourceOwner).Aggregate,
existing.UserID,
encodedPassword,
username.ChangeRequired,
user.ChangeRequired,
),
)
if err != nil {
@ -86,8 +100,51 @@ func (c *Commands) SetSchemaUserPassword(ctx context.Context, username *SetSchem
return pushedEventsToObjectDetails(events), nil
}
type RequestSchemaUserPasswordReset struct {
ResourceOwner string
UserID string
URLTemplate string
NotificationType domain.NotificationType
PlainCode string
ReturnCode bool
}
func (c *Commands) RequestSchemaUserPasswordReset(ctx context.Context, user *RequestSchemaUserPasswordReset) (_ *domain.ObjectDetails, err error) {
existing, err := c.getSchemaUserPasswordExists(ctx, user.ResourceOwner, user.UserID)
if err != nil {
return nil, err
}
if existing.EncodedHash == "" {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-TODO", "Errors.User.Password.NotFound")
}
code, err := c.newEncryptedCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption) //nolint:staticcheck
if err != nil {
return nil, err
}
events, err := c.eventstore.Push(ctx,
authenticator.NewPasswordCodeAddedEvent(ctx,
&authenticator.NewAggregate(existing.UserID, existing.ResourceOwner).Aggregate,
code.Crypted,
code.Expiry,
user.NotificationType,
user.URLTemplate,
user.ReturnCode,
),
)
if err != nil {
return nil, err
}
if user.ReturnCode {
user.PlainCode = code.Plain
}
return pushedEventsToObjectDetails(events), nil
}
func (c *Commands) DeleteSchemaUserPassword(ctx context.Context, resourceOwner, id string) (_ *domain.ObjectDetails, err error) {
existing, err := c.getPasswordExistsWithVerification(ctx, resourceOwner, id)
existing, err := c.getSchemaUserPasswordExists(ctx, resourceOwner, id)
if err != nil {
return nil, err
}
@ -106,18 +163,107 @@ func (c *Commands) DeleteSchemaUserPassword(ctx context.Context, resourceOwner,
return pushedEventsToObjectDetails(events), nil
}
func (c *Commands) getPasswordExistsWithVerification(ctx context.Context, resourceOwner, id string) (*PasswordV3WriteModel, error) {
if id == "" {
type schemaUserPassword struct {
ResourceOwner string
UserID string
VerificationCode string
CurrentPassword string
Password string
EncodedPasswordHash string
}
func (c *Commands) getSchemaUserPasswordExists(ctx context.Context, resourceOwner, id string) (*PasswordV3WriteModel, error) {
return c.getSchemaUserPasswordWithVerification(ctx, &schemaUserPassword{ResourceOwner: resourceOwner, UserID: id})
}
func (c *Commands) getSchemaUserPasswordWithVerification(ctx context.Context, user *schemaUserPassword) (*PasswordV3WriteModel, error) {
if user.UserID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-PoSU5BOZCi", "Errors.IDMissing")
}
writeModel := NewPasswordV3WriteModel(resourceOwner, id)
writeModel := NewPasswordV3WriteModel(user.ResourceOwner, user.UserID)
if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil {
return nil, err
}
// TODO permission through old password and password code
if err := c.checkPermissionUpdateUser(ctx, writeModel.ResourceOwner, writeModel.UserID); err != nil {
return nil, err
// if no verification is set, the user must have the permission to change the password
verification := c.setSchemaUserPasswordWithPermission(writeModel.UserID, writeModel.ResourceOwner)
// otherwise check the password code...
if user.VerificationCode != "" {
verification = c.setSchemaUserPasswordWithVerifyCode(writeModel.CodeCreationDate, writeModel.CodeExpiry, writeModel.Code, user.VerificationCode)
}
// ...or old password
if user.CurrentPassword != "" {
verification = c.checkCurrentPassword(user.Password, user.EncodedPasswordHash, user.CurrentPassword, writeModel.EncodedHash)
}
if verification != nil {
newEncodedPassword, err := verification(ctx)
if err != nil {
return nil, err
}
// use the new hash from the verification in case there is one (e.g. existing pw check)
if newEncodedPassword != "" {
user.EncodedPasswordHash = newEncodedPassword
}
}
return writeModel, nil
}
// setSchemaUserPasswordWithPermission returns a permission check as [setPasswordVerification] implementation
func (c *Commands) setSchemaUserPasswordWithPermission(orgID, userID string) setPasswordVerification {
return func(ctx context.Context) (_ string, err error) {
return "", c.checkPermissionUpdateUser(ctx, orgID, userID)
}
}
// setSchemaUserPasswordWithVerifyCode returns a password code check as [setPasswordVerification] implementation
func (c *Commands) setSchemaUserPasswordWithVerifyCode(
passwordCodeCreationDate time.Time,
passwordCodeExpiry time.Duration,
passwordCode *crypto.CryptoValue,
code string,
) setPasswordVerification {
return func(ctx context.Context) (_ string, err error) {
if passwordCode == nil {
return "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-TODO", "Errors.User.Code.NotFound")
}
_, spanCrypto := tracing.NewNamedSpan(ctx, "crypto.VerifyCode")
defer func() {
spanCrypto.EndWithError(err)
}()
return "", crypto.VerifyCode(passwordCodeCreationDate, passwordCodeExpiry, passwordCode, code, c.userEncryption)
}
}
// checkSchemaUserCurrentPassword returns a password check as [setPasswordVerification] implementation
func (c *Commands) checkSchemaUserCurrentPassword(
newPassword, newEncodedPassword, currentPassword, currentEncodePassword string,
) setPasswordVerification {
// in case the new password is already encoded, we only need to verify the current
if newEncodedPassword != "" {
return func(ctx context.Context) (_ string, err error) {
_, spanPasswap := tracing.NewNamedSpan(ctx, "passwap.Verify")
_, err = c.userPasswordHasher.Verify(currentEncodePassword, currentPassword)
spanPasswap.EndWithError(err)
return "", convertPasswapErr(err)
}
}
// otherwise let's directly verify and return the new generate hash, so we can reuse it in the event
return func(ctx context.Context) (string, error) {
return c.verifyAndUpdateSchemaUserPassword(ctx, currentEncodePassword, currentPassword, newPassword)
}
}
// verifyAndUpdateSchemaUserPassword verify if the old password is correct with the encoded hash and
// returns the hash of the new password if so
func (c *Commands) verifyAndUpdateSchemaUserPassword(ctx context.Context, encodedHash, oldPassword, newPassword string) (string, error) {
if encodedHash == "" {
return "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-TODO", "Errors.User.Password.NotSet")
}
_, spanPasswap := tracing.NewNamedSpan(ctx, "passwap.Verify")
updated, err := c.userPasswordHasher.VerifyAndUpdate(encodedHash, oldPassword, newPassword)
spanPasswap.EndWithError(err)
return updated, convertPasswapErr(err)
}

View File

@ -1,7 +1,11 @@
package command
import (
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/repository/user/authenticator"
"github.com/zitadel/zitadel/internal/repository/user/schemauser"
)
@ -12,6 +16,10 @@ type PasswordV3WriteModel struct {
EncodedHash string
ChangeRequired bool
Code *crypto.CryptoValue
CodeCreationDate time.Time
CodeExpiry time.Duration
}
func NewPasswordV3WriteModel(resourceOwner, id string) *PasswordV3WriteModel {
@ -31,10 +39,16 @@ func (wm *PasswordV3WriteModel) Reduce() error {
wm.UserID = e.UserID
wm.EncodedHash = e.EncodedHash
wm.ChangeRequired = e.ChangeRequired
wm.Code = nil
case *authenticator.PasswordDeletedEvent:
wm.UserID = ""
wm.EncodedHash = ""
wm.ChangeRequired = false
wm.Code = nil
case *user.HumanPasswordCodeAddedEvent:
wm.Code = e.Code
wm.CodeCreationDate = e.CreationDate()
wm.CodeExpiry = e.Expiry
}
}
return wm.WriteModel.Reduce()
@ -49,5 +63,6 @@ func (wm *PasswordV3WriteModel) Query() *eventstore.SearchQueryBuilder {
EventTypes(
authenticator.PasswordCreatedType,
authenticator.PasswordDeletedType,
authenticator.PasswordCodeAddedType,
).Builder()
}

View File

@ -23,7 +23,7 @@ func filterSchemaUserPasswordExisting() expect {
context.Background(),
&authenticator.NewAggregate("user1", "org1").Aggregate,
"user1",
"encoded",
"$plain$x$password",
false,
),
),
@ -242,6 +242,73 @@ func TestCommands_SetSchemaUserPassword(t *testing.T) {
},
},
},
{
"password set, current password, ok",
fields{
eventstore: expectEventstore(
filterSchemaUserPasswordExisting(),
filterPasswordComplexityPolicyExisting(),
expectPush(
authenticator.NewPasswordCreatedEvent(
context.Background(),
&authenticator.NewAggregate("user1", "org1").Aggregate,
"user1",
"$plain$x$password2",
false,
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
},
args{
ctx: authz.NewMockContext("instanceID", "", ""),
user: &SetSchemaUserPassword{
UserID: "user1",
Password: "password2",
CurrentPassword: "password",
ChangeRequired: false,
},
},
res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
{
"password set, code, ok",
fields{
eventstore: expectEventstore(
filterSchemaUserPasswordExisting(),
filterPasswordComplexityPolicyExisting(),
expectPush(
authenticator.NewPasswordCreatedEvent(
context.Background(),
&authenticator.NewAggregate("user1", "org1").Aggregate,
"user1",
"$plain$x$password2",
false,
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
},
args{
ctx: authz.NewMockContext("instanceID", "", ""),
user: &SetSchemaUserPassword{
UserID: "user1",
Password: "password2",
ChangeRequired: false,
},
},
res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -7,4 +7,8 @@ func init() {
eventstore.RegisterFilterEventMapper(AggregateType, UsernameDeletedType, eventstore.GenericEventMapper[UsernameDeletedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, PasswordCreatedType, eventstore.GenericEventMapper[PasswordCreatedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, PasswordDeletedType, eventstore.GenericEventMapper[PasswordDeletedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, JWTCreatedType, eventstore.GenericEventMapper[JWTCreatedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, JWTDeletedType, eventstore.GenericEventMapper[JWTDeletedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, PATCreatedType, eventstore.GenericEventMapper[PATCreatedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, PATDeletedType, eventstore.GenericEventMapper[PATDeletedEvent])
}

View File

@ -19,9 +19,9 @@ type JWTCreatedEvent struct {
UserID string `json:"userID"`
ExpirationDate time.Time `json:"expirationDate,omitempty"`
PublicKey []byte `json:"publicKey,omitempty"`
TriggerOrigin string `json:"triggerOrigin,omitempty"`
ExpirationDate time.Time `json:"expirationDate,omitempty"`
PublicKey []byte `json:"publicKey,omitempty"`
TriggeredAtOrigin string `json:"triggerOrigin,omitempty"`
}
func (e *JWTCreatedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
@ -36,6 +36,10 @@ func (e *JWTCreatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *JWTCreatedEvent) TriggerOrigin() string {
return e.TriggeredAtOrigin
}
func NewJWTCreatedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
@ -49,10 +53,10 @@ func NewJWTCreatedEvent(
aggregate,
JWTCreatedType,
),
UserID: userID,
ExpirationDate: expirationDate,
PublicKey: publicKey,
TriggerOrigin: http.DomainContext(ctx).Origin(),
UserID: userID,
ExpirationDate: expirationDate,
PublicKey: publicKey,
TriggeredAtOrigin: http.DomainContext(ctx).Origin(),
}
}

View File

@ -2,24 +2,29 @@ package authenticator
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
passwordPrefix = eventPrefix + "password."
PasswordCreatedType = passwordPrefix + "created"
PasswordDeletedType = passwordPrefix + "deleted"
passwordPrefix = eventPrefix + "password."
PasswordCreatedType = passwordPrefix + "created"
PasswordDeletedType = passwordPrefix + "deleted"
PasswordCodeAddedType = passwordPrefix + "code.added"
)
type PasswordCreatedEvent struct {
*eventstore.BaseEvent `json:"-"`
UserID string `json:"userID"`
EncodedHash string `json:"encodedHash,omitempty"`
ChangeRequired bool `json:"changeRequired,omitempty"`
TriggerOrigin string `json:"triggerOrigin,omitempty"`
UserID string `json:"userID"`
EncodedHash string `json:"encodedHash,omitempty"`
ChangeRequired bool `json:"changeRequired,omitempty"`
TriggeredAtOrigin string `json:"triggerOrigin,omitempty"`
}
func (e *PasswordCreatedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
@ -34,6 +39,10 @@ func (e *PasswordCreatedEvent) UniqueConstraints() []*eventstore.UniqueConstrain
return nil
}
func (e *PasswordCreatedEvent) TriggerOrigin() string {
return e.TriggeredAtOrigin
}
func NewPasswordCreatedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
@ -47,10 +56,10 @@ func NewPasswordCreatedEvent(
aggregate,
PasswordCreatedType,
),
UserID: userID,
EncodedHash: encodeHash,
ChangeRequired: changeRequired,
TriggerOrigin: http.DomainContext(ctx).Origin(),
UserID: userID,
EncodedHash: encodeHash,
ChangeRequired: changeRequired,
TriggeredAtOrigin: http.DomainContext(ctx).Origin(),
}
}
@ -82,3 +91,54 @@ func NewPasswordDeletedEvent(
),
}
}
type PasswordCodeAddedEvent struct {
*eventstore.BaseEvent `json:"-"`
Code *crypto.CryptoValue `json:"code,omitempty"`
Expiry time.Duration `json:"expiry,omitempty"`
NotificationType domain.NotificationType `json:"notificationType,omitempty"`
URLTemplate string `json:"url_template,omitempty"`
CodeReturned bool `json:"code_returned,omitempty"`
TriggeredAtOrigin string `json:"triggerOrigin,omitempty"`
}
func (e *PasswordCodeAddedEvent) Payload() interface{} {
return e
}
func (e *PasswordCodeAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *PasswordCodeAddedEvent) TriggerOrigin() string {
return e.TriggeredAtOrigin
}
func NewPasswordCodeAddedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
code *crypto.CryptoValue,
expiry time.Duration,
notificationType domain.NotificationType,
urlTemplate string,
codeReturned bool,
) *PasswordCodeAddedEvent {
return &PasswordCodeAddedEvent{
BaseEvent: eventstore.NewBaseEventForPush(
ctx,
aggregate,
PasswordCodeAddedType,
),
Code: code,
Expiry: expiry,
NotificationType: notificationType,
URLTemplate: urlTemplate,
CodeReturned: codeReturned,
TriggeredAtOrigin: http.DomainContext(ctx).Origin(),
}
}
func (e *PasswordCodeAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = event
}

View File

@ -19,9 +19,9 @@ type PATCreatedEvent struct {
UserID string `json:"userID"`
ExpirationDate time.Time `json:"expirationDate,omitempty"`
Scopes []string `json:"scopes"`
TriggerOrigin string `json:"triggerOrigin,omitempty"`
ExpirationDate time.Time `json:"expirationDate,omitempty"`
Scopes []string `json:"scopes"`
TriggeredAtOrigin string `json:"triggerOrigin,omitempty"`
}
func (e *PATCreatedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
@ -49,10 +49,10 @@ func NewPATCreatedEvent(
aggregate,
PATCreatedType,
),
UserID: userID,
ExpirationDate: expirationDate,
Scopes: scopes,
TriggerOrigin: http.DomainContext(ctx).Origin(),
UserID: userID,
ExpirationDate: expirationDate,
Scopes: scopes,
TriggeredAtOrigin: http.DomainContext(ctx).Origin(),
}
}

View File

@ -570,7 +570,7 @@ service ZITADELUsers {
// Set a password
//
// Add, update or reset a user's password with either a verification code or the current password.
// Add or update a user's password with either a verification code, the current password or enough permissions.
rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) {
option (google.api.http) = {
post: "/resources/v3alpha/users/{id}/password"
@ -618,6 +618,30 @@ service ZITADELUsers {
};
}
// Remove a password
//
// Remove a user's password.
rpc RemovePassword (RemovePasswordRequest) returns (RemovePasswordResponse) {
option (google.api.http) = {
delete: "/resources/v3alpha/users/{id}/password"
};
option (zitadel.protoc_gen_zitadel.v2.options) = {
auth_option: {
permission: "authenticated"
}
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
responses: {
key: "200"
value: {
description: "Password successfully removed";
}
};
};
}
// Start a WebAuthN registration
//
// Start the registration of a new WebAuthN device (e.g. Passkeys) for a user.
@ -1587,6 +1611,30 @@ message RequestPasswordResetResponse {
];
}
message RemovePasswordRequest {
optional zitadel.object.v3alpha.Instance instance = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
default: "\"domain from HOST or :authority header\""
}
];
// Optionally expect the user to be in this organization.
optional zitadel.object.v3alpha.Organization organization = 2;
// unique identifier of the user.
string id = 3 [
(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 RemovePasswordResponse {
zitadel.resources.object.v3alpha.Details details = 1;
}
message StartWebAuthNRegistrationRequest {
optional zitadel.object.v3alpha.Instance instance = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {