session management stuff

This commit is contained in:
Hamcha 2023-06-30 15:02:20 +02:00
parent 4bdb810e19
commit 1d6270c551
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
15 changed files with 263 additions and 105 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target /target
/docker-data /docker-data
/.vscode

View file

@ -14,7 +14,7 @@ uuid = { version = "1.3", features = ["v4", "fast-rng", "serde"] }
serde = { version = "1" } serde = { version = "1" }
serde_json = { version = "1", features = ["raw_value"] } 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"] } chrono = { version = "0.4", features = ["serde", "clock"] }
anyhow = "1.0" anyhow = "1.0"
argon2 = "0.5" argon2 = "0.5"

View file

@ -1,4 +1,4 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- noqa: RF05
CREATE TABLE users ( CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

View file

@ -0,0 +1 @@
DROP TABLE sessions;

View file

@ -0,0 +1,9 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- noqa: RF05
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
actor UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
secret VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
expires_at TIMESTAMP NOT NULL
);

View file

@ -3,6 +3,135 @@ use argon2::{
password_hash::{rand_core::OsRng, SaltString}, password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier, Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
}; };
use chrono::{
serde::{ts_seconds, ts_seconds_option},
DateTime, Duration, 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<Vec<u8>>,
/// 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
#[serde(with = "ts_seconds")]
pub created_at: DateTime<Utc>,
#[serde(with = "ts_seconds_option")]
pub modified_at: Option<DateTime<Utc>>,
#[serde(with = "ts_seconds_option")]
pub deleted_at: Option<DateTime<Utc>>,
}
#[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
#[serde(with = "ts_seconds")]
pub created_at: DateTime<Utc>,
#[serde(with = "ts_seconds")]
pub expires_at: DateTime<Utc>,
}
impl Session {
pub async fn create(pool: &Pool<Postgres>, user_id: Uuid, duration: Duration) -> Result<Self> {
let now = Utc::now();
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.naive_utc(),
expires.naive_utc()
)
.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;
let secret = random();
sqlx::query!(
"UPDATE sessions SET secret = $1, expires_at = $2 WHERE id = $3 RETURNING id",
secret,
expires_at.naive_utc(),
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 { pub fn random() -> String {
SaltString::generate(&mut OsRng).to_string() SaltString::generate(&mut OsRng).to_string()

View file

@ -3,47 +3,6 @@ use serde::{Deserialize, Serialize};
use sqlx::{types::Json, FromRow}; use sqlx::{types::Json, FromRow};
use uuid::Uuid; 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<Vec<u8>>,
/// 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
#[serde(with = "ts_seconds")]
pub created_at: DateTime<Utc>,
#[serde(with = "ts_seconds_option")]
pub modified_at: Option<DateTime<Utc>>,
#[serde(with = "ts_seconds_option")]
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize, Serialize, FromRow)]
pub struct Role {
/// Role ID
pub id: Uuid,
/// Role scopes (permissions)
pub scopes: Vec<String>,
}
#[derive(Deserialize, Serialize, FromRow)] #[derive(Deserialize, Serialize, FromRow)]
pub struct Site { pub struct Site {
/// Site internal ID /// Site internal ID

View file

@ -1,3 +1,5 @@
use std::error::Error;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},

View file

@ -14,25 +14,11 @@ use figment::{
providers::{Env, Format, Serialized, Toml}, providers::{Env, Format, Serialized, Toml},
Figment, Figment,
}; };
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use state::AppState; use state::AppState;
use std::{net::SocketAddr, sync::Arc}; use std::{net::SocketAddr, sync::Arc};
#[derive(Deserialize, Serialize)] use crate::state::Config;
struct Config {
bind: String,
database_url: String,
}
impl Default for Config {
fn default() -> Self {
Config {
bind: "127.0.0.1:3000".to_owned(),
database_url: "postgres://artificiale:changeme@localhost/artificiale".to_owned(),
}
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -42,22 +28,23 @@ async fn main() -> Result<()> {
.merge(Toml::file("mabel.toml")) .merge(Toml::file("mabel.toml"))
.merge(Env::prefixed("MABEL_")) .merge(Env::prefixed("MABEL_"))
.extract()?; .extract()?;
let addr: SocketAddr = config.bind.parse()?;
let pool = PgPoolOptions::new() let database = PgPoolOptions::new()
.max_connections(5) .max_connections(5)
.connect(config.database_url.as_str()) .connect(config.database_url.as_str())
.await?; .await?;
sqlx::migrate!().run(&pool).await?; sqlx::migrate!().run(&database).await?;
let shared_state = Arc::new(AppState { database: pool }); let shared_state = Arc::new(AppState { database, config });
let app = Router::new() let app = Router::new()
.route("/auth/login", post(routes::auth::login))
.route("/pages/:site/:slug", get(routes::content::page)) .route("/pages/:site/:slug", get(routes::content::page))
.route("/admin/bootstrap", post(routes::admin::bootstrap)) .route("/admin/bootstrap", post(routes::admin::bootstrap))
.with_state(shared_state); .with_state(shared_state);
let addr: SocketAddr = config.bind.parse()?;
tracing::debug!("listening on {}", addr); tracing::debug!("listening on {}", addr);
Server::bind(&addr).serve(app.into_make_service()).await?; Server::bind(&addr).serve(app.into_make_service()).await?;
Ok(()) Ok(())

View file

@ -1,9 +1,8 @@
use std::sync::Arc;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json; use axum::Json;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::sync::Arc;
use crate::auth::{hash, random}; use crate::auth::{hash, random};
use crate::error::AppError; use crate::error::AppError;
@ -22,8 +21,8 @@ pub async fn bootstrap(State(state): State<Arc<AppState>>) -> Result<Json<Value>
if empty { if empty {
return Err(AppError::ClientError { return Err(AppError::ClientError {
status: StatusCode::BAD_REQUEST, status: StatusCode::BAD_REQUEST,
code: "already-setup".to_string(), code: "already-setup".into(),
message: "The instance was already bootstrapped".to_string(), message: "The instance was already bootstrapped".into(),
}); });
} }

52
src/routes/auth.rs Normal file
View file

@ -0,0 +1,52 @@
use axum::{extract::State, http::StatusCode, Json};
use chrono::Duration;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use crate::{
auth::{verify, Session},
error::AppError,
state::AppState,
};
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
) -> Result<Json<Value>, AppError> {
let user = sqlx::query!("SELECT * FROM users WHERE name = $1", payload.username)
.fetch_optional(&state.database)
.await?;
let invalid = || -> AppError {
AppError::ClientError {
status: StatusCode::UNAUTHORIZED,
code: "invalid-login".into(),
message: "No matching user was found".into(),
}
};
let user = user.ok_or_else(invalid)?;
let plaintext = user.password.ok_or_else(invalid)?;
if !verify(&payload.password, &plaintext)? {
return Err(invalid());
}
let session = Session::create(
&state.database,
user.id,
Duration::seconds(state.config.session_duration.into()),
)
.await?;
Ok(Json(
json!({ "session_token": session.token(), "expires_at": session.expires_at }),
))
}

View file

@ -1,7 +1,6 @@
use std::sync::Arc;
use crate::{content::Page, error::AppError, state::AppState}; use crate::{content::Page, error::AppError, state::AppState};
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use std::sync::Arc;
pub async fn page( pub async fn page(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,

View file

@ -1,2 +1,3 @@
pub mod admin; pub mod admin;
pub mod auth;
pub mod content; pub mod content;

View file

@ -1,5 +1,24 @@
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
#[derive(Deserialize, Serialize, Clone)]
pub struct Config {
pub bind: String,
pub database_url: String,
pub session_duration: i32, // in seconds
}
impl Default for Config {
fn default() -> Self {
Config {
bind: "127.0.0.1:3000".to_owned(),
database_url: "postgres://artificiale:changeme@localhost/artificiale".to_owned(),
session_duration: 1440, // 24min
}
}
}
pub struct AppState { pub struct AppState {
pub database: Pool<Postgres>, pub database: Pool<Postgres>,
pub config: Config,
} }