diff --git a/.gitignore b/.gitignore index b88ba06..4a903f9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +# intellij files +.idea/** diff --git a/.travis.yml b/.travis.yml index c1037c9..66f91d8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -55,6 +55,7 @@ script: cd protobuf && cargo check && cd .. cd r2d2 && cargo check && cd .. cd redis-session && cargo check && cd .. + cd simple-auth-sarver && cargo check && cd .. cd state && cargo check && cd .. cd static_index && cargo check && cd .. cd template_askama && cargo check && cd .. diff --git a/Cargo.toml b/Cargo.toml index d816091..ae0b605 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "protobuf", "r2d2", "redis-session", + "simple-auth-server", "state", "static_index", "template_askama", diff --git a/simple-auth-server/Cargo.toml b/simple-auth-server/Cargo.toml new file mode 100644 index 0000000..25ba940 --- /dev/null +++ b/simple-auth-server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "simple-auth-server" +version = "0.1.0" +authors = ["mygnu "] + +[dependencies] +actix = "0.7.7" +actix-web = "0.7.14" +bcrypt = "0.2.1" +chrono = { version = "0.4.6", features = ["serde"] } +diesel = { version = "1.3.3", features = ["postgres", "uuid", "r2d2", "chrono"] } +dotenv = "0.13.0" +env_logger = "0.6.0" +failure = "0.1.3" +jsonwebtoken = "5.0" +futures = "0.1" +r2d2 = "0.8.3" +serde_derive="1.0.80" +serde_json="1.0" +serde="1.0" +sparkpost = "0.5.2" +uuid = { version = "0.6.5", features = ["serde", "v4"] } diff --git a/simple-auth-server/README.md b/simple-auth-server/README.md new file mode 100644 index 0000000..d86be85 --- /dev/null +++ b/simple-auth-server/README.md @@ -0,0 +1,32 @@ +##### Flow of the event would look like this: + +- Registers with email address ➡ Receive an 📨 with a link to verify +- Follow the link ➡ register with same email and a password +- Login with email and password ➡ Get verified and receive jwt token + +##### Crates we are going to use + +- [actix](https://crates.io/crates/actix) // Actix is a Rust actors framework. +- [actix-web](https://crates.io/crates/actix-web) // Actix web is a simple, pragmatic and extremely fast web framework for Rust. +- [brcypt](https://crates.io/crates/bcrypt) // Easily hash and verify passwords using bcrypt. +- [chrono](https://crates.io/crates/chrono) // Date and time library for Rust. +- [diesel](https://crates.io/crates/diesel) // A safe, extensible ORM and Query Builder for PostgreSQL, SQLite, and MySQL. +- [dotenv](https://crates.io/crates/dotenv) // A dotenv implementation for Rust. +- [env_logger](https://crates.io/crates/env_logger) // A logging implementation for log which is configured via an environment variable. +- [failure](https://crates.io/crates/failure) // Experimental error handling abstraction. +- [jsonwebtoken](https://crates.io/crates/jsonwebtoken) // Create and parse JWT in a strongly typed way. +- [futures](https://crates.io/crates/futures) // An implementation of futures and streams featuring zero allocations, composability, and iterator-like interfaces. +- [r2d2](https://crates.io/crates/r2d2) // A generic connection pool. +- [serde](https://crates.io/crates/serde) // A generic serialization/deserialization framework. +- [serde_json](https://crates.io/crates/serde_json) // A JSON serialization file format. +- [serde_derive](https://crates.io/crates/serde_derive) // Macros 1.1 implementation of #[derive(Serialize, Deserialize)]. +- [sparkpost](https://crates.io/crates/sparkpost) // Rust bindings for sparkpost email api v1. +- [uuid](https://crates.io/crates/uuid) // A library to generate and parse UUIDs. + + +Read the full tutorial series on [hgill.io](https://hgill.io) + +- [Auth Web Microservice with rust using Actix-Web - Complete Tutorial Part 1](https://hgill.io/posts/auth-microservice-rust-actix-web-diesel-complete-tutorial-part-1/) +- [Auth Web Microservice with rust using Actix-Web - Complete Tutorial Part 2](https://hgill.io/posts/auth-microservice-rust-actix-web-diesel-complete-tutorial-part-2/) +- [Auth Web Microservice with rust using Actix-Web - Complete Tutorial Part 3](https://hgill.io/posts/auth-microservice-rust-actix-web-diesel-complete-tutorial-part-3/) + diff --git a/simple-auth-server/diesel.toml b/simple-auth-server/diesel.toml new file mode 100644 index 0000000..92267c8 --- /dev/null +++ b/simple-auth-server/diesel.toml @@ -0,0 +1,5 @@ +# For documentation on how to configure this file, +# see diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" diff --git a/simple-auth-server/migrations/.gitkeep b/simple-auth-server/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/simple-auth-server/migrations/00000000000000_diesel_initial_setup/down.sql b/simple-auth-server/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/simple-auth-server/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/simple-auth-server/migrations/00000000000000_diesel_initial_setup/up.sql b/simple-auth-server/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/simple-auth-server/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/simple-auth-server/migrations/2018-10-09-101948_users/down.sql b/simple-auth-server/migrations/2018-10-09-101948_users/down.sql new file mode 100644 index 0000000..dc3714b --- /dev/null +++ b/simple-auth-server/migrations/2018-10-09-101948_users/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE users; diff --git a/simple-auth-server/migrations/2018-10-09-101948_users/up.sql b/simple-auth-server/migrations/2018-10-09-101948_users/up.sql new file mode 100644 index 0000000..890b667 --- /dev/null +++ b/simple-auth-server/migrations/2018-10-09-101948_users/up.sql @@ -0,0 +1,6 @@ +-- Your SQL goes here +CREATE TABLE users ( + email VARCHAR(100) NOT NULL UNIQUE PRIMARY KEY, + password VARCHAR(64) NOT NULL, --bcrypt hash + created_at TIMESTAMP NOT NULL +); diff --git a/simple-auth-server/migrations/2018-10-16-095633_invitations/down.sql b/simple-auth-server/migrations/2018-10-16-095633_invitations/down.sql new file mode 100644 index 0000000..26f575a --- /dev/null +++ b/simple-auth-server/migrations/2018-10-16-095633_invitations/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE invitations; diff --git a/simple-auth-server/migrations/2018-10-16-095633_invitations/up.sql b/simple-auth-server/migrations/2018-10-16-095633_invitations/up.sql new file mode 100644 index 0000000..b528f06 --- /dev/null +++ b/simple-auth-server/migrations/2018-10-16-095633_invitations/up.sql @@ -0,0 +1,6 @@ +-- Your SQL goes here +CREATE TABLE invitations ( + id UUID NOT NULL UNIQUE PRIMARY KEY, + email VARCHAR(100) NOT NULL, + expires_at TIMESTAMP NOT NULL +); diff --git a/simple-auth-server/src/app.rs b/simple-auth-server/src/app.rs new file mode 100644 index 0000000..f56be1a --- /dev/null +++ b/simple-auth-server/src/app.rs @@ -0,0 +1,54 @@ +use actix::prelude::*; +use actix_web::middleware::identity::{CookieIdentityPolicy, IdentityService}; +use actix_web::{fs, http::Method, middleware::Logger, App}; +use auth_routes::{get_me, login, logout}; +use chrono::Duration; +use invitation_routes::register_email; +use models::DbExecutor; +use register_routes::register_user; + +pub struct AppState { + pub db: Addr, +} + +/// creates and returns the app after mounting all routes/resources +pub fn create_app(db: Addr) -> App { + // secret is a random minimum 32 bytes long base 64 string + let secret: String = std::env::var("SECRET_KEY").unwrap_or_else(|_| "0123".repeat(8)); + let domain: String = std::env::var("DOMAIN").unwrap_or_else(|_| "localhost".to_string()); + + App::with_state(AppState { db }) + .middleware(Logger::default()) + .middleware(IdentityService::new( + CookieIdentityPolicy::new(secret.as_bytes()) + .name("auth") + .path("/") + .domain(domain.as_str()) + .max_age(Duration::days(1)) + .secure(false), // this can only be true if you have https + )) + // everything under '/api/' route + .scope("/api", |api| { + // routes for authentication + api.resource("/auth", |r| { + r.method(Method::POST).with(login); + r.method(Method::DELETE).with(logout); + r.method(Method::GET).with(get_me); + }) + // routes to invitation + .resource("/invitation", |r| { + r.method(Method::POST).with(register_email); + }) + // routes to register as a user after the + .resource("/register/{invitation_id}", |r| { + r.method(Method::POST).with(register_user); + }) + }) + // serve static files + .handler( + "/", + fs::StaticFiles::new("./static/") + .unwrap() + .index_file("index.html"), + ) +} diff --git a/simple-auth-server/src/auth_handler.rs b/simple-auth-server/src/auth_handler.rs new file mode 100644 index 0000000..cedfa90 --- /dev/null +++ b/simple-auth-server/src/auth_handler.rs @@ -0,0 +1,55 @@ +use actix::{Handler, Message}; +use diesel::prelude::*; +use errors::ServiceError; +use models::{DbExecutor, User, SlimUser}; +use bcrypt::verify; +use actix_web::{FromRequest, HttpRequest, middleware::identity::RequestIdentity}; +use utils::decode_token; + +#[derive(Debug, Deserialize)] +pub struct AuthData { + pub email: String, + pub password: String, +} + +impl Message for AuthData { + type Result = Result; +} + +impl Handler for DbExecutor { + type Result = Result; + fn handle(&mut self, msg: AuthData, _: &mut Self::Context) -> Self::Result { + use schema::users::dsl::{users, email}; + let conn: &PgConnection = &self.0.get().unwrap(); + + let mut items = users + .filter(email.eq(&msg.email)) + .load::(conn)?; + + if let Some(user) = items.pop() { + match verify(&msg.password, &user.password) { + Ok(matching) => if matching { + return Ok(user.into()); + }, + Err(_) => (), + } + } + Err(ServiceError::BadRequest("Username and Password don't match".into())) + } +} + +// we need the same data +// simple aliasing makes the intentions clear and its more readable +pub type LoggedUser = SlimUser; + +impl FromRequest for LoggedUser { + type Config = (); + type Result = Result; + fn from_request(req: &HttpRequest, _: &Self::Config) -> Self::Result { + if let Some(identity) = req.identity() { + let user: SlimUser = decode_token(&identity)?; + return Ok(user as LoggedUser); + } + Err(ServiceError::Unauthorized) + } +} diff --git a/simple-auth-server/src/auth_routes.rs b/simple-auth-server/src/auth_routes.rs new file mode 100644 index 0000000..48ee15c --- /dev/null +++ b/simple-auth-server/src/auth_routes.rs @@ -0,0 +1,32 @@ +use actix_web::{AsyncResponder, FutureResponse, HttpResponse, HttpRequest, ResponseError, Json}; +use actix_web::middleware::identity::RequestIdentity; +use futures::future::Future; +use utils::create_token; + +use app::AppState; +use auth_handler::{AuthData, LoggedUser}; + +pub fn login((auth_data, req): (Json, HttpRequest)) + -> FutureResponse { + req.state() + .db + .send(auth_data.into_inner()) + .from_err() + .and_then(move |res| match res { + Ok(user) => { + let token = create_token(&user)?; + req.remember(token); + Ok(HttpResponse::Ok().into()) + } + Err(err) => Ok(err.error_response()), + }).responder() +} + +pub fn logout(req: HttpRequest) -> HttpResponse { + req.forget(); + HttpResponse::Ok().into() +} + +pub fn get_me(logged_user: LoggedUser) -> HttpResponse { + HttpResponse::Ok().json(logged_user) +} diff --git a/simple-auth-server/src/email_service.rs b/simple-auth-server/src/email_service.rs new file mode 100644 index 0000000..f1db117 --- /dev/null +++ b/simple-auth-server/src/email_service.rs @@ -0,0 +1,68 @@ +use models::Invitation; +use sparkpost::transmission::{ + EmailAddress, Message, Options, Recipient, Transmission, TransmissionResponse, +}; + +fn get_api_key() -> String { + std::env::var("SPARKPOST_API_KEY").expect("SPARKPOST_API_KEY must be set") +} + +pub fn send_invitation(invitation: &Invitation) { + let tm = Transmission::new_eu(get_api_key()); + let sending_email = + std::env::var("SENDING_EMAIL_ADDRESS").expect("SENDING_EMAIL_ADDRESS must be set"); + // new email message with sender name and email + let mut email = Message::new(EmailAddress::new(sending_email, "Let's Organise")); + + let options = Options { + open_tracking: false, + click_tracking: false, + transactional: true, + sandbox: false, + inline_css: false, + start_time: None, + }; + + // recipient from the invitation email + let recipient: Recipient = invitation.email.as_str().into(); + + let email_body = format!( + "Please click on the link below to complete registration.
+ + http://localhost:3030/register
+ your Invitation expires on {}", + invitation.id, + invitation.email, + invitation + .expires_at + .format("%I:%M %p %A, %-d %B, %C%y") + .to_string() + ); + + + // complete the email message with details + email + .add_recipient(recipient) + .options(options) + .subject("You have been invited to join Simple-Auth-Server Rust") + .html(email_body); + + let result = tm.send(&email); + + // Note that we only print out the error response from email api + match result { + Ok(res) => { + match res { + TransmissionResponse::ApiResponse(api_res) => { + println!("API Response: \n {:#?}", api_res); + } + TransmissionResponse::ApiError(errors) => { + println!("Response Errors: \n {:#?}", &errors); + } + } + } + Err(error) => { + println!("error \n {:#?}", error); + } + } +} diff --git a/simple-auth-server/src/errors.rs b/simple-auth-server/src/errors.rs new file mode 100644 index 0000000..b65aa4f --- /dev/null +++ b/simple-auth-server/src/errors.rs @@ -0,0 +1,53 @@ +use actix_web::{error::ResponseError, HttpResponse}; +use std::convert::From; +use diesel::result::{DatabaseErrorKind, Error}; +use uuid::ParseError; + + +#[derive(Fail, Debug)] +pub enum ServiceError { + #[fail(display = "Internal Server Error")] + InternalServerError, + + #[fail(display = "BadRequest: {}", _0)] + BadRequest(String), + + #[fail(display = "Unauthorized")] + Unauthorized, +} + +// impl ResponseError trait allows to convert our errors into http responses with appropriate data +impl ResponseError for ServiceError { + fn error_response(&self) -> HttpResponse { + match *self { + ServiceError::InternalServerError => HttpResponse::InternalServerError().json("Internal Server Error, Please try later"), + ServiceError::BadRequest(ref message) => HttpResponse::BadRequest().json(message), + ServiceError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized") + } + } +} + +// we can return early in our handlers if UUID provided by the user is not valid +// and provide a custom message +impl From for ServiceError { + fn from(_: ParseError) -> ServiceError { + ServiceError::BadRequest("Invalid UUID".into()) + } +} + +impl From for ServiceError { + fn from(error: Error) -> ServiceError { + // Right now we just care about UniqueViolation from diesel + // But this would be helpful to easily map errors as our app grows + match error { + Error::DatabaseError(kind, info) => { + if let DatabaseErrorKind::UniqueViolation = kind { + let message = info.details().unwrap_or_else(|| info.message()).to_string(); + return ServiceError::BadRequest(message); + } + ServiceError::InternalServerError + } + _ => ServiceError::InternalServerError + } + } +} diff --git a/simple-auth-server/src/invitation_handler.rs b/simple-auth-server/src/invitation_handler.rs new file mode 100644 index 0000000..425e4c0 --- /dev/null +++ b/simple-auth-server/src/invitation_handler.rs @@ -0,0 +1,39 @@ +use actix::{Handler, Message}; +use chrono::{Duration, Local}; +use diesel::{self, prelude::*}; +use errors::ServiceError; +use models::{DbExecutor, Invitation}; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct CreateInvitation { + pub email: String, +} + +impl Message for CreateInvitation { + type Result = Result; +} + +impl Handler for DbExecutor { + type Result = Result; + + fn handle(&mut self, msg: CreateInvitation, _: &mut Self::Context) -> Self::Result { + use schema::invitations::dsl::*; + let conn: &PgConnection = &self.0.get().unwrap(); + + // creating a new Invitation object with expired at time that is 24 hours from now + let new_invitation = Invitation { + id: Uuid::new_v4(), + email: msg.email.clone(), + expires_at: Local::now().naive_local() + Duration::hours(24), + }; + + let inserted_invitation = diesel::insert_into(invitations) + .values(&new_invitation) + .get_result(conn)?; + + Ok(inserted_invitation) + } +} + + diff --git a/simple-auth-server/src/invitation_routes.rs b/simple-auth-server/src/invitation_routes.rs new file mode 100644 index 0000000..9232c6d --- /dev/null +++ b/simple-auth-server/src/invitation_routes.rs @@ -0,0 +1,22 @@ +use actix_web::{AsyncResponder, FutureResponse, HttpResponse, Json, ResponseError, State}; +use futures::future::Future; + +use app::AppState; +use email_service::send_invitation; +use invitation_handler::CreateInvitation; + +pub fn register_email( + (signup_invitation, state): (Json, State), +) -> FutureResponse { + state + .db + .send(signup_invitation.into_inner()) + .from_err() + .and_then(|db_response| match db_response { + Ok(invitation) => { + send_invitation(&invitation); + Ok(HttpResponse::Ok().into()) + } + Err(err) => Ok(err.error_response()), + }).responder() +} diff --git a/simple-auth-server/src/main.rs b/simple-auth-server/src/main.rs new file mode 100644 index 0000000..f5d096b --- /dev/null +++ b/simple-auth-server/src/main.rs @@ -0,0 +1,66 @@ +// to avoid the warning from diesel macros +#![allow(proc_macro_derive_resolution_fallback)] + +extern crate bcrypt; +extern crate actix; +extern crate actix_web; +extern crate env_logger; +extern crate serde; +extern crate chrono; +extern crate dotenv; +extern crate futures; +extern crate r2d2; +extern crate uuid; +extern crate jsonwebtoken as jwt; +extern crate sparkpost; +#[macro_use] +extern crate diesel; +#[macro_use] +extern crate serde_derive; +#[macro_use] +extern crate failure; + +mod app; +mod models; +mod schema; +mod errors; +mod auth_handler; +mod auth_routes; +mod invitation_handler; +mod invitation_routes; +mod register_handler; +mod register_routes; +mod utils; +mod email_service; + +use models::DbExecutor; +use actix::prelude::*; +use actix_web::server; +use diesel::{r2d2::ConnectionManager, PgConnection}; +use dotenv::dotenv; +use std::env; + + +fn main() { + dotenv().ok(); + std::env::set_var("RUST_LOG", "simple-auth-server=debug,actix_web=info"); + std::env::set_var("RUST_BACKTRACE", "1"); + env_logger::init(); + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let sys = actix::System::new("Actix_Tutorial"); + + // create db connection pool + let manager = ConnectionManager::::new(database_url); + let pool = r2d2::Pool::builder() + .build(manager) + .expect("Failed to create pool."); + + let address: Addr = SyncArbiter::start(4, move || DbExecutor(pool.clone())); + + server::new(move || app::create_app(address.clone())) + .bind("127.0.0.1:3000") + .expect("Can not bind to '127.0.0.1:3000'") + .start(); + + sys.run(); +} diff --git a/simple-auth-server/src/models.rs b/simple-auth-server/src/models.rs new file mode 100644 index 0000000..fcafb9c --- /dev/null +++ b/simple-auth-server/src/models.rs @@ -0,0 +1,58 @@ +use actix::{Actor, SyncContext}; +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, Pool}; +use chrono::{NaiveDateTime, Local}; +use uuid::Uuid; +use std::convert::From; + +use schema::{users, invitations}; + +/// This is db executor actor. can be run in parallel +pub struct DbExecutor(pub Pool>); + +// Actors communicate exclusively by exchanging messages. +// The sending actor can optionally wait for the response. +// Actors are not referenced directly, but by means of addresses. +// Any rust type can be an actor, it only needs to implement the Actor trait. +impl Actor for DbExecutor { + type Context = SyncContext; +} + +#[derive(Debug, Serialize, Deserialize, Queryable, Insertable)] +#[table_name = "users"] +pub struct User { + pub email: String, + pub password: String, + pub created_at: NaiveDateTime, +} + +impl User { + pub fn with_details(email: String, password: String) -> Self { + User { + email, + password, + created_at: Local::now().naive_local(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Queryable, Insertable)] +#[table_name = "invitations"] +pub struct Invitation { + pub id: Uuid, + pub email: String, + pub expires_at: NaiveDateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SlimUser { + pub email: String, +} + +impl From for SlimUser { + fn from(user: User) -> Self { + SlimUser { + email: user.email + } + } +} diff --git a/simple-auth-server/src/register_handler.rs b/simple-auth-server/src/register_handler.rs new file mode 100644 index 0000000..7cf98fd --- /dev/null +++ b/simple-auth-server/src/register_handler.rs @@ -0,0 +1,60 @@ +use actix::{Handler, Message}; +use chrono::Local; +use diesel::prelude::*; +use errors::ServiceError; +use models::{DbExecutor, Invitation, User, SlimUser}; +use uuid::Uuid; +use utils::hash_password; + +// UserData is used to extract data from a post request by the client +#[derive(Debug, Deserialize)] +pub struct UserData { + pub password: String, +} + +// to be used to send data via the Actix actor system +#[derive(Debug)] +pub struct RegisterUser { + pub invitation_id: String, + pub password: String, +} + +impl Message for RegisterUser { + type Result = Result; +} + + +impl Handler for DbExecutor { + type Result = Result; + fn handle(&mut self, msg: RegisterUser, _: &mut Self::Context) -> Self::Result { + use schema::invitations::dsl::{invitations, id}; + use schema::users::dsl::users; + let conn: &PgConnection = &self.0.get().unwrap(); + + // try parsing the string provided by the user as url parameter + // return early with error that will be converted to ServiceError + let invitation_id = Uuid::parse_str(&msg.invitation_id)?; + + invitations.filter(id.eq(invitation_id)) + .load::(conn) + .map_err(|_db_error| ServiceError::BadRequest("Invalid Invitation".into())) + .and_then(|mut result| { + if let Some(invitation) = result.pop() { + // if invitation is not expired + if invitation.expires_at > Local::now().naive_local() { + // try hashing the password, else return the error that will be converted to ServiceError + let password: String = hash_password(&msg.password)?; + let user = User::with_details(invitation.email, password); + let inserted_user: User = diesel::insert_into(users) + .values(&user) + .get_result(conn)?; + + return Ok(inserted_user.into()); + } + } + Err(ServiceError::BadRequest("Invalid Invitation".into())) + }) + } +} + + diff --git a/simple-auth-server/src/register_routes.rs b/simple-auth-server/src/register_routes.rs new file mode 100644 index 0000000..5924ba5 --- /dev/null +++ b/simple-auth-server/src/register_routes.rs @@ -0,0 +1,22 @@ +use actix_web::{AsyncResponder, FutureResponse, HttpResponse, ResponseError, State, Json, Path}; +use futures::future::Future; + +use app::AppState; +use register_handler::{RegisterUser, UserData}; + + +pub fn register_user((invitation_id, user_data, state): (Path, Json, State)) + -> FutureResponse { + let msg = RegisterUser { + // into_inner() returns the inner string value from Path + invitation_id: invitation_id.into_inner(), + password: user_data.password.clone(), + }; + + state.db.send(msg) + .from_err() + .and_then(|db_response| match db_response { + Ok(slim_user) => Ok(HttpResponse::Ok().json(slim_user)), + Err(service_error) => Ok(service_error.error_response()), + }).responder() +} diff --git a/simple-auth-server/src/schema.rs b/simple-auth-server/src/schema.rs new file mode 100644 index 0000000..646632d --- /dev/null +++ b/simple-auth-server/src/schema.rs @@ -0,0 +1,20 @@ +table! { + invitations (id) { + id -> Uuid, + email -> Varchar, + expires_at -> Timestamp, + } +} + +table! { + users (email) { + email -> Varchar, + password -> Varchar, + created_at -> Timestamp, + } +} + +allow_tables_to_appear_in_same_query!( + invitations, + users, +); diff --git a/simple-auth-server/src/utils.rs b/simple-auth-server/src/utils.rs new file mode 100644 index 0000000..1f65d3e --- /dev/null +++ b/simple-auth-server/src/utils.rs @@ -0,0 +1,68 @@ +use bcrypt::{hash, DEFAULT_COST}; +use chrono::{Duration, Local}; +use errors::ServiceError; +use jwt::{decode, encode, Header, Validation}; +use models::SlimUser; +use std::convert::From; +use std::env; + +pub fn hash_password(plain: &str) -> Result { + // get the hashing cost from the env variable or use default + let hashing_cost: u32 = match env::var("HASH_ROUNDS") { + Ok(cost) => cost.parse().unwrap_or(DEFAULT_COST), + _ => DEFAULT_COST, + }; + println!("{}", &hashing_cost); + hash(plain, hashing_cost).map_err(|_| ServiceError::InternalServerError) +} + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + // issuer + iss: String, + // subject + sub: String, + //issued at + iat: i64, + // expiry + exp: i64, + // user email + email: String, +} + +// struct to get converted to token and back +impl Claims { + fn with_email(email: &str) -> Self { + Claims { + iss: "localhost".into(), + sub: "auth".into(), + email: email.to_owned(), + iat: Local::now().timestamp(), + exp: (Local::now() + Duration::hours(24)).timestamp(), + } + } +} + +impl From for SlimUser { + fn from(claims: Claims) -> Self { + SlimUser { + email: claims.email, + } + } +} + +pub fn create_token(data: &SlimUser) -> Result { + let claims = Claims::with_email(data.email.as_str()); + encode(&Header::default(), &claims, get_secret().as_ref()) + .map_err(|_err| ServiceError::InternalServerError) +} + +pub fn decode_token(token: &str) -> Result { + decode::(token, get_secret().as_ref(), &Validation::default()) + .map(|data| Ok(data.claims.into())) + .map_err(|_err| ServiceError::Unauthorized)? +} + +fn get_secret() -> String { + env::var("JWT_SECRET").unwrap_or_else(|_| "my secret".into()) +} diff --git a/simple-auth-server/static/index.html b/simple-auth-server/static/index.html new file mode 100644 index 0000000..6bd45cc --- /dev/null +++ b/simple-auth-server/static/index.html @@ -0,0 +1,31 @@ + + + + + + Actix Web - Auth App + + + + + + + + + diff --git a/simple-auth-server/static/main.css b/simple-auth-server/static/main.css new file mode 100644 index 0000000..abcda1d --- /dev/null +++ b/simple-auth-server/static/main.css @@ -0,0 +1,37 @@ +/* CSSTerm.com Easy CSS login form */ + +.login { + width:600px; + margin:auto; + border:1px #CCC solid; + padding:0px 30px; + background-color: #3b6caf; + color:#FFF; +} + +.field { + background: #1e4f8a; + border:1px #03306b solid; + padding:10px; + margin:5px 25px; + width:215px; + color:#FFF; + } + +.login h1, p, .chbox, .btn { + margin-left:25px; + color:#fff; +} + +.btn { + background-color: #00CCFF; + border:1px #03306b solid; + padding:10px 30px; + font-weight:bold; + margin:25px 25px; + cursor: pointer; +} + +.forgot { + color:#fff; +} diff --git a/simple-auth-server/static/main.js b/simple-auth-server/static/main.js new file mode 100644 index 0000000..b245cb3 --- /dev/null +++ b/simple-auth-server/static/main.js @@ -0,0 +1,19 @@ +function post(url = ``, data = {}) { + // Default options are marked with * + return fetch(url, { + method: 'POST', // *GET, POST, PUT, DELETE, etc. + mode: 'cors', // no-cors, cors, *same-origin + cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + redirect: 'follow', // manual, *follow, error + referrer: 'no-referrer', // no-referrer, *client + body: JSON.stringify(data), // body data type must match "Content-Type" header + }).then(response => response.json()); // parses response to JSON +} + +// window.addEventListener('load', function() { +// console.log('All assets are loaded'); +// console.log(getUrlVars()); +// }); diff --git a/simple-auth-server/static/register.html b/simple-auth-server/static/register.html new file mode 100644 index 0000000..a6869af --- /dev/null +++ b/simple-auth-server/static/register.html @@ -0,0 +1,44 @@ + + + + + + Actix Web - Auth App + + + + + + + + +