Building a Secure Authentication Server in Rust
Authentication is a critical component of most applications. As a developer, properly implementing auth ensures your users‘ data stays protected.
In this comprehensive guide, you‘ll learn how to build a secure authentication server in Rust using proven standards like JSON Web Tokens (JWT).
Why Authentication Matters
Without authentication, your app‘s data and functionality are exposed to anyone. Some key reasons properly implementing auth is important:
- Security: Stops unauthorized access to sensitive user data.
- Access control: Enforce fine-grained controls over who can access what.
- Account ownership: Confirms a user owns an account.
- Improved UX: Saves user data across sessions.
An Overview of JWT-Based Authentication
JWT (pronounced "jot") is an open standard that defines a secure way to transmit information between parties via digital signatures.
Here‘s a high-level overview:
- Authentication: User logs in with credentials
- JWT Generation: Server generates a JWT to identify authenticated sessions
- Sending JWTs: Tokens are included by client app in requests
- Verification: Server decrypts and verifies JWT signature to authenticate each request
The tokens are stored client-side and contain user data encoded into them.
JWTs provide key advantages like avoiding sessions stored on servers and scalability across infrastructure.
Project Setup
With the basics covered, let‘s setup a new Rust server app using:
- Warp: High performance web server framework
- [Other dependencies like bcrypt, jsonwebtoken, etc]
// Importing crates
use warp::Filter;
// HTTP GET route handler
async fn get_posts() -> impl warp::Reply {
"Fetching all posts..."
}
#[tokio::main]
async fn main() {
// GET /posts route
let posts_route = warp::path("posts")
.and(warp::get())
.and_then(get_posts);
// Start server
warp::serve(posts_route)
.run(([127, 0, 0, 1], 3030))
.await
}
This gives us a basic API that handles GET requests for /posts
. Let‘s build upon it to implement authentication.
User Structs and Serialization
Our app needs to define user accounts with attributes like emails, passwords etc that can be persisted. For this, we can define a User struct and implement serialization with serde
:
use serde::{Serialize, Deserialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: i32,
email: String,
password: String,
created_at: DateTime<Utc>
}
Serializing it allows converting user data to formats like JSON for storage or transmission.
For securely storing user info like emails and password hashes, we‘ll integrate a PostgreSQL database later using Diesel ORM.
Hashing Passwords
To protect user passwords before persisting them, we should hash them using bcrypt:
use bcrypt::{hash, verify, DEFAULT_COST};
// Hash plain-text password
let hashed = hash("my_password", DEFAULT_COST)?;
// Verify correct password
let valid = verify("my_password", &hashed)?;
// Won‘t match incorrect password
let invalid = verify("wrong", &hashed)?;
bcrypt allows secure password hashing and salting to maximize security.
User Registration with Hashing
When a new user signs up, we need to hash their password and store the account details.
Our register endpoint can handle this:
use crate::models::User;
use crate::utils::hash_password;
#[post("/signup")]
async fn signup(user_data: Json<UserInput>) -> impl Reply {
// Hash plain-text password
let hashed = hash_password(user_data.password);
// Create user model
let new_user = User {
email: user.email,
password: hashed,
};
// Insert account into DB
insert_user(&new_user);
// 201 Created
StatusCode::CREATED
}
Only the hashed password gets stored, maximizing protection!
JWT Generation
Upon login with valid credentials, we want to generate a signed JWT so clients can use it for authenticated requests later.
use jsonwebtoken::{encode, Header, EncodingKey};
fn generate_token(user: &User) -> Result<String> {
let iss = "my_issuer";
// JWT claims
let mut claims = Claims::new();
claims.insert("sub".to_owned(), user.id);
claims.insert("email".to_owned(), user.email);
// Expiration
let exp = Utc::now()
.checked_add(Duration::hours(1))
.unwrap()
.timestamp();
// JWT header
let header = Header::new(Algorithm::HS256);
// Generate signed JWT
encode(header, &claims, &EncodingKey::from_secret("secret"))
}
Breaking it down:
- Construct claims including user ID
- Set expiration like 1hr from now
- Pass header & claims to encode()
- Sign the JWT with a secret key
We return this upon login success for apps.
JWT Verification Middleware
To authenticate requests using JWTs, we need middleware that:
- Extract tokens from requests
- Decode and validate their signature
- Extract user data like identifiers
- Attach to the request before routes execute
Here is an example VerifyJWT middleware:
use jsonwebtoken::{decode, Validation, Algorithm};
use warp::{http::StatusCode, Filter, Rejection};
// Rejection type if token missing
struct MissingToken;
async fn verify_jwt(token: String) -> Result<Claims, Rejection> {
let secret_key = SECRET.clone();
// Decode and validate
let token_data = decode::<Claims>(
&token,
&DecodingKey::from_secret(secret_key),
&Validation::new(Algorithm::HS256),
)?;
Ok(token_data.claims)
}
// Attach user data to filters
fn with_user_data(filters: impl Filter + Clone) -> BoxedFilter<(Claims,)> {
let auth = warp::any()
.map(verify_jwt)
.untuple_one();
filters.and(auth).boxed()
}
pub fn routes(
user_routes
.and(with_user_data(user_routes))
.recover(handle_missing_token)
)
Now all requests must have valid tokens, propagating user data to subsequent filters! Custom error handling like above allows handling cases like missing tokens.
This protects all routes and data under /users/*
.
Role-Based Authorization
Beyond simple authentication, we often want fine-grained control over what users can access.
For example, only admin users may access reports or analytics:
fn can_view_reports(user: &Claims) -> bool {
user.role == "admin"
}
pub fn reports_route() -> BoxedFilter<(Claims,)> {
let view_reports = warp::any()
.and(with_user_data(verify_role(can_view_reports)));
// GET /reports route
warp::path("reports")
.and(warp::get())
.and(view_reports)
.boxed()
}
The custom verify_role()
middleware checks the user‘s role before allowing access.
Handling Expired JWTs
Since JWT expiration times are short-lived, we need a way to allow clients to refresh tokens without logging in again.
The solution is refresh tokens – long-lived tokens only used to refresh soon-to-expire access tokens.
#[post("/login", data = "<credentials>")]
async fn login(credentials: LoginForm) -> impl Reply {
// Verify and get user
let user = verify_credentials(credentials)?;
// Generate access + refresh JWT
let access_token = generate_access_token(&user);
let refresh_token = generate_refresh_token(&user);
// Send tokens
}
#[post("/refresh")]
async fn refresh(token: String) {
// Decode refresh token
let token_data = decode_token(&token)?;
// Issue new access token
let new_access_token = generate_access_token(&token_data.user);
// Send back
}
The access tokens can be short-lived (15 minutes) while refreshes grant new access without re-auth for longer periods before itself expiring (30 days).
Recap
That wraps up a high-level overview building a secure authentication server in Rust using JWT and common techniques:
- Hashed password storage
- JWT generation upon login
- Verification middleware protecting routes
- Role-based authorization restricting access
- Refresh token flows for renewed access
With these foundations, you can implement robust auth meeting production standards.
Let me know in the comments what authentication topics you want covered deeper!