feat: basic register and login
This commit is contained in:
parent
98be8dd574
commit
327f0cd5b9
39 changed files with 990 additions and 66 deletions
12
src/cli.rs
12
src/cli.rs
|
|
@ -1,8 +1,8 @@
|
|||
use argh::FromArgs;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use log::info;
|
||||
|
||||
use crate::{get_app_context, server::{start_http_server, ServerConfig}};
|
||||
use crate::{get_app_context, server::{start_http_server, ServerConfig}, DEFAULT_ASSETS_PATH};
|
||||
|
||||
#[derive(Debug, FromArgs)]
|
||||
/// Autotasker daemon
|
||||
|
|
@ -14,7 +14,7 @@ struct ServerCliFlags {
|
|||
/// path to the Sqlite3 DB file to use
|
||||
#[argh(option)]
|
||||
database: Option<String>,
|
||||
|
||||
|
||||
/// path to the static assets dir
|
||||
#[argh(option)]
|
||||
static_assets: Option<String>,
|
||||
|
|
@ -27,12 +27,11 @@ struct ServerCliFlags {
|
|||
listen_port: u32
|
||||
}
|
||||
|
||||
const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets";
|
||||
|
||||
/// handle CLI arguments to start process daemon
|
||||
pub async fn start_server_cli() -> Result<()> {
|
||||
info!("Starting minauth");
|
||||
let flags: ServerCliFlags = argh::from_env();
|
||||
let (config, db_pool) = get_app_context(crate::StartAppConfig {
|
||||
let (config, secrets, db_pool) = get_app_context(crate::StartAppConfig {
|
||||
config_path: flags.config,
|
||||
database_path: flags.database
|
||||
}).await.context("Getting app context")?;
|
||||
|
|
@ -43,6 +42,7 @@ pub async fn start_server_cli() -> Result<()> {
|
|||
listen_port: flags.listen_port
|
||||
},
|
||||
config,
|
||||
secrets,
|
||||
db_pool
|
||||
).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}};
|
||||
use minijinja::context;
|
||||
|
||||
use crate::server::AppState;
|
||||
|
||||
|
||||
|
||||
pub async fn authorize_form(
|
||||
State(app_state): State<AppState>
|
||||
) -> impl IntoResponse {
|
||||
// 1. Verify if login
|
||||
|
||||
Html(
|
||||
app_state.templating_env.get_template("pages/authorize.html").unwrap()
|
||||
.render(context!())
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,26 +1,99 @@
|
|||
use axum::{extract::State, response::{Html, IntoResponse}};
|
||||
use chrono::{Duration, SecondsFormat, Utc};
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use axum::{extract::State, http::{HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}, Form};
|
||||
use fully_pub::fully_pub;
|
||||
use minijinja::context;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::{
|
||||
models::user::{User, UserStatus},
|
||||
server::AppState,
|
||||
services::{password::verify_password_hash, session::create_token}
|
||||
};
|
||||
|
||||
pub async fn login_form(
|
||||
State(app_state): State<AppState>
|
||||
) -> impl IntoResponse {
|
||||
Html(
|
||||
app_state.templating_env.get_template("pages/home.html").unwrap()
|
||||
app_state.templating_env.get_template("pages/login.html").unwrap()
|
||||
.render(context!())
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[fully_pub]
|
||||
struct LoginForm {
|
||||
/// handle or email or user_id
|
||||
login: String,
|
||||
password: String
|
||||
}
|
||||
|
||||
const DUMMY_PASSWORD_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$+06ud2g4uVTI7kUIXjWM4g$6XqwuHt/+xl0d5J4BYKuIbg2acBp6udxMCnmJ6QfceY";
|
||||
|
||||
pub async fn perform_login(
|
||||
State(app_state): State<AppState>
|
||||
State(app_state): State<AppState>,
|
||||
Form(login): Form<LoginForm>
|
||||
) -> impl IntoResponse {
|
||||
Html(
|
||||
app_state.templating_env.get_template("pages/home.html").unwrap()
|
||||
.render(context!())
|
||||
.unwrap()
|
||||
)
|
||||
// get user from db
|
||||
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
|
||||
.bind(&login.login)
|
||||
.bind(&login.login)
|
||||
.fetch_one(&app_state.db)
|
||||
.await;
|
||||
|
||||
let password_hash = match &user_res {
|
||||
Ok(u) => u
|
||||
.password_hash
|
||||
.clone()
|
||||
.unwrap_or(DUMMY_PASSWORD_HASH.into()),
|
||||
Err(_e) => DUMMY_PASSWORD_HASH.into()
|
||||
};
|
||||
|
||||
let templ = app_state.templating_env.get_template("pages/login.html").unwrap();
|
||||
|
||||
if verify_password_hash(password_hash, login.password).is_err() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(
|
||||
templ.render(context!(
|
||||
error => Some("Invalid login or password.".to_string())
|
||||
)).unwrap()
|
||||
)
|
||||
).into_response();
|
||||
}
|
||||
|
||||
let user = user_res.expect("Expected User to be found.");
|
||||
if user.status == UserStatus::Disabled {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Html(
|
||||
templ.render(context!(
|
||||
error => Some("This account is disabled.".to_string())
|
||||
)).unwrap()
|
||||
)
|
||||
).into_response();
|
||||
}
|
||||
|
||||
info!("User {:?} {:?} logged in", &user.handle, &user.email);
|
||||
let _result = sqlx::query("UPDATE users SET last_login_at = $2 WHERE id = $1")
|
||||
.bind(user.id.clone())
|
||||
.bind(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
.execute(&app_state.db)
|
||||
.await.unwrap();
|
||||
|
||||
let jwt = create_token(&app_state.secrets, &user);
|
||||
|
||||
// TODO: handle keep_session boolean from form and specify cookie max age only if this setting
|
||||
// is true
|
||||
let cookie_max_age = Duration::days(7).num_seconds();
|
||||
let jwt_cookie = format!("minauth_jwt={jwt}; SameSite=Lax; Max-Age={cookie_max_age}");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Set-Cookie", HeaderValue::from_str(&jwt_cookie).unwrap());
|
||||
headers.insert("Location", HeaderValue::from_str(&format!("/me")).unwrap());
|
||||
|
||||
(StatusCode::FOUND, headers, Html(
|
||||
templ.render(context!()).unwrap()
|
||||
)).into_response()
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
12
src/controllers/ui/logout.rs
Normal file
12
src/controllers/ui/logout.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
use axum::{http::StatusCode, response::Redirect};
|
||||
use axum_extra::extract::CookieJar;
|
||||
|
||||
pub async fn perform_logout(
|
||||
cookies: CookieJar
|
||||
) -> Result<(CookieJar, Redirect), StatusCode> {
|
||||
Ok((
|
||||
cookies.remove("minauth_jwt"),
|
||||
Redirect::to("/")
|
||||
))
|
||||
}
|
||||
|
||||
18
src/controllers/ui/me.rs
Normal file
18
src/controllers/ui/me.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
use axum::{extract::State, response::{Html, IntoResponse}, Extension};
|
||||
use minijinja::context;
|
||||
|
||||
use crate::{server::AppState, services::session::TokenClaims};
|
||||
|
||||
pub async fn me_page(
|
||||
State(app_state): State<AppState>,
|
||||
Extension(token_claims): Extension<TokenClaims>
|
||||
) -> impl IntoResponse {
|
||||
Html(
|
||||
app_state.templating_env.get_template("pages/me.html").unwrap()
|
||||
.render(context!(
|
||||
token_claims => token_claims
|
||||
))
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2,3 +2,5 @@ pub mod home;
|
|||
pub mod authorize;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
pub mod me;
|
||||
pub mod logout;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
use axum::{extract::State, response::{Html, IntoResponse}, Form};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use minijinja::context;
|
||||
use fully_pub::fully_pub;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{models::user::{User, UserStatus}, server::AppState, services::password::get_password_hash};
|
||||
|
||||
pub async fn register_form(
|
||||
State(app_state): State<AppState>
|
||||
) -> impl IntoResponse {
|
||||
Html(
|
||||
app_state.templating_env.get_template("pages/register.html").unwrap()
|
||||
.render(context!())
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[fully_pub]
|
||||
struct RegisterForm {
|
||||
handle: String,
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn perform_register(
|
||||
State(app_state): State<AppState>,
|
||||
Form(register): Form<RegisterForm>
|
||||
) -> impl IntoResponse {
|
||||
let templ = app_state.templating_env.get_template("pages/register.html").unwrap();
|
||||
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE handle = $1 OR email = $2")
|
||||
.bind(®ister.handle)
|
||||
.bind(®ister.email)
|
||||
.fetch_one(&app_state.db)
|
||||
.await;
|
||||
if user_res.is_ok() {
|
||||
// user already exists
|
||||
return Html(
|
||||
templ.render(context!(
|
||||
success => true
|
||||
)).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
let password_hash = Some(
|
||||
get_password_hash(register.password)
|
||||
.expect("To process password").1
|
||||
);
|
||||
let user = User {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
email: Some(register.email),
|
||||
handle: register.handle,
|
||||
full_name: None,
|
||||
picture: None,
|
||||
|
||||
password_hash,
|
||||
activation_token: None,
|
||||
status: UserStatus::Active,
|
||||
created_at: Utc::now(),
|
||||
website: None,
|
||||
last_login_at: None
|
||||
};
|
||||
// save in DB
|
||||
let _result = sqlx::query("INSERT INTO users (id, handle, email, status, password_hash, created_at) VALUES ($1, $2, $3, $4, $5, $6)")
|
||||
.bind(user.id)
|
||||
.bind(user.handle)
|
||||
.bind(user.email)
|
||||
.bind(user.status.to_string())
|
||||
.bind(user.password_hash)
|
||||
.bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
.execute(&app_state.db)
|
||||
.await.unwrap();
|
||||
|
||||
Html(
|
||||
templ
|
||||
.render(context!(
|
||||
success => true
|
||||
))
|
||||
.unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
37
src/main.rs
37
src/main.rs
|
|
@ -4,20 +4,21 @@ pub mod router;
|
|||
pub mod server;
|
||||
pub mod database;
|
||||
pub mod cli;
|
||||
pub mod utils;
|
||||
pub mod services;
|
||||
pub mod middlewares;
|
||||
|
||||
use std::fs;
|
||||
use std::{env, fs};
|
||||
use anyhow::{Result, Context, anyhow};
|
||||
|
||||
use database::prepare_database;
|
||||
use log::info;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::{ConnectOptions, Pool, Sqlite};
|
||||
use models::config::Config;
|
||||
use minijinja::{context, Environment};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use models::config::{AppSecrets, Config};
|
||||
|
||||
const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db";
|
||||
const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets";
|
||||
const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml";
|
||||
pub const DEFAULT_DB_PATH: &'static str = &"/var/lib/autotasker/autotasker.db";
|
||||
pub const DEFAULT_ASSETS_PATH: &'static str = &"/usr/local/lib/autotasker/assets";
|
||||
pub const DEFAULT_CONFIG_PATH: &'static str = &"/etc/autotasker/config.yaml";
|
||||
|
||||
fn get_config(path: String) -> Result<Config> {
|
||||
let inp_def_yaml = fs::read_to_string(path)
|
||||
|
|
@ -37,15 +38,23 @@ async fn main() -> Result<()> {
|
|||
cli::start_server_cli().await
|
||||
}
|
||||
|
||||
async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, Pool<Sqlite>)> {
|
||||
async fn get_app_context(start_app_config: StartAppConfig) -> Result<(Config, AppSecrets, Pool<Sqlite>)> {
|
||||
env_logger::init();
|
||||
let pool = prepare_database(
|
||||
&start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string())
|
||||
).await.context("Could not prepare db")?;
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let database_path = &start_app_config.database_path.unwrap_or(DEFAULT_DB_PATH.to_string());
|
||||
info!("Using database file at {}", database_path);
|
||||
let pool = prepare_database(&database_path).await.context("Could not prepare db.")?;
|
||||
|
||||
let config_path = start_app_config.config_path.unwrap_or(DEFAULT_CONFIG_PATH.to_string());
|
||||
info!("Using config file at {}", &config_path);
|
||||
let config: Config = get_config(config_path).expect("Cannot get config");
|
||||
let config: Config = get_config(config_path)
|
||||
.expect("Cannot get config.");
|
||||
|
||||
Ok((config, pool))
|
||||
dotenvy::dotenv().context("loading .env")?;
|
||||
let secrets = AppSecrets {
|
||||
jwt_secret: env::var("APP_JWT_SECRET").context("Expecting APP_JWT_SECRET env var.")?
|
||||
};
|
||||
|
||||
Ok((config, secrets, pool))
|
||||
}
|
||||
|
|
|
|||
28
src/middlewares/auth.rs
Normal file
28
src/middlewares/auth.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use axum::{extract::{Request, State}, http::{HeaderMap, StatusCode}, middleware::Next, response::{Response, Html, IntoResponse}};
|
||||
use axum_extra::extract::CookieJar;
|
||||
|
||||
use crate::{server::AppState, services::session::verify_token};
|
||||
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(app_state): State<AppState>,
|
||||
cookies: CookieJar,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let jwt = match cookies.get("minauth_jwt") {
|
||||
Some(cookie) => cookie.value(),
|
||||
None => {
|
||||
// return Err((StatusCode::UNAUTHORIZED, Html("Did not found header")));
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
};
|
||||
let token_claims = match verify_token(&app_state.secrets, &jwt) {
|
||||
Ok(val) => val,
|
||||
Err(_e) => {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
};
|
||||
req.extensions_mut().insert(token_claims);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
1
src/middlewares/mod.rs
Normal file
1
src/middlewares/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod auth;
|
||||
|
|
@ -10,15 +10,30 @@ use uuid::Uuid;
|
|||
/// Instance branding/customization config
|
||||
struct InstanceConfig {
|
||||
name: String,
|
||||
logo_uri: String
|
||||
logo_uri: Option<String>
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[fully_pub]
|
||||
struct Application {
|
||||
slug: String,
|
||||
name: String,
|
||||
client_id: String,
|
||||
client_secret: String
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[fully_pub]
|
||||
/// Configuration of this minauthator instance
|
||||
struct Config {
|
||||
/// configure current autotasker instance
|
||||
instance: Option<InstanceConfig>,
|
||||
instance: InstanceConfig,
|
||||
applications: Vec<Application>
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[fully_pub]
|
||||
pub struct AppSecrets {
|
||||
jwt_secret: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod user;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
use fully_pub::fully_pub;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(strum_macros::Display)]
|
||||
#[fully_pub]
|
||||
enum UserStatus {
|
||||
Active,
|
||||
Disabled
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)]
|
||||
#[fully_pub]
|
||||
struct User {
|
||||
/// uuid
|
||||
id: String,
|
||||
handle: String,
|
||||
full_name: Option<String>,
|
||||
email: Option<String>,
|
||||
website: Option<String>,
|
||||
picture: Option<String>, // embeded blob to store profile pic
|
||||
password_hash: Option<String>, // argon2 password hash
|
||||
status: UserStatus,
|
||||
activation_token: Option<String>,
|
||||
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>
|
||||
}
|
||||
|
||||
|
|
@ -1,10 +1,27 @@
|
|||
use axum::{routing::{get, post}, Router};
|
||||
use axum::{middleware, routing::{get, post}, Router};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::{controllers::ui, server::AppState};
|
||||
use crate::{controllers::ui, middlewares::auth::auth_middleware, server::{AppState, ServerConfig}};
|
||||
|
||||
pub fn build_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router<AppState> {
|
||||
let public_routes = Router::new()
|
||||
.route("/", get(ui::home::home))
|
||||
.route("/register", get(ui::register::register_form))
|
||||
.route("/register", post(ui::register::perform_register))
|
||||
.route("/login", get(ui::login::login_form))
|
||||
.route("/login", post(ui::login::perform_login))
|
||||
.route("/login", post(ui::login::perform_login));
|
||||
|
||||
let user_routes = Router::new()
|
||||
.route("/me", get(ui::me::me_page))
|
||||
.route("/logout", get(ui::logout::perform_logout))
|
||||
.route("/authorize", get(ui::authorize::authorize_form))
|
||||
.layer(middleware::from_fn_with_state(app_state, auth_middleware));
|
||||
|
||||
Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(user_routes)
|
||||
.nest_service(
|
||||
"/assets",
|
||||
ServeDir::new(server_config.assets_path.clone())
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,23 +4,17 @@ use anyhow::{Result, Context, anyhow};
|
|||
use log::info;
|
||||
use minijinja::{context, Environment};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use crate::{models::config::Config, router::build_router};
|
||||
use crate::{models::config::{AppSecrets, Config}, router::build_router};
|
||||
|
||||
fn build_templating_env(config: &Config) -> Environment<'static> {
|
||||
let mut templating_env = Environment::new();
|
||||
let mut env = Environment::new();
|
||||
|
||||
let _ = templating_env
|
||||
.add_template("layouts/base.html", include_str!("./templates/layouts/base.html"));
|
||||
let _ = templating_env
|
||||
.add_template("pages/home.html", include_str!("./templates/pages/home.html"));
|
||||
minijinja_embed::load_templates!(&mut env);
|
||||
|
||||
// TODO: better loading with embed https://docs.rs/minijinja-embed/latest/minijinja_embed/
|
||||
templating_env.add_global("gl", context! {
|
||||
instance => context! {
|
||||
version => "1.243".to_string()
|
||||
}
|
||||
env.add_global("gl", context! {
|
||||
instance => config.instance
|
||||
});
|
||||
templating_env
|
||||
env
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -35,25 +29,31 @@ pub struct ServerConfig {
|
|||
#[derive(Debug, Clone)]
|
||||
#[fully_pub]
|
||||
pub struct AppState {
|
||||
secrets: AppSecrets,
|
||||
config: Config,
|
||||
db: Pool<Sqlite>,
|
||||
templating_env: Environment<'static>
|
||||
}
|
||||
|
||||
pub async fn start_http_server(server_config: ServerConfig, config: Config, db_pool: Pool<Sqlite>) -> Result<()> {
|
||||
pub async fn start_http_server(
|
||||
server_config: ServerConfig,
|
||||
config: Config,
|
||||
secrets: AppSecrets,
|
||||
db_pool: Pool<Sqlite>
|
||||
) -> Result<()> {
|
||||
// build state
|
||||
let state = AppState {
|
||||
templating_env: build_templating_env(&config),
|
||||
config,
|
||||
secrets,
|
||||
db: db_pool
|
||||
};
|
||||
|
||||
// build routes
|
||||
let services = build_router()
|
||||
.nest_service(
|
||||
"/assets",
|
||||
ServeDir::new(server_config.assets_path)
|
||||
)
|
||||
let services = build_router(
|
||||
&server_config,
|
||||
state.clone()
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let listen_addr = format!("{}:{}", server_config.listen_host, server_config.listen_port);
|
||||
|
|
|
|||
2
src/services/mod.rs
Normal file
2
src/services/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod password;
|
||||
pub mod session;
|
||||
35
src/services/password.rs
Normal file
35
src/services/password.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use argon2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng,
|
||||
PasswordHash, PasswordHasher, PasswordVerifier, SaltString
|
||||
},
|
||||
Argon2
|
||||
};
|
||||
|
||||
pub fn get_password_hash(password: String) -> Result<(String, String)> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
// Argon2 with default params (Argon2id v19)
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
// Hash password to PHC string ($argon2id$v=19$...)
|
||||
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||
Ok(val) => Ok((salt.to_string(), val.to_string())),
|
||||
Err(_) => Err(anyhow!("Failed to process password."))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_password_hash(password_hash: String, password: String) -> Result<()> {
|
||||
let parsed_hash = match PasswordHash::new(&password_hash) {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
return Err(anyhow!("Failed to parse password hash"));
|
||||
}
|
||||
};
|
||||
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => Err(anyhow!("Failed to verify password."))
|
||||
}
|
||||
}
|
||||
|
||||
38
src/services/session.rs
Normal file
38
src/services/session.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use fully_pub::fully_pub;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use jsonwebtoken::{encode, decode, get_current_timestamp, Header, Algorithm, Validation, EncodingKey, DecodingKey};
|
||||
|
||||
use crate::models::{config::AppSecrets, user::User};
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[fully_pub]
|
||||
struct TokenClaims {
|
||||
sub: String, // user id
|
||||
exp: u64
|
||||
}
|
||||
|
||||
pub fn create_token(secrets: &AppSecrets, user: &User) -> String {
|
||||
let claims = TokenClaims {
|
||||
sub: user.id.clone(),
|
||||
exp: get_current_timestamp() + 86_400
|
||||
};
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(&secrets.jwt_secret.as_bytes())
|
||||
).expect("Create token");
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
pub fn verify_token(secrets: &AppSecrets, jwt: &str) -> Result<TokenClaims> {
|
||||
let token_data = decode::<TokenClaims>(
|
||||
&jwt,
|
||||
&DecodingKey::from_secret(&secrets.jwt_secret.as_bytes()),
|
||||
&Validation::new(Algorithm::HS256)
|
||||
)?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
15
src/templates/components/footer.html
Normal file
15
src/templates/components/footer.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
||||
<p class="col-md-4 mb-0 text-muted">© 2022 Company, Inc</p>
|
||||
|
||||
<a href="/" class="col-md-4 d-flex align-items-center justify-content-center mb-3 mb-md-0 me-md-auto link-dark text-decoration-none">
|
||||
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"></use></svg>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-md-4 justify-content-end">
|
||||
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Home</a></li>
|
||||
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Features</a></li>
|
||||
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Pricing</a></li>
|
||||
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">FAQs</a></li>
|
||||
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">About</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
26
src/templates/components/header.html
Normal file
26
src/templates/components/header.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<header>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="#">Minauth</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="#">Home</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/login">Login</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/register">Register</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/logout">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
@ -4,19 +4,14 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Minauth</title>
|
||||
<link href="/assets/styles/simple.css" rel="stylesheet">
|
||||
<link href="/assets/styles/app.css" rel="stylesheet">
|
||||
<link href="/assets/node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
Minauth
|
||||
</header>
|
||||
{% include "components/header.html" %}
|
||||
<main class="container">
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
<footer>
|
||||
Minauth {{ gl.instance.version }}
|
||||
</footer>
|
||||
{% include "components/footer.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
{% extends "layouts/base.html" %}
|
||||
{% block body %}
|
||||
<h1>Login</h1>
|
||||
<!-- Login form -->
|
||||
{% if error %}
|
||||
<div>
|
||||
Error: {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form id="login-form" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="login" class="form-label">Email or username</label>
|
||||
<input
|
||||
id="login" name="login" type="text"
|
||||
required
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
id="password" name="password" type="password"
|
||||
required
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input id="keep_session" type="checkbox" class="form-check-input">
|
||||
<label class="form-check-label" for="keep_session">Check me out</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
9
src/templates/pages/me.html
Normal file
9
src/templates/pages/me.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "layouts/base.html" %}
|
||||
{% block body %}
|
||||
<h1>Me page</h1>
|
||||
|
||||
<p>
|
||||
{{ token_claims.sub }}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
47
src/templates/pages/register.html
Normal file
47
src/templates/pages/register.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{% extends "layouts/base.html" %}
|
||||
{% block body %}
|
||||
<!-- Register form -->
|
||||
<h1>Register</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">
|
||||
Error: {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if success %}
|
||||
<div class="alert alert-success">
|
||||
If all the information you submitted are valid and unique, you're account
|
||||
has been created and we've sent you a confirmation email.
|
||||
</div>
|
||||
{% endif %}
|
||||
<form id="register-form" method="post">
|
||||
<div class="mb-3">
|
||||
<label for="handle" class="form-label">Handle</label>
|
||||
<input
|
||||
id="handle" name="handle" type="text"
|
||||
minlength="2"
|
||||
maxlength="255"
|
||||
required
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email" name="email" type="email"
|
||||
required
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password" name="password" type="password"
|
||||
required
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Register
|
||||
</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
35
src/utils.rs
Normal file
35
src/utils.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use argon2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng,
|
||||
PasswordHash, PasswordHasher, PasswordVerifier, SaltString
|
||||
},
|
||||
Argon2
|
||||
};
|
||||
|
||||
pub fn get_password_hash(password: String) -> Result<(String, String)> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
// Argon2 with default params (Argon2id v19)
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
// Hash password to PHC string ($argon2id$v=19$...)
|
||||
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||
Ok(val) => Ok((salt.to_string(), val.to_string())),
|
||||
Err(_) => Err(anyhow!("Failed to process password."))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_password_hash(password_hash: String, password: String) -> Result<()> {
|
||||
let parsed_hash = match PasswordHash::new(&password_hash) {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
return Err(anyhow!("Failed to parse password hash"));
|
||||
}
|
||||
};
|
||||
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => Err(anyhow!("Failed to verify password."))
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue