diff --git a/Cargo.toml b/Cargo.toml index b9ae09f..222cdc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,15 @@ [package] - name = "rust-users" -version = "0.0.1" +version = "0.1.0" authors = [ "Ryan Chenkie " ] +edition = "2021" [dependencies] -nickel = "*" -mongodb = "*" -bson = "*" -rustc-serialize = "*" -hyper = "*" -jwt = "*" -rust-crypto = "*" \ No newline at end of file +actix-web = "4.0" +actix-service = "2.0" +env_logger = "0.10" +mongodb = "2.3" +serde = { version = "1.0", features = ["derive"] } +jsonwebtoken = "8.1" +futures-util = "0.3" +futures = "0.3.30" diff --git a/readme.md b/readme.md index d400175..f92ecc5 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,76 @@ # Rust API Example -This repo shows how to implement a RESTful API in Rust with **[Nickel.rs](http://nickel.rs/)** and the **[MongoDB Rust Driver](https://github.com/mongodb-labs/mongo-rust-driver-prototype)**. +This repo shows how to implement a RESTful API in Rust with **[Actix Web](https://actix.rs/)** and the **[MongoDB Rust Driver](https://www.mongodb.com/docs/drivers/rust/current/)**. + +This REST API uses simple JWT authentication. Note that it does not actually integrate with Auth0! + +## Prerequisites + +You will need to have MongoDB installed and running on `localhost:27017`. If on macOS, for instance, follow MongoDB's [installation instructions](https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-os-x/). + +## Usage + +Issue a POST to the `/login` route to obtain a JWT bearer token. + +``` +% curl -v http://127.0.0.1:9000/login -X POST -d '{"email": "Joe User", "password": "secret"}' -H "Content-Type: application/json" +Note: Unnecessary use of -X or --request, POST is already inferred. +* Trying 127.0.0.1:9000... +* Connected to 127.0.0.1 (127.0.0.1) port 9000 +> POST /login HTTP/1.1 +> Host: 127.0.0.1:9000 +> User-Agent: curl/8.4.0 +> Accept: */* +> Content-Type: application/json +> Content-Length: 43 +> +< HTTP/1.1 200 OK +< content-length: 180 +< date: Wed, 10 Jul 2024 14:20:55 GMT +< +* Connection #0 to host 127.0.0.1 left intact +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MjA2MjEyNTYsImV4cCI6MTcyMDYyNDg1NiwiZW1haWwiOiJKb2UgVXNlciIsInBhc3N3b3JkIjoic2VjcmV0In0.CLi9Jc34GUOMuHuK7KDN2BUI2-vX6KI4yfnIN6ngm0E +``` + +The JWT bearer token can now be specified to the protected routes such as `/users`. + +``` +% curl -v -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MjA2MjEyNTYsImV4cCI6MTcyMDYyNDg1NiwiZW1haWwiOiJKb2UgVXNlciIsInBhc3N3b3JkIjoic2VjcmV0In0.CLi9Jc34GUOMuHuK7KDN2BUI2-vX6KI4yfnIN6ngm0E" http://127.0.0.1:9000/users -X POST -d '{"firstname": "Joe", "lastname": "User", "email": "joe@example.org"}' -H "Content-Type: application/json" +Note: Unnecessary use of -X or --request, POST is already inferred. +* Trying 127.0.0.1:9000... +* Connected to 127.0.0.1 (127.0.0.1) port 9000 +> POST /users HTTP/1.1 +> Host: 127.0.0.1:9000 +> User-Agent: curl/8.4.0 +> Accept: */* +> Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MjA2MjEyNTYsImV4cCI6MTcyMDYyNDg1NiwiZW1haWwiOiJKb2UgVXNlciIsInBhc3N3b3JkIjoic2VjcmV0In0.CLi9Jc34GUOMuHuK7KDN2BUI2-vX6KI4yfnIN6ngm0E +> Content-Type: application/json +> Content-Length: 68 +> +< HTTP/1.1 200 OK +< content-length: 11 +< date: Wed, 10 Jul 2024 14:23:07 GMT +< +* Connection #0 to host 127.0.0.1 left intact +Item saved! + +% curl -v -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MjA2MjEyNTYsImV4cCI6MTcyMDYyNDg1NiwiZW1haWwiOiJKb2UgVXNlciIsInBhc3N3b3JkIjoic2VjcmV0In0.CLi9Jc34GUOMuHuK7KDN2BUI2-vX6KI4yfnIN6ngm0E" http://127.0.0.1:9000/users +* Trying 127.0.0.1:9000... +* Connected to 127.0.0.1 (127.0.0.1) port 9000 +> GET /users HTTP/1.1 +> Host: 127.0.0.1:9000 +> User-Agent: curl/8.4.0 +> Accept: */* +> Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MjA2MjEyNTYsImV4cCI6MTcyMDYyNDg1NiwiZW1haWwiOiJKb2UgVXNlciIsInBhc3N3b3JkIjoic2VjcmV0In0.CLi9Jc34GUOMuHuK7KDN2BUI2-vX6KI4yfnIN6ngm0E +> +< HTTP/1.1 200 OK +< content-length: 127 +< content-type: application/json +< date: Wed, 10 Jul 2024 14:23:51 GMT +< +* Connection #0 to host 127.0.0.1 left intact +{"data":[{ "_id": ObjectId("668e994c7eba267f28496f8a"), "firstname": "Joe", "lastname": "User", "email": "joe@example.org" },]} +``` ## Important Snippets @@ -13,98 +83,73 @@ The **GET** `/users` route searches MongoDB for all users and then returns a JSO ... -fn get_data_string(result: MongoResult) -> Result { +async fn get_data_string(result: mongodb::error::Result) -> Result, String> { match result { - Ok(doc) => Ok(Bson::Document(doc).to_json()), - Err(e) => Err(format!("{}", e)) + Ok(doc) => Ok(web::Json(doc)), + Err(e) => Err(format!("{}", e)), } } -router.get("/users", middleware! { |request, response| +async fn get_users() -> impl Responder { + let client_options = ClientOptions::parse("mongodb://localhost:27017").await.unwrap(); + let client = Client::with_options(client_options).unwrap(); - // Connect to the database - let client = Client::connect("localhost", 27017) - .ok().expect("Error establishing connection."); + let collection = client.database("rust-users").collection::("users"); + let mut cursor = collection.find(None, None).await.unwrap(); - // The users collection - let coll = client.db("rust-users").collection("users"); - - // Create cursor that finds all documents - let cursor = coll.find(None, None).unwrap(); - - // Opening for the JSON string to be returned let mut data_result = "{\"data\":[".to_owned(); - for (i, result) in cursor.enumerate() { - match get_data_string(result) { + while let Some(result) = cursor.try_next().await.unwrap() { + match get_data_string(Ok(result)).await { Ok(data) => { - let string_data = if i == 0 { - format!("{}", data) - } else { - format!("{},", data) - }; - + let string_data = format!("{},", data.into_inner()); data_result.push_str(&string_data); - }, - - Err(e) => return response.send(format!("{}", e)) + } + Err(e) => return HttpResponse::InternalServerError().body(e), } } - // Close the JSON string data_result.push_str("]}"); - - // Send back the result - format!("{}", data_result) - -}); + HttpResponse::Ok() + .content_type("application/json") + .body(data_result) +} ... ``` -The **POST** `/users/new` route takes JSON data and saves in the database. The data conforms to the `User` struct. +The **POST** `/users` route takes JSON data and saves in the database. The data conforms to the `User` struct. ```rust // src/main.rs ... -#[derive(RustcDecodable, RustcEncodable)] +#[derive(Serialize, Deserialize)] struct User { firstname: String, lastname: String, - email: String + email: String, } ... -router.post("/users/new", middleware! { |request, response| +async fn new_user(info: web::Json) -> impl Responder { + let client_options = ClientOptions::parse("mongodb://localhost:27017").await.unwrap(); + let client = Client::with_options(client_options).unwrap(); - // Accept a JSON string that corresponds to the User struct - let user = request.json_as::().unwrap(); - - let firstname = user.firstname.to_string(); - let lastname = user.lastname.to_string(); - let email = user.email.to_string(); - - // Connect to the database - let client = Client::connect("localhost", 27017) - .ok().expect("Error establishing connection."); - - // The users collection - let coll = client.db("rust-users").collection("users"); + let collection = client.database("rust-users").collection::("users"); + let user = doc! { + "firstname": &info.firstname, + "lastname": &info.lastname, + "email": &info.email, + }; - // Insert one user - match coll.insert_one(doc! { - "firstname" => firstname, - "lastname" => lastname, - "email" => email - }, None) { - Ok(_) => (StatusCode::Ok, "Item saved!"), - Err(e) => return response.send(format!("{}", e)) + match collection.insert_one(user, None).await { + Ok(_) => HttpResponse::Ok().body("Item saved!"), + Err(e) => HttpResponse::InternalServerError().body(format!("{}", e)), } - -}); +} ... ``` @@ -116,29 +161,20 @@ The **DELETE** `/users/:user_id` takes an `objectid` as a parameter, decodes it ... -router.delete("/users/:user_id", middleware! { |request, response| +async fn delete_user(req: HttpRequest) -> impl Responder { + let client_options = ClientOptions::parse("mongodb://localhost:27017").await.unwrap(); + let client = Client::with_options(client_options).unwrap(); - let client = Client::connect("localhost", 27017) - .ok().expect("Failed to initialize standalone client."); + let collection = client.database("rust-users").collection::("users"); + let object_id = req.match_info().get("id").unwrap(); - // The users collection - let coll = client.db("rust-users").collection("users"); + let id = ObjectId::parse_str(object_id).unwrap(); - // Get the user_id from the request params - let user_id = request.param("user_id").unwrap(); - - // Match the user id to an bson ObjectId - let id = match ObjectId::with_string(user_id) { - Ok(oid) => oid, - Err(e) => return response.send(format!("{}", e)) - }; - - match coll.delete_one(doc! {"_id" => id}, None) { - Ok(_) => (StatusCode::Ok, "Item deleted!"), - Err(e) => return response.send(format!("{}", e)) + match collection.delete_one(doc! {"_id": id}, None).await { + Ok(_) => HttpResponse::Ok().body("Item deleted!"), + Err(e) => HttpResponse::InternalServerError().body(format!("{}", e)), } - -}); +} ... ``` diff --git a/src/main.rs b/src/main.rs index 0bc5152..f9eb2fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,256 +1,207 @@ -#[macro_use] -extern crate nickel; -extern crate rustc_serialize; - -#[macro_use(bson, doc)] -extern crate bson; -extern crate mongodb; -extern crate hyper; -extern crate crypto; -extern crate jwt; - -// Nickel -use nickel::{Nickel, JsonBody, HttpRouter, Request, Response, MiddlewareResult, MediaType}; -use nickel::status::StatusCode::{self, Forbidden}; - -// MongoDB -use mongodb::{Client, ThreadedClient}; -use mongodb::db::ThreadedDatabase; -use mongodb::error::Result as MongoResult; - -// bson -use bson::{Bson, Document}; -use bson::oid::ObjectId; - -// rustc_serialize -use rustc_serialize::json::{Json, ToJson}; -use rustc_serialize::base64; -use rustc_serialize::base64::{FromBase64}; - -// hyper -use hyper::header; -use hyper::header::{Authorization, Bearer}; -use hyper::method::Method; - -// jwt -use std::default::Default; -use crypto::sha2::Sha256; -use jwt::{ - Header, - Registered, - Token, -}; - -#[derive(RustcDecodable, RustcEncodable)] +use actix_service::{Service, Transform}; +use actix_web::{dev::ServiceRequest, dev::ServiceResponse, web, App, Error, HttpServer, HttpRequest, HttpResponse, Responder, middleware::Logger, body::EitherBody}; +use futures_util::future::{ok, Ready}; +use futures_util::TryStreamExt; +use mongodb::{Client, options::ClientOptions, bson::{doc, oid::ObjectId, Document}}; +use serde::{Deserialize, Serialize}; +use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey}; +use std::pin::Pin; +use std::future::Future; +use std::boxed::Box; +use futures_util::TryFutureExt; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Serialize, Deserialize)] struct User { firstname: String, lastname: String, - email: String + email: String, } -static AUTH_SECRET: &'static str = "your_secret_key"; - -#[derive(RustcDecodable, RustcEncodable)] +#[derive(Serialize, Deserialize, Debug)] struct UserLogin { email: String, - password: String + password: String, } -fn get_data_string(result: MongoResult) -> Result { - match result { - Ok(doc) => Ok(Bson::Document(doc).to_json()), - Err(e) => Err(format!("{}", e)) - } +#[derive(Serialize, Deserialize, Debug)] +struct SessionJWT { + iat: u64, + exp: u64, + email: String, + password: String, } -fn authenticator<'mw>(request: &mut Request, response: Response<'mw>, ) -> MiddlewareResult<'mw> { - - // Check if we are getting an OPTIONS request - if request.origin.method.to_string() == "OPTIONS".to_string() { - - // The middleware shouldn't be used for OPTIONS, so continue - response.next_middleware() - - } else { +static AUTH_SECRET: &str = "your_secret_key"; +static JWT_EXPIRATION_SECS: u64 = 3600; - // We don't want to apply the middleware to the login route - if request.origin.uri.to_string() == "/login".to_string() { +async fn get_data_string(result: mongodb::error::Result) -> Result, String> { + match result { + Ok(doc) => Ok(web::Json(doc)), + Err(e) => Err(format!("{}", e)), + } +} - response.next_middleware() +struct Authenticator; + +impl Transform for Authenticator +where + S: Service, Error = Error> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Transform = AuthenticatorMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ok(AuthenticatorMiddleware { service }) + } +} - } else { +struct AuthenticatorMiddleware { + service: S, +} - // Get the full Authorization header from the incoming request headers - let auth_header = match request.origin.headers.get::>() { - Some(header) => header, - None => panic!("No authorization header found") +impl Service for AuthenticatorMiddleware +where + S: Service, Error = Error> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = Pin>>>; + + actix_service::forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let auth_header = match req.headers().get("Authorization") { + Some(header) => header.to_str().unwrap_or(""), + None => "", }; - // Format the header to only take the value - let jwt = header::HeaderFormatter(auth_header).to_string(); - - // We don't need the Bearer part, - // so get whatever is after an index of 7 - let jwt_slice = &jwt[7..]; - - // Parse the token - let token = Token::::parse(jwt_slice).unwrap(); - - // Get the secret key as bytes - let secret = AUTH_SECRET.as_bytes(); - - // Generic example - // Verify the token - if token.verify(&secret, Sha256::new()) { - - response.next_middleware() - + let jwt = if auth_header.starts_with("Bearer ") { + &auth_header[7..] } else { + "" + }; - response.error(Forbidden, "Access denied") - + if req.method() == "OPTIONS" || req.path() == "/login" { + return Box::pin(self.service.call(req).map_ok(|res| res.map_into_left_body())); } - } - } -} - -fn main() { - - let mut server = Nickel::new(); - let mut router = Nickel::router(); - - server.utilize(authenticator); - - router.post("/login", middleware! { |request| - - // Accept a JSON string that corresponds to the User struct - let user = request.json_as::().unwrap(); - - // Get the email and password - let email = user.email.to_string(); - let password = user.password.to_string(); - - // Simple password checker - if password == "secret".to_string() { - - let header: Header = Default::default(); - - // For the example, we just have one claim - // You would also want iss, exp, iat etc - let claims = Registered { - sub: Some(email.into()), - ..Default::default() - }; - - let token = Token::new(header, claims); - - // Sign the token - let jwt = token.signed(AUTH_SECRET.as_bytes(), Sha256::new()).unwrap(); - - format!("{}", jwt) + let token_data = decode::(&jwt, &DecodingKey::from_secret(AUTH_SECRET.as_ref()), &Validation::default()); + if let Ok(_) = token_data { + Box::pin(self.service.call(req).map_ok(|res| res.map_into_left_body())) } else { - format!("Incorrect username or password") + let res = req.into_response(HttpResponse::Forbidden().finish().map_into_right_body()); + Box::pin(async { Ok(res) }) } + } +} - }); +#[actix_web::main] +async fn main() -> std::io::Result<()> { + env_logger::init(); + + HttpServer::new(|| { + App::new() + .wrap(Logger::default()) + .wrap(Authenticator) + .service( + web::resource("/login") + .route(web::post().to(login)) + ) + .service( + web::resource("/users") + .route(web::get().to(get_users)) + .route(web::post().to(new_user)) + ) + .service( + web::resource("/users/{id}") + .route(web::delete().to(delete_user)) + ) + }) + .bind("127.0.0.1:9000")? + .run() + .await +} - router.get("/users", middleware! { |request, mut response| +async fn login(info: web::Json) -> impl Responder { + let email = info.email.clone(); + let password = info.password.clone(); - // Connect to the database - let client = Client::connect("localhost", 27017) - .ok().expect("Error establishing connection."); + if password == "secret" { + let start = SystemTime::now(); + let since_the_epoch = start + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); - // The users collection - let coll = client.db("rust-users").collection("users"); + let claims = SessionJWT { iat: since_the_epoch, exp: since_the_epoch+JWT_EXPIRATION_SECS, email, password }; + let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(AUTH_SECRET.as_ref())).unwrap(); - // Create cursor that finds all documents - let cursor = coll.find(None, None).unwrap(); + HttpResponse::Ok().body(token) + } else { + HttpResponse::BadRequest().body("Incorrect username or password") + } +} - // Opening for the JSON string to be returned - let mut data_result = "{\"data\":[".to_owned(); +async fn get_users() -> impl Responder { + let client_options = ClientOptions::parse("mongodb://localhost:27017").await.unwrap(); + let client = Client::with_options(client_options).unwrap(); - for (i, result) in cursor.enumerate() { - match get_data_string(result) { - Ok(data) => { - let string_data = if i == 0 { - format!("{}", data) - } else { - format!("{},", data) - }; + let collection = client.database("rust-users").collection::("users"); + let mut cursor = collection.find(None, None).await.unwrap(); - data_result.push_str(&string_data); - }, + let mut data_result = "{\"data\":[".to_owned(); - Err(e) => return response.send(format!("{}", e)) + while let Some(result) = cursor.try_next().await.unwrap() { + match get_data_string(Ok(result)).await { + Ok(data) => { + let string_data = format!("{},", data.into_inner()); + data_result.push_str(&string_data); } + Err(e) => return HttpResponse::InternalServerError().body(e), } + } - // Close the JSON string - data_result.push_str("]}"); - - // Set the returned type as JSON - response.set(MediaType::Json); - - // Send back the result - format!("{}", data_result) - - }); - - router.post("/users/new", middleware! { |request, response| - - // Accept a JSON string that corresponds to the User struct - let user = request.json_as::().unwrap(); - - let firstname = user.firstname.to_string(); - let lastname = user.lastname.to_string(); - let email = user.email.to_string(); - - // Connect to the database - let client = Client::connect("localhost", 27017) - .ok().expect("Error establishing connection."); - - // The users collection - let coll = client.db("rust-users").collection("users"); - - // Insert one user - match coll.insert_one(doc! { - "firstname" => firstname, - "lastname" => lastname, - "email" => email - }, None) { - Ok(_) => (StatusCode::Ok, "Item saved!"), - Err(e) => return response.send(format!("{}", e)) - } - - }); - - router.delete("/users/:id", middleware! { |request, response| - - let client = Client::connect("localhost", 27017) - .ok().expect("Failed to initialize standalone client."); + data_result.push_str("]}"); + HttpResponse::Ok() + .content_type("application/json") + .body(data_result) +} - // The users collection - let coll = client.db("rust-users").collection("users"); +async fn new_user(info: web::Json) -> impl Responder { + let client_options = ClientOptions::parse("mongodb://localhost:27017").await.unwrap(); + let client = Client::with_options(client_options).unwrap(); - // Get the user_id from the request params - let object_id = request.param("id").unwrap(); + let collection = client.database("rust-users").collection::("users"); + let user = doc! { + "firstname": &info.firstname, + "lastname": &info.lastname, + "email": &info.email, + }; - // Match the user id to an bson ObjectId - let id = match ObjectId::with_string(object_id) { - Ok(oid) => oid, - Err(e) => return response.send(format!("{}", e)) - }; + match collection.insert_one(user, None).await { + Ok(_) => HttpResponse::Ok().body("Item saved!"), + Err(e) => HttpResponse::InternalServerError().body(format!("{}", e)), + } +} - match coll.delete_one(doc! {"_id" => id}, None) { - Ok(_) => (StatusCode::Ok, "Item deleted!"), - Err(e) => return response.send(format!("{}", e)) - } +async fn delete_user(req: HttpRequest) -> impl Responder { + let client_options = ClientOptions::parse("mongodb://localhost:27017").await.unwrap(); + let client = Client::with_options(client_options).unwrap(); - }); + let collection = client.database("rust-users").collection::("users"); + let object_id = req.match_info().get("id").unwrap(); - server.utilize(router); + let id = ObjectId::parse_str(object_id).unwrap(); - server.listen("127.0.0.1:9000"); -} \ No newline at end of file + match collection.delete_one(doc! {"_id": id}, None).await { + Ok(_) => HttpResponse::Ok().body("Item deleted!"), + Err(e) => HttpResponse::InternalServerError().body(format!("{}", e)), + } +}