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
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
42 changes: 42 additions & 0 deletions examples/demo/src/controllers/middleware/api_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Axum middleware for validating API key tokens.

use async_trait::async_trait;
use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use loco_rs::{app::AppContext, controller::middleware::auth, errors::Error};
use serde::{Deserialize, Serialize};

use crate::models::_entities::users;

#[derive(Debug, Deserialize, Serialize)]
// Represents the data structure for the API token.
pub struct ApiToken {
Copy link
Contributor

@jondot jondot Dec 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering,
how possible is it to say:

pub struct ApiToken<T>
 ... everything depends on T
 ... includng the extractor 

and we know that T should eventually be users::Model,
maybe this T will be a T: Trait where T: FetchByApiKey,

and the User impl that FetchByApiKey.

Now this middleware becomes shareable by everyone

PS: i think we can adopt the same pattern for the JWT auth by creating a trait T: FetchByJWT

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API token is generated within the user app (Loco framework is not familiar with that). In this scenario, I think the parameter is meaningless, as the user has complete control over it.

About the FetchByJWT i totally agree. maybe we can consider moving the claims to be a

Copy link
Contributor

@jondot jondot Dec 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice what I did in the latest commit. I had a feeling if we move things around we might get something, I think it worked:

Authenticable trait lets us encapsulate all the logic, and then -- just refer to an extractor. Use ApiToken<T> and ApiToken<users::Model> to give Rust all the type information it needs to resolve.

On this "Authenticable" concept we can also encode the JWT behavior, and "capture" a user just like in api key auth.
There is a good chance that instead of having claims we can have the actual user model if we use the same trick.

pub user: users::Model,
}

#[async_trait]
// Implementing the `FromRequestParts` trait for `ApiToken` to enable extracting it from the request.
impl<S> FromRequestParts<S> for ApiToken
where
AppContext: FromRef<S>,
S: Send + Sync,
{
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 = auth::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 = users::Model::find_by_api_key(&state.db, &api_key).await?;
jondot marked this conversation as resolved.
Show resolved Hide resolved

Ok(Self { user })
}
}
1 change: 1 addition & 0 deletions examples/demo/src/controllers/middleware/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod api_key;
1 change: 1 addition & 0 deletions examples/demo/src/controllers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod auth;
pub mod middleware;
pub mod notes;
pub mod user;
16 changes: 13 additions & 3 deletions examples/demo/src/controllers/user.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
use loco_rs::prelude::*;

use crate::controllers::middleware::api_key::ApiToken;
use crate::{models::_entities::users, views::user::CurrentResponse};
use loco_rs::prelude::*;

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_by_api_key(
auth: ApiToken,
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
14 changes: 14 additions & 0 deletions examples/demo/src/models/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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 Down Expand Up @@ -117,6 +118,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;
}
2 changes: 2 additions & 0 deletions starters/rest-api/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 All @@ -40,6 +41,7 @@ pub enum Users {
Email,
Name,
Password,
ApiKey,
ResetToken,
ResetSentAt,
EmailVerificationToken,
Expand Down
42 changes: 42 additions & 0 deletions starters/rest-api/src/controllers/middleware/api_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Axum middleware for validating API key tokens.

use async_trait::async_trait;
use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use loco_rs::{app::AppContext, controller::middleware::auth, errors::Error};
use serde::{Deserialize, Serialize};

use crate::models::_entities::users;

#[derive(Debug, Deserialize, Serialize)]
// Represents the data structure for the API token.
pub struct ApiToken {
pub user: users::Model,
}

#[async_trait]
// Implementing the `FromRequestParts` trait for `ApiToken` to enable extracting it from the request.
impl<S> FromRequestParts<S> for ApiToken
where
AppContext: FromRef<S>,
S: Send + Sync,
{
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 = auth::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 = users::Model::find_by_api_key(&state.db, &api_key).await?;

Ok(Self { user })
}
}
1 change: 1 addition & 0 deletions starters/rest-api/src/controllers/middleware/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod api_key;
1 change: 1 addition & 0 deletions starters/rest-api/src/controllers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod auth;
pub mod middleware;
pub mod notes;
pub mod user;
16 changes: 13 additions & 3 deletions starters/rest-api/src/controllers/user.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
use loco_rs::prelude::*;

use crate::controllers::middleware::api_key::ApiToken;
use crate::{models::_entities::users, views::user::CurrentResponse};
use loco_rs::prelude::*;

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_by_api_key(
auth: ApiToken,
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 starters/rest-api/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$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
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$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
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 starters/rest-api/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
14 changes: 14 additions & 0 deletions starters/rest-api/src/models/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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 Down Expand Up @@ -117,6 +118,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
///
/// # Errors
Expand Down
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
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$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
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
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$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
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]\"}",
)
Loading
Loading