Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add token based api auth #217

Merged
merged 9 commits into from
Dec 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
# Changelog

## vNext

* Validate if seaorm CLI is installed before running `cargo loco db entities` and show a better error to the user. [https://github.com/loco-rs/loco/pull/212](https://github.com/loco-rs/loco/pull/212)
* Adding to `saas and `rest-api` starters a redis and DB in GitHub action workflow to allow users work with github action out of the box. [https://github.com/loco-rs/loco/pull/215](https://github.com/loco-rs/loco/pull/215)
* Adding the app name and the environment to the DB name when creating a new starter. [https://github.com/loco-rs/loco/pull/216](https://github.com/loco-rs/loco/pull/216)
* Adding a new auth middleware based on rest-api token. [https://github.com/loco-rs/loco/pull/217](https://github.com/loco-rs/loco/pull/217)
* Fix generator when users adding a `created_at` or `update_at` fields. [https://github.com/loco-rs/loco/pull/214](https://github.com/loco-rs/loco/pull/214)

#### Authentication: Added API Token Authentication!

* See [https://github.com/loco-rs/loco/pull/217](https://github.com/loco-rs/loco/pull/217)
Now when you generate a `saas starter` or `rest api` starter you will get additional authentication methods for free:

* Added: authentication added -- **api authentication** where each user has an API token in the schema, and you can authenticate with `Bearer` against that user.
* Added: authentication added -- `JWTWithUser` extractor, which is a convenience for resolving the authenticated JWT claims into a current user from database

**migrating an existing codebase**

Add the following to your generated `src/models/user.rs`:

```rust
#[async_trait]
impl Authenticable for super::_entities::users::Model {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(users::Column::ApiKey.eq(api_key))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}

async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
super::_entities::users::Model::find_by_pid(db, claims_key).await
}
}
```

Update imports in this file to include `model::Authenticable`:

```rust
use loco_rs::{
auth, hash,
model::{Authenticable, ModelError, ModelResult},
validation,
validator::Validate,
};
```


## v0.1.8

Expand Down
2 changes: 2 additions & 0 deletions examples/demo/migration/src/m20220101_000001_users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ impl MigrationTrait for Migration {
.col(uuid(Users::Pid).borrow_mut())
.col(string_uniq(Users::Email).borrow_mut())
.col(string(Users::Password).borrow_mut())
.col(string(Users::ApiKey).borrow_mut().unique_key())
.col(string(Users::Name).borrow_mut())
.col(string_null(Users::ResetToken).borrow_mut())
.col(timestamp_null(Users::ResetSentAt).borrow_mut())
Expand Down Expand Up @@ -41,6 +42,7 @@ pub enum Users {
Email,
Name,
Password,
ApiKey,
ResetToken,
ResetSentAt,
EmailVerificationToken,
Expand Down
20 changes: 16 additions & 4 deletions examples/demo/src/controllers/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@ use loco_rs::prelude::*;

use crate::{models::_entities::users, views::user::CurrentResponse};

async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Json<CurrentResponse>> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user))
async fn current(
auth: auth::JWTWithUser<users::Model>,
State(_ctx): State<AppContext>,
) -> Result<Json<CurrentResponse>> {
format::json(CurrentResponse::new(&auth.user))
}

async fn current_by_api_key(
auth: auth::ApiToken<users::Model>,
State(_ctx): State<AppContext>,
) -> Result<Json<CurrentResponse>> {
format::json(CurrentResponse::new(&auth.user))
}

pub fn routes() -> Routes {
Routes::new().prefix("user").add("/current", get(current))
Routes::new()
.prefix("user")
.add("/current", get(current))
.add("/current_api_key", get(current_by_api_key))
}
2 changes: 2 additions & 0 deletions examples/demo/src/fixtures/users.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
pid: 11111111-1111-1111-1111-111111111111
email: [email protected]
password: "$argon2id$v=19$m=19456,t=2,p=1$JkBeJfBWoNlj4D684c568g$bKRQ+Ud8qwYGIaAu+x+4flGHPS4WJh3ylUUV4sEtDBY"
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
name: user1
created_at: "2023-11-12T12:34:56.789"
updated_at: "2023-11-12T12:34:56.789"
- id: 2
pid: 22222222-2222-2222-2222-222222222222
email: [email protected]
password: "$argon2id$v=19$m=19456,t=2,p=1$JkBeJfBWoNlj4D684c568g$bKRQ+Ud8qwYGIaAu+x+4flGHPS4WJh3ylUUV4sEtDBY"
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
name: user2
created_at: "2023-11-12T12:34:56.789"
updated_at: "2023-11-12T12:34:56.789"
2 changes: 2 additions & 0 deletions examples/demo/src/models/_entities/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub struct Model {
#[sea_orm(unique)]
pub email: String,
pub password: String,
#[sea_orm(unique)]
pub api_key: String,
pub name: String,
pub reset_token: Option<String>,
pub reset_sent_at: Option<DateTime>,
Expand Down
32 changes: 31 additions & 1 deletion examples/demo/src/models/users.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use async_trait::async_trait;
use chrono::offset::Local;
use loco_rs::{
auth, hash,
model::{ModelError, ModelResult},
model::{Authenticable, ModelError, ModelResult},
validation,
validator::Validate,
};
Expand Down Expand Up @@ -52,6 +53,7 @@ impl ActiveModelBehavior for super::_entities::users::ActiveModel {
if insert {
let mut this = self;
this.pid = ActiveValue::Set(Uuid::new_v4());
this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4()));
Ok(this)
} else {
Ok(self)
Expand All @@ -60,6 +62,21 @@ impl ActiveModelBehavior for super::_entities::users::ActiveModel {
}
}

#[async_trait]
impl Authenticable for super::_entities::users::Model {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(users::Column::ApiKey.eq(api_key))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}

async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self> {
super::_entities::users::Model::find_by_pid(db, claims_key).await
}
}

impl super::_entities::users::Model {
/// finds a user by the provided email
///
Expand Down Expand Up @@ -117,6 +134,19 @@ impl super::_entities::users::Model {
user.ok_or_else(|| ModelError::EntityNotFound)
}

/// finds a user by the provided api key
///
/// # Errors
///
/// When could not find user by the given token or DB query error
pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self> {
let user = users::Entity::find()
.filter(users::Column::ApiKey.eq(api_key))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}

/// Verifies whether the provided plain password matches the hashed password
pub fn verify_password(&self, password: &str) -> bool {
hash::verify_password(password, &self.password)
Expand Down
1 change: 1 addition & 0 deletions examples/demo/tests/cmd/cli.trycmd
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@ $ blo-cli routes
[DELETE] /notes/:id
[POST] /notes/:id
[GET] /user/current
[GET] /user/current_api_key

```
1 change: 1 addition & 0 deletions examples/demo/tests/models/snapshots/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Ok(
pid: PID,
email: "[email protected]",
password: "PASSWORD",
api_key: "lo-PID",
name: "framework",
reset_token: None,
reset_sent_at: None,
Expand Down
1 change: 1 addition & 0 deletions examples/demo/tests/models/snapshots/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Ok(
pid: 11111111-1111-1111-1111-111111111111,
email: "[email protected]",
password: "$argon2id$v=19$m=19456,t=2,p=1$JkBeJfBWoNlj4D684c568g$bKRQ+Ud8qwYGIaAu+x+4flGHPS4WJh3ylUUV4sEtDBY",
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
name: "user1",
reset_token: None,
reset_sent_at: None,
Expand Down
1 change: 1 addition & 0 deletions examples/demo/tests/models/snapshots/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Ok(
pid: 11111111-1111-1111-1111-111111111111,
email: "[email protected]",
password: "$argon2id$v=19$m=19456,t=2,p=1$JkBeJfBWoNlj4D684c568g$bKRQ+Ud8qwYGIaAu+x+4flGHPS4WJh3ylUUV4sEtDBY",
api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
name: "user1",
reset_token: None,
reset_sent_at: None,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: tests/requests/user.rs
expression: "(response.status_code(), response.text())"
---
(
200,
"{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"[email protected]\"}",
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Ok(
pid: PID,
email: "[email protected]",
password: "PASSWORD",
api_key: "lo-PID",
name: "loco",
reset_token: None,
reset_sent_at: None,
Expand Down
23 changes: 23 additions & 0 deletions examples/demo/tests/requests/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,26 @@ async fn can_get_current_user() {
})
.await;
}

#[tokio::test]
#[serial]
async fn can_get_current_user_with_api_key() {
configure_insta!();

testing::request::<App, _, _>(|request, ctx| async move {
let user_data = prepare_data::init_user_login(&request, &ctx).await;

let (auth_key, auth_value) = prepare_data::auth_header(&user_data.user.api_key);
let response = request
.get("/user/current_api_key")
.add_header(auth_key, auth_value)
.await;

with_settings!({
filters => testing::cleanup_user_model()
}, {
assert_debug_snapshot!((response.status_code(), response.text()));
});
})
.await;
}
89 changes: 85 additions & 4 deletions src/controller/middleware/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,55 @@ use axum::{
};
use serde::{Deserialize, Serialize};

use crate::{app::AppContext, auth, errors::Error};
use crate::{app::AppContext, auth, errors::Error, model::Authenticable};

// Define constants for token prefix and authorization header
const TOKEN_PREFIX: &str = "Bearer ";
const AUTH_HEADER: &str = "authorization";

// Define a struct to represent user authentication information serialized
// to/from JSON
#[derive(Debug, Deserialize, Serialize)]
pub struct JWTWithUser<T: Authenticable> {
pub claims: auth::jwt::UserClaims,
pub user: T,
}

// Implement the FromRequestParts trait for the Auth struct
#[async_trait]
impl<S, T> FromRequestParts<S> for JWTWithUser<T>
where
AppContext: FromRef<S>,
S: Send + Sync,
T: Authenticable,
{
type Rejection = Error;

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Error> {
let token = extract_token_from_header(&parts.headers)
.map_err(|e| Error::Unauthorized(e.to_string()))?;

let state: AppContext = AppContext::from_ref(state);

let jwt_secret = state.config.get_jwt_config()?;

match auth::jwt::JWT::new(&jwt_secret.secret).validate(&token) {
Ok(claims) => {
let user = T::find_by_claims_key(&state.db, &claims.claims.pid)
.await
.map_err(|_| Error::Unauthorized("token is not valid".to_string()))?;
Ok(Self {
claims: claims.claims,
user,
})
}
Err(_err) => {
return Err(Error::Unauthorized("token is not valid".to_string()));
}
}
}
}

// Define a struct to represent user authentication information serialized
// to/from JSON
#[derive(Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -66,9 +109,7 @@ where
claims: claims.claims,
}),
Err(_err) => {
return Err(Error::Unauthorized(
"[Auth] token is not valid.".to_string(),
));
return Err(Error::Unauthorized("token is not valid".to_string()));
}
}
}
Expand All @@ -88,3 +129,43 @@ pub fn extract_token_from_header(headers: &HeaderMap) -> eyre::Result<String> {
.ok_or_else(|| eyre::eyre!("error strip {} value", AUTH_HEADER))?
.to_string())
}

// ---------------------------------------
//
// API Token Auth / Extractor
//
// ---------------------------------------
#[derive(Debug, Deserialize, Serialize)]
// Represents the data structure for the API token.
pub struct ApiToken<T: Authenticable> {
pub user: T,
}

#[async_trait]
// Implementing the `FromRequestParts` trait for `ApiToken` to enable extracting
// it from the request.
impl<S, T> FromRequestParts<S> for ApiToken<T>
where
AppContext: FromRef<S>,
S: Send + Sync,
T: Authenticable,
{
type Rejection = Error;

// Extracts `ApiToken` from the request parts.
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Error> {
// Extract API key from the request header.
let api_key = extract_token_from_header(&parts.headers)
.map_err(|e| Error::Unauthorized(e.to_string()))?;

// Convert the state reference to the application context.
let state: AppContext = AppContext::from_ref(state);

// Retrieve user information based on the API key from the database.
let user = T::find_by_api_key(&state.db, &api_key)
.await
.map_err(|e| Error::Unauthorized(e.to_string()))?;

Ok(Self { user })
}
}
8 changes: 8 additions & 0 deletions src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//!
//! Useful when using `sea_orm` and want to propagate errors

use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -35,3 +37,9 @@ pub enum ModelError {

#[allow(clippy::module_name_repetitions)]
pub type ModelResult<T, E = ModelError> = std::result::Result<T, E>;

#[async_trait]
pub trait Authenticable: Clone {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult<Self>;
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult<Self>;
}
Loading
Loading