mirror of
https://github.com/actix/examples
synced 2025-06-27 01:27:43 +02:00
restructure folders
This commit is contained in:
74
auth/simple-auth-server/src/auth_handler.rs
Normal file
74
auth/simple-auth-server/src/auth_handler.rs
Normal 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)
|
||||
}
|
67
auth/simple-auth-server/src/email_service.rs
Normal file
67
auth/simple-auth-server/src/email_service.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
59
auth/simple-auth-server/src/errors.rs
Normal file
59
auth/simple-auth-server/src/errors.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
47
auth/simple-auth-server/src/invitation_handler.rs
Normal file
47
auth/simple-auth-server/src/invitation_handler.rs
Normal 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)
|
||||
}
|
74
auth/simple-auth-server/src/main.rs
Normal file
74
auth/simple-auth-server/src/main.rs
Normal 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
|
||||
}
|
57
auth/simple-auth-server/src/models.rs
Normal file
57
auth/simple-auth-server/src/models.rs
Normal 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 }
|
||||
}
|
||||
}
|
61
auth/simple-auth-server/src/register_handler.rs
Normal file
61
auth/simple-auth-server/src/register_handler.rs
Normal 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()))
|
||||
})
|
||||
}
|
17
auth/simple-auth-server/src/schema.rs
Normal file
17
auth/simple-auth-server/src/schema.rs
Normal 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,);
|
28
auth/simple-auth-server/src/utils.rs
Normal file
28
auth/simple-auth-server/src/utils.rs
Normal 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
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user