Howdy

Username and password sign-in

This guide adds accounts to a Howdy app with Howdy Auth. By the end, a visitor can register, choose a username, and sign in with that username and a password.

One thing to know before starting: Howdy Auth identifies an account by its email address, and registration confirms the address by emailing a token. That address is also how a forgotten password is recovered. So a username does not replace the email address. It is a unique field on the user, and signing in with it is one small route of your own. The guide builds both.

It assumes a project made with howdy new my_app, on SQLite. See Getting started if you do not have one.

Install

Add Howdy Auth and Howdy Database to the dependencies in gleam.toml:

howdy_auth = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.1.0", path = "auth" }
howdy_database = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.1.0", path = "database" }

howdy_database provides howdy/migration, which runs the auth migrations.

Download them, and build the native library that hashes passwords. This needs make, a C compiler and the Erlang headers:

gleam deps download
make -C build/packages/jargon/c_src

Run the make command again whenever the build folder is deleted.

Create the tables

Howdy Auth brings its own migrations. In src/my_app/platform/database.gleam, add a function that runs them:

import howdy/auth
import howdy/migration
import howdy/service

pub fn migrate_auth(db: Repo) -> Result(Nil, String) {
  migration.run(db, [auth.schema()])
  |> result.map_error(service.message)
}

Configure auth

Create src/my_app/accounts/identity.gleam. It builds the one Auth value the rest of the app shares:

import gleam/io
import gloo/repo.{type Repo}
import howdy/auth.{type Auth}
import howdy/auth/secret

pub fn new(
  db: Repo,
  origin: String,
  deliver: fn(auth.Delivery) -> Result(Nil, Nil),
) -> Auth {
  let assert Ok(identity) = auth.new(repo: db, origin: origin, deliver: deliver)
  let identity = auth.allow_registration(identity)
  let assert Ok(identity) = auth.with_passwords(identity)
  identity
}

/// For development only: print the token instead of emailing it.
pub fn print_email(delivery: auth.Delivery) -> Result(Nil, Nil) {
  io.println(
    "Token for " <> delivery.email <> ": " <> secret.reveal(delivery.token),
  )
  Ok(Nil)
}
  • allow_registration lets visitors create accounts.
  • with_passwords turns on passwords. Without it there are only email tokens.
  • deliver is the function that sends email. Printing the token is fine on your own machine. Before you deploy, replace print_email with one that really sends: see Sending the email.

Mount it

In src/my_app.gleam, take the Auth as an argument to app, and mount the JSON API and the starter pages:

import howdy/auth.{type Auth}
import howdy/auth/pages
import howdy/auth/routes

pub fn app(db: Repo, identity: Auth) -> howdy.App {
  howdy.new()
  |> howdy.controller(routes.api(identity, at: "/api/auth"))
  |> howdy.controller(pages.routes(identity, at: "/auth", api_at: "/api/auth"))
  |> howdy.controller(todos.controller(db))
  |> howdy.controller(site.controller(db))
  // The root static controller is a catch-all, so mount it last.
  |> howdy.controller(static.serve("/", from: "priv/public"))
}

Pass it through configured in the same way, then build it in main:

pub fn main() -> Nil {
  load_environment()
  let db = database()
  let assert Ok(_) = database.migrate(db)

  // Auth gets a connection of its own to the same file.
  let auth_db = database()
  let assert Ok(_) = database.migrate_auth(auth_db)
  let identity =
    identity.new(auth_db, "http://localhost:8789", identity.print_email)

  let assert Ok(_) = configured(db, identity) |> howdy.start
  process.sleep_forever()
}

Two details matter here:

  • The origin must be the address in the browser's address bar, exactly. Open the app at http://localhost:8789, not http://127.0.0.1:8789, or sign-in is refused with 403. In production it is your https:// address; read it from the environment.
  • Gloo gives each SQLite Repo one connection, so auth gets its own Repo. On PostgreSQL, pass the pool you already have.

The files in dev/ and test/ that call app need the new argument too. The compiler will point at each of them.

Register

Start the app with gleam dev and open http://localhost:8789/auth/password/register. Enter an email address and a password.

Passwords must be at least 15 characters, and common ones are refused, so password123456789 will not do. A few unrelated words work well.

The page asks for the token that was "emailed". Copy it from the terminal, where print_email wrote it, and paste it in. The account now exists, and the browser holds a session cookie. http://localhost:8789/auth/account lists your sessions and signs you out.

This much is a complete email and password sign-in: http://localhost:8789/auth/password/login takes the address and the password. The rest of the guide adds the username.

Declare the username

Create src/my_app/accounts/username.gleam:

import gleam/list
import gleam/string
import howdy/auth/field

pub fn definition() -> field.Field(String) {
  field.text("username")
  |> field.unique
  |> field.check(fn(name) {
    let length = string.length(name)
    let allowed =
      string.to_graphemes(name)
      |> list.all(string.contains("abcdefghijklmnopqrstuvwxyz0123456789_", _))
    case length >= 3 && length <= 30 && allowed {
      True -> Ok(Nil)
      False -> Error("must be 3 to 30 lowercase letters, digits or underscores")
    }
  })
}

/// Usernames are compared in lower case, without surrounding spaces.
pub fn normalize(name: String) -> String {
  string.lowercase(string.trim(name))
}

field.unique makes the database refuse a username somebody else holds, even when two people ask for it at the same moment. The check keeps usernames to a small alphabet, which also rules out an @: a username can never be mistaken for an email address.

Choose a username, and sign in with it

Create src/my_app/accounts/accounts_controller.gleam:

import gleam/dynamic/decode
import gleam/json
import gleam/option
import gleam/result
import howdy/auth.{type Auth}
import howdy/auth/field
import howdy/auth/secret
import howdy/auth/user
import howdy/auth/users
import howdy/body
import howdy/context
import howdy/controller
import howdy/cookie
import howdy/guard
import howdy/service
import my_app/accounts/username

pub fn controller(identity: Auth) -> controller.Controller {
  controller.new("/api/accounts")
  |> controller.post("/username", choose_username(identity, _))
  |> controller.post("/login", login(identity, _))
  |> controller.get("/me", me(identity, _))
}

fn choose_username(identity: Auth, ctx: controller.Context) {
  use principal <- guard.require(ctx, auth.required(identity))
  use name <- body.json(
    ctx,
    decode.field("username", decode.string, decode.success),
  )
  users.update(
    identity,
    principal.user.id,
    [field.set(username.definition(), username.normalize(name))],
    by: user.Acting(principal),
  )
  |> service.respond(ctx, user.to_json)
}

auth.required is a guard, so only a signed-in visitor reaches users.update, and they can only change their own username, because the id comes from their session and not from the request. A username that is taken answers 409, and one that fails the check answers 400 with the message.

Next, in the same file, the sign-in. Look the username up, then hand the account's email address and the password to Howdy Auth:

pub fn sign_in(
  identity: Auth,
  name: String,
  password: String,
  client: String,
) -> service.Result(auth.Session) {
  let name = username.normalize(name)
  use found <- result.try(users.find(
    identity,
    where: username.definition(),
    is: name,
  ))
  let email = case found {
    [user] -> user.email
    // Nobody has this username. Check the password anyway, against an
    // address that can never have an account.
    _ -> name <> "@username.invalid"
  }
  auth.login_password_from(identity, email, password, client)
}

Do not return early when the username is unknown. login_password_from does the same slow password check whether or not the account exists, and gives the same 401 either way. Sending unknown usernames through it keeps that true for your route, so nobody can use the sign-in form to find out which usernames are taken. It also means guesses against a username are slowed down just as guesses against an address are.

The route wraps that function. It sets the same cookie that Howdy Auth's own routes set, so every guard in the app accepts the session:

fn login(identity: Auth, ctx: controller.Context) {
  use _ <- guard.require(ctx, auth.check_origin(identity, _))
  use #(name, password) <- body.json(ctx, {
    use name <- decode.field("username", decode.string)
    use password <- decode.field("password", decode.string)
    decode.success(#(name, password))
  })
  let client = context.client_ip(ctx.request) |> option.unwrap("")
  case sign_in(identity, name, password, client) {
    Error(error) -> service.error_response(ctx, error)
    Ok(session) ->
      controller.json(ctx, user.to_json(session.user))
      |> cookie.set(
        auth.cookie_name(identity),
        secret.reveal(session.token),
        cookie.defaults()
          |> cookie.secure(auth.secure(identity))
          |> cookie.max_age(auth.policy(identity).session_seconds),
      )
  }
}

auth.check_origin refuses a sign-in posted from another site. client is what the password limits count by; if the app runs behind a proxy, read the proxy's header instead of the socket address.

Last, a route that shows who is signed in. Fields are not loaded with the session, so it reads them:

fn me(identity: Auth, ctx: controller.Context) {
  use principal <- guard.require(ctx, auth.required(identity))
  users.fields(identity, principal.user.id)
  |> service.respond(ctx, fn(data) {
    json.object([
      #("email", json.string(principal.user.email)),
      #(
        "username",
        json.nullable(
          field.get(data, username.definition()) |> option.from_result,
          json.string,
        ),
      ),
    ])
  })
}

Mount the controller in app, above the static files:

|> howdy.controller(accounts.controller(identity))

Try it

While you are still signed in from registering, choose a username from the browser's console:

await fetch('/api/accounts/username', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({username: 'ada'}),
});

Sign out at /auth/account, then sign in with the username:

const response = await fetch('/api/accounts/login', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({
    username: 'ada',
    password: 'a long and unusual passphrase',
  }),
});

/api/accounts/me now answers with the email address and the username. A real app would put a form in front of each of these calls: see Forms and Building a UI.

Protect your pages

Anything that needs a signed-in visitor uses the same guard:

controller.guarded("/dashboard", auth.required(identity))
|> controller.get("/", fn(ctx) {
  controller.text(ctx, "Hello, " <> ctx.guard.user.email)
})
|> controller.build()

A visitor who is not signed in gets 401. Howdy Auth never redirects, so sending them to your sign-in page is up to you.

Forgotten passwords

A password is reset through the email address, which is why every account has one. The visitor asks for a token at /auth/login, pastes it in, and within ten minutes sets a new password at /auth/account. Doing so signs them out everywhere else. Passwords explains why it works this way.

Test it

The flow can be tested end to end with howdy/testing. Use an in-memory database, and a deliver function that hands the token to the test:

import gleam/erlang/process
import gleam/json
import gleam/list
import gloo/adapter/sqlite
import howdy/auth
import howdy/auth/secret
import howdy/testing
import my_app
import my_app/accounts/identity
import my_app/platform/database

const origin = "http://localhost:8789"

const password = "a long and unusual passphrase"

pub fn username_sign_in_test() {
  let assert Ok(db) = sqlite.start(sqlite.memory())
  let assert Ok(_) = database.migrate(db)
  let assert Ok(_) = database.migrate_auth(db)
  let inbox = process.new_subject()
  let identity =
    identity.new(db, origin, fn(delivery: auth.Delivery) {
      process.send(inbox, secret.reveal(delivery.token))
      Ok(Nil)
    })
  let app = my_app.app(db, identity)
  let cookie_name = auth.cookie_name(identity)

  // Register, and confirm the address with the token.
  let res =
    testing.post(
      "/api/auth/password/register",
      json.object([
        #("email", json.string("ada@example.com")),
        #("password", json.string(password)),
      ]),
    )
    |> testing.send(app)
  assert res.status == 202
  let assert Ok(token) = process.receive(inbox, 1000)
  let res =
    testing.post(
      "/api/auth/session",
      json.object([#("token", json.string(token))]),
    )
    |> testing.header("origin", origin)
    |> testing.send(app)
  let assert Ok(session) = list.key_find(testing.cookies(res), cookie_name)

  // Choose a username.
  let res =
    testing.post(
      "/api/accounts/username",
      json.object([#("username", json.string("Ada"))]),
    )
    |> testing.header("origin", origin)
    |> testing.cookie(cookie_name, session)
    |> testing.send(app)
  assert res.status == 200

  // Sign in with it.
  let login = fn(name, password) {
    testing.post(
      "/api/accounts/login",
      json.object([
        #("username", json.string(name)),
        #("password", json.string(password)),
      ]),
    )
    |> testing.header("origin", origin)
    |> testing.send(app)
  }
  assert login("ada", password).status == 200
  assert login("ada", "not the right passphrase").status == 401
  assert login("nobody", password).status == 401
}

A test shares one in-memory database between the app and auth, which is fine because a test makes one request at a time. Password hashing is slow on purpose, so keep the number of sign-ins in your tests small.

Where next