Add simple yew frontend

This commit is contained in:
Marko Korhonen 2020-04-13 17:48:42 +03:00
parent a0e8cfe5f9
commit bbaf01dafe
No known key found for this signature in database
GPG key ID: 911B85FBC6003FE5
26 changed files with 711 additions and 31 deletions

9
project/backend/.env Normal file
View file

@ -0,0 +1,9 @@
APP_NAME=thesis
BIND=127.0.0.1
PORT=3880
DOMAIN=localhost
DATABASE_URL=mysql://diesel:fuel@localhost/thesis
JWT_SECRET=taiCoh4dEvoo0IefEvoo0IefaiNai7uv
SECRET=la4kuuPhSai2johyIephaa4fahm5Siey
ALLOWED_ORIGIN=localhost
COOKIE_SECURE=false

1
project/backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

1944
project/backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,24 @@
[package]
name = "thesis-backend"
version = "0.1.0"
authors = ["Marko Korhonen <marko.korhonen@reekynet.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "2.0.0"
actix-rt = "1.0.0"
serde = { version = "1.0.104", features = ["derive"] }
diesel = { version = "1.4.3", features = ["mysql", "r2d2", "chrono"] }
dotenv = "0.15.0"
bcrypt = "0.6.2"
env_logger = "0.7.1"
r2d2 = "0.8.8"
crypto = "0.0.2"
jsonwebtoken = "7.1.0"
chrono = { version = "0.4.11", features = ["serde"] }
actix-cors = "0.2.0"
actix-identity = "0.2.1"
futures = "0.3.4"
actix-files = "0.2.1"

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

View file

@ -0,0 +1 @@
DROP TABLE users;

View file

@ -0,0 +1,8 @@
CREATE TABLE users (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) UNIQUE NOT NULL,
`password` varchar(128) NOT NULL,
`admin` boolean NOT NULL,
`created_at` timestamp NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View file

@ -0,0 +1,15 @@
use diesel::r2d2::{ConnectionManager, Pool, PoolError, PooledConnection};
use diesel::MysqlConnection;
pub type DbPool = r2d2::Pool<ConnectionManager<MysqlConnection>>;
pub type MyPooledConnection = PooledConnection<ConnectionManager<MysqlConnection>>;
fn init_pool(database_url: &str) -> Result<DbPool, PoolError> {
let manager = ConnectionManager::<MysqlConnection>::new(database_url);
Pool::builder().build(manager)
}
pub fn get_pool() -> DbPool {
let connspec = std::env::var("DATABASE_URL").expect("DATABASE_URL");
init_pool(&connspec).expect("Failed to create DB pool")
}

View file

@ -0,0 +1,33 @@
use bcrypt::BcryptError;
use diesel::result;
use std::fmt;
pub enum CustomError {
HashError(BcryptError),
DBError(result::Error),
PasswordMatchError(String),
PasswordWrong(String),
}
impl From<BcryptError> for CustomError {
fn from(error: BcryptError) -> Self {
CustomError::HashError(error)
}
}
impl From<result::Error> for CustomError {
fn from(error: result::Error) -> Self {
CustomError::DBError(error)
}
}
impl std::fmt::Display for CustomError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CustomError::HashError(error) => write!(f, "{}", error),
CustomError::DBError(error) => write!(f, "{}", error),
CustomError::PasswordMatchError(error) => write!(f, "{}", error),
CustomError::PasswordWrong(error) => write!(f, "{}", error),
}
}
}

View file

@ -0,0 +1,96 @@
use crate::{
db_connection::DbPool,
errors::CustomError,
handlers::pool_handler,
models::user::{AuthUser, DeleteUser, RegisterUser, User},
utils::jwt::{decode_token, encode_token, UserWithToken},
};
use actix_identity::Identity;
use actix_web::{delete, dev::Payload, post, web, FromRequest, HttpRequest, HttpResponse};
use futures::future::Future;
use std::pin::Pin;
pub type LoggedUser = UserWithToken;
impl FromRequest for LoggedUser {
type Error = HttpResponse;
type Config = ();
type Future = Pin<Box<dyn Future<Output = Result<UserWithToken, HttpResponse>>>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let fut = Identity::from_request(req, payload);
Box::pin(async move {
if let Some(identity) = fut.await?.identity() {
let user = decode_token(&identity)?;
return Ok(user);
};
Err(HttpResponse::Unauthorized().finish())
})
}
}
#[post("/auth/register")]
pub async fn register(
new_user: web::Json<RegisterUser>,
pool: web::Data<DbPool>,
) -> Result<HttpResponse, HttpResponse> {
let connection = pool_handler(pool)?;
let register_user = new_user
.into_inner()
.validation()
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))?;
User::create(register_user, &connection)
.map(|_r| HttpResponse::Ok().json("User created successfully"))
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))
}
#[post("/auth/delete")]
pub async fn delete(
user: LoggedUser,
user_to_delete: web::Json<DeleteUser>,
pool: web::Data<DbPool>,
) -> Result<HttpResponse, HttpResponse> {
if user.admin || user.username == user_to_delete.username {
let connection = pool_handler(pool)?;
user_to_delete.delete(&connection).map_err(|e| match e {
CustomError::DBError(diesel::result::Error::NotFound) => {
HttpResponse::NotFound().json(e.to_string())
}
_ => HttpResponse::InternalServerError().json(e.to_string()),
})?;
Ok(HttpResponse::Ok().json("User deleted successfully"))
} else {
Err(HttpResponse::Unauthorized().json("Only admins can delete users"))
}
}
#[post("/auth/login")]
pub async fn login(
id: Identity,
auth_user: web::Json<AuthUser>,
pool: web::Data<DbPool>,
) -> Result<HttpResponse, HttpResponse> {
let connection = pool_handler(pool)?;
let user = auth_user.login(&connection).map_err(|e| match e {
CustomError::DBError(diesel::result::Error::NotFound) => {
HttpResponse::NotFound().json(e.to_string())
}
_ => HttpResponse::InternalServerError().json(e.to_string()),
})?;
let token = encode_token(user.id, &user.username, user.admin).map_err(|e| match e {
_ => HttpResponse::InternalServerError().finish(),
})?;
id.remember(String::from(token));
Ok(HttpResponse::Ok().json(user))
}
#[delete("/auth/logout")]
pub async fn logout(id: Identity) -> Result<HttpResponse, HttpResponse> {
id.forget();
Ok(HttpResponse::Ok().into())
}

View file

@ -0,0 +1,9 @@
use crate::db_connection::{DbPool, MyPooledConnection};
use actix_web::{web, HttpResponse};
pub mod authentication;
pub fn pool_handler(pool: web::Data<DbPool>) -> Result<MyPooledConnection, HttpResponse> {
pool.get()
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))
}

View file

@ -0,0 +1,80 @@
#[macro_use]
extern crate diesel;
extern crate dotenv;
pub mod db_connection;
pub mod errors;
pub mod handlers;
pub mod models;
pub mod schema;
pub mod utils;
use actix_cors::Cors;
use actix_files::Files;
use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{http::header, middleware::Logger, App, HttpServer};
use chrono::Duration;
use db_connection::get_pool;
use dotenv::dotenv;
use handlers::authentication;
pub fn get_env(var_name: &str) -> String {
match std::env::var(&var_name) {
Ok(var) => return var,
Err(e) => {
eprintln!(
"Failed to read required environment variable: {}",
&var_name
);
eprintln!("Reason: {}", e.to_string());
panic!("Can't continue without variable");
}
};
}
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=debug,diesel=debug");
env_logger::init();
dotenv().ok();
let bind = get_env("BIND");
let port = get_env("PORT");
let address = format!("{}:{}", bind, port);
println!("Starting server at: http://{}", &address);
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.wrap(
Cors::new()
.allowed_origin(get_env("ALLOWED_ORIGIN").as_str())
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
.allowed_headers(vec![
header::AUTHORIZATION,
header::CONTENT_TYPE,
header::ACCEPT,
])
.max_age(3600)
.finish(),
)
.wrap(IdentityService::new(
CookieIdentityPolicy::new(get_env("SECRET").as_bytes())
.domain(get_env("DOMAIN"))
.name(get_env("APP_NAME"))
.path("/")
.max_age(Duration::days(1).num_seconds())
.secure(false),
))
.data(get_pool())
.service(Files::new("/", "./static").index_file("index.html"))
.service(authentication::register)
.service(authentication::login)
.service(authentication::logout)
.service(authentication::delete)
})
.bind(address)?
.run()
.await
}

View file

@ -0,0 +1,15 @@
use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};
pub type DbPool = r2d2::Pool<ConnectionManager<DbConnection>>;
pub type PooledConnection = r2d2::Pool<ConnectionManager<MysqlConnection>>;
fn init_pool(database_url: &str) -> Result<PgPool, PoolError> {
let manager = ConnectionManager::<MysqlConnection>::new(database_url);
r2d2::Pool::builder().build(manager)
}
pub fn get_pool() -> DbPool {
let connspec = std::env::var("DATABASE_URL").expect("DATABASE_URL");
init_pool(connspec).expect("Failed to create DB pool")
}

View file

@ -0,0 +1 @@
pub mod user;

View file

@ -0,0 +1,119 @@
use crate::{errors::CustomError, schema::users};
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{Local, NaiveDateTime};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[table_name = "users"]
pub struct User {
#[serde(skip)]
pub id: i32,
pub username: String,
#[serde(skip)]
pub password: String,
pub admin: bool,
pub created_at: NaiveDateTime,
}
// another user struct for new users since id is missing on
// creation (created by mariadb)
#[derive(Debug, Clone, Serialize, Deserialize, Insertable)]
#[table_name = "users"]
pub struct NewUser {
pub username: String,
pub password: String,
pub admin: bool,
pub created_at: NaiveDateTime,
}
impl User {
pub fn hash_password(password: String) -> Result<String, CustomError> {
Ok(hash(password, DEFAULT_COST)?)
}
pub fn create(
register_user: RegisterUser,
connection: &MysqlConnection,
) -> Result<usize, CustomError> {
use crate::schema::users::dsl::users;
let new_user = NewUser {
username: register_user.username,
admin: register_user.admin,
password: Self::hash_password(register_user.password)?,
created_at: Local::now().naive_local(),
};
Ok(diesel::insert_into(users)
.values(new_user)
.execute(connection)?)
}
}
#[derive(Serialize, Deserialize)]
pub struct RegisterUser {
pub username: String,
pub password: String,
pub admin: bool,
pub password_confirmation: String,
}
impl RegisterUser {
pub fn validation(self) -> Result<RegisterUser, CustomError> {
let passwords_are_equal = self.password == self.password_confirmation;
let password_not_empty = self.password.len() > 0;
if passwords_are_equal && password_not_empty {
Ok(self)
} else if !passwords_are_equal {
Err(CustomError::PasswordMatchError(
"Password and confirmation do not match".to_string(),
))
} else {
Err(CustomError::PasswordWrong(
"Wrong or empty password".to_string(),
))
}
}
}
#[derive(Deserialize)]
pub struct AuthUser {
pub username: String,
pub password: String,
}
impl AuthUser {
pub fn login(&self, connection: &MysqlConnection) -> Result<User, CustomError> {
use crate::schema::users::dsl::*;
let user = users
.filter(username.eq(&self.username))
.first::<User>(connection)?;
let verify_password = verify(&self.password, &user.password)
.map_err(|_e| CustomError::PasswordWrong("Wrong password".to_string()))?;
if verify_password {
Ok(user)
} else {
Err(CustomError::PasswordWrong("Wrong password".to_string()))
}
}
}
#[derive(Deserialize)]
pub struct DeleteUser {
pub username: String,
}
impl DeleteUser {
pub fn delete(&self, connection: &MysqlConnection) -> Result<bool, CustomError> {
use crate::schema::users::dsl::*;
match diesel::delete(users.filter(username.eq(&self.username))).execute(connection) {
Ok(_r) => Ok(true),
Err(e) => Err(CustomError::DBError(e)),
}
}
}

View file

@ -0,0 +1,9 @@
table! {
users (id) {
id -> Integer,
username -> Varchar,
password -> Varchar,
admin -> Bool,
created_at -> Timestamp,
}
}

View file

@ -0,0 +1,62 @@
extern crate bcrypt;
extern crate jsonwebtoken;
use crate::get_env;
use actix_web::HttpResponse;
use chrono::{Duration, Local};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: i32,
name: String,
admin: bool,
exp: usize,
}
#[derive(Deserialize)]
pub struct UserWithToken {
pub id: i32,
pub username: String,
pub admin: bool,
}
impl From<Claims> for UserWithToken {
fn from(claims: Claims) -> Self {
UserWithToken {
id: claims.sub,
username: claims.name,
admin: claims.admin,
}
}
}
impl Claims {
fn with_username(id: i32, username: &str, admin: bool) -> Self {
Claims {
sub: id,
name: username.into(),
admin,
exp: (Local::now() + Duration::hours(24)).timestamp() as usize,
}
}
}
pub fn encode_token(id: i32, username: &str, admin: bool) -> Result<String, HttpResponse> {
let claims = Claims::with_username(id, username, admin);
let secret = get_env("JWT_SECRET");
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&Header::default(), &claims, &key)
.map_err(|e| HttpResponse::InternalServerError().json(e.to_string()))
}
pub fn decode_token(token: &str) -> Result<UserWithToken, HttpResponse> {
let secret = get_env("JWT_SECRET");
let key = DecodingKey::from_secret(secret.as_bytes());
decode::<Claims>(token, &key, &Validation::default())
.map(|data| data.claims.into())
.map_err(|e| HttpResponse::Unauthorized().json(e.to_string()))
}

View file

@ -0,0 +1 @@
pub mod jwt;

1
project/backend/static Symbolic link
View file

@ -0,0 +1 @@
../frontend/target/deploy