feat: implement basic session authentication
parent
df741c931b
commit
902de85131
|
@ -68,6 +68,18 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.18"
|
||||
|
@ -82,17 +94,6 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.83"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.4.0"
|
||||
|
@ -101,14 +102,14 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
|||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core 0.4.5",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
|
@ -116,7 +117,7 @@ dependencies = [
|
|||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit 0.7.3",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
|
@ -134,54 +135,6 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
|
||||
dependencies = [
|
||||
"axum-core 0.5.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"itoa",
|
||||
"matchit 0.8.4",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"sync_wrapper",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.0"
|
||||
|
@ -208,8 +161,8 @@ version = "0.10.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fc6f625a1f7705c6cf62d0d070794e94668988b1c38111baeec177c715f7b"
|
||||
dependencies = [
|
||||
"axum 0.8.1",
|
||||
"axum-core 0.5.0",
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"futures-util",
|
||||
|
@ -226,9 +179,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
|
||||
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -250,12 +203,27 @@ dependencies = [
|
|||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
@ -318,7 +286,8 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
|
|||
name = "calathea"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"argon2",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"chrono",
|
||||
"r2d2",
|
||||
|
@ -477,6 +446,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -820,12 +790,6 @@ version = "0.4.22"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
|
@ -952,6 +916,17 @@ dependencies = [
|
|||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
|
@ -1360,6 +1335,12 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.92"
|
||||
|
|
|
@ -8,7 +8,8 @@ path = "src/main.rs"
|
|||
name = "calathea"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7.9", features = ["macros"] }
|
||||
argon2 = "0.5.3"
|
||||
axum = { version = "0.8.0", features = ["macros"] }
|
||||
axum-extra = { version = "0.10.0", features = ["cookie"] }
|
||||
chrono = { version = "0.4.39", features = ["serde"] }
|
||||
r2d2 = "0.8.10"
|
||||
|
|
|
@ -66,7 +66,7 @@ pub fn run_migrations(pool: &DbPool, migrations: &[&str]) -> rusqlite::Result<()
|
|||
while next_version < migrations.len() {
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
tx.execute(migrations[next_version], ())?;
|
||||
tx.execute_batch(migrations[next_version])?;
|
||||
|
||||
let cur_time = chrono::Local::now().timestamp();
|
||||
tx.execute(
|
||||
|
|
|
@ -1,11 +1,21 @@
|
|||
use argon2::password_hash::rand_core::{OsRng, RngCore};
|
||||
use rusqlite::Row;
|
||||
|
||||
use super::{DbError, DbPool, User};
|
||||
|
||||
pub struct Session {
|
||||
id: u64,
|
||||
user_id: i32,
|
||||
pub id: u64,
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn from_row(row: &Row<'_>) -> Result<Self, rusqlite::Error> {
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
user_id: row.get("user_id")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn user_from_id(pool: &DbPool, id: u64) -> Result<Option<super::User>, DbError> {
|
||||
let conn = pool.get()?;
|
||||
|
||||
|
@ -16,4 +26,14 @@ impl Session {
|
|||
Err(err) => Err(DbError::Db(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_user(pool: &DbPool, user_id: i32) -> Result<Self, DbError> {
|
||||
let id: u64 = OsRng.next_u64();
|
||||
|
||||
let conn = pool.get()?;
|
||||
let mut stmt =
|
||||
conn.prepare("insert into sessions (id, user_id) values ($1, $2) returning *")?;
|
||||
|
||||
Ok(stmt.query_row((&id, &user_id), Self::from_row)?)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, SaltString},
|
||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
};
|
||||
use rusqlite::Row;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
@ -5,17 +9,17 @@ use super::{DbError, DbPool};
|
|||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
id: i32,
|
||||
username: String,
|
||||
password_hash: String,
|
||||
admin: bool,
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct NewUser {
|
||||
username: String,
|
||||
password: String,
|
||||
admin: bool,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
impl User {
|
||||
|
@ -27,19 +31,48 @@ impl User {
|
|||
admin: row.get("admin")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn by_username(pool: &DbPool, username: impl AsRef<str>) -> Result<Option<Self>, DbError> {
|
||||
let conn = pool.get()?;
|
||||
|
||||
let mut stmt = conn.prepare("select * from users where username = $1")?;
|
||||
|
||||
match stmt.query_row((username.as_ref(),), User::from_row) {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(err) => Err(DbError::Db(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_password(&self, password: impl AsRef<str>) -> bool {
|
||||
let password_hash = PasswordHash::new(&self.password_hash).unwrap();
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(password.as_ref().as_bytes(), &password_hash)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_password(password: impl AsRef<str>) -> String {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
argon2
|
||||
.hash_password(password.as_ref().as_bytes(), &salt)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl NewUser {
|
||||
pub fn insert(self, pool: &DbPool) -> Result<User, DbError> {
|
||||
let conn = pool.get()?;
|
||||
|
||||
let password_hash = hash_password(&self.password);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"insert into users (username, password_hash, admin) values ($1, $2, $3) returning *",
|
||||
)?;
|
||||
|
||||
Ok(stmt.query_row(
|
||||
(&self.username, &self.password, &self.admin),
|
||||
User::from_row,
|
||||
)?)
|
||||
Ok(stmt.query_row((&self.username, password_hash, &self.admin), User::from_row)?)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Form, Router,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::{
|
||||
cookie::{Cookie, SameSite},
|
||||
CookieJar,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tera::Context;
|
||||
|
||||
use crate::db::Session;
|
||||
use crate::db::{Session, User};
|
||||
|
||||
use super::error::AppError;
|
||||
use super::{error::AppError, render_view};
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(ctx): State<crate::Context>,
|
||||
|
@ -34,3 +41,55 @@ pub async fn auth_middleware(
|
|||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app() -> Router<crate::Context> {
|
||||
Router::new().route("/login", get(get_login).post(post_login))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Login {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn get_login(
|
||||
State(ctx): State<crate::Context>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let context = Context::new();
|
||||
|
||||
Ok(Html(render_view(
|
||||
&ctx.tera,
|
||||
"views/login.html",
|
||||
&context,
|
||||
&headers,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn post_login(
|
||||
State(ctx): State<crate::Context>,
|
||||
jar: CookieJar,
|
||||
Form(login): Form<Login>,
|
||||
) -> Result<(CookieJar, Html<String>), AppError> {
|
||||
if let Some(user) = User::by_username(&ctx.pool, &login.username)? {
|
||||
if user.verify_password(&login.password) {
|
||||
let session = Session::new_for_user(&ctx.pool, user.id)?;
|
||||
let session_id = session.id.to_string();
|
||||
|
||||
Ok((
|
||||
jar.add(
|
||||
Cookie::build(("session_id", session_id))
|
||||
.secure(true)
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.build(),
|
||||
),
|
||||
Html(String::new()),
|
||||
))
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ use crate::db;
|
|||
pub enum AppError {
|
||||
Db(db::DbError),
|
||||
Tera(tera::Error),
|
||||
Unauthorized,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
|
@ -16,6 +17,7 @@ impl fmt::Display for AppError {
|
|||
match self {
|
||||
Self::Db(_) => write!(f, "database error"),
|
||||
Self::Tera(_) => write!(f, "error rendering template"),
|
||||
Self::Unauthorized => write!(f, "unauthorized"),
|
||||
Self::NotFound => write!(f, "not found"),
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +28,7 @@ impl std::error::Error for AppError {
|
|||
match self {
|
||||
Self::Db(err) => Some(err),
|
||||
Self::Tera(err) => Some(err),
|
||||
Self::NotFound => None,
|
||||
Self::NotFound | Self::Unauthorized => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -65,6 +67,7 @@ impl IntoResponse for AppError {
|
|||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
Self::NotFound => StatusCode::NOT_FOUND.into_response(),
|
||||
Self::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
|
||||
_ => {
|
||||
tracing::error!("{}", self.stack());
|
||||
|
||||
|
|
|
@ -59,6 +59,7 @@ pub fn app(ctx: crate::Context, static_dir: &str) -> axum::Router {
|
|||
ctx.clone(),
|
||||
auth::auth_middleware,
|
||||
))
|
||||
.merge(auth::app())
|
||||
.route("/", get(get_index))
|
||||
.nest_service("/static", ServeDir::new(static_dir))
|
||||
.with_state(ctx.clone());
|
||||
|
|
|
@ -13,7 +13,7 @@ use super::error::AppError;
|
|||
|
||||
pub fn app() -> axum::Router<crate::Context> {
|
||||
Router::new()
|
||||
.route("/:id", get(get_plant_page))
|
||||
.route("/{id}", get(get_plant_page))
|
||||
.route("/", post(post_plant))
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<article>
|
||||
<form hx-post="/login" hx-target="#content">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" id="username" name="username">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password">
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
</article>
|
Loading…
Reference in New Issue