From 3402b67441b2777a1b737625d0cc6f49686a5709 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Sat, 1 Jul 2023 16:15:19 +0200 Subject: [PATCH] session extractor done --- Cargo.toml | 2 +- src/auth.rs | 195 ------------------------------------------ src/auth/hash.rs | 23 +++++ src/auth/mod.rs | 3 + src/auth/session.rs | 151 ++++++++++++++++++++++++++++++++ src/auth/user.rs | 101 ++++++++++++++++++++++ src/error.rs | 10 +-- src/routes/admin.rs | 10 ++- src/routes/auth.rs | 10 ++- src/routes/content.rs | 2 +- src/state.rs | 2 +- 11 files changed, 299 insertions(+), 210 deletions(-) delete mode 100644 src/auth.rs create mode 100644 src/auth/hash.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/session.rs create mode 100644 src/auth/user.rs diff --git a/Cargo.toml b/Cargo.toml index a9a984d..d4c7053 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ serde_json = { version = "1", features = ["raw_value"] } figment = { version = "0.10", features = ["toml", "env"] } chrono = { version = "0.4", features = ["serde", "clock"] } anyhow = "1.0" -argon2 = "0.5" +argon2 = { version = "0.5", features = ["std", "alloc"] } url = "2.4" [profile.dev.package.sqlx-macros] diff --git a/src/auth.rs b/src/auth.rs deleted file mode 100644 index a166f78..0000000 --- a/src/auth.rs +++ /dev/null @@ -1,195 +0,0 @@ -use anyhow::{anyhow, Result}; -use argon2::{ - password_hash::{rand_core::OsRng, SaltString}, - Argon2, PasswordHash, PasswordHasher, PasswordVerifier, -}; -use chrono::{Duration, NaiveDateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, Pool, Postgres}; -use uuid::Uuid; - -#[derive(Deserialize, Serialize, FromRow)] -pub struct User { - /// User internal ID - pub id: Uuid, - - /// User name (unique per instance, shows up in URLs) - pub name: String, - - /// User email (for validation/login) - pub email: Option, - - /// Hashed password - pub password: Option, - - /// User's chosen displayed name - pub display_name: Option, - - /// Biography / User description - pub bio: Option, - - /// User roles (as role IDs) - pub roles: Vec, - - /// Times - pub created_at: NaiveDateTime, - pub modified_at: Option, - pub deleted_at: Option, -} - -impl Default for User { - fn default() -> Self { - Self { - id: Uuid::nil(), - name: Default::default(), - email: Default::default(), - password: Default::default(), - display_name: Default::default(), - bio: Default::default(), - roles: Default::default(), - created_at: Default::default(), - modified_at: Default::default(), - deleted_at: Default::default(), - } - } -} - -impl User { - pub async fn create( - pool: &Pool, - username: &str, - password: &str, - roles: &Vec, - ) -> Result { - let result = sqlx::query!( - r#"INSERT INTO users ( name, password, roles ) VALUES ( $1,$2,$3 ) RETURNING id, created_at"#, - username, - hash(&password)?, - roles, - ) - .fetch_one(pool) - .await?; - Ok(Self { - id: result.id, - name: username.to_owned(), - roles: roles.to_owned(), - created_at: result.created_at, - ..Default::default() - }) - } - - pub async fn find(pool: &Pool, name: &str) -> Result> { - Ok(sqlx::query_as("SELECT * FROM users WHERE name = $1") - .bind(name) - .fetch_optional(pool) - .await?) - } -} - -#[derive(Deserialize, Serialize, FromRow)] -pub struct Role { - /// Role ID - pub id: Uuid, - - /// Role scopes (permissions) - pub scopes: Vec, -} - -#[derive(Deserialize, Serialize, FromRow)] -pub struct Session { - /// Role ID - pub id: Uuid, - - /// User ID - pub actor: Uuid, - - /// Secret - pub secret: String, - - /// Times - pub created_at: NaiveDateTime, - pub expires_at: NaiveDateTime, -} - -impl Session { - pub async fn create(pool: &Pool, user_id: Uuid, duration: Duration) -> Result { - let now = Utc::now().naive_utc(); - let expires = now + duration; - let secret = random(); - let result = sqlx::query!( - "INSERT INTO sessions (actor, secret, created_at, expires_at) VALUES ($1, $2, $3, $4) RETURNING id", - user_id, - secret, - now, - expires - ) - .fetch_one(pool) - .await?; - Ok(Self { - id: result.id, - actor: user_id, - secret: secret, - created_at: now, - expires_at: now + duration, - }) - } - - pub async fn find(pool: &Pool, id: Uuid) -> Result> { - Ok(sqlx::query_as("SELECT * FROM sessions WHERE id = $1") - .bind(id) - .fetch_optional(pool) - .await?) - } - - pub async fn refresh(self: Self, pool: &Pool, duration: Duration) -> Result { - let expires_at = (Utc::now() + duration).naive_utc(); - let secret = random(); - - sqlx::query!( - "UPDATE sessions SET secret = $1, expires_at = $2 WHERE id = $3 RETURNING id", - secret, - expires_at, - self.id - ) - .fetch_one(pool) - .await?; - - Ok(Session { - secret, - expires_at, - ..self - }) - } - - pub fn token(self: &Self) -> String { - format!("{}:{}", self.id.as_u128(), self.secret) - } - - pub fn parse_token(token: String) -> Result<(Uuid, String)> { - let (uuid_str, token_str) = token.split_once(":").ok_or(anyhow!("malformed token"))?; - Ok(( - Uuid::from_u128(uuid_str.parse::()?), - token_str.to_string(), - )) - } -} - -pub fn random() -> String { - SaltString::generate(&mut OsRng).to_string() -} - -pub fn hash(plaintext: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let hashed = Argon2::default() - .hash_password(plaintext.as_bytes(), &salt) - .map_err(|err| anyhow!(err))? - .to_string(); - Ok(hashed) -} - -pub fn verify(plaintext: &str, hash: &str) -> Result { - let parsed_hash = PasswordHash::new(hash).map_err(|err| anyhow!(err))?; - Ok(Argon2::default() - .verify_password(plaintext.as_bytes(), &parsed_hash) - .is_ok()) -} diff --git a/src/auth/hash.rs b/src/auth/hash.rs new file mode 100644 index 0000000..f8b49cb --- /dev/null +++ b/src/auth/hash.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, +}; + +pub fn random() -> String { + SaltString::generate(&mut OsRng).to_string() +} + +pub fn hash(plaintext: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(plaintext.as_bytes(), &salt)? + .to_string()) +} + +pub fn verify(plaintext: &str, hash: &str) -> Result { + let parsed_hash = PasswordHash::new(hash)?; + Ok(Argon2::default() + .verify_password(plaintext.as_bytes(), &parsed_hash) + .is_ok()) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..3a1b4ad --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod hash; +pub mod session; +pub mod user; diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..57e1502 --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,151 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use axum::{ + async_trait, + extract::{FromRequestParts, State}, + http::{header::COOKIE, request::Parts, StatusCode}, +}; +use chrono::{Duration, NaiveDateTime, Utc}; +use cookie::Cookie; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Pool, Postgres}; +use uuid::Uuid; + +use crate::{error::AppError, state::AppState}; + +use super::{hash::random, user::User}; + +const INVALID_SESSION: AppError = AppError::ClientError { + status: StatusCode::UNAUTHORIZED, + code: "authentication-required", + message: "Please log-in and submit a valid session as a cookie", +}; + +const USER_NOT_FOUND: AppError = AppError::ClientError { + status: StatusCode::UNAUTHORIZED, + code: "user-not-found", + message: "The logged-in user was not found", +}; + +#[derive(Deserialize, Serialize, FromRow)] +pub struct Session { + /// Role ID + pub id: Uuid, + + /// User ID + pub actor: Uuid, + + /// Secret + pub secret: String, + + /// Times + pub created_at: NaiveDateTime, + pub expires_at: NaiveDateTime, +} + +impl Session { + pub async fn create(pool: &Pool, user_id: Uuid, duration: Duration) -> Result { + let now = Utc::now().naive_utc(); + let expires = now + duration; + let secret = random(); + let result = sqlx::query!( + "INSERT INTO sessions (actor, secret, created_at, expires_at) VALUES ($1, $2, $3, $4) RETURNING id", + user_id, + secret, + now, + expires + ) + .fetch_one(pool) + .await?; + Ok(Self { + id: result.id, + actor: user_id, + secret: secret, + created_at: now, + expires_at: now + duration, + }) + } + + pub async fn find(pool: &Pool, id: Uuid) -> Result> { + Ok(sqlx::query_as("SELECT * FROM sessions WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?) + } + + pub async fn refresh(self: Self, pool: &Pool, duration: Duration) -> Result { + let expires_at = (Utc::now() + duration).naive_utc(); + let secret = random(); + + sqlx::query!( + "UPDATE sessions SET secret = $1, expires_at = $2 WHERE id = $3 RETURNING id", + secret, + expires_at, + self.id + ) + .fetch_one(pool) + .await?; + + Ok(Session { + secret, + expires_at, + ..self + }) + } + + pub fn token(self: &Self) -> String { + format!("{}:{}", self.id.as_u128(), self.secret) + } + + pub fn parse_token(token: &str) -> Result<(Uuid, String)> { + let (uuid_str, token_str) = token + .split_once(":") + .ok_or_else(|| anyhow!("malformed token"))?; + Ok(( + Uuid::from_u128(uuid_str.parse::()?), + token_str.to_string(), + )) + } + + pub async fn user(self: &Self, pool: &Pool) -> Result> { + match User::get_id(pool, self.actor).await? { + Some(user) => Ok(user), + None => Err(USER_NOT_FOUND), + } + } +} + +#[async_trait] +impl FromRequestParts> for Session { + type Rejection = AppError<'static>; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + if let Some(cookie) = parts.headers.get(COOKIE) { + let cookie_str = cookie.to_str()?; + let cookie = Cookie::parse(cookie_str)?; + let (session_id, session_secret) = Session::parse_token(cookie.value())?; + + let State(state) = State::>::from_request_parts(parts, state).await?; + + match Session::find(&state.database, session_id).await? { + None => Err(INVALID_SESSION), + Some(session) => { + println!("{:?}<{:?}", session.expires_at, Utc::now().naive_utc()); + if session.secret != session_secret { + return Err(INVALID_SESSION); + } + if session.expires_at < Utc::now().naive_utc() { + return Err(INVALID_SESSION); + } + Ok(session) + } + } + } else { + return Err(INVALID_SESSION); + } + } +} diff --git a/src/auth/user.rs b/src/auth/user.rs new file mode 100644 index 0000000..d3cddb3 --- /dev/null +++ b/src/auth/user.rs @@ -0,0 +1,101 @@ +use anyhow::Result; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Pool, Postgres}; +use uuid::Uuid; + +use super::hash::hash; + +#[derive(Deserialize, Serialize, FromRow)] +pub struct User { + /// User internal ID + pub id: Uuid, + + /// User name (unique per instance, shows up in URLs) + pub name: String, + + /// User email (for validation/login) + pub email: Option, + + /// Hashed password + pub password: Option, + + /// User's chosen displayed name + pub display_name: Option, + + /// Biography / User description + pub bio: Option, + + /// User roles (as role IDs) + pub roles: Vec, + + /// Times + pub created_at: NaiveDateTime, + pub modified_at: Option, + pub deleted_at: Option, +} + +impl Default for User { + fn default() -> Self { + Self { + id: Uuid::nil(), + name: Default::default(), + email: Default::default(), + password: Default::default(), + display_name: Default::default(), + bio: Default::default(), + roles: Default::default(), + created_at: Default::default(), + modified_at: Default::default(), + deleted_at: Default::default(), + } + } +} + +impl User { + pub async fn create( + pool: &Pool, + username: &str, + password: &str, + roles: &Vec, + ) -> Result { + let result = sqlx::query!( + r#"INSERT INTO users ( name, password, roles ) VALUES ( $1,$2,$3 ) RETURNING id, created_at"#, + username, + hash(&password)?, + roles, + ) + .fetch_one(pool) + .await?; + Ok(Self { + id: result.id, + name: username.to_owned(), + roles: roles.to_owned(), + created_at: result.created_at, + ..Default::default() + }) + } + + pub async fn get_id(pool: &Pool, id: Uuid) -> Result> { + Ok(sqlx::query_as("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?) + } + + pub async fn find(pool: &Pool, name: &str) -> Result> { + Ok(sqlx::query_as("SELECT * FROM users WHERE name = $1") + .bind(name) + .fetch_optional(pool) + .await?) + } +} + +#[derive(Deserialize, Serialize, FromRow)] +pub struct Role { + /// Role ID + pub id: Uuid, + + /// Role scopes (permissions) + pub scopes: Vec, +} diff --git a/src/error.rs b/src/error.rs index bf94491..44e619e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,16 +5,16 @@ use axum::{ }; use serde_json::json; -pub enum AppError { +pub enum AppError<'a> { ServerError(anyhow::Error), ClientError { status: StatusCode, - code: String, - message: String, + code: &'a str, + message: &'a str, }, } -impl IntoResponse for AppError { +impl IntoResponse for AppError<'_> { fn into_response(self) -> Response { match self { AppError::ServerError(err) => ( @@ -31,7 +31,7 @@ impl IntoResponse for AppError { } } -impl From for AppError +impl From for AppError<'_> where E: Into, { diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 36217ea..d23a6e7 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -5,10 +5,12 @@ use axum::Json; use serde_json::json; use std::sync::Arc; -use crate::auth::{random, User}; -use crate::error::AppError; -use crate::roles::ROLE_SUPERADMIN; -use crate::state::AppState; +use crate::{ + auth::{hash::random, user::User}, + error::AppError, + roles::ROLE_SUPERADMIN, + state::AppState, +}; pub async fn bootstrap(State(state): State>) -> impl IntoResponse { // Only allow this request if the user table is completely empty! diff --git a/src/routes/auth.rs b/src/routes/auth.rs index ee5a860..9c94a31 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -11,7 +11,7 @@ use serde_json::json; use std::sync::Arc; use crate::{ - auth::{verify, Session, User}, + auth::{hash::verify, session::Session, user::User}, error::AppError, state::AppState, }; @@ -72,6 +72,10 @@ pub async fn login( Ok(response) } -pub async fn me() -> String { - "test".into() +pub async fn me( + State(state): State>, + session: Session, +) -> Result> { + let user = session.user(&state.database).await?; + Ok(user.name) } diff --git a/src/routes/content.rs b/src/routes/content.rs index 5287735..dab2589 100644 --- a/src/routes/content.rs +++ b/src/routes/content.rs @@ -6,7 +6,7 @@ use crate::{content::Page, error::AppError, state::AppState}; pub async fn page( State(state): State>, Path((site, slug)): Path<(String, String)>, -) -> Result { +) -> Result> { let page_query = sqlx::query_as( "SELECT p.* FROM pages p JOIN sites s ON p.site = s.id WHERE p.slug = $1 AND s.name = $2", ) diff --git a/src/state.rs b/src/state.rs index 891ec07..5674a66 100644 --- a/src/state.rs +++ b/src/state.rs @@ -30,7 +30,7 @@ impl Default for Config { Config { bind: "127.0.0.1:3000".into(), database_url: "postgres://artificiale:changeme@localhost/artificiale".into(), - session_duration: 1440, // 24min + session_duration: 3600, // 60min base_url: "http://localhost".into(), } }