1
0
mirror of https://github.com/actix/examples synced 2025-06-28 18:00:37 +02:00

simple-auth-server added to examples (#65)

This commit is contained in:
Harry Gill
2018-12-09 15:55:36 +00:00
committed by Douman
parent 5871328be2
commit 2fc76ac49b
30 changed files with 870 additions and 0 deletions

View 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"),
)
}

View 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)
}
}

View 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)
}

View 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);
}
}
}

View 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
}
}
}

View 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)
}
}

View 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()
}

View 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();
}

View 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
}
}
}

View 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()))
})
}
}

View 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()
}

View 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,
);

View 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())
}