diff --git a/.github/workflows/loco-gen-deploy.yml b/.github/workflows/loco-gen-deploy.yml index f2ebdd754..e852f170a 100644 --- a/.github/workflows/loco-gen-deploy.yml +++ b/.github/workflows/loco-gen-deploy.yml @@ -9,6 +9,7 @@ on: env: RUST_TOOLCHAIN: stable TOOLCHAIN_PROFILE: minimal + LOCO_DEV_MODE_PATH: ${{ github.workspace }} jobs: g-deploy-docker: @@ -36,8 +37,16 @@ jobs: - name: create myapp run: | loco new -n myapp --db sqlite --bg async --assets serverside -a + working-directory: /tmp - - name: - run: cargo loco generate deployment --kind docker && docker build -t demo . - working-directory: ./myapp + - name: generate deployment + run: cargo loco generate deployment --kind docker + working-directory: /tmp/myapp + - name: copy local loco dependency + run: | + cp -r ${{github.workspace}} /tmp/myapp/loco + + - name: build deployment + run: docker build -t demo . + working-directory: /tmp/myapp diff --git a/examples/demo/src/controllers/auth.rs b/examples/demo/src/controllers/auth.rs index 3c2cbb0ec..d6de58b40 100644 --- a/examples/demo/src/controllers/auth.rs +++ b/examples/demo/src/controllers/auth.rs @@ -53,7 +53,7 @@ async fn register( let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(UserSession::new(&user, &token)) } @@ -130,7 +130,7 @@ async fn login(State(ctx): State, Json(params): Json) - let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(UserSession::new(&user, &token)) diff --git a/examples/demo/src/models/users.rs b/examples/demo/src/models/users.rs index bd2e3f2c3..d9762d3a8 100644 --- a/examples/demo/src/models/users.rs +++ b/examples/demo/src/models/users.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use chrono::offset::Local; use loco_rs::{auth::jwt, hash, prelude::*}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::Map; use uuid::Uuid; pub use super::_entities::users::{self, ActiveModel, Entity, Model}; @@ -216,12 +216,10 @@ impl super::_entities::users::Model { /// # Errors /// /// when could not convert user claims to jwt token - pub fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult { - Ok(jwt::JWT::new(secret).generate_token( - expiration, - self.pid.to_string(), - Some(json!({"Roll": "Administrator"})), - )?) + pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { + let mut claims = Map::new(); + claims.insert("Role".to_string(), "Administrator".into()); + Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), claims)?) } } diff --git a/loco-gen/src/lib.rs b/loco-gen/src/lib.rs index 676b4c8d5..c5fa87148 100644 --- a/loco-gen/src/lib.rs +++ b/loco-gen/src/lib.rs @@ -273,10 +273,12 @@ pub fn generate(rrgen: &RRgen, component: Component, appinfo: &AppInfo) -> Resul port, } => match kind { DeploymentKind::Docker => { + let loco_dev_mode_path = std::env::var("LOCO_DEV_MODE_PATH"); let vars = json!({ "pkg_name": appinfo.app_name, "copy_asset_folder": asset_folder.unwrap_or_default(), - "fallback_file": fallback_file.unwrap_or_default() + "fallback_file": fallback_file.unwrap_or_default(), + "loco_dev_mode_path": loco_dev_mode_path.unwrap_or_default() }); render_template(rrgen, Path::new("deployment/docker"), &vars)? } diff --git a/loco-gen/src/templates/deployment/docker/docker.t b/loco-gen/src/templates/deployment/docker/docker.t index 99b7ca8e8..4128b1884 100644 --- a/loco-gen/src/templates/deployment/docker/docker.t +++ b/loco-gen/src/templates/deployment/docker/docker.t @@ -7,7 +7,10 @@ FROM rust:1.83.0-slim as builder WORKDIR /usr/src/ COPY . . - +{% if loco_dev_mode_path -%} +# The `loco` root folder should be moved to this dockerfile path so the context can copy it to the image +RUN mkdir -p {{loco_dev_mode_path}} && mv loco/* {{loco_dev_mode_path}} && rm -rf loco +{% endif -%} RUN cargo build --release FROM debian:bookworm-slim diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap index 2f2b97733..a24c94efa 100644 --- a/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap @@ -7,7 +7,8 @@ FROM rust:1.83.0-slim as builder WORKDIR /usr/src/ COPY . . - +# The `loco` root folder should be moved to this dockerfile path so the context can copy it to the image +RUN mkdir -p /home/runner/work/loco/loco && mv loco/* /home/runner/work/loco/loco && rm -rf loco RUN cargo build --release FROM debian:bookworm-slim diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap index 9771babfe..40bf74923 100644 --- a/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap @@ -7,7 +7,8 @@ FROM rust:1.83.0-slim as builder WORKDIR /usr/src/ COPY . . - +# The `loco` root folder should be moved to this dockerfile path so the context can copy it to the image +RUN mkdir -p /home/runner/work/loco/loco && mv loco/* /home/runner/work/loco/loco && rm -rf loco RUN cargo build --release FROM debian:bookworm-slim diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap index 1d13e04cd..bfae77c73 100644 --- a/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap @@ -7,7 +7,8 @@ FROM rust:1.83.0-slim as builder WORKDIR /usr/src/ COPY . . - +# The `loco` root folder should be moved to this dockerfile path so the context can copy it to the image +RUN mkdir -p /home/runner/work/loco/loco && mv loco/* /home/runner/work/loco/loco && rm -rf loco RUN cargo build --release FROM debian:bookworm-slim diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap index ca69e1070..b5b913a96 100644 --- a/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap @@ -7,7 +7,8 @@ FROM rust:1.83.0-slim as builder WORKDIR /usr/src/ COPY . . - +# The `loco` root folder should be moved to this dockerfile path so the context can copy it to the image +RUN mkdir -p /home/runner/work/loco/loco && mv loco/* /home/runner/work/loco/loco && rm -rf loco RUN cargo build --release FROM debian:bookworm-slim diff --git a/loco-new/base_template/src/controllers/auth.rs b/loco-new/base_template/src/controllers/auth.rs index 1bf50aeb6..87d2a2c61 100644 --- a/loco-new/base_template/src/controllers/auth.rs +++ b/loco-new/base_template/src/controllers/auth.rs @@ -70,10 +70,7 @@ async fn register( /// Verify register user. if the user not verified his email, he can't login to /// the system. #[debug_handler] -async fn verify( - State(ctx): State, - Path(token): Path, -) -> Result { +async fn verify(State(ctx): State, Path(token): Path) -> Result { let user = users::Model::find_by_verification_token(&ctx.db, &token).await?; if user.email_verified_at.is_some() { @@ -143,7 +140,7 @@ async fn login(State(ctx): State, Json(params): Json) - let jwt_secret = ctx.config.get_jwt_config()?; let token = user - .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; format::json(LoginResponse::new(&user, &token)) @@ -158,14 +155,14 @@ async fn current(auth: auth::JWT, State(ctx): State) -> Result ModelResult { - Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), None)?) + pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { + Ok(jwt::JWT::new(secret).generate_token(expiration, self.pid.to_string(), Map::new())?) } } diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 0a5f3b435..03ee996d1 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -2,23 +2,24 @@ //! //! This module provides functionality for working with JSON Web Tokens (JWTs) //! and password hashing. - use jsonwebtoken::{ decode, encode, errors::Result as JWTResult, get_current_timestamp, Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation, }; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; /// Represents the default JWT algorithm used by the [`JWT`] struct. const JWT_ALGORITHM: Algorithm = Algorithm::HS512; /// Represents the claims associated with a user JWT. +#[cfg_attr(test, derive(Eq, PartialEq))] #[derive(Debug, Serialize, Deserialize)] pub struct UserClaims { pub pid: String, exp: u64, - pub claims: Option, + #[serde(default, flatten)] + pub claims: Map, } /// Represents the JWT configuration and operations. @@ -61,17 +62,18 @@ impl JWT { /// /// # Example /// ```rust + /// use serde_json::Map; /// use loco_rs::auth; /// - /// auth::jwt::JWT::new("PqRwLF2rhHe8J22oBeHy").generate_token(&604800, "PID".to_string(), None); + /// auth::jwt::JWT::new("PqRwLF2rhHe8J22oBeHy").generate_token(604800, "PID".to_string(), Map::new()); /// ``` pub fn generate_token( &self, - expiration: &u64, + expiration: u64, pid: String, - claims: Option, + claims: Map, ) -> JWTResult { - let exp = get_current_timestamp().saturating_add(*expiration); + let exp = get_current_timestamp().saturating_add(expiration); let claims = UserClaims { pid, exp, claims }; @@ -119,18 +121,27 @@ mod tests { use super::*; #[rstest] - #[case("valid token", 60, None)] - #[case("token expired", 1, None)] - #[case("valid token and custom claims", 60, Some(json!({})))] - #[tokio::test] - async fn can_generate_token( + #[case("valid token", 60, json!({}))] + #[case("token expired", 1, json!({}))] + #[case("valid token and custom string claims", 60, json!({ "custom": "claim",}))] + #[case("valid token and custom boolean claims",60, json!({ "custom": true,}))] + #[case("valid token and custom number claims",60, json!({ "custom": 123,}))] + #[case("valid token and custom nested claims",60, json!({ "level1": { "level2": { "level3": "claim" } } }))] + #[case("valid token and custom array claims",60, json!({ "array": [1, 2, 3] }))] + #[case("valid token and custom nested array claims",60, json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }))] + fn can_generate_token( #[case] test_name: &str, #[case] expiration: u64, - #[case] claims: Option, + #[case] json_claims: Value, ) { + let claims = json_claims + .as_object() + .expect("case input claims must be an object") + .clone(); let jwt = JWT::new("PqRwLF2rhHe8J22oBeHy"); + let token = jwt - .generate_token(&expiration, "pid".to_string(), claims) + .generate_token(expiration, "pid".to_string(), claims) .unwrap(); std::thread::sleep(std::time::Duration::from_secs(3)); @@ -140,4 +151,76 @@ mod tests { assert_debug_snapshot!(test_name, jwt.validate(&token)); }); } + + #[rstest] + #[case::without_custom_claims(json!({}))] + #[case::with_custom_string_claims(json!({ "custom": "claim",}))] + #[case::with_custom_boolean_claims(json!({ "custom": true,}))] + #[case::with_custom_number_claims(json!({ "custom": 123,}))] + #[case::with_custom_nested_claims(json!({ "level1": { "level2": { "level3": "claim" } } }))] + #[case::with_custom_array_claims(json!({ "array": [1, 2, 3] }))] + #[case::with_custom_nested_array_claims(json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }))] + // we use `Value` to reduce code duplicity in the case inputs + fn serialize_user_claims(#[case] json_claims: Value) { + let claims = json_claims + .as_object() + .expect("case input claims must be an object") + .clone(); + let input_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims: claims.clone(), + }; + + let mut expected_claim = Map::new(); + expected_claim.insert("pid".to_string(), "pid".into()); + expected_claim.insert("exp".to_string(), 60.into()); + // we add the claims in a flattened way + expected_claim.extend(claims); + let expected_value = Value::from(expected_claim); + + // We check between `Value` instead of `String` to avoid key ordering issues when serializing. + // It is because `expected_value` has all the keys in alphabetical order, as the `Value` serialization ensures that. + // But when serializing `input_user_claims`, first the `pid` and `exp` fields are serialized (in that order), + // and then the claims are serialized in alfabetic order. So, the resulting JSON string from the `input_user_claims` serialization + // may have the `pid` and `exp` fields unordered which differs from the `Value` serialization. + assert_eq!( + expected_value, + serde_json::to_value(&input_user_claims).unwrap() + ); + } + + #[rstest] + #[case::without_custom_claims(json!({}))] + #[case::with_custom_string_claims(json!({ "custom": "claim",}))] + #[case::with_custom_boolean_claims(json!({ "custom": true,}))] + #[case::with_custom_number_claims(json!({ "custom": 123,}))] + #[case::with_custom_nested_claims(json!({ "level1": { "level2": { "level3": "claim" } } }))] + #[case::with_custom_array_claims(json!({ "array": [1, 2, 3] }))] + #[case::with_custom_nested_array_claims(json!({ "level1": { "level2": { "level3": [1, 2, 3] } } }))] + // we use `Value` to reduce code duplicity in the case inputs + fn deserialize_user_claims(#[case] json_claims: Value) { + let claims = json_claims + .as_object() + .expect("case input claims must be an object") + .clone(); + + let mut input_claims = Map::new(); + input_claims.insert("pid".to_string(), "pid".into()); + input_claims.insert("exp".to_string(), 60.into()); + // we add the claims in a flattened way + input_claims.extend(claims.clone()); + let input_json = Value::from(input_claims).to_string(); + + let expected_user_claims = UserClaims { + pid: "pid".to_string(), + exp: 60, + claims, + }; + + assert_eq!( + expected_user_claims, + serde_json::from_str(&input_json).unwrap() + ); + } } diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom array claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom array claims.snap new file mode 100644 index 000000000..5b6475448 --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom array claims.snap @@ -0,0 +1,33 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "array": Array [ + Number(1), + Number(2), + Number(3), + ], + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom boolean claims.snap similarity index 84% rename from src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap rename to src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom boolean claims.snap index 654ddac07..80864361a 100644 --- a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom claims.snap +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom boolean claims.snap @@ -1,6 +1,5 @@ --- source: src/auth/jwt.rs -assertion_line: 133 expression: jwt.validate(&token) --- Ok( @@ -22,9 +21,9 @@ Ok( claims: UserClaims { pid: "pid", exp: EXP, - claims: Some( - Object {}, - ), + claims: { + "custom": Bool(true), + }, }, }, ) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested array claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested array claims.snap new file mode 100644 index 000000000..42dde602d --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested array claims.snap @@ -0,0 +1,37 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "level1": Object { + "level2": Object { + "level3": Array [ + Number(1), + Number(2), + Number(3), + ], + }, + }, + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested claims.snap new file mode 100644 index 000000000..5f127c3f0 --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom nested claims.snap @@ -0,0 +1,33 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "level1": Object { + "level2": Object { + "level3": String("claim"), + }, + }, + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap new file mode 100644 index 000000000..faa86be9e --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom number claims.snap @@ -0,0 +1,29 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "custom": Number(123), + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom string claims.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom string claims.snap new file mode 100644 index 000000000..3a4c9bb9e --- /dev/null +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token and custom string claims.snap @@ -0,0 +1,29 @@ +--- +source: src/auth/jwt.rs +expression: jwt.validate(&token) +--- +Ok( + TokenData { + header: Header { + typ: Some( + "JWT", + ), + alg: HS512, + cty: None, + jku: None, + jwk: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }, + claims: UserClaims { + pid: "pid", + exp: EXP, + claims: { + "custom": String("claim"), + }, + }, + }, +) diff --git a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap index d7255f840..4bf3415ac 100644 --- a/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap +++ b/src/auth/snapshots/loco_rs__auth__jwt__tests__valid token.snap @@ -21,7 +21,7 @@ Ok( claims: UserClaims { pid: "pid", exp: EXP, - claims: None, + claims: {}, }, }, )