diff --git a/CHANGELOG.md b/CHANGELOG.md index d69341f22..793c3fdbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 { + 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 { + 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 diff --git a/examples/demo/migration/src/m20220101_000001_users.rs b/examples/demo/migration/src/m20220101_000001_users.rs index f42d4b337..da8a59426 100644 --- a/examples/demo/migration/src/m20220101_000001_users.rs +++ b/examples/demo/migration/src/m20220101_000001_users.rs @@ -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()) @@ -41,6 +42,7 @@ pub enum Users { Email, Name, Password, + ApiKey, ResetToken, ResetSentAt, EmailVerificationToken, diff --git a/examples/demo/src/controllers/user.rs b/examples/demo/src/controllers/user.rs index fd9178b07..f51006547 100644 --- a/examples/demo/src/controllers/user.rs +++ b/examples/demo/src/controllers/user.rs @@ -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) -> Result> { - let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; - format::json(CurrentResponse::new(&user)) +async fn current( + auth: auth::JWTWithUser, + State(_ctx): State, +) -> Result> { + format::json(CurrentResponse::new(&auth.user)) +} + +async fn current_by_api_key( + auth: auth::ApiToken, + State(_ctx): State, +) -> Result> { + 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)) } diff --git a/examples/demo/src/fixtures/users.yaml b/examples/demo/src/fixtures/users.yaml index a9785cb67..32ebc95ca 100644 --- a/examples/demo/src/fixtures/users.yaml +++ b/examples/demo/src/fixtures/users.yaml @@ -3,6 +3,7 @@ pid: 11111111-1111-1111-1111-111111111111 email: user1@example.com 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" @@ -10,6 +11,7 @@ pid: 22222222-2222-2222-2222-222222222222 email: user2@example.com 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" diff --git a/examples/demo/src/models/_entities/users.rs b/examples/demo/src/models/_entities/users.rs index d436e1bad..ef1366260 100644 --- a/examples/demo/src/models/_entities/users.rs +++ b/examples/demo/src/models/_entities/users.rs @@ -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, pub reset_sent_at: Option, diff --git a/examples/demo/src/models/users.rs b/examples/demo/src/models/users.rs index 51089c3cb..1420c2026 100644 --- a/examples/demo/src/models/users.rs +++ b/examples/demo/src/models/users.rs @@ -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, }; @@ -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) @@ -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 { + 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 { + super::_entities::users::Model::find_by_pid(db, claims_key).await + } +} + impl super::_entities::users::Model { /// finds a user by the provided email /// @@ -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 { + 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) diff --git a/examples/demo/tests/cmd/cli.trycmd b/examples/demo/tests/cmd/cli.trycmd index 62b4cb3fa..0208a5f13 100644 --- a/examples/demo/tests/cmd/cli.trycmd +++ b/examples/demo/tests/cmd/cli.trycmd @@ -91,5 +91,6 @@ $ blo-cli routes [DELETE] /notes/:id [POST] /notes/:id [GET] /user/current +[GET] /user/current_api_key ``` diff --git a/examples/demo/tests/models/snapshots/can_create_with_password@users.snap b/examples/demo/tests/models/snapshots/can_create_with_password@users.snap index be6cc1f71..6e66fd35a 100644 --- a/examples/demo/tests/models/snapshots/can_create_with_password@users.snap +++ b/examples/demo/tests/models/snapshots/can_create_with_password@users.snap @@ -10,6 +10,7 @@ Ok( pid: PID, email: "test@framework.com", password: "PASSWORD", + api_key: "lo-PID", name: "framework", reset_token: None, reset_sent_at: None, diff --git a/examples/demo/tests/models/snapshots/can_find_by_email@users.snap b/examples/demo/tests/models/snapshots/can_find_by_email@users.snap index aecdef437..1c6295990 100644 --- a/examples/demo/tests/models/snapshots/can_find_by_email@users.snap +++ b/examples/demo/tests/models/snapshots/can_find_by_email@users.snap @@ -10,6 +10,7 @@ Ok( pid: 11111111-1111-1111-1111-111111111111, email: "user1@example.com", 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, diff --git a/examples/demo/tests/models/snapshots/can_find_by_pid@users.snap b/examples/demo/tests/models/snapshots/can_find_by_pid@users.snap index aecdef437..1c6295990 100644 --- a/examples/demo/tests/models/snapshots/can_find_by_pid@users.snap +++ b/examples/demo/tests/models/snapshots/can_find_by_pid@users.snap @@ -10,6 +10,7 @@ Ok( pid: 11111111-1111-1111-1111-111111111111, email: "user1@example.com", 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, diff --git a/examples/demo/tests/requests/snapshots/can_get_current_user_with_api_key@auth_request.snap b/examples/demo/tests/requests/snapshots/can_get_current_user_with_api_key@auth_request.snap new file mode 100644 index 000000000..85d53db17 --- /dev/null +++ b/examples/demo/tests/requests/snapshots/can_get_current_user_with_api_key@auth_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/user.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}", +) diff --git a/examples/demo/tests/requests/snapshots/can_register@auth_request.snap b/examples/demo/tests/requests/snapshots/can_register@auth_request.snap index 6140f95e5..0c0e13bb7 100644 --- a/examples/demo/tests/requests/snapshots/can_register@auth_request.snap +++ b/examples/demo/tests/requests/snapshots/can_register@auth_request.snap @@ -10,6 +10,7 @@ Ok( pid: PID, email: "test@loco.com", password: "PASSWORD", + api_key: "lo-PID", name: "loco", reset_token: None, reset_sent_at: None, diff --git a/examples/demo/tests/requests/user.rs b/examples/demo/tests/requests/user.rs index 55e83b975..15134e113 100644 --- a/examples/demo/tests/requests/user.rs +++ b/examples/demo/tests/requests/user.rs @@ -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::(|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; +} diff --git a/src/controller/middleware/auth.rs b/src/controller/middleware/auth.rs index eee460675..dac6fd49d 100644 --- a/src/controller/middleware/auth.rs +++ b/src/controller/middleware/auth.rs @@ -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 { + pub claims: auth::jwt::UserClaims, + pub user: T, +} + +// Implement the FromRequestParts trait for the Auth struct +#[async_trait] +impl FromRequestParts for JWTWithUser +where + AppContext: FromRef, + S: Send + Sync, + T: Authenticable, +{ + type Rejection = Error; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + 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)] @@ -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())); } } } @@ -88,3 +129,43 @@ pub fn extract_token_from_header(headers: &HeaderMap) -> eyre::Result { .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 { + pub user: T, +} + +#[async_trait] +// Implementing the `FromRequestParts` trait for `ApiToken` to enable extracting +// it from the request. +impl FromRequestParts for ApiToken +where + AppContext: FromRef, + 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 { + // 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 }) + } +} diff --git a/src/model.rs b/src/model.rs index 19bb460bc..284759bb3 100644 --- a/src/model.rs +++ b/src/model.rs @@ -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)] @@ -35,3 +37,9 @@ pub enum ModelError { #[allow(clippy::module_name_repetitions)] pub type ModelResult = std::result::Result; + +#[async_trait] +pub trait Authenticable: Clone { + async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult; + async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult; +} diff --git a/starters/rest-api/migration/src/m20220101_000001_users.rs b/starters/rest-api/migration/src/m20220101_000001_users.rs index 646bf2591..0feaab54e 100644 --- a/starters/rest-api/migration/src/m20220101_000001_users.rs +++ b/starters/rest-api/migration/src/m20220101_000001_users.rs @@ -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()) @@ -40,6 +41,7 @@ pub enum Users { Email, Name, Password, + ApiKey, ResetToken, ResetSentAt, EmailVerificationToken, diff --git a/starters/rest-api/src/fixtures/users.yaml b/starters/rest-api/src/fixtures/users.yaml index f7a86b848..8b414e473 100644 --- a/starters/rest-api/src/fixtures/users.yaml +++ b/starters/rest-api/src/fixtures/users.yaml @@ -3,6 +3,7 @@ pid: 11111111-1111-1111-1111-111111111111 email: user1@example.com 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" @@ -10,6 +11,7 @@ pid: 22222222-2222-2222-2222-222222222222 email: user2@example.com 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" diff --git a/starters/rest-api/src/models/_entities/users.rs b/starters/rest-api/src/models/_entities/users.rs index d436e1bad..ef1366260 100644 --- a/starters/rest-api/src/models/_entities/users.rs +++ b/starters/rest-api/src/models/_entities/users.rs @@ -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, pub reset_sent_at: Option, diff --git a/starters/rest-api/src/models/users.rs b/starters/rest-api/src/models/users.rs index 36344e29e..6d89446bc 100644 --- a/starters/rest-api/src/models/users.rs +++ b/starters/rest-api/src/models/users.rs @@ -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) @@ -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 { + 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 diff --git a/starters/rest-api/tests/models/snapshots/can_create_with_password@users.snap b/starters/rest-api/tests/models/snapshots/can_create_with_password@users.snap index be6cc1f71..6e66fd35a 100644 --- a/starters/rest-api/tests/models/snapshots/can_create_with_password@users.snap +++ b/starters/rest-api/tests/models/snapshots/can_create_with_password@users.snap @@ -10,6 +10,7 @@ Ok( pid: PID, email: "test@framework.com", password: "PASSWORD", + api_key: "lo-PID", name: "framework", reset_token: None, reset_sent_at: None, diff --git a/starters/rest-api/tests/models/snapshots/can_find_by_email@users.snap b/starters/rest-api/tests/models/snapshots/can_find_by_email@users.snap index 92e2f2467..f4ba90415 100644 --- a/starters/rest-api/tests/models/snapshots/can_find_by_email@users.snap +++ b/starters/rest-api/tests/models/snapshots/can_find_by_email@users.snap @@ -10,6 +10,7 @@ Ok( pid: 11111111-1111-1111-1111-111111111111, email: "user1@example.com", 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, diff --git a/starters/rest-api/tests/models/snapshots/can_find_by_pid@users.snap b/starters/rest-api/tests/models/snapshots/can_find_by_pid@users.snap index 92e2f2467..f4ba90415 100644 --- a/starters/rest-api/tests/models/snapshots/can_find_by_pid@users.snap +++ b/starters/rest-api/tests/models/snapshots/can_find_by_pid@users.snap @@ -10,6 +10,7 @@ Ok( pid: 11111111-1111-1111-1111-111111111111, email: "user1@example.com", 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, diff --git a/starters/rest-api/tests/requests/snapshots/can_get_current_user_with_api_key@user_request.snap b/starters/rest-api/tests/requests/snapshots/can_get_current_user_with_api_key@user_request.snap new file mode 100644 index 000000000..85d53db17 --- /dev/null +++ b/starters/rest-api/tests/requests/snapshots/can_get_current_user_with_api_key@user_request.snap @@ -0,0 +1,8 @@ +--- +source: tests/requests/user.rs +expression: "(response.status_code(), response.text())" +--- +( + 200, + "{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\"}", +) diff --git a/starters/rest-api/tests/requests/snapshots/can_register@auth_request.snap b/starters/rest-api/tests/requests/snapshots/can_register@auth_request.snap index 6140f95e5..0c0e13bb7 100644 --- a/starters/rest-api/tests/requests/snapshots/can_register@auth_request.snap +++ b/starters/rest-api/tests/requests/snapshots/can_register@auth_request.snap @@ -10,6 +10,7 @@ Ok( pid: PID, email: "test@loco.com", password: "PASSWORD", + api_key: "lo-PID", name: "loco", reset_token: None, reset_sent_at: None, diff --git a/starters/saas/migration/src/m20220101_000001_users.rs b/starters/saas/migration/src/m20220101_000001_users.rs index 646bf2591..0feaab54e 100644 --- a/starters/saas/migration/src/m20220101_000001_users.rs +++ b/starters/saas/migration/src/m20220101_000001_users.rs @@ -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()) @@ -40,6 +41,7 @@ pub enum Users { Email, Name, Password, + ApiKey, ResetToken, ResetSentAt, EmailVerificationToken, diff --git a/starters/saas/src/fixtures/users.yaml b/starters/saas/src/fixtures/users.yaml index f7a86b848..8b414e473 100644 --- a/starters/saas/src/fixtures/users.yaml +++ b/starters/saas/src/fixtures/users.yaml @@ -3,6 +3,7 @@ pid: 11111111-1111-1111-1111-111111111111 email: user1@example.com 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" @@ -10,6 +11,7 @@ pid: 22222222-2222-2222-2222-222222222222 email: user2@example.com 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" diff --git a/starters/saas/src/models/_entities/users.rs b/starters/saas/src/models/_entities/users.rs index d436e1bad..ef1366260 100644 --- a/starters/saas/src/models/_entities/users.rs +++ b/starters/saas/src/models/_entities/users.rs @@ -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, pub reset_sent_at: Option, diff --git a/starters/saas/src/models/users.rs b/starters/saas/src/models/users.rs index 36344e29e..6d89446bc 100644 --- a/starters/saas/src/models/users.rs +++ b/starters/saas/src/models/users.rs @@ -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) @@ -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 { + 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 diff --git a/starters/saas/tests/models/snapshots/can_create_with_password@users.snap b/starters/saas/tests/models/snapshots/can_create_with_password@users.snap index be6cc1f71..6e66fd35a 100644 --- a/starters/saas/tests/models/snapshots/can_create_with_password@users.snap +++ b/starters/saas/tests/models/snapshots/can_create_with_password@users.snap @@ -10,6 +10,7 @@ Ok( pid: PID, email: "test@framework.com", password: "PASSWORD", + api_key: "lo-PID", name: "framework", reset_token: None, reset_sent_at: None, diff --git a/starters/saas/tests/models/snapshots/can_find_by_email@users.snap b/starters/saas/tests/models/snapshots/can_find_by_email@users.snap index 92e2f2467..f4ba90415 100644 --- a/starters/saas/tests/models/snapshots/can_find_by_email@users.snap +++ b/starters/saas/tests/models/snapshots/can_find_by_email@users.snap @@ -10,6 +10,7 @@ Ok( pid: 11111111-1111-1111-1111-111111111111, email: "user1@example.com", 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, diff --git a/starters/saas/tests/models/snapshots/can_find_by_pid@users.snap b/starters/saas/tests/models/snapshots/can_find_by_pid@users.snap index 92e2f2467..f4ba90415 100644 --- a/starters/saas/tests/models/snapshots/can_find_by_pid@users.snap +++ b/starters/saas/tests/models/snapshots/can_find_by_pid@users.snap @@ -10,6 +10,7 @@ Ok( pid: 11111111-1111-1111-1111-111111111111, email: "user1@example.com", 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, diff --git a/starters/saas/tests/requests/snapshots/can_register@auth_request.snap b/starters/saas/tests/requests/snapshots/can_register@auth_request.snap index 6140f95e5..0c0e13bb7 100644 --- a/starters/saas/tests/requests/snapshots/can_register@auth_request.snap +++ b/starters/saas/tests/requests/snapshots/can_register@auth_request.snap @@ -10,6 +10,7 @@ Ok( pid: PID, email: "test@loco.com", password: "PASSWORD", + api_key: "lo-PID", name: "loco", reset_token: None, reset_sent_at: None,