jwt implementation and login, not working yet

This commit is contained in:
filipriec
2025-03-25 10:15:17 +01:00
parent 9393294af8
commit cd32c175a4
12 changed files with 318 additions and 1 deletions

61
Cargo.lock generated
View File

@@ -1024,8 +1024,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1541,6 +1543,21 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lasso" name = "lasso"
version = "0.7.3" version = "0.7.3"
@@ -1928,6 +1945,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -2345,6 +2372,20 @@ dependencies = [
"tstr", "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]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.7" version = "0.9.7"
@@ -2554,6 +2595,7 @@ dependencies = [
"common", "common",
"dashmap", "dashmap",
"dotenvy", "dotenvy",
"jsonwebtoken",
"lazy_static", "lazy_static",
"prost", "prost",
"regex", "regex",
@@ -2641,6 +2683,18 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "sized-chunks" name = "sized-chunks"
version = "0.6.5" version = "0.6.5"
@@ -3521,6 +3575,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.4"
@@ -3551,6 +3611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
dependencies = [ dependencies = [
"getrandom 0.3.1", "getrandom 0.3.1",
"serde",
] ]
[[package]] [[package]]

View File

@@ -6,6 +6,7 @@ import "common.proto";
service AuthService { service AuthService {
rpc Register(RegisterRequest) returns (AuthResponse); rpc Register(RegisterRequest) returns (AuthResponse);
rpc Login(LoginRequest) returns (LoginResponse);
} }
message RegisterRequest { message RegisterRequest {
@@ -21,3 +22,16 @@ message AuthResponse {
string email = 3; // Registered email (if provided) string email = 3; // Registered email (if provided)
string role = 4; // Default role: 'accountant' 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
}

Binary file not shown.

View File

@@ -25,6 +25,32 @@ pub struct AuthResponse {
#[prost(string, tag = "4")] #[prost(string, tag = "4")]
pub role: ::prost::alloc::string::String, 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. /// Generated client implementations.
pub mod auth_service_client { pub mod auth_service_client {
#![allow( #![allow(
@@ -137,6 +163,27 @@ pub mod auth_service_client {
.insert(GrpcMethod::new("multieko2.auth.AuthService", "Register")); .insert(GrpcMethod::new("multieko2.auth.AuthService", "Register"));
self.inner.unary(req, path, codec).await self.inner.unary(req, path, codec).await
} }
pub async fn login(
&mut self,
request: impl tonic::IntoRequest<super::LoginRequest>,
) -> std::result::Result<tonic::Response<super::LoginResponse>, 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. /// Generated server implementations.
@@ -156,6 +203,10 @@ pub mod auth_service_server {
&self, &self,
request: tonic::Request<super::RegisterRequest>, request: tonic::Request<super::RegisterRequest>,
) -> std::result::Result<tonic::Response<super::AuthResponse>, tonic::Status>; ) -> std::result::Result<tonic::Response<super::AuthResponse>, tonic::Status>;
async fn login(
&self,
request: tonic::Request<super::LoginRequest>,
) -> std::result::Result<tonic::Response<super::LoginResponse>, tonic::Status>;
} }
#[derive(Debug)] #[derive(Debug)]
pub struct AuthServiceServer<T> { pub struct AuthServiceServer<T> {
@@ -278,6 +329,49 @@ pub mod auth_service_server {
}; };
Box::pin(fut) Box::pin(fut)
} }
"/multieko2.auth.AuthService/Login" => {
#[allow(non_camel_case_types)]
struct LoginSvc<T: AuthService>(pub Arc<T>);
impl<T: AuthService> tonic::server::UnaryService<super::LoginRequest>
for LoginSvc<T> {
type Response = super::LoginResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::LoginRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as AuthService>::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 { Box::pin(async move {
let mut response = http::Response::new(empty_body()); let mut response = http::Response::new(empty_body());

View File

@@ -26,7 +26,8 @@ lazy_static = "1.5.0"
regex = "1.11.1" regex = "1.11.1"
bcrypt = "0.17.0" bcrypt = "0.17.0"
validator = { version = "0.20.0", features = ["derive"] } 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] [lib]
name = "server" name = "server"

View File

@@ -1,5 +1,7 @@
// src/auth/handlers.rs // src/auth/handlers.rs
pub mod register; pub mod register;
pub mod login;
pub use register::*; pub use register::*;
pub use login::*;

View File

@@ -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<Response<LoginResponse>, 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,
}))
}

7
server/src/auth/logic.rs Normal file
View File

@@ -0,0 +1,7 @@
// src/auth/logic.rs
pub mod jwt;
pub mod middleware;
pub use jwt::*;
pub use middleware::*;

View File

@@ -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<Keys> = 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<String, AuthError> {
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<Claims, AuthError> {
let keys = KEYS.get().ok_or(AuthError::ConfigError("JWT not initialized".to_string()))?;
decode::<Claims>(token, &keys.decoding, &Validation::default())
.map(|data| data.claims)
.map_err(|e| AuthError::JwtError(e.to_string()))
}

View File

@@ -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<tonic::Request<()>, 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)
}
}

View File

@@ -1,5 +1,6 @@
// src/auth/mod.rs // src/auth/mod.rs
pub mod models; pub mod models;
pub mod logic;
pub mod handlers; pub mod handlers;

View File

@@ -14,6 +14,14 @@ pub struct RegisterRequest {
pub password_confirmation: String, 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)] #[derive(Debug, thiserror::Error)]
pub enum AuthError { pub enum AuthError {
#[error("Passwords do not match")] #[error("Passwords do not match")]
@@ -24,4 +32,10 @@ pub enum AuthError {
DatabaseError(String), DatabaseError(String),
#[error("Hashing error: {0}")] #[error("Hashing error: {0}")]
HashingError(String), HashingError(String),
#[error("Invalid credentials")]
InvalidCredentials,
#[error("JWT error: {0}")]
JwtError(String),
#[error("Configuration error: {0}")]
ConfigError(String),
} }