Added a user system with no proper user validation but working authorisation.

This commit is contained in:
2022-03-29 19:32:21 +02:00
parent ee500203e2
commit af0dcee159
42 changed files with 28429 additions and 185 deletions

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target
.vscode
app.toml
*.sqlite

2280
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "gamenight"
version = "0.1.0"
authors = ["Dennis Brentjes <d.brentjes@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rocket = { version = "0.5.0-rc.1", features = ["default", "json"] }
libsqlite3-sys = { version = ">=0.8.0, <0.19.0", features = ["bundled"] }
rocket_sync_db_pools = { version = "0.1.0-rc.1", features = ["diesel_sqlite_pool"] }
diesel = { version = "1.4.8", features = ["sqlite"] }
diesel_migrations = "1.4.0"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["handlebars"] }
chrono = "0.4.19"
serde = "1.0.136"
password-hash = "0.4"
argon2 = "0.4"
rand_core = { version = "0.6", features = ["std"] }
diesel-derive-enum = { version = "1.1", features = ["sqlite"] }
jsonwebtoken = "8.1"

8
backend/app.toml.example Normal file
View File

@@ -0,0 +1,8 @@
#Copy this file over to Rocket.toml after changing all relevant values.
[default]
jwt_secret = "some really good secret"
[global.databases]
gamenight_database = { url = "gamenight.sqlite" }

View File

@@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
drop table gamenight;
drop table known_games;

View File

@@ -0,0 +1,12 @@
-- Your SQL goes here
CREATE TABLE gamenight (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
game text TEXT NOT NULL,
datetime TEXT NOT NULL
);
CREATE TABLE known_games (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
game TEXT UNIQUE NOT NULL
);

View File

@@ -0,0 +1,4 @@
-- This file should undo anything in `up.sql`
drop table user;
drop table pwd;

View File

@@ -0,0 +1,11 @@
CREATE TABLE user (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
role TEXT NOT NULL
);
CREATE TABLE pwd (
id INTEGER NOT NULL PRIMARY KEY,
password TEXT NOT NULL
);

30
backend/readme.md Normal file
View File

@@ -0,0 +1,30 @@
# Gamenight
Een online tooltje voor het organiseren van een \(board\)gamenight
Het doel, Je kan een GameNight organiseren, je geeft wanneer je zou willen spelen, je geeft aan waar je zin in hebt, welke games je hebt, en of je kan hosten. Mensen kunnen zich erbij klikken, hopelijk volgt er een spelletjes consensus, en go.
Ik wil iets maken wat georganiseerder is dan een mailthread en minder push bericht is dan een boargame appgroep.
Geplande features:
* Account systeem zodat het niet allemaal publiek is.
* Manier om een event toe te voegen.
* Manier voor owners om een event te verwijderen.
* Lijst aan upcoming events.
* Lijst aan archived events
* Manier om te koppelen aan je account welke games je hebt, zodat dit automatisch aangevult.wordt als je een party joined,
* manier om comments te plaatsen op een event.
* manier om een of meer spellen te selecten zodat mensen dit ook daadwerkelijk meenemen en niet iedereen alles hoeft mee te nemen #QOL
* manier om recurring game avonden te plannen.
Meta features:
* Api apart van de site ontwikkelen zodat je shit kan automagiseren, zelf push berichten kan fixen als je wil via de API.
* Een beetje sexy website bouwen zodat hij op zijn minst bruikbaar is op je mobiel.
# Mee devven?
Graag!
Belangrijkste devding dat je moet weten is dat je diesel migrations kan genereren en invulling kan geven, deze database migraties worden automatisch uitgevoerd als je de binary daarna start, of je kan ze handmatig uitvoeren met de diesel executable zelf. Ik weet niet zeker of je diesel nog handmatig moet installeren, maar ik denk het wel `cargo install diesel`
database migration genereren: `diesel migration generate <descriptive name>`

View File

@@ -0,0 +1,3 @@
echo $JWT
curl -X GET -H "Authorization: Bearer ${JWT}" localhost:8000/api/gamenights

View File

@@ -0,0 +1 @@
curl -X POST -H "Content-Type: application/json" -d '{"username": "a", "password": "c"}' localhost:8000/api/login

View File

@@ -0,0 +1 @@
curl -X POST -H "Content-Type: application/json" -d '{"username": "a", "email": "b", "password": "c", "password_repeat": "d"}' localhost:8000/api/register

178
backend/src/api.rs Normal file
View File

@@ -0,0 +1,178 @@
use crate::AppConfig;
use rocket::request::Outcome;
use jsonwebtoken::decode;
use crate::schema::DbConn;
use jsonwebtoken::DecodingKey;
use jsonwebtoken::Validation;
use rocket::State;
use chrono::Utc;
use jsonwebtoken::{Header, EncodingKey};
use crate::schema;
use std::borrow::Cow;
use jsonwebtoken::encode;
use rocket::serde::json::{Json, json, Value};
use rocket::http::Status;
use rocket::request::{self, Request, FromRequest};
use rocket::outcome::Outcome::{Success, Failure};
use serde::{Serialize, Deserialize};
pub struct Referer(String);
#[derive(Debug)]
pub enum ReferrerError {
Missing,
MoreThanOne
}
#[derive(Debug, Responder)]
pub enum ApiResponseVariant {
Status(Status),
// Redirect(Redirect),
Value(Value),
// Flash(Flash<Redirect>)
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Referer {
type Error = ReferrerError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let referers : Vec<_> = req.headers().get("Referer").collect();
match referers.len() {
0 => Failure((Status::BadRequest, ReferrerError::Missing)),
1 => Success(Referer(referers[0].to_string())),
_ => Failure((Status::BadRequest, ReferrerError::MoreThanOne)),
}
}
}
#[derive(Serialize, Deserialize, Debug)]
struct ApiResponse {
result: Cow<'static, str>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option::<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
jwt: Option::<Cow<'static, str>>
}
impl ApiResponse {
const SUCCES_RESULT: Cow<'static, str> = Cow::Borrowed("Ok");
const FAILURE_RESULT: Cow<'static, str> = Cow::Borrowed("Failure");
const SUCCES: Self = Self {
result: Self::SUCCES_RESULT,
message: None,
jwt: None
};
fn error(message: String) -> Self {
Self {
result: Self::FAILURE_RESULT,
message: Some(Cow::Owned(message)),
jwt: None
}
}
fn login_response(jwt: String) -> Self {
Self {
result: Self::SUCCES_RESULT,
message: None,
jwt: Some(Cow::Owned(jwt))
}
}
}
#[derive(Debug)]
pub enum ApiError {
RequestError(String),
}
const AUTH_HEADER: &str = "Authorization";
const BEARER: &str = "Bearer ";
#[rocket::async_trait]
impl<'r> FromRequest<'r> for schema::User {
type Error = ApiError;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let header = match req.headers().get_one(AUTH_HEADER) {
Some(header) => header,
None => return Outcome::Failure((Status::BadRequest, ApiError::RequestError("No authorization header found".to_string())))
};
if !header.starts_with(BEARER) {
return Outcome::Failure((Status::BadRequest, ApiError::RequestError("Invalid Authorization header.".to_string())))
};
let app_config = req.guard::<&State<AppConfig>>().await.unwrap().inner();
let jwt = header.trim_start_matches(BEARER).to_owned();
let token = match decode::<Claims>(&jwt, &DecodingKey::from_secret(app_config.jwt_secret.as_bytes()), &Validation::default()) {
Ok(token) => token,
Err(error) => return Outcome::Failure((Status::BadRequest, ApiError::RequestError(error.to_string())))
};
let id = token.claims.uid;
let conn = req.guard::<DbConn>().await.unwrap();
return Outcome::Success(schema::get_user(conn, id).await)
}
}
#[get("/gamenights")]
pub async fn gamenights(conn: DbConn, user: Option<schema::User>) -> ApiResponseVariant {
if user.is_some() {
let gamenights = schema::get_all_gamenights(conn).await;
ApiResponseVariant::Value(json!(gamenights))
}
else {
ApiResponseVariant::Status(Status::Unauthorized)
}
}
#[post("/gamenight", format = "application/json", data = "<gamenight_json>")]
pub async fn gamenight_post_json(conn: DbConn, user: Option<schema::User>, gamenight_json: Json<schema::GameNightNoId>) -> ApiResponseVariant {
if user.is_some() {
schema::insert_gamenight(conn, gamenight_json.into_inner()).await;
ApiResponseVariant::Value(json!(ApiResponse::SUCCES))
}
else {
ApiResponseVariant::Status(Status::Unauthorized)
}
}
#[post("/register", format = "application/json", data = "<register_json>")]
pub async fn register_post_json(conn: DbConn, register_json: Json<schema::Register>) -> ApiResponseVariant {
match schema::insert_user(conn, register_json.into_inner()).await {
Ok(_) => ApiResponseVariant::Value(json!(ApiResponse::SUCCES)),
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string())))
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
exp: i64,
uid: i32,
role: schema::Role,
}
#[post("/login", format = "application/json", data = "<login_json>")]
pub async fn login_post_json(conn: DbConn, config: &State<AppConfig>, login_json: Json<schema::Login>) -> ApiResponseVariant {
match schema::login(conn, login_json.into_inner()).await {
Err(err) => ApiResponseVariant::Value(json!(ApiResponse::error(err.to_string()))),
Ok(login_result) => {
let my_claims = Claims {
exp: Utc::now().timestamp() + chrono::Duration::days(7).num_seconds(),
uid: login_result.id.unwrap(),
role: login_result.role.unwrap()
};
let secret = &config.inner().jwt_secret;
match encode(&Header::default(), &my_claims, &EncodingKey::from_secret(secret.as_bytes()))
{
Ok(token) => ApiResponseVariant::Value(json!(ApiResponse::login_response(token))),
Err(error) => ApiResponseVariant::Value(json!(ApiResponse::error(error.to_string())))
}
}
}
}

46
backend/src/main.rs Normal file
View File

@@ -0,0 +1,46 @@
#[macro_use] extern crate rocket;
#[macro_use] extern crate diesel_migrations;
#[macro_use] extern crate diesel;
use rocket::{fairing::AdHoc, figment::{Figment, providers::{Serialized, Toml, Env, Format}, Profile}};
use rocket_dyn_templates::Template;
use serde::{Deserialize, Serialize};
mod api;
pub mod schema;
mod site;
#[derive(Debug, Deserialize, Serialize)]
pub struct AppConfig {
jwt_secret: String
}
impl Default for AppConfig {
fn default() -> AppConfig {
AppConfig { jwt_secret: String::from("secret") }
}
}
#[launch]
fn rocket() -> _ {
let figment = Figment::from(rocket::Config::default())
.merge(Serialized::defaults(AppConfig::default()))
.merge(Toml::file("App.toml").nested())
.merge(Env::prefixed("APP_").global())
.select(Profile::from_env_or("APP_PROFILE", "default"));
let rocket = rocket::custom(figment)
.attach(schema::DbConn::fairing())
.attach(Template::fairing())
.attach(AdHoc::on_ignite("Run Migrations", schema::run_migrations))
.attach(AdHoc::config::<AppConfig>())
.mount("/", routes![site::index, site::gamenights,
site::add_game_night, site::register])
.mount("/api", routes![
api::gamenights, api::gamenight_post_json,
api::register_post_json,
api::login_post_json
]);
rocket
}

258
backend/src/schema.rs Normal file
View File

@@ -0,0 +1,258 @@
use argon2::PasswordVerifier;
use argon2::PasswordHash;
use diesel_derive_enum::DbEnum;
use crate::diesel::QueryDsl;
use crate::diesel::BoolExpressionMethods;
use crate::diesel::ExpressionMethods;
use crate::diesel::Connection;
use rocket_sync_db_pools::database;
use serde::{Serialize, Deserialize};
use rocket::{Rocket, Build};
use diesel::RunQueryDsl;
use argon2::{
password_hash::{
rand_core::OsRng,
PasswordHasher
},
Argon2
};
use argon2::password_hash::SaltString;
#[database("gamenight_database")]
pub struct DbConn(diesel::SqliteConnection);
table! {
gamenight (id) {
id -> Integer,
game -> Text,
datetime -> Text,
}
}
table! {
known_games (game) {
id -> Integer,
game -> Text,
}
}
table! {
use diesel::sql_types::Integer;
use diesel::sql_types::Text;
use super::RoleMapping;
user(id) {
id -> Integer,
username -> Text,
email -> Text,
role -> RoleMapping,
}
}
table! {
pwd(id) {
id -> Integer,
password -> Text,
}
}
allow_tables_to_appear_in_same_query!(
gamenight,
known_games,
);
pub enum DatabaseError {
Hash(password_hash::Error),
Query(String)
}
impl std::fmt::Display for DatabaseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
match self {
DatabaseError::Hash(err) => write!(f, "{}", err),
DatabaseError::Query(err) => write!(f, "{}", err)
}
}
}
pub async fn get_all_gamenights(conn: DbConn) -> Vec::<GameNight> {
conn.run(|c| {
gamenight::table.load::<GameNight>(c).unwrap()
}).await
}
pub async fn insert_gamenight(conn: DbConn, new_gamenight: GameNightNoId) -> () {
conn.run(|c| {
diesel::insert_into(gamenight::table)
.values(new_gamenight)
.execute(c)
.unwrap()
}).await;
}
pub async fn insert_user(conn: DbConn, new_user: Register) -> Result<(), DatabaseError> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = match argon2.hash_password(new_user.password.as_bytes(), &salt) {
Ok(hash) => hash.to_string(),
Err(error) => return Err(DatabaseError::Hash(error))
};
match conn.run(move |c| {
c.transaction(|| {
diesel::insert_into(user::table)
.values((user::username.eq(&new_user.username), user::email.eq(&new_user.email), user::role.eq(Role::User)))
.execute(c)?;
let ids : Vec::<i32> = match user::table
.filter(user::username.eq(&new_user.username).and(user::email.eq(&new_user.email)))
.select(user::id)
.get_results(c) {
Ok(id) => id,
Err(e) => return Err(e)
};
diesel::insert_into(pwd::table)
.values((pwd::id.eq(ids[0]), pwd::password.eq(&password_hash)))
.execute(c)
})
}).await {
Err(e) => Err(DatabaseError::Query(e.to_string())),
_ => Ok(()),
}
}
pub async fn login(conn: DbConn, login: Login) -> Result<LoginResult, DatabaseError> {
conn.run(move |c| -> Result<LoginResult, DatabaseError> {
let id : i32 = match user::table
.filter(user::username.eq(&login.username))
.or_filter(user::email.eq(&login.username))
.select(user::id)
.get_results(c) {
Ok(id) => id[0],
Err(error) => return Err(DatabaseError::Query(error.to_string()))
};
let pwd : String = match pwd::table
.filter(pwd::id.eq(id))
.select(pwd::password)
.get_results::<String>(c) {
Ok(pwd) => pwd[0].clone(),
Err(error) => return Err(DatabaseError::Query(error.to_string()))
};
let parsed_hash = match PasswordHash::new(&pwd) {
Ok(hash) => hash,
Err(error) => return Err(DatabaseError::Hash(error))
};
if Argon2::default().verify_password(&login.password.as_bytes(), &parsed_hash).is_ok() {
let role : Role = match user::table
.filter(user::id.eq(id))
.select(user::role)
.get_results::<Role>(c) {
Ok(role) => role[0].clone(),
Err(error) => return Err(DatabaseError::Query(error.to_string()))
};
Ok(LoginResult {
result: true,
id: Some(id),
role: Some(role)
})
}
else {
Ok(LoginResult {
result: false,
id: None,
role: None,
})
}
}).await
}
pub async fn get_user(conn: DbConn, id: i32) -> User {
conn.run(move |c| {
user::table
.filter(user::id.eq(id))
.first(c)
.unwrap()
}).await
}
pub async fn run_migrations(rocket: Rocket<Build>) -> Rocket<Build> {
// This macro from `diesel_migrations` defines an `embedded_migrations`
// module containing a function named `run`. This allows the example to be
// run and tested without any outside setup of the database.
embed_migrations!();
let conn = DbConn::get_one(&rocket).await.expect("database connection");
conn.run(|c| embedded_migrations::run(c)).await.expect("can run migrations");
rocket
}
#[derive(Debug, Serialize, Deserialize, DbEnum, Clone)]
pub enum Role {
Admin,
User,
}
#[derive(Serialize, Deserialize, Debug, Insertable, Queryable)]
#[table_name="user"]
pub struct User {
pub id: i32,
pub username: String,
pub email: String,
pub role: Role
}
#[derive(Serialize, Deserialize, Debug, FromForm, Insertable)]
#[table_name="known_games"]
pub struct GameNoId {
pub game : String,
}
#[derive(Serialize, Deserialize, Debug, FromForm, Queryable)]
pub struct Game {
pub id: i32,
pub game : String,
}
#[derive(Serialize, Deserialize, Debug, FromForm, Insertable)]
#[table_name="gamenight"]
pub struct GameNightNoId {
pub game : String,
pub datetime : String,
}
#[derive(Serialize, Deserialize, Debug, FromForm, Queryable)]
pub struct GameNight {
pub id: i32,
pub game : String,
pub datetime : String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Register {
pub username: String,
pub email: String,
pub password: String,
pub password_repeat: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Login {
pub username: String,
pub password: String
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LoginResult {
pub result: bool,
pub id: Option<i32>,
pub role: Option<Role>
}

88
backend/src/site.rs Normal file
View File

@@ -0,0 +1,88 @@
use std::borrow::Cow;
use serde::{Serialize, Deserialize};
use rocket_dyn_templates::Template;
use rocket::response::{Redirect};
use rocket::request::{FlashMessage};
use crate::schema;
#[derive(Serialize, Deserialize, Debug)]
struct FlashData {
has_data: bool,
kind: Cow<'static, str>,
message: Cow<'static, str>
}
impl FlashData {
const EMPTY: Self = Self { has_data: false, message: Cow::Borrowed(""), kind: Cow::Borrowed("") };
}
#[derive(Serialize, Deserialize, Debug)]
struct GameNightsData {
gamenights: Vec::<schema::GameNight>,
flash: FlashData
}
#[get("/gamenights")]
pub async fn gamenights(conn: schema::DbConn) -> Template {
let gamenights = schema::get_all_gamenights(conn).await;
let data = GameNightsData {
gamenights: gamenights,
flash: FlashData::EMPTY
};
Template::render("gamenights", &data)
}
#[get("/")]
pub async fn index() -> Redirect {
Redirect::to(uri!(gamenights))
}
#[derive(Serialize, Deserialize, Debug)]
struct GameNightAddData {
post_url: String,
flash : FlashData
}
#[get("/gamenight/add")]
pub async fn add_game_night(flash: Option<FlashMessage<'_>>) -> Template {
let flash_data = match flash {
None => FlashData::EMPTY,
Some(flash) => FlashData {
has_data: true,
message: Cow::Owned(flash.message().to_string()),
kind: Cow::Owned(flash.kind().to_string())
}
};
let data = GameNightAddData {
post_url: "/api/gamenight".to_string(),
flash: flash_data
};
Template::render("gamenight_add", &data)
}
#[derive(Serialize, Deserialize, Debug)]
struct RegisterData {
flash : FlashData
}
#[get("/register")]
pub async fn register(flash: Option<FlashMessage<'_>>) -> Template {
let flash_data = match flash {
None => FlashData::EMPTY,
Some(flash) => FlashData {
has_data: true,
message: Cow::Owned(flash.message().to_string()),
kind: Cow::Owned(flash.kind().to_string())
}
};
let data = RegisterData {
flash: flash_data
};
Template::render("register", &data)
}

View File

@@ -0,0 +1,5 @@
{{#if has_data}}
<div>
<p>{{kind}}: {{message}}</p>
</div>
{{/if}}

View File

@@ -0,0 +1,16 @@
<html>
<head>
</head>
<body>
{{> flash flash }}
<form action="{{post_url}}" method="post">
<label for="game">Game:</label><br>
<input type="text" id="game" name="game"><br>
<label for="datetime">Wanneer:</label><br>
<input type="text" id="datetime" name="datetime">
<input type="submit" value="Submit">
</form>
</body>
</html>

View File

@@ -0,0 +1,14 @@
<html>
<head>
</head>
<body>
{{> flash flash }}
{{#each gamenights}}
<div>
<span>game: {{this.game}}</span>
<span>when: {{this.datetime}}</span>
</div>
{{/each}}
</body>
</html>

View File

@@ -0,0 +1,19 @@
<html>
<head>
</head>
<body>
{{> flash flash }}
<form action="/api/register" method="post">
<label for="username">Username:</label><br>
<input type="text" id="username" name="username" required><br>
<label for="email">Email:</label><br>
<input type="text" id="email" name="email" required><br>
<label for="password">Password:</label><br>
<input type="password" id="password" name="password" required><br>
<label for="password_repeat">Repeat password:</label><br>
<input type="password" id="password_repeat" name="password_repeat" required><br>
<input type="submit">
</form>
</body>
</html>1