Add basic register and login
All checks were successful
Build Crate / build (push) Successful in 1m45s

This commit is contained in:
2024-08-28 16:53:08 -04:00
parent f4f491085d
commit 5af626b746
23 changed files with 397 additions and 31 deletions

View File

@@ -19,7 +19,6 @@ once_cell = "1.18.0"
web-sys = { version = "0.3.64", features = ["Window", "Storage"] }
cfg-if = "1.0.0"
chrono = { version = "0.4.38", features = ["serde", "wasm-bindgen"] }
password-auth = "1.0.0"
lazy_static = "1.5"
[target.'cfg(engine)'.dev-dependencies]
@@ -38,6 +37,7 @@ sea-orm = { version = "1.0", features = [
"with-chrono",
] }
jsonwebtoken = "9.3.0"
argon2 = "0.5"
[target.'cfg(client)'.dependencies]
wasm-bindgen = "0.2.93"

View File

@@ -15,11 +15,11 @@ impl MigrationTrait for Migration {
Table::create()
.table(User::Table)
.col(pk_auto(User::Id))
.col(string(User::Username))
.col(string(User::Password))
.col(string(User::Salt))
.col(timestamp_with_time_zone(User::CreationTime))
.col(timestamp_with_time_zone(User::LastActiveTime))
.col(string_uniq(User::Username))
.col(string(User::PasswordHashAndSalt))
.col(string_null(User::Nickname))
.col(timestamp(User::CreationTime))
.col(timestamp(User::LastActiveTime))
.col(boolean(User::IsAdmin))
.col(string_null(User::Email))
.col(string_null(User::Avatar))
@@ -41,8 +41,8 @@ pub enum User {
Table,
Id,
Username,
Password,
Salt,
PasswordHashAndSalt,
Nickname,
CreationTime,
LastActiveTime,
IsAdmin,

View File

@@ -145,14 +145,14 @@ fn login_form_capsule<G: Html>(
}
}
div (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") {
h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in to our platform"}
h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Sign in"}
div {
label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Username"}
input (bind:value = state.username, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {}
}
div {
label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Password"}
input (bind:value = state.password, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){}
input (bind:value = state.password, type = "password", class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){}
}
div (class="flex justify-between"){
(match props.remember_me {

View File

@@ -1 +1,181 @@
use lazy_static::lazy_static;
use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
use web_sys::Event;
cfg_if::cfg_if! {
if #[cfg(client)] {
use crate::{
models::auth::{RegisterRequest},
endpoints::REGISTER,
state_enums::{LoginState, OpenState},
templates::{get_api_path},
global_state::{self, AppStateRx},
models::auth::WebAuthInfo,
};
use reqwest::StatusCode;
}
}
lazy_static! {
pub static ref REGISTER_FORM: Capsule<PerseusNodeType, RegisterFormProps> = get_capsule();
}
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "RegisterFormStateRx")]
struct RegisterFormState {
username: String,
password: String,
nickname: String,
registration_code: String,
email: String,
}
impl RegisterFormStateRx {
#[cfg(client)]
fn reset(&self) {
self.username.set(String::new());
self.password.set(String::new());
self.nickname.set(String::new());
self.registration_code.set(String::new());
self.email.set(String::new());
}
}
#[derive(Clone)]
pub struct RegisterFormProps {
pub nickname: bool,
pub registration_code: bool,
pub email: bool,
}
#[auto_scope]
fn register_form_capsule<G: Html>(
cx: Scope,
state: &RegisterFormStateRx,
props: RegisterFormProps,
) -> View<G> {
let close_modal = move |_event: Event| {
#[cfg(client)]
{
spawn_local_scoped(cx, async move {
state.reset();
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
global_state.modals_open.register.set(OpenState::Closed)
});
}
};
let handle_register = move |_event: Event| {
#[cfg(client)]
{
let registration_code = state.registration_code.get().as_ref().clone();
spawn_local_scoped(cx, async move {
let register_info = RegisterRequest {
username: state.username.get().as_ref().clone(),
password: state.password.get().as_ref().clone(),
nickname: state.nickname.get().as_ref().clone(),
email: state.email.get().as_ref().clone(),
registration_code,
};
// // @todo clean up error handling
let client = reqwest::Client::new();
let response = client
.post(get_api_path(REGISTER).as_str())
.json(&register_info)
.send()
.await
.unwrap();
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
if response.status() != StatusCode::OK {
// todo update to some type of alert
state.username.set(response.status().to_string());
return;
}
// Open login modal
global_state.modals_open.login.set(OpenState::Open);
state.reset();
// Close modal
state.reset();
global_state.modals_open.register.set(OpenState::Closed);
});
}
};
view! { cx,
div (class="overflow-x-hidden overflow-y-auto fixed h-modal md:h-full top-4 left-0 right-0 md:inset-0 z-50 justify-center items-center"){
div (class="relative md:mx-auto w-full md:w-1/2 lg:w-1/3 z-0 my-10") {
div (class="bg-white rounded-lg shadow relative dark:bg-gray-700"){
div (class="flex justify-end p-2"){
button (on:click = close_modal, class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-800 dark:hover:text-white"){
"Back"
}
}
div (class="space-y-6 px-6 lg:px-8 pb-4 sm:pb-6 xl:pb-8") {
h3 (class="text-xl font-medium text-gray-900 dark:text-white"){"Register"}
div {
label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300") {"Username"}
input (bind:value = state.username, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white") {}
}
div {
label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Password"}
input (bind:value = state.password, type = "password", class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){}
}
(match props.registration_code {
true => { view!{cx,
div {
label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Registration code"}
input (bind:value = state.registration_code, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){}
}
}},
false => {view!{cx,}},
})
(match props.nickname {
true => { view!{cx,
div {
label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Nickname (optional)"}
input (bind:value = state.nickname, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){}
}
}},
false => {view!{cx,}},
})
(match props.email {
true => { view!{cx,
div {
label (class="text-sm font-medium text-gray-900 block mb-2 dark:text-gray-300"){"Email (optional)"}
input (bind:value = state.email, class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white"){}
}
}},
false => {view!{cx,}},
})
button (on:click = handle_register, class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"){"Register"}
}
}
}
}
}
}
pub fn get_capsule<G: Html>() -> Capsule<G, RegisterFormProps> {
Capsule::build(Template::build("register_form").build_state_fn(get_build_state))
.empty_fallback()
.view_with_state(register_form_capsule)
.build()
}
#[engine_only_fn]
async fn get_build_state(_info: StateGeneratorInfo<()>) -> RegisterFormState {
RegisterFormState {
username: String::new(),
password: String::new(),
nickname: String::new(),
registration_code: String::new(),
email: String::new(),
}
}

View File

@@ -36,6 +36,16 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro
}
};
let handle_register = move |_event: Event| {
#[cfg(client)]
{
spawn_local_scoped(cx, async move {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
global_state.modals_open.register.set(OpenState::Open);
});
}
};
let handle_log_out = move |_event: Event| {
#[cfg(client)]
{
@@ -61,10 +71,10 @@ pub fn Header<'a, G: Html>(cx: Scope<'a>, HeaderProps { game, title }: HeaderPro
match *global_state.auth.state.get() {
LoginState::NotAuthenticated => {
view! { cx,
button(class = "text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700") {
button(on:click = handle_register, class = "text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-100 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700") {
"Register"
}
button(on:click = handle_log_in,class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") {
button(on:click = handle_log_in, class = "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800") {
"Log in"
}
}

View File

@@ -2,6 +2,7 @@ use crate::{
capsules::{
forgot_password_form::{ForgotPasswordFormProps, FORGOT_PASSWORD_FORM},
login_form::{LoginFormProps, LOGIN_FORM},
register_form::{RegisterFormProps, REGISTER_FORM},
},
components::header::{Header, HeaderProps},
global_state::AppStateRx,
@@ -57,6 +58,22 @@ pub fn Layout<'a, G: Html>(
view!{ cx, }
}
})
(match *global_state.modals_open.register.get() {
OpenState::Open => {
view! { cx,
(REGISTER_FORM.widget(cx, "",
RegisterFormProps{
registration_code: true,
nickname: true,
email: true,
}
))
}
}
OpenState::Closed => {
view!{ cx, }
}
})
(match *global_state.modals_open.forgot_password.get() {
OpenState::Open => {
view! { cx,

View File

@@ -1,4 +1,4 @@
pub const REGISTER: &str = "/api/login";
pub const REGISTER: &str = "/api/register";
pub const LOGIN: &str = "/api/login";
pub const LOGIN_TEST: &str = "/api/login-test";
pub const FORGOT_PASSWORD: &str = "/api/forgot-password";

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use super::sea_orm_active_enums::GameType;
use sea_orm::entity::prelude::*;

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub mod prelude;

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub use super::game::Entity as Game;
pub use super::game_to_team_result::Entity as GameToTeamResult;

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0-rc.5
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -8,11 +8,12 @@ use serde::{Deserialize, Serialize};
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub username: String,
pub password: String,
pub salt: String,
pub creation_time: DateTimeWithTimeZone,
pub last_active_time: DateTimeWithTimeZone,
pub password_hash_and_salt: String,
pub nickname: Option<String>,
pub creation_time: DateTime,
pub last_active_time: DateTime,
pub is_admin: bool,
pub email: Option<String>,
pub avatar: Option<String>,

View File

@@ -76,6 +76,7 @@ pub fn main<G: Html>() -> PerseusApp<G> {
.template(crate::templates::overall_board::get_template())
.capsule_ref(&*crate::capsules::login_form::LOGIN_FORM)
.capsule_ref(&*crate::capsules::forgot_password_form::FORGOT_PASSWORD_FORM)
.capsule_ref(&*crate::capsules::register_form::REGISTER_FORM)
.error_views(crate::error_views::get_error_views())
.index_view(|cx| {
view! { cx,

View File

@@ -32,6 +32,15 @@ pub struct WebAuthInfo {
pub remember_me: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RegisterRequest {
pub username: String,
pub password: String,
pub email: String,
pub nickname: String,
pub registration_code: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ForgotPasswordRequest {
pub username: String,

19
src/models/generic.rs Normal file
View File

@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct GenericResponse {
pub status: String,
}
impl GenericResponse {
pub fn ok() -> Self {
GenericResponse {
status: String::new(),
}
}
pub fn err(msg: &str) -> Self {
GenericResponse {
status: msg.to_string(),
}
}
}

View File

@@ -1 +1,2 @@
pub mod auth;
pub mod generic;

View File

@@ -1,24 +1,60 @@
use crate::entity::prelude::*;
use crate::models::auth::{Claims, LoginInfo, LoginResponse};
use crate::{
models::auth::{Claims, LoginInfo, LoginResponse},
entity::user::{self, Entity},
models::auth::RegisterRequest,
server::server_state::ServerState,
};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::Argon2;
use argon2::PasswordHash;
use argon2::PasswordHasher;
use argon2::PasswordVerifier;
use axum::{
extract::{Json, State},
http::{HeaderMap, StatusCode},
};
use futures::sink::Fanout;
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::InsertResult;
use sea_orm::QueryFilter;
use sea_orm::Set;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
pub fn is_valid_user(username: &str, password: &str) -> bool {
return true;
pub async fn credentials_are_correct(username: &str, password: &str, state: &ServerState) -> bool {
// Get user
let existing_user: Option<user::Model> = User::find()
.filter(user::Column::Username.eq(username))
.one(&state.db_conn)
.await
.unwrap();
let hash_to_check: String = match existing_user {
Some(user) => user.password_hash_and_salt,
None => {
// @todo make dummy password hash
return false;
}
};
return Argon2::default()
.verify_password(
password.as_bytes(),
&PasswordHash::new(hash_to_check.as_str()).unwrap(),
)
.is_ok();
}
pub async fn post_login_user(
State(state): State<ServerState>,
Json(login_info): Json<LoginInfo>,
) -> Result<Json<LoginResponse>, StatusCode> {
let user_authenticated = is_valid_user(&login_info.username, &login_info.password);
let user_authenticated =
credentials_are_correct(&login_info.username, &login_info.password, &state);
match user_authenticated {
match user_authenticated.await {
false => Err(StatusCode::UNAUTHORIZED),
true => {
let expires = match login_info.remember_me {

View File

@@ -1,2 +1,3 @@
pub mod forgot_password;
pub mod login;
pub mod register;

View File

@@ -0,0 +1,89 @@
use crate::entity::prelude::*;
use crate::models::generic::GenericResponse;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::Argon2;
use argon2::PasswordHash;
use argon2::PasswordHasher;
use axum::{extract::State, http::StatusCode, Json};
use chrono::Utc;
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::InsertResult;
use sea_orm::QueryFilter;
use sea_orm::Set;
use crate::{
entity::user::{self, Entity},
models::auth::RegisterRequest,
server::server_state::ServerState,
};
pub async fn post_register_user(
State(state): State<ServerState>,
Json(register_info): Json<RegisterRequest>,
) -> (StatusCode, Json<GenericResponse>) {
// TODO -> update to use env, maybe prevent brute force too
if register_info.registration_code != "ferris" {
return (
StatusCode::UNAUTHORIZED,
Json(GenericResponse::err("Incorrect registration code")),
);
}
// See if username already exists
let username = register_info.username;
let existing_user: Option<user::Model> = User::find()
.filter(user::Column::Username.eq(username.clone()))
.one(&state.db_conn)
.await
.unwrap();
if existing_user.is_some() {
return (
StatusCode::BAD_REQUEST,
Json(GenericResponse::err("Username already exists")),
);
}
// Generate password
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(register_info.password.as_bytes(), &salt)
.unwrap()
.to_string();
let phc_string = PasswordHash::new(&password_hash).unwrap().to_string();
// If the username doen't exist, create the user
let new_user = user::ActiveModel {
username: Set(username),
password_hash_and_salt: Set(phc_string),
nickname: Set({
if register_info.nickname == "" {
None
} else {
Some(register_info.nickname)
}
}),
creation_time: Set(Utc::now().naive_utc()),
last_active_time: Set(Utc::now().naive_utc()),
is_admin: Set(false),
email: Set({
if register_info.email == "" {
None
} else {
Some(register_info.email)
}
}),
avatar: Set(None),
forgot_password_request: Set(None),
..Default::default()
};
// TODO -> error handling
let db_resp = user::Entity::insert(new_user)
.exec(&state.db_conn)
.await
.unwrap();
return (StatusCode::OK, Json(GenericResponse::ok()));
}

View File

@@ -1,5 +1,5 @@
// (Server only) Routes
use crate::endpoints::{FORGOT_PASSWORD, LOGIN, LOGIN_TEST};
use crate::endpoints::{FORGOT_PASSWORD, LOGIN, LOGIN_TEST, REGISTER};
use axum::routing::{post, Router};
use futures::executor::block_on;
use sea_orm::Database;
@@ -8,12 +8,14 @@ use super::{
auth::{
forgot_password::post_forgot_password,
login::{post_login_user, post_test_login},
register::post_register_user,
},
server_state::ServerState,
};
pub fn get_api_router(state: ServerState) -> Router {
Router::new()
.route(REGISTER, post(post_register_user))
.route(LOGIN, post(post_login_user))
.route(LOGIN_TEST, post(post_test_login))
.route(FORGOT_PASSWORD, post(post_forgot_password))