mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-07 05:33:53 +00:00
feat: ResetPassword endpoint
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user