start implementing basic login functionality
This commit is contained in:
parent
8b4440f723
commit
eabeb7ed7b
14 changed files with 326 additions and 8 deletions
|
@ -6,11 +6,18 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rocket = "0.4.10"
|
rocket = { version= "0.5.0-rc.1", features = ["json"] }
|
||||||
diesel = { version = "1.4.4", features = ["postgres"] }
|
diesel = { version = "1.4.4", features = ["postgres", "r2d2"] }
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
|
rust-argon2 = "0.8"
|
||||||
|
rand = "0.8.4"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_bytes = "0.11"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
base64 = "0.13.0"
|
||||||
|
|
||||||
[dependencies.rocket_contrib]
|
|
||||||
version = "0.4.10"
|
[dependencies.rocket_sync_db_pools]
|
||||||
default-features = false
|
version = "0.1.0-rc.1"
|
||||||
features = ["diesel_postgres_pool"]
|
features = ["diesel_postgres_pool"]
|
2
backend/Rocket.toml
Normal file
2
backend/Rocket.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[debug.databases.postgresql_database]
|
||||||
|
url = "postgresql://planetwars:planetwars@localhost/planetwars"
|
1
backend/migrations/2021-12-13-145111_users/down.sql
Normal file
1
backend/migrations/2021-12-13-145111_users/down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE users;
|
6
backend/migrations/2021-12-13-145111_users/up.sql
Normal file
6
backend/migrations/2021-12-13-145111_users/up.sql
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
CREATE TABLE users(
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(52) NOT NULL,
|
||||||
|
password_salt BYTEA NOT NULL,
|
||||||
|
password_hash BYTEA NOT NULL
|
||||||
|
);
|
1
backend/migrations/2021-12-13-151129_sessions/down.sql
Normal file
1
backend/migrations/2021-12-13-151129_sessions/down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE sessions;
|
5
backend/migrations/2021-12-13-151129_sessions/up.sql
Normal file
5
backend/migrations/2021-12-13-151129_sessions/up.sql
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
user_id integer REFERENCES users(id) NOT NULL,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE
|
||||||
|
)
|
2
backend/src/db/mod.rs
Normal file
2
backend/src/db/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod sessions;
|
||||||
|
pub mod users;
|
46
backend/src/db/sessions.rs
Normal file
46
backend/src/db/sessions.rs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
use super::users::User;
|
||||||
|
use crate::schema::{sessions, users};
|
||||||
|
use base64;
|
||||||
|
use diesel::PgConnection;
|
||||||
|
use diesel::{insert_into, prelude::*, Insertable, RunQueryDsl};
|
||||||
|
use rand::{self, Rng};
|
||||||
|
|
||||||
|
#[derive(Insertable)]
|
||||||
|
#[table_name = "sessions"]
|
||||||
|
struct NewSession {
|
||||||
|
token: String,
|
||||||
|
user_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug, PartialEq)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: i32,
|
||||||
|
pub user_id: i32,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_session(user: &User, conn: &PgConnection) -> Session {
|
||||||
|
let new_session = NewSession {
|
||||||
|
token: gen_session_token(),
|
||||||
|
user_id: user.user_id,
|
||||||
|
};
|
||||||
|
let session = insert_into(sessions::table)
|
||||||
|
.values(&new_session)
|
||||||
|
.get_result::<Session>(conn)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_user_by_session(token: &str, conn: &PgConnection) -> QueryResult<(Session, User)> {
|
||||||
|
sessions::table
|
||||||
|
.inner_join(users::table)
|
||||||
|
.filter(sessions::token.eq(&token))
|
||||||
|
.first::<(Session, User)>(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gen_session_token() -> String {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let token: [u8; 32] = rng.gen();
|
||||||
|
return base64::encode(&token);
|
||||||
|
}
|
106
backend/src/db/users.rs
Normal file
106
backend/src/db/users.rs
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
use crate::{schema::users, DbConn};
|
||||||
|
use argon2;
|
||||||
|
use diesel::{prelude::*, PgConnection};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Credentials<'a> {
|
||||||
|
pub username: &'a str,
|
||||||
|
pub password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Insertable)]
|
||||||
|
#[table_name = "users"]
|
||||||
|
pub struct NewUser<'a> {
|
||||||
|
pub username: &'a str,
|
||||||
|
pub password_hash: &'a [u8],
|
||||||
|
pub password_salt: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Queryable, Debug)]
|
||||||
|
pub struct User {
|
||||||
|
pub user_id: i32,
|
||||||
|
pub username: String,
|
||||||
|
pub password_salt: Vec<u8>,
|
||||||
|
pub password_hash: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make this configurable somewhere
|
||||||
|
fn argon2_config() -> argon2::Config<'static> {
|
||||||
|
argon2::Config {
|
||||||
|
variant: argon2::Variant::Argon2i,
|
||||||
|
version: argon2::Version::Version13,
|
||||||
|
mem_cost: 4096,
|
||||||
|
time_cost: 3,
|
||||||
|
lanes: 1,
|
||||||
|
thread_mode: argon2::ThreadMode::Sequential,
|
||||||
|
// TODO: set a secret
|
||||||
|
secret: &[],
|
||||||
|
ad: &[],
|
||||||
|
hash_length: 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_user(credentials: &Credentials, conn: &PgConnection) -> QueryResult<User> {
|
||||||
|
let argon_config = argon2_config();
|
||||||
|
|
||||||
|
let salt: [u8; 32] = rand::thread_rng().gen();
|
||||||
|
let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap();
|
||||||
|
let new_user = NewUser {
|
||||||
|
username: &credentials.username,
|
||||||
|
password_salt: &salt,
|
||||||
|
password_hash: &hash,
|
||||||
|
};
|
||||||
|
diesel::insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.get_result::<User>(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticate_user(credentials: &Credentials, db_conn: &PgConnection) -> Option<User> {
|
||||||
|
let user = users::table
|
||||||
|
.filter(users::username.eq(&credentials.username))
|
||||||
|
.first::<User>(db_conn)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let password_matches = argon2::verify_raw(
|
||||||
|
credentials.password.as_bytes(),
|
||||||
|
&user.password_salt,
|
||||||
|
&user.password_hash,
|
||||||
|
&argon2_config(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if password_matches {
|
||||||
|
return Some(user);
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_argon() {
|
||||||
|
let credentials = Credentials {
|
||||||
|
username: "piepkonijn",
|
||||||
|
password: "geheim123",
|
||||||
|
};
|
||||||
|
let argon_config = argon2_config();
|
||||||
|
|
||||||
|
let salt: [u8; 32] = rand::thread_rng().gen();
|
||||||
|
let hash = argon2::hash_raw(credentials.password.as_bytes(), &salt, &argon_config).unwrap();
|
||||||
|
let new_user = NewUser {
|
||||||
|
username: &credentials.username,
|
||||||
|
password_hash: &hash,
|
||||||
|
password_salt: &salt,
|
||||||
|
};
|
||||||
|
|
||||||
|
let password_matches = argon2::verify_raw(
|
||||||
|
credentials.password.as_bytes(),
|
||||||
|
&new_user.password_salt,
|
||||||
|
&new_user.password_hash,
|
||||||
|
&argon2_config(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(password_matches);
|
||||||
|
}
|
|
@ -1,15 +1,36 @@
|
||||||
#![feature(proc_macro_hygiene, decl_macro)]
|
#![feature(proc_macro_hygiene, decl_macro)]
|
||||||
|
|
||||||
|
use rocket::{Build, Rocket};
|
||||||
|
use rocket_sync_db_pools::database;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate rocket;
|
extern crate rocket;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate rocket_contrib;
|
extern crate diesel;
|
||||||
|
|
||||||
|
mod db;
|
||||||
|
mod routes;
|
||||||
|
mod schema;
|
||||||
|
|
||||||
|
#[database("postgresql_database")]
|
||||||
|
pub struct DbConn(diesel::PgConnection);
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
fn index() -> &'static str {
|
fn index() -> &'static str {
|
||||||
"Hello, world!"
|
"Hello, world!"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
#[launch]
|
||||||
rocket::ignite().mount("/", routes![index]).launch();
|
fn rocket() -> Rocket<Build> {
|
||||||
|
rocket::build()
|
||||||
|
.mount(
|
||||||
|
"/",
|
||||||
|
routes![
|
||||||
|
index,
|
||||||
|
routes::users::register,
|
||||||
|
routes::users::login,
|
||||||
|
routes::users::current_user,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.attach(DbConn::fairing())
|
||||||
}
|
}
|
||||||
|
|
1
backend/src/routes/mod.rs
Normal file
1
backend/src/routes/mod.rs
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub mod users;
|
100
backend/src/routes/users.rs
Normal file
100
backend/src/routes/users.rs
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
use crate::db::{sessions, users};
|
||||||
|
use crate::{
|
||||||
|
db::users::{Credentials, User},
|
||||||
|
DbConn,
|
||||||
|
};
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::request::{self, FromRequest, Outcome, Request};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AuthTokenError {
|
||||||
|
BadCount,
|
||||||
|
Missing,
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: error handling and proper lifetimes
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for User {
|
||||||
|
type Error = AuthTokenError;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
let keys: Vec<_> = request.headers().get("Authorization").collect();
|
||||||
|
let token = match keys.len() {
|
||||||
|
0 => return Outcome::Failure((Status::BadRequest, AuthTokenError::Missing)),
|
||||||
|
1 => keys[0].to_string(),
|
||||||
|
_ => return Outcome::Failure((Status::BadRequest, AuthTokenError::BadCount)),
|
||||||
|
};
|
||||||
|
let db = request.guard::<DbConn>().await.unwrap();
|
||||||
|
let (_session, user) = db
|
||||||
|
.run(move |conn| sessions::find_user_by_session(&token, conn))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
Outcome::Success(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct UserData {
|
||||||
|
pub user_id: i32,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<User> for UserData {
|
||||||
|
fn from(user: User) -> Self {
|
||||||
|
UserData {
|
||||||
|
user_id: user.user_id,
|
||||||
|
username: user.username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegistrationParams {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/register", data = "<params>")]
|
||||||
|
pub async fn register(db_conn: DbConn, params: Json<RegistrationParams>) -> Json<UserData> {
|
||||||
|
db_conn
|
||||||
|
.run(move |conn| {
|
||||||
|
let credentials = Credentials {
|
||||||
|
username: ¶ms.username,
|
||||||
|
password: ¶ms.password,
|
||||||
|
};
|
||||||
|
let user = users::create_user(&credentials, conn).unwrap();
|
||||||
|
Json(user.into())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginParams {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/login", data = "<params>")]
|
||||||
|
pub async fn login(db_conn: DbConn, params: Json<LoginParams>) -> String {
|
||||||
|
db_conn
|
||||||
|
.run(move |conn| {
|
||||||
|
let credentials = Credentials {
|
||||||
|
username: ¶ms.username,
|
||||||
|
password: ¶ms.password,
|
||||||
|
};
|
||||||
|
// TODO: handle failures
|
||||||
|
let user = users::authenticate_user(&credentials, conn).unwrap();
|
||||||
|
let session = sessions::create_session(&user, conn);
|
||||||
|
return session.token;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/users/me")]
|
||||||
|
pub async fn current_user(user: User) -> Json<UserData> {
|
||||||
|
Json(user.into())
|
||||||
|
}
|
20
backend/src/schema.rs
Normal file
20
backend/src/schema.rs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
table! {
|
||||||
|
sessions (id) {
|
||||||
|
id -> Int4,
|
||||||
|
user_id -> Int4,
|
||||||
|
token -> Varchar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
users (id) {
|
||||||
|
id -> Int4,
|
||||||
|
username -> Varchar,
|
||||||
|
password_salt -> Bytea,
|
||||||
|
password_hash -> Bytea,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
joinable!(sessions -> users (user_id));
|
||||||
|
|
||||||
|
allow_tables_to_appear_in_same_query!(sessions, users,);
|
0
backend/tests/common.rs
Normal file
0
backend/tests/common.rs
Normal file
Loading…
Reference in a new issue