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_registrationlets visitors create accounts. -
with_passwordsturns on passwords. Without it there are only email tokens. -
deliveris the function that sends email. Printing the token is fine on your own machine. Before you deploy, replaceprint_emailwith 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, nothttp://127.0.0.1:8789, or sign-in is refused with403. In production it is yourhttps://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
- Sessions and policy changes how long a session lasts and how short a password may be.
- Roles and permissions decides what each user may do.
- User administration creates users by invitation, suspends them, and explains the limits to set behind a proxy.
