Add authentication#
Put a login page in front of routes, decide per action who may take it, and scope a model's rows to their owner. Adding sign in walks through the same steps with explanation; this is the recipe.
1. The artifact#
libraryDependencies += "io.eezo" %% "eezo-auth" % eezoVersion
Compile / run / mainClass := Some("Main") // there will be a second main belowNaming eezo-auth turns on the rule that every mounted route declares who may reach it.
2. A user and a guard#
package models
import io.eezo.auth.{Guard, Password}
import io.eezo.core.Id
import io.eezo.core.html.Url
import io.eezo.db.*
import io.eezo.db.Scopes.read
case class User(id: Id[User], email: String, password: Password) derives Table
object User {
given Column[Password] = Column[String].imap(Password.stored)(_.value)
def byEmail(email: String): Option[User] =
read(Table[User].where(_.email === email).first())
given guard: Guard[User] = Guard[User](
find = id => read(Table[User].findById(id)),
credentials = email => byEmail(email).map(user => (user.id, user.password)),
home = Url.Mounted("/")
)
}Guard[User] also takes login (default /login) and lifetime (default fourteen days from
the sign in). Register the table in your Schema with .unique(_.email).
3. Declare every route#
A model, in its companion:
import io.eezo.http.{Action, Owned}
object Book {
// signed in for everything; the owner's rows for everything but the list and the detail page
given Owned[Book, User] =
User.guard.required[Book].owning(_.owner).except(Action.Index, Action.Show)
}owner: Id[User] is a field on the model. It's never on a form; eezo fills it from the current
user on create and update.
Without ownership, just a guard:
given Guarded[Book] = User.guard.required // every action
given Guarded[Book] = User.guard.only(Action.Index) // just the listA handwritten page, in its own object:
given Guarded[Index.type] = Guarded.public
given Guarded[Admin.type] = User.guard.requiredA route that says nothing is a compile error in the generated table, naming the object.
4. The routes you get#
Declaring on a model mounts GET /login, POST /login and POST /logout beside it. Under a
mount they move with the resource, so /admin/login. A refused page answers 303 to the login
page and remembers where you were going.
5. The first user#
No sign-up page is mounted. A second DbApp in the project makes a user from the environment:
object CreateUser extends DbApp {
override def schema: Schema = AppSchema
override def boot(): Unit = {
val email = sys.env.getOrElse("APP_EMAIL", sys.error("APP_EMAIL is not set"))
val password = sys.env.getOrElse("APP_PASSWORD", sys.error("APP_PASSWORD is not set"))
val hashed = Field[Password].read(password).fold(message => sys.error(message), identity)
transact { Table[User].insert(User(Id.gen(), email, hashed)) }
}
}Type the password at a prompt so it stays out of your shell history, then run
APP_EMAIL=... sbt "runMain CreateUser".
6. Sign out#
User.guard.logoutForm(request)Renders a Sign out button, with the CSRF token, for a browser that's signed in, and nothing
otherwise. Outside a mount, pass the finished address: logoutForm(request, Url.Absolute("/admin/logout")).
7. In the handler#
User.guard.current(request) is the signed-in user on a guarded route. On a public route there
may be nobody, and asking is an error, so don't.
Production notes#
- Set
EEZO_SECRETwhere the application runs. Without it eezo signs sessions with a throwaway key per process, and every restart signs everyone out. - The login page answers every attempt at one bcrypt each, about a quarter of a second, right or
wrong. There's no rate limit in the application; put one on
POST /loginat your reverse proxy or your platform. - A sign in lasts
lifetimefrom the moment it was made, however active the user is.