mirror of
https://github.com/actix/examples
synced 2024-11-30 09:12:54 +01:00
simple-auth-server added to examples (#65)
This commit is contained in:
parent
5871328be2
commit
2fc76ac49b
3
.gitignore
vendored
3
.gitignore
vendored
@ -10,3 +10,6 @@ Cargo.lock
|
|||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# intellij files
|
||||||
|
.idea/**
|
||||||
|
@ -55,6 +55,7 @@ script:
|
|||||||
cd protobuf && cargo check && cd ..
|
cd protobuf && cargo check && cd ..
|
||||||
cd r2d2 && cargo check && cd ..
|
cd r2d2 && cargo check && cd ..
|
||||||
cd redis-session && cargo check && cd ..
|
cd redis-session && cargo check && cd ..
|
||||||
|
cd simple-auth-sarver && cargo check && cd ..
|
||||||
cd state && cargo check && cd ..
|
cd state && cargo check && cd ..
|
||||||
cd static_index && cargo check && cd ..
|
cd static_index && cargo check && cd ..
|
||||||
cd template_askama && cargo check && cd ..
|
cd template_askama && cargo check && cd ..
|
||||||
|
@ -22,6 +22,7 @@ members = [
|
|||||||
"protobuf",
|
"protobuf",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"redis-session",
|
"redis-session",
|
||||||
|
"simple-auth-server",
|
||||||
"state",
|
"state",
|
||||||
"static_index",
|
"static_index",
|
||||||
"template_askama",
|
"template_askama",
|
||||||
|
22
simple-auth-server/Cargo.toml
Normal file
22
simple-auth-server/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "simple-auth-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["mygnu <tech@hgill.io>"]
|
||||||
|
|
||||||
|
[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"] }
|
32
simple-auth-server/README.md
Normal file
32
simple-auth-server/README.md
Normal file
@ -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/)
|
||||||
|
|
5
simple-auth-server/diesel.toml
Normal file
5
simple-auth-server/diesel.toml
Normal file
@ -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"
|
0
simple-auth-server/migrations/.gitkeep
Normal file
0
simple-auth-server/migrations/.gitkeep
Normal file
@ -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();
|
@ -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;
|
@ -0,0 +1,2 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
DROP TABLE users;
|
@ -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
|
||||||
|
);
|
@ -0,0 +1,2 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
DROP TABLE invitations;
|
@ -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
|
||||||
|
);
|
54
simple-auth-server/src/app.rs
Normal file
54
simple-auth-server/src/app.rs
Normal file
@ -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<DbExecutor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// creates and returns the app after mounting all routes/resources
|
||||||
|
pub fn create_app(db: Addr<DbExecutor>) -> App<AppState> {
|
||||||
|
// 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"),
|
||||||
|
)
|
||||||
|
}
|
55
simple-auth-server/src/auth_handler.rs
Normal file
55
simple-auth-server/src/auth_handler.rs
Normal file
@ -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<SlimUser, ServiceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler<AuthData> for DbExecutor {
|
||||||
|
type Result = Result<SlimUser, ServiceError>;
|
||||||
|
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::<User>(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<S> FromRequest<S> for LoggedUser {
|
||||||
|
type Config = ();
|
||||||
|
type Result = Result<LoggedUser, ServiceError>;
|
||||||
|
fn from_request(req: &HttpRequest<S>, _: &Self::Config) -> Self::Result {
|
||||||
|
if let Some(identity) = req.identity() {
|
||||||
|
let user: SlimUser = decode_token(&identity)?;
|
||||||
|
return Ok(user as LoggedUser);
|
||||||
|
}
|
||||||
|
Err(ServiceError::Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
32
simple-auth-server/src/auth_routes.rs
Normal file
32
simple-auth-server/src/auth_routes.rs
Normal file
@ -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<AuthData>, HttpRequest<AppState>))
|
||||||
|
-> FutureResponse<HttpResponse> {
|
||||||
|
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<AppState>) -> HttpResponse {
|
||||||
|
req.forget();
|
||||||
|
HttpResponse::Ok().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_me(logged_user: LoggedUser) -> HttpResponse {
|
||||||
|
HttpResponse::Ok().json(logged_user)
|
||||||
|
}
|
68
simple-auth-server/src/email_service.rs
Normal file
68
simple-auth-server/src/email_service.rs
Normal file
@ -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. <br/>
|
||||||
|
<a href=\"http://localhost:3000/register.html?id={}&email={}\">
|
||||||
|
http://localhost:3030/register</a> <br>
|
||||||
|
your Invitation expires on <strong>{}</strong>",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
53
simple-auth-server/src/errors.rs
Normal file
53
simple-auth-server/src/errors.rs
Normal file
@ -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<ParseError> for ServiceError {
|
||||||
|
fn from(_: ParseError) -> ServiceError {
|
||||||
|
ServiceError::BadRequest("Invalid UUID".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Error> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
39
simple-auth-server/src/invitation_handler.rs
Normal file
39
simple-auth-server/src/invitation_handler.rs
Normal file
@ -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<Invitation, ServiceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler<CreateInvitation> for DbExecutor {
|
||||||
|
type Result = Result<Invitation, ServiceError>;
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
22
simple-auth-server/src/invitation_routes.rs
Normal file
22
simple-auth-server/src/invitation_routes.rs
Normal file
@ -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<CreateInvitation>, State<AppState>),
|
||||||
|
) -> FutureResponse<HttpResponse> {
|
||||||
|
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()
|
||||||
|
}
|
66
simple-auth-server/src/main.rs
Normal file
66
simple-auth-server/src/main.rs
Normal file
@ -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::<PgConnection>::new(database_url);
|
||||||
|
let pool = r2d2::Pool::builder()
|
||||||
|
.build(manager)
|
||||||
|
.expect("Failed to create pool.");
|
||||||
|
|
||||||
|
let address: Addr<DbExecutor> = 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();
|
||||||
|
}
|
58
simple-auth-server/src/models.rs
Normal file
58
simple-auth-server/src/models.rs
Normal file
@ -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<ConnectionManager<PgConnection>>);
|
||||||
|
|
||||||
|
// 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<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<User> for SlimUser {
|
||||||
|
fn from(user: User) -> Self {
|
||||||
|
SlimUser {
|
||||||
|
email: user.email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
60
simple-auth-server/src/register_handler.rs
Normal file
60
simple-auth-server/src/register_handler.rs
Normal file
@ -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<SlimUser, ServiceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl Handler<RegisterUser> for DbExecutor {
|
||||||
|
type Result = Result<SlimUser, ServiceError>;
|
||||||
|
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::<Invitation>(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()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
22
simple-auth-server/src/register_routes.rs
Normal file
22
simple-auth-server/src/register_routes.rs
Normal file
@ -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<String>, Json<UserData>, State<AppState>))
|
||||||
|
-> FutureResponse<HttpResponse> {
|
||||||
|
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()
|
||||||
|
}
|
20
simple-auth-server/src/schema.rs
Normal file
20
simple-auth-server/src/schema.rs
Normal file
@ -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,
|
||||||
|
);
|
68
simple-auth-server/src/utils.rs
Normal file
68
simple-auth-server/src/utils.rs
Normal file
@ -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<String, ServiceError> {
|
||||||
|
// 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<Claims> for SlimUser {
|
||||||
|
fn from(claims: Claims) -> Self {
|
||||||
|
SlimUser {
|
||||||
|
email: claims.email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_token(data: &SlimUser) -> Result<String, ServiceError> {
|
||||||
|
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<SlimUser, ServiceError> {
|
||||||
|
decode::<Claims>(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())
|
||||||
|
}
|
31
simple-auth-server/static/index.html
Normal file
31
simple-auth-server/static/index.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>Actix Web - Auth App</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" type="text/css" media="screen" href="main.css" />
|
||||||
|
<script src="main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login">
|
||||||
|
<h1>Email Invitation</h1>
|
||||||
|
|
||||||
|
<p>Please enter your email receive Invitation</p>
|
||||||
|
<input class="field" type="text" placeholder="email" id="email" /> <br />
|
||||||
|
<input class="btn" type="submit" value="Send Email" onclick="sendVerificationEmail()" />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<script>
|
||||||
|
function sendVerificationEmail() {
|
||||||
|
let email = document.querySelector('#email');
|
||||||
|
|
||||||
|
post('api/invitation', { email: email.value }).then(data => {
|
||||||
|
alert('Please check your email.');
|
||||||
|
email.value = '';
|
||||||
|
console.error(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
37
simple-auth-server/static/main.css
Normal file
37
simple-auth-server/static/main.css
Normal file
@ -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;
|
||||||
|
}
|
19
simple-auth-server/static/main.js
Normal file
19
simple-auth-server/static/main.js
Normal file
@ -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());
|
||||||
|
// });
|
44
simple-auth-server/static/register.html
Normal file
44
simple-auth-server/static/register.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<title>Actix Web - Auth App</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" type="text/css" media="screen" href="main.css" />
|
||||||
|
<script src="main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login">
|
||||||
|
<h1>Register Account</h1>
|
||||||
|
|
||||||
|
<p>Please enter your email and new password</p>
|
||||||
|
<input class="field" type="text" placeholder="email" id="email" />
|
||||||
|
<input class="field" type="password" placeholder="Password" id="password" />
|
||||||
|
<input class="btn" type="submit" value="Register" onclick="register()" />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<script>
|
||||||
|
function getUrlVars() {
|
||||||
|
var vars = {};
|
||||||
|
var parts = window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(m, key, value) {
|
||||||
|
vars[key] = value;
|
||||||
|
});
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
function register() {
|
||||||
|
let password = document.querySelector('#password');
|
||||||
|
let invitation_id = getUrlVars().id;
|
||||||
|
|
||||||
|
post('api/register/' + invitation_id, { password: password.value }).then(data => {
|
||||||
|
password.value = '';
|
||||||
|
console.error(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
let email = document.querySelector('#email');
|
||||||
|
email.value = getUrlVars().email;
|
||||||
|
console.log(getUrlVars());
|
||||||
|
});
|
||||||
|
</script>
|
Loading…
Reference in New Issue
Block a user