SierraSoftworks/Iridium

Transforming before Validation

Closed this issue · 7 comments

Hi.
I'm having an issue with saving/changing a password field in the database.
As far as I see, the @Transform decorator hashes the password field before it is validated, which makes further validation meaningless.

@Validate("password", function(schema, data, path) {
    return this.assert(Validator.password(data), "invalid password.");
})
export class UserEntity extends Instance<IUser, UserEntity> implements IUser {
    @ObjectID public id?: mongo.ObjectID;
    @Transform((data) => data, (data) => HashPassword(data))
    @Property(String, false) public password?: string;
}

And the same goes for the onSaving event, which is triggered before the validation, in case I try to hash my password there.

I would love to learn about some workaround, if there is one.

Hi @karinehovhannisyan,

You're correct, unfortunately the validation is done on the underlying data since the stored data is in fact all that is held in memory by Iridium (the transforms are done when the property is accessed, with the exception of document level transforms).

My suggestion is, however, to store a passwordHash field on your document and then have an instance level password setter which handles this: it more clearly delinates the difference between the insecure "password" and the secure "password hash" and is less likely to result in data oddities (like writing the insecure password to a field and only being able to read the hash).

export class UserEntity extends Instance<IUser, UserEntity> implements IUser {
    @ObjectID public id?: mongo.ObjectID;
    @Property(String) public passwordHash?: string;

    set password(value: string) {
        const validated = Validator.password(value)
        if (!validated.valid) throw validated.error
        this.passwordHash = HashPassword(value)
    }
}

In practice, this approach does effectively the same thing you are attempting to do, however it foregoes the "magic" of the @Transform and @Validate decorators and applies them manually.

Please let me know if this doesn't solve your requirements, or if you've got a problem with the approach that you'd like me to try and resolve within Iridium.

Regards,

Hi @spartan563 ,

Thank you for your response. I've modified my code as you have mentioned and faced a couple of issues.

Although when I change the password field and save the document, the setter is triggered, but

  1. When I try to save a document from outside, the setter doesn't modify neither the password nor passwordHash
user.password = "validpass123";
await user.save();

Now the password has its initial value and passwordHash is undefined.

  1. When I provide explicit Changes, it modifies the password field only
await user.save({$set: {password: "validpass123"}})

Here the password is indeed validpass123 and passwordHash is still undefined.
Could you help me with that?

Hi, @spartan563!

Any news regarding this issue? :)

Sorry @karinehovhannisyan

Can you quickly tell me how you're creating your instance? I suspect you're just doing new User() and then setting properties there?

Instead, can you try using new userModel.Instance() and setting those properties? If it does work, please let me know and I'll explain why you're running into the issue.

interface UserDoc {
  _id?: string;
  username: string;
  passwordHash?: string;
}

@Collection("users")
class User extends Instance<UserDoc, User> {
  @ObjectID
  _id: string;

  @Property(String)
  username: string;

  @Property(String)
  passwordHash: string;

  set password(value: string) {
    const validated = Validator.password(value)
    if (!validated.valid) throw validated.error
    this.passwordHash = HashPassword(value)
  }
}

class MyCore extends Core {
  Users = new Model<UserDoc, User>(this, User)
}

// Test it out
const core = new MyCore("mongo://localhost:27017/test")
await core.connect()

const user = new core.Users.Instance({ username: "testUser" })
user.password = "test"
await user.save()

const savedUser = await core.Users.get(user._id)
console.log("Saved User Password Hash:", savedUser.passwordHash)

Hi @spartan563

In reality, I do use the Instance constructor, however, I face the issue when also using the insert method to create the document.

Any thoughts about this?

Hi @karinehovhannisyan, sorry this has taken a while to address. With v8.0.0-alpha.13 we've standardized the meaning of TDocument and clarified the role that transforms (and validation) play in the process. Specifically:

  • TInstance represents your application's view of an object's schema
  • TDocument (and your schema properties - @Property) represent the DB's view of an object's schema
  • Schema validation (@Property) applies to the DB's view of an object's schema
  • Transforms are responsible for converting the types of fields on a TDocument to the types on a TInstance (and vice versa)

As a result, there is no support for validating schema fields before transforms (this was always the behaviour, but it is now standardized and explained by the core design). This also results in changes to the recommended use of the .insert() and .create() methods - with them both recommending that "DB-ready" (in your case: post-hashing) documents be provided to them. For the time being they still support the previous behaviour and we'll be looking at how to provide a nice developer experience for users who still wish to provide "application style" objects to them while leveraging transforms.

To address the need you have for validation when assigning a value to the password property, you can now utilize the Instance._setField protected method in conjunction with a custom setter on your object.

interface UserDoc {
  _id?: string;
  username: string;
  passwordHash?: string;
}

@Collection("users")
class User extends Instance<UserDoc, User> {
  @ObjectID
  _id: string;

  @Property(String)
  username: string;

  set password(value: string) {
    const validated = Validator.password(value)
    if (!validated.valid) throw validated.error
    this._setField("passwordHash", HashPassword(value))
  }

  isCorrectPassword(value: string) {
    return this._getField("passwordHash") === HashPassword(value)
  }

  static createUser(username: string, password: string): UserDoc {
    const validated = Validator.password(value)
    if (!validated.valid) throw validated.error

    return {
      username,
      passwordHash: HashPassword(password)
    }
  }
}

class MyCore extends Core {
  Users = new Model<UserDoc, User>(this, User)
}

// Test it out
const core = new MyCore("mongo://localhost:27017/test")
await core.connect()

// Creating using an Instance
const user = new core.Users.Instance({ username: "testUser" })
user.password = "test"
await user.save()

// Inserting a user with a helper function
await core.Users.insert(core.Users.createUser("testUser2", "test"))

Unfortunately, regardless of all the improvements we're making to the way this stuff works, it still comes down to the need for you to perform application-level data validation, since Iridium's validation logic is primarily concerned with the DB-level data. The motivation for that approach is borne of the experience that applications tend to include weird bugs (a badly written transform function etc) and we'd rather do everything we can to safeguard the data (potentially at the cost of a short-term application crash) than safeguarding the application at the cost of the data.

Hi @spartan563,

Thank you for explanation. In that case I will proceed with pre-database validation :)