1
0
mirror of https://github.com/actix/examples synced 2025-06-26 17:17:42 +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,23 @@
[package]
name = "simple-auth-server"
version = "1.0.0"
edition = "2021"
[dependencies]
actix-web = "4.0.0-beta.21"
actix-identity = "0.4.0-beta.8"
chrono = { version = "0.4.6", features = ["serde"] }
derive_more = "0.99.0"
diesel = { version = "1.4.5", features = ["postgres", "uuidv07", "r2d2", "chrono"] }
dotenv = "0.15"
env_logger = "0.9.0"
futures = "0.3.1"
r2d2 = "0.8"
rust-argon2 = "1.0.0"
lazy_static = "1.4.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sparkpost = "0.5.2"
uuid = { version = "0.8.2", features = ["serde", "v4"] }
time = "0.3.7"

View File

@ -0,0 +1,47 @@
## Auth Web Microservice with Rust using Actix Web
### 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 auth cookie
### Available Routes
- [GET /](http://localhost:8080/)
- [POST /api/invitation](http://localhost:8080/api/invitation)
- [POST /api/invitation/:invitation_id](http://localhost:8080/api/invitation/:invitation_id)
- [GET /api/auth](http://localhost:8080/api/auth)
- [POST /api/auth](http://localhost:8080/api/auth)
- [DELETE /api/auth](http://localhost:8080/api/auth)
### Crates Used
- [actix-web](https://crates.io/crates/actix-web) // Actix Web is a simple, pragmatic and extremely fast web framework for Rust.
- [rust-argon2](https://crates.io/crates/rust-argon2) // crate for hashing passwords using the cryptographically-secure Argon2 hashing algorithm.
- [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.
- [derive_more](https://crates.io/crates/derive_more) // Convenience macros to derive tarits easily
- [env_logger](https://crates.io/crates/env_logger) // A logging implementation for log which is configured via an environment variable.
- [futures](https://crates.io/crates/futures) // An implementation of futures and streams featuring zero allocations, composability, and iterator-like interfaces.
- [lazy_static](https://docs.rs/lazy_static) // A macro for declaring lazily evaluated statics.
- [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 [gill.net.in](https://gill.net.in)
- [Auth Web Microservice with Rust using Actix Web v2 - Complete Tutorial](https://gill.net.in/posts/auth-microservice-rust-actix-web1.0-diesel-complete-tutorial/)
### Dependencies
On Ubuntu 19.10:
```
sudo apt install libclang-dev libpq-dev
```

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

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

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE users;

View File

@ -0,0 +1,6 @@
-- Your SQL goes here
CREATE TABLE users (
email VARCHAR(100) NOT NULL UNIQUE PRIMARY KEY,
hash VARCHAR(122) NOT NULL, --argon hash
created_at TIMESTAMP NOT NULL
);

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE invitations;

View File

@ -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
);

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

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

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

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

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