Sqlite.Auth.Password

Create, hash, store, validate, change, reset, and delete users' passwords.

This module prevents saving passwords as plain-text strings through its functions. The Password type enforces this at compile time. Passwords must be hashed before they can be saved to the database, and all verification functions properly compare against the stored password hash.

Migrations

All migrations are meant to be plug-and-play with the gren-sqlite-migrate package:

Sqlite.Migrate.migrate database
    { secureContext = secureContext }
    (Sqlite.Auth.Password.migrationV1 ++ yourOtherMigrations)

If you are using a different method of migration, the individual SQL statements can be extracted from each migration's statements field.

migrationV1 : { name : String, statements : Array String }

Initial migration required for functions in this module to operate. Adds the passwords and password_reset_tokens tables. Both reference users, so run this after Sqlite.Auth.User.migrationV1.

The first statement creates the passwords table:

CREATE TABLE IF NOT EXISTS passwords (
    user_id TEXT PRIMARY KEY NOT NULL,
    password TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    last_used INTEGER,
    failed_login_attempts_count INTEGER NOT NULL CHECK (failed_login_attempts_count >= 0),
    locked_until INTEGER,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
  • user_id is the primary key and references the users (in the Sqlite.Auth.User module), so there is one password per user.
  • password is the user's hashed password, stored in PHC string format.
  • Both created_at and updated_at are posix timestamp values.
  • last_used is a posix timestamp of the last successful check, or null if never.
  • failed_login_attempts_count counts consecutive failed checks.
  • locked_until is the posix timestamp the password stays locked until after too many failures, or null when unlocked.

The second statement creates the password_reset_tokens table:

CREATE TABLE IF NOT EXISTS password_reset_tokens (
    reset_token TEXT PRIMARY KEY NOT NULL,
    user_id TEXT NOT NULL UNIQUE,
    created_at INTEGER NOT NULL,
    expires_at INTEGER NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
  • reset_token is the primary key and stores the SHA-256 hash of the token.
  • user_id references the users and is UNIQUE, so there is at most one live reset token per user.
  • created_at is a posix timestamp, and expires_at is the posix timestamp the token stops being redeemable.

Working with passwords

type Password state

A password, with a phantom type state of either PlainText or Hashed. Only the hashed form can be saved to the database.

type PlainText

A password in plain text, as received from the user.

type Hashed

A password that has been hashed and is safe to persist.

Creating passwords

type alias Options =
{ iterations : Int
, maxFailedAttempts : Int
, lockoutDurationMillis : Int
, resetTokenLifetimeMillis : Int
}

The options the password functions take.

  • iterations is the PBKDF2 round count for new hashes. The Crypto library enforces a minimum of 600000 for SHA-256. Lower values make hash fail with a HashError. Values ten times the default (6000000 or above) are also denied as error
  • maxFailedAttempts and lockoutDurationMillis control the lockout.
  • resetTokenLifetimeMillis is how long a reset token stays redeemable. Longer lifetimes forgive users who click late; shorter ones shrink the window a leaked link stays live. See requestReset.
defaultOptions : Options

The default, recommended, options for password hashing.

{ iterations = 600000
, maxFailedAttempts = 5
, lockoutDurationMillis = 900000 -- 15 minutes
, resetTokenLifetimeMillis = 1800000 -- 30 minutes
}
fromString : String -> Password PlainText

Turn a String into a plain-text Password, ready to hash, or to pass to validate.

hash :
SecureContext
-> Options
-> Password PlainText
-> Task HashError (Password Hashed)

Hash a plain-text password into a Password Hashed ready for add. Uses PBKDF2-SHA256 with options.iterations rounds and a random 16-byte salt. Returns a Password Hashed that can then be saved to the database.

Iterations above ten times the default are rejected with TooManyIterations, so the cost of every stored hash stays bounded.

Note that the algorithm used to produce the password hash is SHA-256. This cannot currently be changed.

type HashError
= ImportPbkdf2KeyError ImportPbkdf2KeyError
| Pbkdf2DeriveBitsError Pbkdf2DeriveBitsError
| TooManyIterations Int

Errors that can happen while deriving a password hash.

  • ImportPbkdf2KeyError happens when the plain-text password cannot be imported as PBKDF2 key material.
  • Pbkdf2DeriveBitsError happens when the PBKDF2 derivation itself fails.
  • TooManyIterations happens raised when hash is asked for more rounds than ten times the default. It carries the rejected count.

Adding and validating passwords

add : Database -> Password Hashed -> User -> Task Error User

Store a hashed password for the given user to the database, returning the user on success for further pipeline steps.

User.new database secureContext
    |> Task.andThen (Username.new database alice)
    |> Task.andThen (Password.add database hashedPassword)
validate :
Database
-> SecureContext
-> Options
-> Password PlainText
-> Maybe User
-> Task ValidateError Outcome

Check a plain-text password against a user's stored password. The user argument is the Maybe that Sqlite.Auth.Username.user returns to make chaining easier. For example, you might handle user login like this:

Username.user database givenUserName
    |> Task.andThen (Password.validate database secureContext options candidate)
    |> Task.andThen
        (\outcome ->
            when outcome is
                Password.Verified proof ->
                    Session.issue database secureContext sessionOptions proof
                ...
        )
type Outcome
= Verified Verified
| Rejected
| Locked

The outcome of validating a candidate password.

  • Verified contains the verified user Sqlite.Auth.Session.issue requires to issue a session.
  • Rejected means the candidate did not match or the user has no password.
  • Locked means the password is temporarily locked after repeated failures.
type ValidateError
= StorageError Error
| VerificationError VerifyError

An error when validating a password.

  • StorageError is an underlying error in the Sqlite database.
  • VerificationError is a fault in the stored hash itself; see VerifyError for the ways it can happen.
type VerifyError
= ParsePhcStringError Error
| UnknownDigestAlgorithm String
| MissingSalt
| InvalidSalt Error
| SaltTooLong Int
| MissingHash
| InvalidHash Error
| HashLengthMismatch ({ expected : Int, actual : Int })
| MissingIterations
| InvalidIterations String
| IterationsTooLarge Int
| RederiveError HashError

The ways a stored PHC string can fail to verify against other than a plain mismatch.

  • ParsePhcStringError is when the stored hash is not a valid PHC string.
  • UnknownDigestAlgorithm is when the PHC string names a digest algorithm this module does not support.
  • MissingSalt is when the PHC string has no salt segment.
  • InvalidSalt is when the salt segment is not valid base64.
  • SaltTooLong is when the salt exceeds the accepted maximum length.
  • MissingHash is when the PHC string has no hash segment.
  • InvalidHash is when the hash segment is not valid base64.
  • HashLengthMismatch is when the hash length does not match the digest algorithm's native output size.
  • MissingIterations is when the PHC string has no i (iterations) parameter.
  • InvalidIterations is when the i parameter is not a valid integer.
  • IterationsTooLarge is when the iteration count exceeds the accepted maximum.
  • RederiveError is when re-deriving the candidate's hash fails; it wraps a HashError.

Changing, resetting, and deleting passwords

change :
Database
-> Password Hashed
-> Verified
-> Task Error Verified

Replace a user's password. This requires the Verified type. A password check with validate creates it, and so does verifying a live session through Sqlite.Auth.Session.verify. To require the current password, check it with validate and pass along the Verified that check returns.

Changing the password revokes the user's sessions and deletes any outstanding reset token.

All operations are completed in a SQLite transaction, preventing data corruption.

delete : Database -> Verified -> Task Error {}

Remove a user's password and any outstanding reset token, leaving the account without a password. Requires the Verified type, which a password check with validate or a live session through Sqlite.Auth.Session.verify creates.

Existing sessions are not revoked. They are a separate credential and keep working until they expire. Deleting a password when there is none succeeds and changes nothing. Both writes happen in one transaction.

requestReset :
Database
-> SecureContext
-> Options
-> User
-> Task Error { token : String
}

Creates a reset token for the given user and returns { token } exactly once. It's intended for this token to be transmitted safely, such as being emailed to the user.

The token is a standard base64 string, so it can contain +, /, and =. This library does not make the token safe for any particular transport: if it travels in a URL or form field, encode it for that context and hand reset back the exact original string.

A new request replaces any existing token for the same user, so the previous link stops working immediately.

To reset the password using the generated token, use the reset function.

reset :
Database
-> SecureContext
-> { token : String, new : Password Hashed }
-> Task ResetError Verified

Use a generated reset token to set a new password. No user is needed, because the token resolves the user itself.

If successful, this function deletes the token, replaces the password, and revokes all sessions the user may have. This function returns Verified, allowing you to issue a new session if you choose after a successful reset.

Note: A token can be redeemed only once and is invalid after redemption.

Password.reset database secureContext
    { token = generatedToken
    , new = hashedNew -- `Password Hashed`
    }
    |> Task.andThen (Session.issue database secureContext sessionOptions)
type ResetError
= InvalidResetToken
| ResetStorageError Error

The ways redeeming a reset token can go wrong.

  • InvalidResetToken is a token that matches no live row, whether it is unknown, already used, replaced, or expired. The caller cannot tell which, deliberately.
  • ResetStorageError is a database fault.