Part 4: Users and Authentication
Add the users app, session-backed authentication server functions, and a nav shell that reflects login state.
Part 4: Users and Authentication
The poll app can list, display, and vote. Now add accounts: a users app, a minimal User model, login/register/logout/current-user server functions, session middleware, auth routes, and a shared navigation bar.
This is the first basis tutorial part that uses dependency injection directly. Keep the DI surface small here. The REST tutorial's dependency-injection part goes deeper into factories, scopes, caching, and type keys.
Create the Users App
Generate the app:
reinhardt-admin startapp users --template pagesRegister it next to polls:
use reinhardt::installed_apps;
installed_apps! {
polls: "polls",
users: "users",
}Define the User Model
Open src/apps/users/models.rs. The example uses a minimal user model, not full = true:
#[cfg_attr(native, user(hasher = Argon2Hasher, username_field = "username", manager = false))]
#[model(app_label = "users", table_name = "users")]
#[derive(Default, Clone, Serialize, Deserialize)]
pub struct User {
#[field(primary_key = true)]
pub id: i64,
#[field(max_length = 150, unique = true)]
pub username: String,
#[field(max_length = 255, skip_info = true)]
pub password_hash: Option<String>,
#[field(default = true)]
pub is_active: bool,
#[field(default = false, skip_info = true)]
pub is_superuser: bool,
#[field(include_in_new = false, skip_info = true)]
pub last_login: Option<DateTime<Utc>>,
#[field(auto_now_add = true, skip_info = true)]
pub created_at: DateTime<Utc>,
}manager = false opts out of the generated user manager because this tutorial keeps a project-local manager that owns password hashing, uniqueness checks, and persistence.
Add the Auth User Manager
The manager is server-only. It stores a DatabaseConnection and is registered through an injectable factory:
#[derive(Clone)]
pub struct AuthUserManager {
db: DatabaseConnection,
}
#[injectable_factory(scope = "transient")]
async fn auth_user_manager_factory(
#[inject] db: Depends<DatabaseConnection>,
) -> AuthUserManager {
AuthUserManager { db: (*db).clone() }
}The Depends<DatabaseConnection> wrapper dereferences to the inner connection. Cloning here is cheap because the connection handle is internally shared.
The manager implements BaseUserManager<User>:
#[async_trait]
impl BaseUserManager<User> for AuthUserManager {
async fn create_user(
&mut self,
username: &str,
password: Option<&str>,
extra: HashMap<String, Value>,
) -> Result<User, Error> {
let new_user = self.build_user(username, password, &extra).await?;
User::objects()
.create_with_conn(&self.db, &new_user)
.await
.map_err(|e| Error::Database(e.to_string()))
}
}build_user trims usernames, rejects empty or overlong names, checks uniqueness, enforces password length, and calls set_password. Keep that logic in the manager so server functions stay focused on request/session flow.
Share Auth DTOs
Update src/shared/types.rs to re-export UserInfo and define login/register DTOs:
pub use crate::apps::polls::models::{ChoiceInfo, QuestionInfo};
pub use crate::apps::users::models::UserInfo;#[dto]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
#[validate(length(
min = 1,
max = 150,
message = "Username must be between 1 and 150 characters"
))]
pub username: String,
#[validate(length(min = 1, message = "Password must not be empty"))]
pub password: String,
}RegisterRequest follows the same pattern and adds password_confirmation. The example validates password equality with a helper instead of relying on validator crate field-name matching:
#[cfg(server)]
impl RegisterRequest {
pub fn validate_passwords_match(&self) -> Result<(), &'static str> {
if self.password == self.password_confirmation {
Ok(())
} else {
Err("Passwords do not match")
}
}
}Add Auth Server Functions
Create src/apps/users/server_fn.rs. Login validates the request, checks the password, and rotates the session:
#[server_fn]
pub async fn login(
username: String,
password: String,
#[inject] _db: DatabaseConnection,
#[inject] session: SessionData,
#[inject] store: Depends<SessionStore>,
) -> std::result::Result<UserInfo, ServerFnError> {
let mut session = session;
let request = LoginRequest { username, password };
request
.validate()
.map_err(|e| ServerFnError::application(format!("Validation failed: {}", e)))?;
let manager = User::objects();
let user = manager
.filter(User::field_username().eq(request.username.trim().to_string()))
.first()
.await
.map_err(|e| ServerFnError::application(format!("Database error: {}", e)))?
.ok_or_else(|| ServerFnError::server(401, "Invalid credentials"))?;
let valid = user
.check_password(&request.password)
.map_err(|e| ServerFnError::application(format!("Password check failed: {}", e)))?;
if !valid {
return Err(ServerFnError::server(401, "Invalid credentials"));
}
if !user.is_active() {
return Err(ServerFnError::server(403, "User account is inactive"));
}
session
.login(&store, user.id())
.map_err(|e| ServerFnError::application(format!("Session error: {}", e)))?;
Ok(UserInfo::from(user))
}Register creates the user through the injected AuthUserManager and then logs the new account in:
#[server_fn]
pub async fn register(
username: String,
password: String,
password_confirmation: String,
#[inject] user_manager: Depends<AuthUserManager>,
#[inject] session: SessionData,
#[inject] store: Depends<SessionStore>,
) -> std::result::Result<UserInfo, ServerFnError> {
let mut session = session;
let request = RegisterRequest {
username,
password,
password_confirmation,
};
request
.validate()
.map_err(|e| ServerFnError::application(format!("Validation failed: {}", e)))?;
request
.validate_passwords_match()
.map_err(ServerFnError::application)?;
let mut user_manager: AuthUserManager = (*user_manager).clone();
let saved = user_manager
.create_user(
request.username.trim(),
Some(&request.password),
HashMap::new(),
)
.await
.map_err(|e| ServerFnError::application(e.to_string()))?;
session
.login(&store, saved.id())
.map_err(|e| ServerFnError::application(format!("Session error: {}", e)))?;
Ok(UserInfo::from(saved))
}Add logout and current-user lookup:
#[server_fn]
pub async fn logout(
#[inject] session: SessionData,
#[inject] store: Depends<SessionStore>,
) -> std::result::Result<(), ServerFnError> {
let mut session = session;
if session.get::<i64>(USER_ID_SESSION_KEY).is_none() {
return Err(ServerFnError::server(401, "Not authenticated"));
}
session.logout(&store);
Ok(())
}
#[server_fn]
pub async fn current_user(
#[inject] _db: DatabaseConnection,
#[inject] session: SessionData,
) -> std::result::Result<Option<UserInfo>, ServerFnError> {
let user_id = match session.get::<i64>(USER_ID_SESSION_KEY) {
Some(id) => id,
None => return Ok(None),
};
let user = User::objects()
.filter(User::field_id().eq(user_id))
.first()
.await
.map_err(|e| ServerFnError::application(format!("Database error: {}", e)))?;
Ok(user.map(UserInfo::from))
}Register Auth Routes
The users app follows the same split as polls:
#[cfg(server)]
pub mod server_urls;
#[cfg(client)]
pub mod client_router;Server routes register server-function markers:
use crate::apps::users::server_fn::{current_user, login, logout, register};
use reinhardt::ServerRouter;
use reinhardt::pages::server_fn::ServerFnRouterExt;
pub fn server_url_patterns() -> ServerRouter {
ServerRouter::new()
.server_fn(login::marker)
.server_fn(logout::marker)
.server_fn(register::marker)
.server_fn(current_user::marker)
}Client routes expose the login, logout, and signup pages:
pub fn client_url_patterns() -> ClientRouter {
ClientRouter::new()
.route("login", "/login/", login_page)
.route("logout", "/logout/", logout_page)
.route("signup", "/signup/", signup_page)
}Mount the users app in src/config/urls.rs on both targets:
#[cfg(server)]
let router = router.server(|s| {
s.mount("/", crate::apps::polls::urls::server_url_patterns())
.mount("/", crate::apps::users::urls::server_url_patterns())
});#[cfg(client)]
let router = router
.mount_unified(
"/",
UnifiedRouter::new().client(|_| crate::apps::polls::urls::client_url_patterns()),
)
.mount_unified(
"/",
UnifiedRouter::new().client(|_| crate::apps::users::urls::client_url_patterns()),
);Add Session Middleware
The session middleware owns the SessionStore DI registration. Add it once in src/config/urls.rs:
#[cfg(server)]
fn create_session_middleware() -> SessionMiddleware {
let config = SessionConfig::new("sessionid".to_string(), Duration::from_secs(1_209_600))
.with_http_only(true)
.with_same_site("Lax".to_string())
.with_path("/".to_string());
SessionMiddleware::new(config)
}Then attach it to the project router:
#[cfg(server)]
let router = router.with_middleware(create_session_middleware());After this, server functions can inject SessionData and Depends<SessionStore>.
Build the Auth Pages
The users client module hosts forms:
pub mod components;The login form binds directly to the login server function:
pub fn login_form() -> Page {
let login_form = form! {
name: LoginForm,
server_fn: login,
method: Post,
redirect_on_success: "/",
fields: {
username: CharField {
label: "Username",
placeholder: "your-username",
max_length: 150,
class: "form-control",
}
password: PasswordField {
label: "Password",
placeholder: "Enter your password",
class: "form-control",
}
}
};The signup form uses register; the logout form uses logout with no fields. The generated #[server_fn] client stubs handle the HTTP call and CSRF header.
The page aggregator wraps each auth form in the shared nav:
pub fn login_page() -> Page {
with_nav(crate::apps::users::client::components::login_form())
}
pub fn logout_page() -> Page {
with_nav(crate::apps::users::client::components::logout_form())
}
pub fn signup_page() -> Page {
with_nav(crate::apps::users::client::components::signup_form())
}Show Login State in the Nav
The nav calls current_user() as a reactive action:
pub fn nav_bar() -> Page {
let load_user =
use_action(|_: ()| async move { current_user().await.map_err(|e| e.to_string()) });
load_user.dispatch(());When a user is present, show their username and a logout link:
if load_user.is_pending() {
div {
class: "flex items-center gap-3",
aria_busy: "true",
span {
class: "sr-only",
"Checking sign-in status"
}
}
} else if let Some(Some(user)) = load_user.result() {
div {
class: "flex items-center gap-3",
span {
class: "text-sm text-muted",
{
format!("Signed in as {}", user.username)
}
}
a {
href: logout_href.clone(),
class: "btn-secondary",
"Logout"
}
}
}The unauthenticated branch links to signup and login:
let login_href = users_routes::reverse("login", &[]);
let logout_href = users_routes::reverse("logout", &[]);
let signup_href = users_routes::reverse("signup", &[]);Migrate and Check
Generate and apply the users migration:
cargo make makemigrations
cargo make migrateRun the app:
cargo make devOpen /signup/, create an account, and confirm that the nav changes from Sign up/Login to Signed in as <username> plus Logout. Then sign out and sign back in at /login/.
Checkpoint
Before continuing:
Useruses#[user(..., manager = false)]plus#[model(...)].AuthUserManageris registered with#[injectable_factory(scope = "transient")].login,register,logout, andcurrent_userare registered in the users server router.- Session middleware is attached once in
src/config/urls.rs. - The nav resolves auth links through
users_routes::reverse(...).