session extractor done

This commit is contained in:
Hamcha 2023-07-01 16:15:19 +02:00
parent b3380648bb
commit 3402b67441
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
11 changed files with 299 additions and 210 deletions

View file

@ -17,7 +17,7 @@ serde_json = { version = "1", features = ["raw_value"] }
figment = { version = "0.10", features = ["toml", "env"] } figment = { version = "0.10", features = ["toml", "env"] }
chrono = { version = "0.4", features = ["serde", "clock"] } chrono = { version = "0.4", features = ["serde", "clock"] }
anyhow = "1.0" anyhow = "1.0"
argon2 = "0.5" argon2 = { version = "0.5", features = ["std", "alloc"] }
url = "2.4" url = "2.4"
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]

View file

@ -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<String>,
/// Hashed password
pub password: Option<String>,
/// User's chosen displayed name
pub display_name: Option<String>,
/// Biography / User description
pub bio: Option<String>,
/// User roles (as role IDs)
pub roles: Vec<Uuid>,
/// Times
pub created_at: NaiveDateTime,
pub modified_at: Option<NaiveDateTime>,
pub deleted_at: Option<NaiveDateTime>,
}
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<Postgres>,
username: &str,
password: &str,
roles: &Vec<Uuid>,
) -> Result<Self> {
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<Postgres>, name: &str) -> Result<Option<Self>> {
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<String>,
}
#[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<Postgres>, user_id: Uuid, duration: Duration) -> Result<Self> {
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<Postgres>, id: Uuid) -> Result<Option<Self>> {
Ok(sqlx::query_as("SELECT * FROM sessions WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?)
}
pub async fn refresh(self: Self, pool: &Pool<Postgres>, duration: Duration) -> Result<Self> {
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::<u128>()?),
token_str.to_string(),
))
}
}
pub fn random() -> String {
SaltString::generate(&mut OsRng).to_string()
}
pub fn hash(plaintext: &str) -> Result<String> {
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<bool> {
let parsed_hash = PasswordHash::new(hash).map_err(|err| anyhow!(err))?;
Ok(Argon2::default()
.verify_password(plaintext.as_bytes(), &parsed_hash)
.is_ok())
}

23
src/auth/hash.rs Normal file
View file

@ -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<String> {
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<bool> {
let parsed_hash = PasswordHash::new(hash)?;
Ok(Argon2::default()
.verify_password(plaintext.as_bytes(), &parsed_hash)
.is_ok())
}

3
src/auth/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod hash;
pub mod session;
pub mod user;

151
src/auth/session.rs Normal file
View file

@ -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<Postgres>, user_id: Uuid, duration: Duration) -> Result<Self> {
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<Postgres>, id: Uuid) -> Result<Option<Self>> {
Ok(sqlx::query_as("SELECT * FROM sessions WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?)
}
pub async fn refresh(self: Self, pool: &Pool<Postgres>, duration: Duration) -> Result<Self> {
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::<u128>()?),
token_str.to_string(),
))
}
pub async fn user(self: &Self, pool: &Pool<Postgres>) -> Result<User, AppError<'static>> {
match User::get_id(pool, self.actor).await? {
Some(user) => Ok(user),
None => Err(USER_NOT_FOUND),
}
}
}
#[async_trait]
impl FromRequestParts<Arc<AppState>> for Session {
type Rejection = AppError<'static>;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
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::<Arc<AppState>>::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);
}
}
}

101
src/auth/user.rs Normal file
View file

@ -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<String>,
/// Hashed password
pub password: Option<String>,
/// User's chosen displayed name
pub display_name: Option<String>,
/// Biography / User description
pub bio: Option<String>,
/// User roles (as role IDs)
pub roles: Vec<Uuid>,
/// Times
pub created_at: NaiveDateTime,
pub modified_at: Option<NaiveDateTime>,
pub deleted_at: Option<NaiveDateTime>,
}
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<Postgres>,
username: &str,
password: &str,
roles: &Vec<Uuid>,
) -> Result<Self> {
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<Postgres>, id: Uuid) -> Result<Option<Self>> {
Ok(sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?)
}
pub async fn find(pool: &Pool<Postgres>, name: &str) -> Result<Option<Self>> {
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<String>,
}

View file

@ -5,16 +5,16 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
pub enum AppError { pub enum AppError<'a> {
ServerError(anyhow::Error), ServerError(anyhow::Error),
ClientError { ClientError {
status: StatusCode, status: StatusCode,
code: String, code: &'a str,
message: String, message: &'a str,
}, },
} }
impl IntoResponse for AppError { impl IntoResponse for AppError<'_> {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {
AppError::ServerError(err) => ( AppError::ServerError(err) => (
@ -31,7 +31,7 @@ impl IntoResponse for AppError {
} }
} }
impl<E> From<E> for AppError impl<E> From<E> for AppError<'_>
where where
E: Into<anyhow::Error>, E: Into<anyhow::Error>,
{ {

View file

@ -5,10 +5,12 @@ use axum::Json;
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::auth::{random, User}; use crate::{
use crate::error::AppError; auth::{hash::random, user::User},
use crate::roles::ROLE_SUPERADMIN; error::AppError,
use crate::state::AppState; roles::ROLE_SUPERADMIN,
state::AppState,
};
pub async fn bootstrap(State(state): State<Arc<AppState>>) -> impl IntoResponse { pub async fn bootstrap(State(state): State<Arc<AppState>>) -> impl IntoResponse {
// Only allow this request if the user table is completely empty! // Only allow this request if the user table is completely empty!

View file

@ -11,7 +11,7 @@ use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
auth::{verify, Session, User}, auth::{hash::verify, session::Session, user::User},
error::AppError, error::AppError,
state::AppState, state::AppState,
}; };
@ -72,6 +72,10 @@ pub async fn login(
Ok(response) Ok(response)
} }
pub async fn me() -> String { pub async fn me(
"test".into() State(state): State<Arc<AppState>>,
session: Session,
) -> Result<String, AppError<'static>> {
let user = session.user(&state.database).await?;
Ok(user.name)
} }

View file

@ -6,7 +6,7 @@ use crate::{content::Page, error::AppError, state::AppState};
pub async fn page( pub async fn page(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path((site, slug)): Path<(String, String)>, Path((site, slug)): Path<(String, String)>,
) -> Result<String, AppError> { ) -> Result<String, AppError<'static>> {
let page_query = sqlx::query_as( 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", "SELECT p.* FROM pages p JOIN sites s ON p.site = s.id WHERE p.slug = $1 AND s.name = $2",
) )

View file

@ -30,7 +30,7 @@ impl Default for Config {
Config { Config {
bind: "127.0.0.1:3000".into(), bind: "127.0.0.1:3000".into(),
database_url: "postgres://artificiale:changeme@localhost/artificiale".into(), database_url: "postgres://artificiale:changeme@localhost/artificiale".into(),
session_duration: 1440, // 24min session_duration: 3600, // 60min
base_url: "http://localhost".into(), base_url: "http://localhost".into(),
} }
} }