Authentication & Authorization
Authenticatable- Logging in and out
- Reading the current user
- Route guards
- Multiple guards
- Authorization:
Policy<U> - API tokens:
larust-sanctum - Roles & permissions:
larust-permissions - Resource-scoped permission bundles:
larust-shield - Next
Authenticatable
Any type your app wants to log in as implements one small trait:
impl Authenticatable for User {
fn auth_id(&self) -> i64 { self.id }
async fn find_for_auth(id: i64) -> Result<Option<Self>, AppError> {
User::find(id).await
}
}
--auth-scaffolded apps generate this for you already.
Logging in and out
Password hashing (argon2) and session storage are handled for you; logging in is: verify the password, then store the user’s id in the session (rotating the session id on success, preventing session fixation):
pub async fn login(session: Session, request: LoginRequest) -> Result<impl IntoResponse, AppError> {
let validated = request.validated();
let user = User::query().where_eq("email", &validated.email).first().await?
.filter(|user| larust_support::auth::verify_password(&user.password_hash, &validated.password).unwrap_or(false))
.ok_or_else(|| AppError::Http { status: StatusCode::UNPROCESSABLE_ENTITY, message: "Invalid credentials.".into() })?;
// Stores the user's id in the session and rotates the session id
// (preventing session fixation) - Laravel's `Auth::login($user)`.
larust_support::auth::login(&session, &user).await?;
Ok(redirect().route("posts.index")?)
}
Password checks always run at the same cost regardless of whether the email matched a real user - a mismatched-email short-circuit is a real, if minor, timing side-channel this framework deliberately avoids, even though the error message looks identical either way.
Reading the current user
Three ways, depending on what you need:
// 1. Require login - a real extractor. No session, or a stale/deleted
// user id, rejects the request before your handler runs.
pub async fn create(Auth(user): Auth<User>) -> Result<impl IntoResponse, AppError> { ... }
// 2. Optional - the route works either way, you just branch on it.
let viewer: Option<User> = larust_support::auth::user::<User>(&session).await?;
// 3. Just a yes/no, when you don't need the user itself (e.g. a nav bar).
let is_authenticated = larust_support::auth::check(&session).await?;
Route guards
route.group("", |r: Router| {
r.middleware(axum::middleware::from_fn(require_auth))
.get("/posts/create", PostController::create)
})
require_auth redirects a guest to the login page; redirect_authenticated
does the inverse (Laravel’s guest middleware) - bouncing an
already-logged-in user away from /login//register. Both are plain
functions, attached like any other middleware - see
Routing.
Multiple guards
Laravel’s guard('admin') concept: a second Authenticatable type, logged
in independently of the first, in the same browser session. Name it by
overriding one associated constant - the only thing distinguishing a guard
from the default:
impl Authenticatable for Admin {
const GUARD: &'static str = "admin";
fn auth_id(&self) -> i64 { self.id }
async fn find_for_auth(id: i64) -> Result<Option<Self>, AppError> {
Admin::find(id).await
}
}
Every function above has a _for::<U> counterpart that targets a specific
guard instead of the default ("web") one:
larust_support::auth::login(&session, &admin).await?; // stores it under "admin"'s own slot
larust_support::auth::check_for::<Admin>(&session).await?; // is *this* guard logged in?
larust_support::auth::logout_for::<Admin>(&session).await?; // clears only "admin"'s slot
user::<U> and the Auth<U> extractor already read through U::GUARD
automatically - Auth<Admin> and Auth<User> resolve independent login
state on the same request without any extra ceremony:
pub async fn dashboard(Auth(admin): Auth<Admin>) -> impl IntoResponse { ... }
pub async fn profile(Auth(user): Auth<User>) -> impl IntoResponse { ... }
Route guards follow the same pattern - require_auth_for::<Admin> and
redirect_authenticated_for::<Admin> sit next to the default-guard
require_auth/redirect_authenticated, so an admin section can require
its own login independently of the regular user guard:
route.group("/admin", |r: Router| {
r.middleware(axum::middleware::from_fn(require_auth_for::<Admin>))
.get("/dashboard", AdminController::dashboard)
})
require_auth_for::<Admin> redirects a guest to a route named
"admin.login" ("{guard}.login", not the plain "login"
require_auth uses) - name your admin login route accordingly:
route.post("/admin/login", AdminAuthController::login).name("admin.login")
A single-guard app never has to think about any of this - a plain User
that never overrides GUARD keeps using the exact "_auth_user_id"
session key it always has, and every non-_for function above keeps
working unchanged. Only the second Authenticatable type needs a
GUARD override; forgetting one collides with the default guard’s slot
rather than failing at compile time, so name every guard beyond the first.
Authorization: Policy<U>
impl Policy<User> for Post {
fn view_any(_user: &User) -> bool { true }
fn view(&self, _user: &User) -> bool { true }
fn create(_user: &User) -> bool { true }
fn update(&self, user: &User) -> bool { self.user_id == user.id }
fn delete(&self, user: &User) -> bool { self.user_id == user.id }
}
Five methods, same names and meaning as Laravel’s own policy abilities.
Every one has a matching authorize_* default method that converts
straight to a 403:
Post::authorize_create(&user)?; // fn create, static
larust_support::auth::authorize(post.can_manage(&user).await?)?; // ad hoc bool, same helper underneath
post.authorize_update(&user)?; // fn update, instance
xr make:policy Post generates the trait impl skeleton for you
(--user <Type> if your Authenticatable isn’t named User). Policy
methods are deliberately synchronous - if a real check needs an await
(a database lookup for a moderator role, say, as the reference app’s own
Post::can_manage does), write that as an ordinary async fn on the
model itself and call it from the controller instead of trying to force
it through Policy’s own methods.
API tokens: larust-sanctum
Laravel Sanctum’s personal-access-token flow, for stateless (non-session) API auth:
let plaintext = larust_sanctum::create_token(&user, "cli-tool", None).await?;
// "42|f3a1...c9" - shown to the caller exactly once; only its hash is
// ever stored, so it can't be recovered from a database dump later.
pub async fn me(ApiAuth(user): ApiAuth<User>) -> Json<UserResponse> { ... }
ApiAuth<U> is the API-route counterpart to Auth<U> - it authenticates
via a Bearer <token> header instead of a session cookie, resolving the
same Authenticatable type. revoke_token(id)/
revoke_all_tokens_for(&user) round out token management (Laravel’s
$token->delete()/$user->tokens()->delete()).
Roles & permissions: larust-permissions
An optional xr new feature (--features permissions, or pick it in the
wizard):
permissions::create_role(Role::Moderator).await?;
permissions::create_permission(Permission::ManagePosts).await?;
permissions::grant_role_permission(Role::Moderator, Permission::ManagePosts).await?;
permissions::assign_role(&user, Role::Moderator).await?;
if permissions::has_permission_to(&user, Permission::ManagePosts).await? { ... }
permissions::authorize_permission(&user, Permission::ManagePosts).await?; // straight to a 403
Role and permission names are your own type implementing a small marker
trait (RoleName/PermissionName) - typically a plain enum, so a typo’d
permission name is a compile error rather than a string that silently
never matches anything.
Checking them from a template: @can/@role
The same checks, directly in a .blade.xr template, for when a permission
or role gates a piece of markup rather than an entire route:
@can(Permission::ManagePosts)
<a href="/posts//edit">Edit</a>
@else
<span class="text-muted">Read only</span>
@endcan
@role(Role::Moderator)
<p class="post-meta">Editing as a moderator.</p>
@endrole
expr is a real Rust expression, not a quoted string - @can(Permission::ManagePosts)
resolves through has_permission_to, @role(Role::Moderator) through
has_role, and a typo’d name (Permission::ManagePost, missing the s)
is a compile error at the template’s own call site, the same guarantee
every other permission/role check in this framework already has. Both
directives take an optional @else (no @elsecan/@elserole chaining -
a single name has nothing to chain against); @role with no @else
simply renders nothing when the check fails.
Using either one requires a user: &U binding in the view!(...) call’s
own context, and an async, Result-returning call site - a permission
check is a real database round trip:
Ok(view!("posts.edit", { user: &user, post, /* ... */ }))
Requires the permissions feature on larust-support (the same one
larust_support::permission itself needs) - using either directive
without it fails with an ordinary “unresolved module” compile error.
isAdmin(): AdminRole
Laravel’s own $user->isAdmin() is usually a method apps hand-roll for
themselves - there’s no such thing on Laravel’s base User. This
framework’s version is the same idea, compile-checked: implement one
small marker trait on your own Role type, naming which variant counts
as “admin”:
impl larust_support::permission::AdminRole for Role {
fn admin() -> Self { Role::Admin }
}
Then reach for is_admin/authorize_admin the same way you’d reach for
has_role/has_permission_to:
permission::is_admin::<User, Role>(&user).await?; // bool
permission::authorize_admin::<User, Role>(&user).await?; // straight to a 403
The type parameters are the one bit of ceremony this adds over a plain
global function - resolved once, the recommended way, in a tiny app-level
wrapper (larust-permissions’s own reference app does exactly this in
app/Permissions/mod.rs):
pub async fn is_admin(user: &User) -> Result<bool, AppError> {
permission::is_admin::<User, Role>(user).await
}
so every other call site in your app is just is_admin(&user).await? -
no magic "admin" string anywhere, and a role your app calls something
else entirely (the reference app’s own top role is Role::Moderator) is
exactly as valid an answer to AdminRole::admin() as one literally named
Admin.
Resource-scoped permission bundles: larust-shield
A second, optional xr new feature (--features shield, or pick it in
the wizard - implies permissions), inspired by FilamentPHP’s popular
bezhansalleh/filament-shield
plugin: instead of one hand-written Permission variant per ability
(Permission::ManagePosts), declare your app’s resources and get a
standard view_any/view/create/update/delete bundle for each one
automatically:
#[derive(Copy, Clone)]
enum Resource { Posts, Comments }
impl shield::ResourceName for Resource {
fn name(&self) -> &'static str {
match self {
Resource::Posts => "posts",
Resource::Comments => "comments",
}
}
}
shield::create_resource_permissions(Resource::Posts).await?; // all 5 abilities at once
// Filament Shield's own per-resource ability checkboxes on a role,
// as one call: "Editor can view and update posts, nothing else."
shield::grant_resource_abilities(Role::Editor, Resource::Posts, &[
shield::Ability::View,
shield::Ability::Update,
]).await?;
shield::can(&user, Resource::Posts, shield::Ability::Update).await?; // bool
shield::authorize_ability(&user, Resource::Posts, shield::Ability::Update)?; // straight to a 403
shield::ResourcePermission is a real permission::PermissionName -
this crate adds no new storage and no new checking primitive, only a
second, resource-shaped way to name a permission larust-permissions
already knows how to store and check. That means it drops straight into
everything above with zero glue code: @can(shield::ResourcePermission::update(Resource::Posts))
works in a template today, exactly like any other permission.
Deliberately narrower than the plugin it’s inspired by: no auto-scanning
of your models (list your own resources by hand, the same way you already
list roles/permissions - this framework has no reflection-based registry
to scan in the first place), and only the five abilities Policy<U>
already established, not Shield’s fuller delete_any/restore/
force_delete/… list - most of which have nothing real backing them
here (migrations are forward-only, there’s no soft-delete convention). An
app that genuinely needs more can still reach larust-permissions
directly with its own PermissionName impl.
Next
Mail & Notifications covers reaching users outside the request/response cycle.