session extractor done
This commit is contained in:
parent
b3380648bb
commit
3402b67441
11 changed files with 299 additions and 210 deletions
|
@ -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]
|
||||
|
|
195
src/auth.rs
195
src/auth.rs
|
@ -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
23
src/auth/hash.rs
Normal 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
3
src/auth/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod hash;
|
||||
pub mod session;
|
||||
pub mod user;
|
151
src/auth/session.rs
Normal file
151
src/auth/session.rs
Normal 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
101
src/auth/user.rs
Normal 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>,
|
||||
}
|
10
src/error.rs
10
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<E> From<E> for AppError
|
||||
impl<E> From<E> for AppError<'_>
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
|
|
|
@ -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<Arc<AppState>>) -> impl IntoResponse {
|
||||
// Only allow this request if the user table is completely empty!
|
||||
|
|
|
@ -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<Arc<AppState>>,
|
||||
session: Session,
|
||||
) -> Result<String, AppError<'static>> {
|
||||
let user = session.user(&state.database).await?;
|
||||
Ok(user.name)
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ use crate::{content::Page, error::AppError, state::AppState};
|
|||
pub async fn page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((site, slug)): Path<(String, String)>,
|
||||
) -> Result<String, AppError> {
|
||||
) -> Result<String, AppError<'static>> {
|
||||
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",
|
||||
)
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue