From cd32c175a48fc2eda02a46ccb35c8a8fdb3e8d47 Mon Sep 17 00:00:00 2001 From: filipriec Date: Tue, 25 Mar 2025 10:15:17 +0100 Subject: [PATCH] jwt implementation and login, not working yet --- Cargo.lock | 61 ++++++++++++++++++ common/proto/auth.proto | 14 +++++ common/src/proto/descriptor.bin | Bin 20131 -> 21080 bytes common/src/proto/multieko2.auth.rs | 94 ++++++++++++++++++++++++++++ server/Cargo.toml | 3 +- server/src/auth/handlers.rs | 2 + server/src/auth/handlers/login.rs | 46 ++++++++++++++ server/src/auth/logic.rs | 7 +++ server/src/auth/logic/jwt.rs | 55 ++++++++++++++++ server/src/auth/logic/middleware.rs | 22 +++++++ server/src/auth/mod.rs | 1 + server/src/auth/models.rs | 14 +++++ 12 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 server/src/auth/handlers/login.rs create mode 100644 server/src/auth/logic.rs create mode 100644 server/src/auth/logic/jwt.rs create mode 100644 server/src/auth/logic/middleware.rs diff --git a/Cargo.lock b/Cargo.lock index f7fdd89..6c356f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,8 +1024,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1541,6 +1543,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lasso" version = "0.7.3" @@ -1928,6 +1945,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2345,6 +2372,20 @@ dependencies = [ "tstr", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.7" @@ -2554,6 +2595,7 @@ dependencies = [ "common", "dashmap", "dotenvy", + "jsonwebtoken", "lazy_static", "prost", "regex", @@ -2641,6 +2683,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "sized-chunks" version = "0.6.5" @@ -3521,6 +3575,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.4" @@ -3551,6 +3611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.1", + "serde", ] [[package]] diff --git a/common/proto/auth.proto b/common/proto/auth.proto index 7a9585b..bcb30df 100644 --- a/common/proto/auth.proto +++ b/common/proto/auth.proto @@ -6,6 +6,7 @@ import "common.proto"; service AuthService { rpc Register(RegisterRequest) returns (AuthResponse); + rpc Login(LoginRequest) returns (LoginResponse); } message RegisterRequest { @@ -21,3 +22,16 @@ message AuthResponse { string email = 3; // Registered email (if provided) string role = 4; // Default role: 'accountant' } + +message LoginRequest { + string identifier = 1; // Can be username or email + string password = 2; +} + +message LoginResponse { + string access_token = 1; // JWT token + string token_type = 2; // Usually "Bearer" + int32 expires_in = 3; // Expiration in seconds (86400 for 24 hours) + string user_id = 4; // User's UUID in string format + string role = 5; // User's role +} diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 17d38ede51a9e12ac64d34f147a435c411c89601..edd4e4a718bd551e8735dbf8b1110e96421e9bdd 100644 GIT binary patch delta 1473 zcmZ`(!EW0|5ap69M_Nj!lgm)`Vt=Dm4ucEz`^$v^MNUw=5}+pQ0| zvrBUSnAM-gzl70={Bf{s-uq=OHgo#Yg z!pOB?WaXr$PbLxXvKz}(CTHQyrOM6*!pV$tMw56h-HJL@zYpIKWbguE(AU=m0C@e9hYRTI z1wajGeOLfKHjC38W4yMAL3XMRbHSp876)|Ox5e?ZQ=$HqYfn%C?R=oc0RnAccU68s zAU^Q=Y@kG-Bp@djKc~xJK7S$nM>0rc;ul)zQc6uaDYAq#QRoB!9@*e&;L?9 z!H>d4kcDw1!bqfY8b`BK?0xg#=>C0i0UJC#5|{BZN$(U>@bM05O9e&=hYVPe+t~w5?JSN`dcXdV}Caxa>=wr zwg5yFl7SXju27+rj595i?6JZkh$1LVih@d+qy(uhVPsX(MGFY4G6um@%j8YORmR3~ zT#Gu7miX zA+*-M8BRvJ%S_S}1+=P@qJQ+V#ltyHmhlh6W;8h-{xfk~;6QOh+g{9ZDrx=4a5>r> m59Mq;3h+?Q(oN3hI0@&A{qwcU@leXCMO;RN#O`JNb^QmUmm_%q diff --git a/common/src/proto/multieko2.auth.rs b/common/src/proto/multieko2.auth.rs index 208f829..ff60458 100644 --- a/common/src/proto/multieko2.auth.rs +++ b/common/src/proto/multieko2.auth.rs @@ -25,6 +25,32 @@ pub struct AuthResponse { #[prost(string, tag = "4")] pub role: ::prost::alloc::string::String, } +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LoginRequest { + /// Can be username or email + #[prost(string, tag = "1")] + pub identifier: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub password: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LoginResponse { + /// JWT token + #[prost(string, tag = "1")] + pub access_token: ::prost::alloc::string::String, + /// Usually "Bearer" + #[prost(string, tag = "2")] + pub token_type: ::prost::alloc::string::String, + /// Expiration in seconds (86400 for 24 hours) + #[prost(int32, tag = "3")] + pub expires_in: i32, + /// User's UUID in string format + #[prost(string, tag = "4")] + pub user_id: ::prost::alloc::string::String, + /// User's role + #[prost(string, tag = "5")] + pub role: ::prost::alloc::string::String, +} /// Generated client implementations. pub mod auth_service_client { #![allow( @@ -137,6 +163,27 @@ pub mod auth_service_client { .insert(GrpcMethod::new("multieko2.auth.AuthService", "Register")); self.inner.unary(req, path, codec).await } + pub async fn login( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/multieko2.auth.AuthService/Login", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("multieko2.auth.AuthService", "Login")); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -156,6 +203,10 @@ pub mod auth_service_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + async fn login( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; } #[derive(Debug)] pub struct AuthServiceServer { @@ -278,6 +329,49 @@ pub mod auth_service_server { }; Box::pin(fut) } + "/multieko2.auth.AuthService/Login" => { + #[allow(non_camel_case_types)] + struct LoginSvc(pub Arc); + impl tonic::server::UnaryService + for LoginSvc { + type Response = super::LoginResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::login(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LoginSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new(empty_body()); diff --git a/server/Cargo.toml b/server/Cargo.toml index 4b68978..1385291 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -26,7 +26,8 @@ lazy_static = "1.5.0" regex = "1.11.1" bcrypt = "0.17.0" validator = { version = "0.20.0", features = ["derive"] } -uuid = { version = "1.16.0", features = ["v4"] } +uuid = { version = "1.16.0", features = ["serde", "v4"] } +jsonwebtoken = "9.3.1" [lib] name = "server" diff --git a/server/src/auth/handlers.rs b/server/src/auth/handlers.rs index f20cc8a..0af37ea 100644 --- a/server/src/auth/handlers.rs +++ b/server/src/auth/handlers.rs @@ -1,5 +1,7 @@ // src/auth/handlers.rs pub mod register; +pub mod login; pub use register::*; +pub use login::*; diff --git a/server/src/auth/handlers/login.rs b/server/src/auth/handlers/login.rs new file mode 100644 index 0000000..0eb6a7b --- /dev/null +++ b/server/src/auth/handlers/login.rs @@ -0,0 +1,46 @@ +// src/auth/handlers/login.rs +use bcrypt::verify; +use tonic::{Request, Response, Status}; +use crate::db::PgPool; +use crate::auth::{models::AuthError, logic::jwt}; // Fixed import path +use common::proto::multieko2::auth::{LoginRequest, LoginResponse}; + +pub async fn login( + pool: &PgPool, + request: LoginRequest, +) -> Result, Status> { + let user = sqlx::query!( + r#" + SELECT id, password_hash, role + FROM users + WHERE username = $1 OR email = $1 + "#, + request.identifier + ) + .fetch_optional(pool) + .await + .map_err(|e| Status::internal(e.to_string()))? + .ok_or_else(|| Status::unauthenticated("Invalid credentials"))?; + + // Handle the optional password_hash + let password_hash = user.password_hash + .ok_or_else(|| Status::internal("User account has no password set"))?; + + // Verify the password + if !verify(&request.password, &password_hash) + .map_err(|e| Status::internal(e.to_string()))? + { + return Err(Status::unauthenticated("Invalid credentials")); + } + + let token = jwt::generate_token(user.id, &user.role) + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(LoginResponse { + access_token: token, + token_type: "Bearer".to_string(), + expires_in: 86400, // 24 hours + user_id: user.id.to_string(), + role: user.role, + })) +} diff --git a/server/src/auth/logic.rs b/server/src/auth/logic.rs new file mode 100644 index 0000000..67e40b6 --- /dev/null +++ b/server/src/auth/logic.rs @@ -0,0 +1,7 @@ +// src/auth/logic.rs + +pub mod jwt; +pub mod middleware; + +pub use jwt::*; +pub use middleware::*; diff --git a/server/src/auth/logic/jwt.rs b/server/src/auth/logic/jwt.rs new file mode 100644 index 0000000..585b6e3 --- /dev/null +++ b/server/src/auth/logic/jwt.rs @@ -0,0 +1,55 @@ +// src/auth/jwt.rs +use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; +use std::sync::OnceLock; +use crate::auth::models::AuthError; + +static KEYS: OnceLock = OnceLock::new(); + +struct Keys { + encoding: EncodingKey, + decoding: DecodingKey, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: Uuid, // User ID + pub exp: i64, // Expiration time + pub role: String, // User role +} + +pub fn init_jwt() -> Result<(), AuthError> { + let secret = std::env::var("JWT_SECRET") + .map_err(|_| AuthError::ConfigError("JWT_SECRET must be set".to_string()))?; + + KEYS.set(Keys { + encoding: EncodingKey::from_secret(secret.as_bytes()), + decoding: DecodingKey::from_secret(secret.as_bytes()), + }).map_err(|_| AuthError::ConfigError("Failed to initialize JWT keys".to_string()))?; + + Ok(()) +} + +pub fn generate_token(user_id: Uuid, role: &str) -> Result { + let keys = KEYS.get().ok_or(AuthError::ConfigError("JWT not initialized".to_string()))?; + + let exp = OffsetDateTime::now_utc() + Duration::hours(24); + let claims = Claims { + sub: user_id, + exp: exp.unix_timestamp(), + role: role.to_string(), + }; + + encode(&Header::default(), &claims, &keys.encoding) + .map_err(|e| AuthError::JwtError(e.to_string())) +} + +pub fn validate_token(token: &str) -> Result { + let keys = KEYS.get().ok_or(AuthError::ConfigError("JWT not initialized".to_string()))?; + + decode::(token, &keys.decoding, &Validation::default()) + .map(|data| data.claims) + .map_err(|e| AuthError::JwtError(e.to_string())) +} diff --git a/server/src/auth/logic/middleware.rs b/server/src/auth/logic/middleware.rs new file mode 100644 index 0000000..27738fe --- /dev/null +++ b/server/src/auth/logic/middleware.rs @@ -0,0 +1,22 @@ +// src/auth/middleware.rs +use tonic::{metadata::MetadataValue, service::Interceptor, Status}; +use crate::auth::{logic::jwt, models::AuthError}; + +pub struct AuthInterceptor; + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: tonic::Request<()>) -> Result, Status> { + let metadata = request.metadata(); + let token = metadata.get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .ok_or(Status::unauthenticated("Missing authorization header"))?; + + let claims = jwt::validate_token(token) + .map_err(|e| Status::unauthenticated(e.to_string()))?; + + // Store claims in request extensions + request.extensions_mut().insert(claims); + Ok(request) + } +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index a3529b4..ca9a817 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -1,5 +1,6 @@ // src/auth/mod.rs pub mod models; +pub mod logic; pub mod handlers; diff --git a/server/src/auth/models.rs b/server/src/auth/models.rs index ca64358..31d2d3f 100644 --- a/server/src/auth/models.rs +++ b/server/src/auth/models.rs @@ -14,6 +14,14 @@ pub struct RegisterRequest { pub password_confirmation: String, } +#[derive(Debug, Validate, Deserialize)] +pub struct LoginRequest { + #[validate(length(min = 1))] + pub identifier: String, + #[validate(length(min = 1))] + pub password: String, +} + #[derive(Debug, thiserror::Error)] pub enum AuthError { #[error("Passwords do not match")] @@ -24,4 +32,10 @@ pub enum AuthError { DatabaseError(String), #[error("Hashing error: {0}")] HashingError(String), + #[error("Invalid credentials")] + InvalidCredentials, + #[error("JWT error: {0}")] + JwtError(String), + #[error("Configuration error: {0}")] + ConfigError(String), }