Basic working app
This commit is contained in:
commit
f08b3e0bcb
13 changed files with 3321 additions and 0 deletions
2
.env
Normal file
2
.env
Normal file
|
@ -0,0 +1,2 @@
|
|||
DATABASE_URL=sqlite:./db.sqlite
|
||||
SQLX_OFFLINE=true
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
*.sqlite*
|
2748
Cargo.lock
generated
Normal file
2748
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "tld-registration"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-csrf = { version = "0.2", features = [ "serde" ] }
|
||||
actix-files = "0.6.0-beta.6"
|
||||
actix-rt = "2"
|
||||
actix-session = "0.5.0-beta.2"
|
||||
actix-web = { version = "4.0.0-beta.8", features = [ "rustls" ] }
|
||||
anyhow = "1"
|
||||
handlebars = { version = "4", features = [ "dir_source" ] }
|
||||
lettre = { version = "0.9", features = [ "serde-impls" ] }
|
||||
once_cell = "1"
|
||||
rand = { version = "0.8", features = [ "small_rng" ] }
|
||||
serde = "1"
|
||||
sodiumoxide = "0.2"
|
||||
sqlx = { version = "0.5", features = [ "runtime-actix-rustls", "offline", "sqlite" ] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.2"
|
6
db_queries/init.sql
Normal file
6
db_queries/init.sql
Normal file
|
@ -0,0 +1,6 @@
|
|||
CREATE TABLE IF NOT EXISTS users(
|
||||
email TEXT PRIMARY KEY,
|
||||
password BLOB NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_email ON users(email);
|
41
sqlx-data.json
Normal file
41
sqlx-data.json
Normal file
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"db": "SQLite",
|
||||
"57f6668b1fb93316e1beff8c2189a59da3a34995afc962a4704bc7d196a159f3": {
|
||||
"query": "INSERT INTO users (email, password) VALUES (?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
},
|
||||
"b6e4ea9605cd77fef15491515dea3aa792a92e59672043a111c8dd673e59bd08": {
|
||||
"query": "SELECT password FROM users WHERE email = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "password",
|
||||
"ordinal": 0,
|
||||
"type_info": "Blob"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
"dd98414343cc029a0cc106382532de954050ded749f3a3f7d9dc077ed7515572": {
|
||||
"query": "CREATE TABLE IF NOT EXISTS users(\n email TEXT PRIMARY KEY,\n password BLOB NOT NULL\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS ix_email ON users(email);",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
}
|
||||
}
|
||||
}
|
318
src/main.rs
Normal file
318
src/main.rs
Normal file
|
@ -0,0 +1,318 @@
|
|||
mod session;
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use session::Session;
|
||||
|
||||
use actix_csrf::extractor::{CsrfCookie, CsrfToken};
|
||||
use actix_csrf::Csrf;
|
||||
use actix_session::CookieSession;
|
||||
use actix_web::cookie::SameSite;
|
||||
use actix_web::error::InternalError;
|
||||
use actix_web::http::header::LOCATION;
|
||||
use actix_web::http::{Method, StatusCode};
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::web::{Form, Query};
|
||||
use actix_web::{get, post, App, HttpRequest, HttpResponse, HttpServer, Responder};
|
||||
use handlebars::Handlebars;
|
||||
use lettre::EmailAddress;
|
||||
use once_cell::sync::{Lazy, OnceCell};
|
||||
use rand::prelude::StdRng;
|
||||
use serde::de::Visitor;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sodiumoxide::crypto::pwhash::argon2id13::{self, HashedPassword, HASHEDPASSWORDBYTES};
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use sqlx::SqlitePool;
|
||||
use tracing::error;
|
||||
|
||||
static TEMPLATE_ENGINE: Lazy<Handlebars> = Lazy::new(|| {
|
||||
let mut handlebars = Handlebars::new();
|
||||
|
||||
handlebars.set_strict_mode(true);
|
||||
handlebars.set_dev_mode(true);
|
||||
|
||||
handlebars
|
||||
.register_templates_directory(".hbs", "src/templates")
|
||||
.expect("failed to load template directory");
|
||||
handlebars
|
||||
});
|
||||
|
||||
static DB_POOL: OnceCell<SqlitePool> = OnceCell::new();
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
sodiumoxide::init().unwrap();
|
||||
|
||||
let db = {
|
||||
let db_options = SqliteConnectOptions::from_str("db.sqlite")
|
||||
.unwrap()
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePool::connect_with(db_options).await.unwrap();
|
||||
sqlx::query_file!("db_queries/init.sql")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
};
|
||||
DB_POOL.set(db).unwrap();
|
||||
|
||||
HttpServer::new(|| {
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.wrap(
|
||||
CookieSession::private(&[0; 32])
|
||||
.name("session")
|
||||
.path("/")
|
||||
.secure(true)
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict),
|
||||
)
|
||||
.wrap(
|
||||
Csrf::<StdRng>::new()
|
||||
.set_cookie(Method::GET, "/login")
|
||||
.validate_cookie(Method::POST, "/login")
|
||||
.set_cookie(Method::GET, "/register")
|
||||
.validate_cookie(Method::POST, "/register"),
|
||||
)
|
||||
.service(index)
|
||||
.service(login_ui)
|
||||
.service(login)
|
||||
.service(register_ui)
|
||||
.service(register)
|
||||
.service(account_ui)
|
||||
.service(logout)
|
||||
.service(actix_files::Files::new("/static", "src/static"))
|
||||
})
|
||||
.bind("127.0.0.1:8080")?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
enum SessionState {
|
||||
Anonymous,
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
async fn index() -> impl Responder {
|
||||
match TEMPLATE_ENGINE.render("index", &()) {
|
||||
Ok(resp) => Ok(HttpResponse::Ok().body(resp)),
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
Err(InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginQuery {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/login")]
|
||||
async fn login_ui(csrf: CsrfToken, mut query: Query<LoginQuery>) -> impl Responder {
|
||||
#[derive(Serialize)]
|
||||
struct TemplateArgs {
|
||||
error: Option<String>,
|
||||
csrf: CsrfToken,
|
||||
}
|
||||
|
||||
match TEMPLATE_ENGINE.render(
|
||||
"login",
|
||||
&TemplateArgs {
|
||||
error: query.error.take(),
|
||||
csrf,
|
||||
},
|
||||
) {
|
||||
Ok(resp) => Ok(HttpResponse::Ok().body(resp)),
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
Err(InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Login {
|
||||
csrf: CsrfToken,
|
||||
email: EmailAddress,
|
||||
password: Password,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
struct Password(String);
|
||||
|
||||
impl<'de> Deserialize<'de> for Password {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::{Error, Unexpected};
|
||||
|
||||
struct SecretDeserializer;
|
||||
|
||||
impl<'de> Visitor<'de> for SecretDeserializer {
|
||||
type Value = Password;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a password between 8 and 64 bytes")
|
||||
}
|
||||
|
||||
fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
if v.len() < 8 || v.len() > 64 {
|
||||
println!("password failed");
|
||||
return Err(Error::invalid_value(
|
||||
Unexpected::Str("password with invalid size"),
|
||||
&"a password between 8 and 64 bytes",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Password(v.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_string(SecretDeserializer)
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/login")]
|
||||
async fn login(csrf_cookie: CsrfCookie, form: Form<Login>, session: Session) -> impl Responder {
|
||||
if !csrf_cookie.validate(form.csrf.as_ref()) {
|
||||
return HttpResponse::BadRequest().finish();
|
||||
}
|
||||
|
||||
let email: &str = form.email.as_ref();
|
||||
let verified = sqlx::query!("SELECT password FROM users WHERE email = ?", email)
|
||||
.fetch_one(DB_POOL.get().expect("db connection to be set"))
|
||||
.await;
|
||||
|
||||
if let Ok(record) = verified {
|
||||
let verified = argon2id13::pwhash_verify(
|
||||
&HashedPassword::from_slice(&record.password).unwrap(),
|
||||
form.password.0.as_bytes(),
|
||||
);
|
||||
if verified {
|
||||
let redirect_to = session.get_redirect_url();
|
||||
session.init(&form.email);
|
||||
|
||||
let mut resp = HttpResponse::SeeOther();
|
||||
|
||||
if let Some(path) = redirect_to {
|
||||
resp.insert_header((LOCATION, path.to_string()));
|
||||
} else {
|
||||
resp.insert_header((LOCATION, "/account"));
|
||||
}
|
||||
|
||||
return resp.finish();
|
||||
}
|
||||
} else {
|
||||
// To guard against timing attacks, we'll construct a fake password to
|
||||
// hash. We won't even check if it's successful, we just need to compute
|
||||
// the hash. Since we don't check the result, this must be in a separate
|
||||
// branch from the success branch, else it's possible to actually
|
||||
// succeed from a bogey hash.
|
||||
let mut data = [0_u8; HASHEDPASSWORDBYTES];
|
||||
assert!(argon2id13::STRPREFIX.len() < HASHEDPASSWORDBYTES);
|
||||
for (i, c) in argon2id13::STRPREFIX.iter().enumerate() {
|
||||
data[i] = *c;
|
||||
}
|
||||
|
||||
// Rust shouldn't optimize this out since it ultimately calls out to
|
||||
// a C function, so it shouldn't find out that the function is pure.
|
||||
argon2id13::pwhash_verify(
|
||||
&HashedPassword::from_slice(&data).unwrap(),
|
||||
form.password.0.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
HttpResponse::SeeOther()
|
||||
.insert_header((LOCATION, "/login?error=true"))
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[get("/register")]
|
||||
async fn register_ui(csrf: CsrfToken) -> impl Responder {
|
||||
#[derive(Serialize)]
|
||||
struct TemplateArgs {
|
||||
csrf: CsrfToken,
|
||||
}
|
||||
|
||||
match TEMPLATE_ENGINE.render("register", &TemplateArgs { csrf }) {
|
||||
Ok(resp) => Ok(HttpResponse::Ok().body(resp)),
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
Err(InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegistrationInfo {
|
||||
csrf: CsrfToken,
|
||||
email: EmailAddress,
|
||||
password: Password,
|
||||
}
|
||||
|
||||
#[post("/register")]
|
||||
async fn register(
|
||||
csrf_cookie: CsrfCookie,
|
||||
form: Form<RegistrationInfo>,
|
||||
session: Session,
|
||||
) -> impl Responder {
|
||||
if !csrf_cookie.validate(form.csrf.as_ref()) {
|
||||
return HttpResponse::BadRequest().finish();
|
||||
}
|
||||
|
||||
let hashed = {
|
||||
let res = argon2id13::pwhash(
|
||||
form.password.0.as_bytes(),
|
||||
argon2id13::OPSLIMIT_INTERACTIVE,
|
||||
argon2id13::MEMLIMIT_INTERACTIVE,
|
||||
);
|
||||
if let Ok(res) = res {
|
||||
res
|
||||
} else {
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
}
|
||||
};
|
||||
let hashed = hashed.as_ref();
|
||||
|
||||
let email: &str = form.email.as_ref();
|
||||
let insert_res = sqlx::query!(
|
||||
"INSERT INTO users (email, password) VALUES (?, ?)",
|
||||
email,
|
||||
hashed,
|
||||
)
|
||||
.execute(DB_POOL.get().expect("db connection to be set"))
|
||||
.await;
|
||||
|
||||
if insert_res.is_ok() {
|
||||
session.init(&form.email);
|
||||
|
||||
HttpResponse::SeeOther()
|
||||
.insert_header((LOCATION, "/account"))
|
||||
.finish()
|
||||
} else {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/account")]
|
||||
async fn account_ui(req: HttpRequest, session: Session) -> impl Responder {
|
||||
if let Err(error) = session.validate_or_redirect(req.uri()) {
|
||||
return error;
|
||||
}
|
||||
|
||||
HttpResponse::Ok().body(format!("{:?}", session.email()))
|
||||
}
|
||||
|
||||
#[get("/logout")]
|
||||
async fn logout(session: Session) -> impl Responder {
|
||||
// It should be ok to logout without a CSRF token; the worst case is that
|
||||
// the user is logged out, which is fail-safe.
|
||||
session.purge();
|
||||
HttpResponse::SeeOther()
|
||||
.append_header((LOCATION, "/"))
|
||||
.finish()
|
||||
}
|
83
src/session.rs
Normal file
83
src/session.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use std::convert::TryFrom;
|
||||
use std::future::{ready, Ready};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use actix_web::dev::Payload;
|
||||
use actix_web::http::header::LOCATION;
|
||||
use actix_web::http::Uri;
|
||||
use actix_web::{FromRequest, HttpRequest, HttpResponse};
|
||||
use lettre::EmailAddress;
|
||||
use rand::{thread_rng, Fill};
|
||||
|
||||
pub struct Session(actix_session::Session);
|
||||
|
||||
impl FromRequest for Session {
|
||||
type Config = <actix_session::Session as FromRequest>::Config;
|
||||
type Error = <actix_session::Session as FromRequest>::Error;
|
||||
type Future = Ready<Result<Self, Self::Error>>;
|
||||
|
||||
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||
ready(
|
||||
actix_session::Session::from_request(req, payload)
|
||||
.into_inner()
|
||||
.map(Self),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn init(&self, email: &EmailAddress) {
|
||||
self.0.clear();
|
||||
self.0
|
||||
.insert("email", email)
|
||||
.expect("email serialization to work");
|
||||
let mut buf: [u8; 32] = [0; 32];
|
||||
buf.try_fill(&mut thread_rng()).expect("rng to fill buf");
|
||||
self.0
|
||||
.insert(
|
||||
"expires",
|
||||
SystemTime::now() + Duration::from_secs(60 * 60 * 24),
|
||||
)
|
||||
.expect("setting expiration to work");
|
||||
}
|
||||
|
||||
pub fn purge(&self) {
|
||||
self.0.purge();
|
||||
}
|
||||
|
||||
pub fn validate_or_redirect(&self, redirect_to: &Uri) -> Result<(), HttpResponse> {
|
||||
if self.expired() {
|
||||
self.0.clear();
|
||||
self.set_redirect_url(redirect_to);
|
||||
Err(HttpResponse::SeeOther()
|
||||
.insert_header((LOCATION, "/login"))
|
||||
.finish())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expired(&self) -> bool {
|
||||
self.0
|
||||
.get("expires")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map_or(true, |expires: SystemTime| expires < SystemTime::now())
|
||||
}
|
||||
|
||||
pub fn get_redirect_url(&self) -> Option<Uri> {
|
||||
self.0
|
||||
.remove("redirect_to")
|
||||
.and_then(|v| Uri::try_from(&v).ok())
|
||||
}
|
||||
|
||||
pub fn set_redirect_url(&self, url: &Uri) {
|
||||
self.0
|
||||
.insert("redirect_to", url.to_string())
|
||||
.expect("setting a str to work");
|
||||
}
|
||||
|
||||
pub fn email(&self) -> Option<EmailAddress> {
|
||||
self.0.get("email").ok().flatten()
|
||||
}
|
||||
}
|
31
src/static/css/primary.css
Normal file
31
src/static/css/primary.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'M PLUS 1p', sans-serif;
|
||||
}
|
||||
|
||||
p, label, input {
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
9
src/templates/head.hbs
Normal file
9
src/templates/head.hbs
Normal file
|
@ -0,0 +1,9 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+1p:wght@300&family=Noto+Sans&display=swap&subset=latin"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/primary.css">
|
||||
</head>
|
9
src/templates/index.hbs
Normal file
9
src/templates/index.hbs
Normal file
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head }}
|
||||
<body>
|
||||
<h1>A TLD for everyone</h1>
|
||||
|
||||
<h2>About us</h2>
|
||||
</body>
|
||||
</html>
|
25
src/templates/login.hbs
Normal file
25
src/templates/login.hbs
Normal file
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head }}
|
||||
|
||||
<body>
|
||||
<h1>A TLD for everyone</h1>
|
||||
|
||||
<h2>Login</h2>
|
||||
{{#if error}}
|
||||
<p>Invalid Username or Password.</p>
|
||||
{{/if}}
|
||||
<form action="/login" method="post">
|
||||
<input name="csrf" type="hidden" value="{{csrf}}" />
|
||||
<label>Email
|
||||
<input name="email" type="email" autocomplete="email" required="required" />
|
||||
</label>
|
||||
<label>Password
|
||||
<input name="password" type="password" minlength="8" autocomplete="current-password" required="required" />
|
||||
</label>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
24
src/templates/register.hbs
Normal file
24
src/templates/register.hbs
Normal file
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head }}
|
||||
|
||||
<body>
|
||||
<h1>A TLD for everyone</h1>
|
||||
|
||||
<h2>Sign up</h2>
|
||||
{{#if error}}
|
||||
<p>The email is already used. Please try again.</p>
|
||||
{{/if}}
|
||||
<form action="/register" method="post">
|
||||
<input name="csrf" type="hidden" value="{{csrf}}">
|
||||
<label>Email
|
||||
<input name="email" type="email" autocomplete="email" required="required" aria-required="true" />
|
||||
</label>
|
||||
<label>Password
|
||||
<input name="password" type="password" minlength="8" required="required" aria-required="true" />
|
||||
</label>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
</html>
|
Loading…
Reference in a new issue