1
0
mirror of https://github.com/actix/examples synced 2025-06-27 01:27:43 +02:00

restructure folders

This commit is contained in:
Rob Ede
2022-02-18 02:01:48 +00:00
parent 4d8573c3fe
commit cc3d356209
201 changed files with 52 additions and 49 deletions

View File

@ -0,0 +1,74 @@
use actix_identity::Identity;
use actix_web::{dev::Payload, web, Error, FromRequest, HttpRequest, HttpResponse};
use diesel::prelude::*;
use diesel::PgConnection;
use futures::future::{err, ok, Ready};
use serde::Deserialize;
use crate::errors::ServiceError;
use crate::models::{Pool, SlimUser, User};
use crate::utils::verify;
#[derive(Debug, Deserialize)]
pub struct AuthData {
pub email: String,
pub password: String,
}
// we need the same data
// simple aliasing makes the intentions clear and its more readable
pub type LoggedUser = SlimUser;
impl FromRequest for LoggedUser {
type Error = Error;
type Future = Ready<Result<LoggedUser, Error>>;
fn from_request(req: &HttpRequest, pl: &mut Payload) -> Self::Future {
if let Ok(identity) = Identity::from_request(req, pl).into_inner() {
if let Some(user_json) = identity.identity() {
if let Ok(user) = serde_json::from_str(&user_json) {
return ok(user);
}
}
}
err(ServiceError::Unauthorized.into())
}
}
pub async fn logout(id: Identity) -> HttpResponse {
id.forget();
HttpResponse::Ok().finish()
}
pub async fn login(
auth_data: web::Json<AuthData>,
id: Identity,
pool: web::Data<Pool>,
) -> Result<HttpResponse, actix_web::Error> {
let user = web::block(move || query(auth_data.into_inner(), pool)).await??;
let user_string = serde_json::to_string(&user).unwrap();
id.remember(user_string);
Ok(HttpResponse::Ok().finish())
}
pub async fn get_me(logged_user: LoggedUser) -> HttpResponse {
HttpResponse::Ok().json(logged_user)
}
/// Diesel query
fn query(auth_data: AuthData, pool: web::Data<Pool>) -> Result<SlimUser, ServiceError> {
use crate::schema::users::dsl::{email, users};
let conn: &PgConnection = &pool.get().unwrap();
let mut items = users
.filter(email.eq(&auth_data.email))
.load::<User>(conn)?;
if let Some(user) = items.pop() {
if let Ok(matching) = verify(&user.hash, &auth_data.password) {
if matching {
return Ok(user.into());
}
}
}
Err(ServiceError::Unauthorized)
}

View File

@ -0,0 +1,67 @@
// email_service.rs
use crate::errors::ServiceError;
use crate::models::Invitation;
use sparkpost::transmission::{
EmailAddress, Message, Options, Recipient, Transmission, TransmissionResponse,
};
lazy_static::lazy_static! {
static ref API_KEY: String = std::env::var("SPARKPOST_API_KEY").expect("SPARKPOST_API_KEY must be set");
}
pub fn send_invitation(invitation: &Invitation) -> Result<(), ServiceError> {
let tm = Transmission::new_eu(API_KEY.as_str());
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")
);
// 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);
Ok(())
}
TransmissionResponse::ApiError(errors) => {
println!("Response Errors: \n {:#?}", &errors);
Err(ServiceError::InternalServerError)
}
},
Err(error) => {
println!("Send Email Error: \n {:#?}", error);
Err(ServiceError::InternalServerError)
}
}
}

View File

@ -0,0 +1,59 @@
use actix_web::{error::ResponseError, HttpResponse};
use derive_more::Display;
use diesel::result::{DatabaseErrorKind, Error as DBError};
use std::convert::From;
use uuid::Error as ParseError;
#[derive(Debug, Display)]
pub enum ServiceError {
#[display(fmt = "Internal Server Error")]
InternalServerError,
#[display(fmt = "BadRequest: {}", _0)]
BadRequest(String),
#[display(fmt = "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<DBError> for ServiceError {
fn from(error: DBError) -> 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 {
DBError::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,47 @@
use actix_web::{web, HttpResponse};
use diesel::{prelude::*, PgConnection};
use serde::Deserialize;
use crate::email_service::send_invitation;
use crate::models::{Invitation, Pool};
#[derive(Deserialize)]
pub struct InvitationData {
pub email: String,
}
pub async fn post_invitation(
invitation_data: web::Json<InvitationData>,
pool: web::Data<Pool>,
) -> Result<HttpResponse, actix_web::Error> {
// run diesel blocking code
web::block(move || create_invitation(invitation_data.into_inner().email, pool))
.await??;
Ok(HttpResponse::Ok().finish())
}
fn create_invitation(
eml: String,
pool: web::Data<Pool>,
) -> Result<(), crate::errors::ServiceError> {
let invitation = dbg!(query(eml, pool)?);
send_invitation(&invitation)
}
/// Diesel query
fn query(
eml: String,
pool: web::Data<Pool>,
) -> Result<Invitation, crate::errors::ServiceError> {
use crate::schema::invitations::dsl::invitations;
let new_invitation: Invitation = eml.into();
let conn: &PgConnection = &pool.get().unwrap();
let inserted_invitation = diesel::insert_into(invitations)
.values(&new_invitation)
.get_result(conn)?;
Ok(inserted_invitation)
}

View File

@ -0,0 +1,74 @@
#[macro_use]
extern crate diesel;
use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{middleware, web, App, HttpServer};
use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};
use time::Duration;
mod auth_handler;
mod email_service;
mod errors;
mod invitation_handler;
mod models;
mod register_handler;
mod schema;
mod utils;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
std::env::set_var(
"RUST_LOG",
"simple-auth-server=debug,actix_web=info,actix_server=info",
);
env_logger::init();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
// create db connection pool
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool: models::Pool = r2d2::Pool::builder()
.build(manager)
.expect("Failed to create pool.");
let domain: String =
std::env::var("DOMAIN").unwrap_or_else(|_| "localhost".to_string());
// Start http server
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
// enable logger
.wrap(middleware::Logger::default())
.wrap(IdentityService::new(
CookieIdentityPolicy::new(utils::SECRET_KEY.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
))
.app_data(web::JsonConfig::default().limit(4096))
// everything under '/api/' route
.service(
web::scope("/api")
.service(
web::resource("/invitation")
.route(web::post().to(invitation_handler::post_invitation)),
)
.service(
web::resource("/register/{invitation_id}")
.route(web::post().to(register_handler::register_user)),
)
.service(
web::resource("/auth")
.route(web::post().to(auth_handler::login))
.route(web::delete().to(auth_handler::logout))
.route(web::get().to(auth_handler::get_me)),
),
)
})
.bind("127.0.0.1:3000")?
.run()
.await
}

View File

@ -0,0 +1,57 @@
use super::schema::*;
use diesel::{r2d2::ConnectionManager, PgConnection};
use serde::{Deserialize, Serialize};
// type alias to use in multiple places
pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;
#[derive(Debug, Serialize, Deserialize, Queryable, Insertable)]
#[table_name = "users"]
pub struct User {
pub email: String,
pub hash: String,
pub created_at: chrono::NaiveDateTime,
}
impl User {
pub fn from_details<S: Into<String>, T: Into<String>>(email: S, pwd: T) -> Self {
User {
email: email.into(),
hash: pwd.into(),
created_at: chrono::Local::now().naive_local(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Queryable, Insertable)]
#[table_name = "invitations"]
pub struct Invitation {
pub id: uuid::Uuid,
pub email: String,
pub expires_at: chrono::NaiveDateTime,
}
// any type that implements Into<String> can be used to create Invitation
impl<T> From<T> for Invitation
where
T: Into<String>,
{
fn from(email: T) -> Self {
Invitation {
id: uuid::Uuid::new_v4(),
email: email.into(),
expires_at: chrono::Local::now().naive_local() + chrono::Duration::hours(24),
}
}
}
#[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,61 @@
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
use serde::Deserialize;
use crate::errors::ServiceError;
use crate::models::{Invitation, Pool, SlimUser, User};
use crate::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,
}
pub async fn register_user(
invitation_id: web::Path<String>,
user_data: web::Json<UserData>,
pool: web::Data<Pool>,
) -> Result<HttpResponse, actix_web::Error> {
let user = web::block(move || {
query(
invitation_id.into_inner(),
user_data.into_inner().password,
pool,
)
})
.await??;
Ok(HttpResponse::Ok().json(&user))
}
fn query(
invitation_id: String,
password: String,
pool: web::Data<Pool>,
) -> Result<SlimUser, crate::errors::ServiceError> {
use crate::schema::invitations::dsl::{id, invitations};
use crate::schema::users::dsl::users;
let invitation_id = uuid::Uuid::parse_str(&invitation_id)?;
let conn: &PgConnection = &pool.get().unwrap();
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 > chrono::Local::now().naive_local() {
// try hashing the password, else return the error that will be converted to ServiceError
let password: String = hash_password(&password)?;
dbg!(&password);
let user = User::from_details(invitation.email, password);
let inserted_user: User =
diesel::insert_into(users).values(&user).get_result(conn)?;
dbg!(&inserted_user);
return Ok(inserted_user.into());
}
}
Err(ServiceError::BadRequest("Invalid Invitation".into()))
})
}

View File

@ -0,0 +1,17 @@
table! {
invitations (id) {
id -> Uuid,
email -> Varchar,
expires_at -> Timestamp,
}
}
table! {
users (email) {
email -> Varchar,
hash -> Varchar,
created_at -> Timestamp,
}
}
allow_tables_to_appear_in_same_query!(invitations, users,);

View File

@ -0,0 +1,28 @@
use crate::errors::ServiceError;
use argon2::{self, Config};
lazy_static::lazy_static! {
pub static ref SECRET_KEY: String = std::env::var("SECRET_KEY").unwrap_or_else(|_| "0123".repeat(8));
}
const SALT: &[u8] = b"supersecuresalt";
// WARNING THIS IS ONLY FOR DEMO PLEASE DO MORE RESEARCH FOR PRODUCTION USE
pub fn hash_password(password: &str) -> Result<String, ServiceError> {
let config = Config {
secret: SECRET_KEY.as_bytes(),
..Default::default()
};
argon2::hash_encoded(password.as_bytes(), SALT, &config).map_err(|err| {
dbg!(err);
ServiceError::InternalServerError
})
}
pub fn verify(hash: &str, password: &str) -> Result<bool, ServiceError> {
argon2::verify_encoded_ext(hash, password.as_bytes(), SECRET_KEY.as_bytes(), &[])
.map_err(|err| {
dbg!(err);
ServiceError::Unauthorized
})
}