- add: initial database and auth services

This commit is contained in:
2024-11-25 20:45:16 -05:00
parent 6a35b5b373
commit 3ff22c9a5b
24 changed files with 817 additions and 1 deletions

6
.gitignore vendored
View File

@@ -4,9 +4,13 @@
debug/
target/
auth-service/.env
database-service/.env
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
auth-service/Cargo.lock
database-service/Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk

20
auth-service/Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "auth-service"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.41.1", features = ["full"] }
tonic = "0.12.3"
jsonwebtoken = "9.3.0"
argon2 = "0.5.3"
serde = { version = "1.0", features = ["derive"] }
dotenv = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "chrono"] }
prost = "0.13.3"
prost-types = "0.13.3"
chrono = { version = "0.4.38", features = ["serde"] }
[build-dependencies]
tonic-build = "0.12.3"

16
auth-service/build.rs Normal file
View File

@@ -0,0 +1,16 @@
fn main() {
// gRPC Server code
tonic_build::configure()
.build_server(true) // Generate gRPC server code
.compile_well_known_types(true)
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
.compile_protos(&["../proto/auth.proto"], &["../proto"])
.unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
// gRPC Client code
tonic_build::configure()
.build_server(false) // Generate gRPC client code
.compile_well_known_types(true)
.compile_protos(&["../proto/database.proto"], &["../proto"])
.unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
}

View File

@@ -0,0 +1,36 @@
use tonic::transport::Channel;
use crate::database::{database_service_client::DatabaseServiceClient, GetUserByUsernameRequest, GetUserRequest, GetUserResponse};
#[derive(Clone)]
pub struct DatabaseClient {
client: DatabaseServiceClient<Channel>,
}
impl DatabaseClient {
pub async fn connect(endpoint: &str) -> Result<Self, Box<dyn std::error::Error>> {
let client = DatabaseServiceClient::connect(endpoint.to_string()).await?;
Ok(Self { client })
}
pub async fn get_user_by_userid(
&mut self,
user_id: i32,
) -> Result<GetUserResponse, Box<dyn std::error::Error>> {
let request = tonic::Request::new(GetUserRequest {
user_id,
});
let response = self.client.get_user(request).await?;
Ok(response.into_inner())
}
pub async fn get_user_by_username(
&mut self,
username: &str,
) -> Result<GetUserResponse, Box<dyn std::error::Error>> {
let request = tonic::Request::new(GetUserByUsernameRequest {
username: username.to_string(),
});
let response = self.client.get_user_by_username(request).await?;
Ok(response.into_inner())
}
}

52
auth-service/src/grpc.rs Normal file
View File

@@ -0,0 +1,52 @@
use tonic::{Request, Response, Status};
use crate::jwt::{generate_token, validate_token};
use crate::users::verify_user;
use crate::database_client::DatabaseClient;
use crate::auth::auth_service_server::{AuthService};
use crate::auth::{LoginRequest, LoginResponse, ValidateTokenRequest, ValidateTokenResponse};
use tracing::{info, warn};
pub struct MyAuthService {
pub db_client: DatabaseClient,
}
#[tonic::async_trait]
impl AuthService for MyAuthService {
async fn login(
&self,
request: Request<LoginRequest>,
) -> Result<Response<LoginResponse>, Status> {
let req = request.into_inner();
info!("Login attempt for username: {}", req.username);
if let Some(user_id) = verify_user(self.db_client.clone(), &req.username, &req.password).await {
let token = generate_token(&user_id, vec!["user".to_string()])
.map_err(|_| Status::internal("Token generation failed"))?;
info!("Login successful for username: {}", req.username);
Ok(Response::new(LoginResponse { token, user_id }))
} else {
warn!("Invalid login attempt for username: {}", req.username);
Err(Status::unauthenticated("Invalid credentials"))
}
}
async fn validate_token(
&self,
request: Request<ValidateTokenRequest>,
) -> Result<Response<ValidateTokenResponse>, Status> {
let req = request.into_inner();
match validate_token(&req.token) {
Ok(user_id) => Ok(Response::new(ValidateTokenResponse {
valid: true,
user_id,
})),
Err(_) => Ok(Response::new(ValidateTokenResponse {
valid: false,
user_id: "".to_string(),
})),
}
}
}

36
auth-service/src/jwt.rs Normal file
View File

@@ -0,0 +1,36 @@
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use serde::{Serialize, Deserialize};
use std::env;
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String, // Subject (user ID)
roles: Vec<String>, // Roles/permissions
exp: usize, // Expiration time
}
pub fn generate_token(user_id: &str, roles: Vec<String>) -> Result<String, jsonwebtoken::errors::Error> {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::days(1))
.expect("valid timestamp")
.timestamp() as usize;
let claims = Claims {
sub: user_id.to_owned(),
roles,
exp: expiration,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
}
pub fn validate_token(token: &str) -> Result<String, jsonwebtoken::errors::Error> {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)?;
Ok(token_data.claims.sub)
}

11
auth-service/src/lib.rs Normal file
View File

@@ -0,0 +1,11 @@
pub mod grpc;
pub mod jwt;
pub mod database_client;
pub mod users;
pub mod auth {
tonic::include_proto!("auth"); // Path matches the package name in auth.proto
}
pub mod database {
tonic::include_proto!("database"); // Matches package name in database.proto
}

45
auth-service/src/main.rs Normal file
View File

@@ -0,0 +1,45 @@
use dotenv::dotenv;
use std::env;
use tonic::transport::Server;
use auth_service::grpc::MyAuthService;
use auth_service::database_client::DatabaseClient;
use auth_service::auth::auth_service_server::AuthServiceServer;
pub mod auth {
tonic::include_proto!("auth"); // Path matches the package name in auth.proto
}
pub mod database {
tonic::include_proto!("database"); // Matches package name in database.proto
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load environment variables from .env
dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_thread_names(true)
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::rfc_3339())
.init();
// Set the gRPC server address
let addr = env::var("AUTH_SERVICE_ADDR").unwrap_or_else(|_| "127.0.0.1:50051".to_string());
let db_addr = env::var("DATABASE_SERVICE_ADDR").unwrap_or_else(|_| "http://127.0.0.1:50052".to_string());
let database_client = DatabaseClient::connect(&db_addr).await?;
let addr = addr.parse().expect("Invalid address");
let auth_service = MyAuthService {
db_client: database_client,
};
println!("Authentication Service running on {}", addr);
// Start the gRPC server
Server::builder()
.add_service(AuthServiceServer::new(auth_service))
.serve(addr)
.await?;
Ok(())
}

32
auth-service/src/users.rs Normal file
View File

@@ -0,0 +1,32 @@
use crate::database_client::DatabaseClient;
use argon2::{
password_hash::{
rand_core::OsRng,
PasswordHash, PasswordHasher, PasswordVerifier, SaltString
},
Argon2
};
pub fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2.hash_password(password.as_ref(), &salt).unwrap().to_string()
}
pub fn verify_password(password: &str, hash: &str) -> bool {
let parsed_hash = PasswordHash::new(&hash).unwrap();
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
}
pub async fn verify_user(mut db_client: DatabaseClient,
username: &str, password: &str) -> Option<String> {
// Placeholder: Replace with a gRPC call to the Database Service
let user = db_client.get_user_by_username(username).await.ok()?;
if verify_password(password, &user.hashed_password) {
Some(user.user_id.to_string())
} else {
None
}
}

View File

@@ -0,0 +1,60 @@
#[cfg(test)]
mod tests {
use super::*;
use dotenv::dotenv;
use tonic::Request;
use auth_service::auth::auth_service_server::AuthService;
use auth_service::auth::{LoginRequest, LoginResponse, ValidateTokenRequest, ValidateTokenResponse};
use auth_service::database_client::DatabaseClient;
use auth_service::grpc::MyAuthService;
use auth_service::jwt;
#[tokio::test]
async fn test_login() {
dotenv().ok();
// Mock dependencies or use the actual Database Service
let db_client = DatabaseClient::connect("http://127.0.0.1:50052").await.unwrap();
let auth_service = MyAuthService {
db_client,
};
// Create a test LoginRequest
let request = Request::new(LoginRequest {
username: "test".into(),
password: "test".into(),
});
// Call the login method
let response = auth_service.login(request).await.unwrap().into_inner();
// Verify the response
assert!(!response.token.is_empty());
assert_eq!(response.user_id, "9"); // Replace with the expected user ID
}
#[tokio::test]
async fn test_validate_token() {
dotenv().ok();
let db_client = DatabaseClient::connect("http://127.0.0.1:50052").await.unwrap();
let auth_service = MyAuthService {
db_client,
};
// Generate a token for testing
let token = jwt::generate_token("123", Vec::from(["".to_string()])).unwrap();
// Create a ValidateTokenRequest
let request = Request::new(ValidateTokenRequest { token });
// Call the validate_token method
let response = auth_service.validate_token(request).await.unwrap().into_inner();
// Verify the response
assert!(response.valid);
assert_eq!(response.user_id, "123");
}
}

View File

@@ -0,0 +1,21 @@
[package]
name = "database-service"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tonic-build = "0.12.3"
[dependencies]
tokio = { version = "1.41.1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "chrono"] }
tonic = "0.12.3"
serde = { version = "1.0", features = ["derive"] }
dotenv = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "chrono"] }
prost = "0.13.3"
prost-types = "0.13.3"
redis = "0.27.5"
deadpool-redis = "0.18.0"
serde_json = "1.0.133"

View File

@@ -0,0 +1,8 @@
fn main() {
tonic_build::configure()
.build_server(true)
.compile_well_known_types(true)
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
.compile_protos(&["../proto/database.proto"], &["../proto"])
.unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
}

View File

@@ -0,0 +1,24 @@
use sqlx::PgPool;
use std::sync::Arc;
use crate::redis_cache::RedisCache;
use crate::users::UsersService;
pub struct Database {
pub users_service: UsersService, // User-specific functionality
}
impl Database {
pub async fn new(pool: PgPool, cache: Arc<RedisCache>) -> Self {
let users_service = UsersService { pool, cache };
Self { users_service }
}
pub async fn health_check(&self) -> bool {
// Simple query to check database health
sqlx::query("SELECT 1")
.execute(&self.users_service.pool)
.await
.is_ok()
}
}

View File

@@ -0,0 +1,63 @@
use crate::db::Database;
use crate::database::{CreateUserRequest, CreateUserResponse, GetUserRequest, GetUserByUsernameRequest, GetUserResponse};
use tonic::{Request, Response, Status};
use crate::database::database_service_server::{DatabaseService};
use tracing::{debug, error};
pub struct MyDatabaseService {
pub db: Database, // Use the Database struct from users.rs
}
#[tonic::async_trait]
impl DatabaseService for MyDatabaseService {
async fn get_user(
&self,
request: Request<GetUserRequest>,
) -> Result<Response<GetUserResponse>, Status> {
let req = request.into_inner();
let user = self.db.users_service.get_user_by_id(req.user_id)
.await
.map_err(|_| Status::not_found("User not found"))?;
Ok(Response::new(GetUserResponse {
user_id: user.id,
username: user.username,
email: user.email,
hashed_password: user.hashed_password,
}))
}
async fn get_user_by_username(
&self,
request: Request<GetUserByUsernameRequest>,
) -> Result<Response<GetUserResponse>, Status> {
let req = request.into_inner();
let user = self.db.users_service.get_user_by_username(&req.username)
.await
.map_err(|_| Status::not_found("User not found"))?;
Ok(Response::new(GetUserResponse {
user_id: user.id,
username: user.username,
email: user.email,
hashed_password: user.hashed_password,
}))
}
async fn create_user(
&self,
request: Request<CreateUserRequest>,
) -> Result<Response<CreateUserResponse>, Status> {
let req = request.into_inner();
let user_id = self.db.users_service.create_user(&req.username, &req.email, &req.hashed_password)
.await
.map_err(|_| Status::internal("Failed to create user"))?;
// Return the newly created user ID
Ok(Response::new(CreateUserResponse { user_id: user_id }))
}
}

View File

@@ -0,0 +1,8 @@
pub mod users;
pub mod redis_cache;
pub mod db;
pub mod grpc;
pub mod database {
tonic::include_proto!("database");
}

View File

@@ -0,0 +1,48 @@
use dotenv::dotenv;
use std::env;
use tonic::transport::Server;
use database_service::db::Database;
use database_service::redis_cache::RedisCache;
use database::database_service_server::DatabaseServiceServer;
use std::sync::Arc;
use database_service::database;
use database_service::grpc::MyDatabaseService;
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_thread_names(true)
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::rfc_3339())
.init();
let addr = env::var("DATABASE_SERVICE_ADDR").unwrap_or_else(|_| "127.0.0.1:50052".to_string());
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
let addr = addr.parse().expect("Invalid address");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to create PostgreSQL connection pool");
let redis_cache = RedisCache::new(&redis_url);
let cache = Arc::new(redis_cache); // Share the cache instance between tasks
let database_service = MyDatabaseService {
db: Database::new(pool, cache).await,
};
// Pass `shared_cache` into services as needed
println!("Database Service running on {}", addr);
Server::builder()
.add_service(DatabaseServiceServer::new(database_service))
.serve(addr)
.await?;
Ok(())
}

View File

@@ -0,0 +1,60 @@
use deadpool_redis::{Config, Pool, Runtime};
use redis::{AsyncCommands, RedisError};
use serde::{de::DeserializeOwned, Serialize};
pub struct RedisCache {
pub pool: Pool,
}
impl RedisCache {
pub fn new(redis_url: &str) -> Self {
let cfg = Config::from_url(redis_url);
let pool = cfg.create_pool(Some(Runtime::Tokio1)).expect("Failed to create Redis pool");
RedisCache { pool }
}
pub async fn set<T: Serialize + std::marker::Send + std::marker::Sync>(
&self,
key: &String,
value: &T,
ttl: u64,
) -> Result<(), redis::RedisError> {
let mut conn = self.pool.get().await
.map_err(|err| {
redis::RedisError::from((
redis::ErrorKind::IoError,
"Failed to get Redis connection",
format!("{:?}", err),
))
})?;
let serialized_value = serde_json::to_string(value)
.map_err(|err| RedisError::from((
redis::ErrorKind::IoError,
"Serialization error",
format!("Serialization error: {}", err),
)))?;
conn.set_ex(key, serialized_value, ttl).await
}
pub async fn get<T: DeserializeOwned>(&self, key: &String) -> Result<Option<T>, redis::RedisError> {
let mut conn = self.pool.get().await
.map_err(|err| {
redis::RedisError::from((
redis::ErrorKind::IoError,
"Failed to get Redis connection",
format!("{:?}", err),
))
})?;
if let Some(serialized_value) = conn.get::<_, Option<String>>(key).await? {
let deserialized_value = serde_json::from_str(&serialized_value)
.map_err(|err| RedisError::from((
redis::ErrorKind::IoError,
"Deserialization error",
format!("Deserialization error: {}", err),
)))?;
Ok(Some(deserialized_value))
} else {
Ok(None)
}
}
}

View File

@@ -0,0 +1,123 @@
use sqlx::Error;
use sqlx::PgPool;
use serde::{Serialize, Deserialize};
use std::sync::Arc;
use crate::redis_cache::RedisCache;
use tracing::{debug, error};
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: i32,
pub username: String,
pub email: String,
pub hashed_password: String,
}
pub struct UsersService {
pub pool: PgPool,
pub cache: Arc<RedisCache>, // Shared Redis cache
}
impl UsersService {
pub async fn create_user(
&self,
username: &str,
email: &str,
hashed_password: &str,
) -> Result<i32, Error> {
let result = sqlx::query!(
r#"
INSERT INTO users (username, email, hashed_password)
VALUES ($1, $2, $3)
RETURNING id
"#,
username,
email,
hashed_password
)
.fetch_one(&self.pool)
.await?;
Ok(result.id)
}
pub async fn get_user_by_id(&self, user_id: i32) -> Result<User, sqlx::Error> {
// Check Redis cache first
if let Ok(Some(cached_user)) = self.cache.get::<User>(&format!("user:{}", user_id)).await {
return Ok(cached_user);
}
// Fetch from PostgreSQL
let user = sqlx::query_as!(
User,
"SELECT id, username, email, hashed_password FROM users WHERE id = $1",
user_id
)
.fetch_one(&self.pool)
.await?;
// Store result in Redis
self.cache
.set(&format!("user:{}", user_id), &user, 3600)
.await
.unwrap_or_else(|err| eprintln!("Failed to cache user: {:?}", err));
Ok(user)
}
pub async fn get_user_by_username(&self, username: &str) -> Result<User, Error> {
// Check Redis cache first
if let Ok(Some(cached_user)) = self.cache.get::<User>(&format!("user_by_username:{}", username)).await {
return Ok(cached_user);
}
// Fetch from PostgreSQL
let user = sqlx::query_as!(
User,
"SELECT id, username, email, hashed_password FROM users WHERE username = $1",
username
)
.fetch_one(&self.pool)
.await?;
// Store result in Redis
self.cache
.set(&format!("user_by_username:{}", username), &user, 3600)
.await
.unwrap_or_else(|err| eprintln!("Failed to cache user: {:?}", err));
Ok(user)
}
pub async fn update_user_email(&self, user_id: i32, new_email: &str) -> Result<(), Error> {
sqlx::query!(
r#"
UPDATE users
SET email = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
"#,
new_email,
user_id
)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn delete_user(&self, user_id: i32) -> Result<(), Error> {
sqlx::query!(
r#"
DELETE FROM users
WHERE id = $1
"#,
user_id
)
.execute(&self.pool)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,30 @@
use sqlx::{PgPool, Executor};
use tokio;
#[tokio::test]
async fn test_get_user() {
// Set up a temporary in-memory PostgreSQL database
let pool = PgPool::connect("postgres://user:password@localhost/test_database").await.unwrap();
// Create the test table
pool.execute(
r#"
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
hashed_password TEXT NOT NULL
);
INSERT INTO users (user_id, username, email, hashed_password)
VALUES ('123', 'test_user', 'test@example.com', 'hashed_password_example');
"#,
)
.await
.unwrap();
// Test the `get_user` function
let user = get_user(&pool, "123").await.unwrap();
assert_eq!(user.user_id, "123");
assert_eq!(user.username, "test_user");
assert_eq!(user.email, "test@example.com");
}

View File

@@ -0,0 +1,25 @@
use tonic::{Request, Response};
use database_service::database::database_service_server::DatabaseService;
use database_service::database::GetUserRequest;
use database_service::MyDatabaseService;
#[tokio::test]
async fn test_grpc_get_user() {
let pool = setup_test_pool().await; // Set up your test pool
let cache = setup_test_cache().await; // Set up mock Redis cache
let service = MyDatabaseService { pool, cache };
// Create a mock gRPC request
let request = Request::new(GetUserRequest {
user_id: 123,
});
// Call the service
let response = service.get_user(request).await.unwrap().into_inner();
// Validate the response
assert_eq!(response.user_id, 123);
assert_eq!(response.username, "test_user");
assert_eq!(response.email, "test@example.com");
}

View File

@@ -0,0 +1,14 @@
#[cfg(test)]
mod tests {
use super::*;
use dotenv::dotenv;
use database_service::db::Database;
#[tokio::test]
async fn test_health_check() {
dotenv().ok();
let database_url = std::env::var("DATABASE_URL").unwrap();
let db = Database::new(&database_url).await;
assert!(db.health_check().await);
}
}

View File

@@ -0,0 +1,19 @@
use deadpool_redis::{Config, Pool, Runtime};
use redis::AsyncCommands;
use database_service::redis_cache::RedisCache;
#[tokio::test]
async fn test_redis_cache() {
let redis_url = "redis://127.0.0.1:6379";
let cache = RedisCache::new(redis_url);
let key = &"test_key".to_string();
let value = "test_value";
// Test setting a value
cache.set(key, &value, 10).await.unwrap();
// Test getting the value
let cached_value: Option<String> = cache.get(key).await.unwrap();
assert_eq!(cached_value, Some("test_value".to_string()));
}

27
proto/auth.proto Normal file
View File

@@ -0,0 +1,27 @@
syntax = "proto3";
package auth;
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse);
}
message LoginRequest {
string username = 1;
string password = 2;
}
message LoginResponse {
string token = 1;
string user_id = 2;
}
message ValidateTokenRequest {
string token = 1;
}
message ValidateTokenResponse {
bool valid = 1;
string user_id = 2;
}

34
proto/database.proto Normal file
View File

@@ -0,0 +1,34 @@
syntax = "proto3";
package database;
service DatabaseService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc GetUserByUsername(GetUserByUsernameRequest) returns (GetUserResponse);
}
message GetUserRequest {
int32 user_id = 1;
}
message GetUserByUsernameRequest {
string username = 1;
}
message GetUserResponse {
int32 user_id = 1;
string username = 2;
string email = 3;
string hashed_password = 4;
}
message CreateUserRequest {
string username = 1;
string email = 2;
string hashed_password = 3;
}
message CreateUserResponse {
int32 user_id = 1;
}