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

3
.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 @@
DROP TABLE pages; DROP TABLE pages;
DROP TABLE sites; DROP TABLE sites;
DROP TABLE audit; DROP TABLE audit;
DROP TABLE users; DROP TABLE users;

View File

@ -1,47 +1,47 @@
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(),
name VARCHAR UNIQUE NOT NULL, name VARCHAR UNIQUE NOT NULL,
email VARCHAR, email VARCHAR,
password VARCHAR, password VARCHAR,
display_name VARCHAR, display_name VARCHAR,
bio TEXT, bio TEXT,
roles UUID[] NOT NULL, roles UUID [] NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now (), created_at TIMESTAMP NOT NULL DEFAULT now(),
modified_at TIMESTAMP, modified_at TIMESTAMP,
deleted_at TIMESTAMP deleted_at TIMESTAMP
); );
CREATE TABLE sites ( CREATE TABLE sites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, owner UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name VARCHAR UNIQUE NOT NULL, name VARCHAR UNIQUE NOT NULL,
title VARCHAR NOT NULL, title VARCHAR NOT NULL,
description TEXT, description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT now (), created_at TIMESTAMP NOT NULL DEFAULT now(),
modified_at TIMESTAMP, modified_at TIMESTAMP,
deleted_at TIMESTAMP deleted_at TIMESTAMP
); );
CREATE TABLE pages ( CREATE TABLE pages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
site UUID NOT NULL REFERENCES sites(id) ON DELETE CASCADE, site UUID NOT NULL REFERENCES sites (id) ON DELETE CASCADE,
author UUID REFERENCES users(id) ON DELETE SET NULL, author UUID REFERENCES users (id) ON DELETE SET NULL,
slug VARCHAR NOT NULL, slug VARCHAR NOT NULL,
title VARCHAR NOT NULL, title VARCHAR NOT NULL,
description TEXT, description TEXT,
tags VARCHAR[] NOT NULL, tags VARCHAR [] NOT NULL,
blocks JSONB NOT NULL, blocks JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now (), created_at TIMESTAMP NOT NULL DEFAULT now(),
modified_at TIMESTAMP, modified_at TIMESTAMP,
deleted_at TIMESTAMP deleted_at TIMESTAMP
); );
CREATE TABLE audit ( CREATE TABLE audit (
actor UUID REFERENCES users(id) ON DELETE SET NULL, actor UUID REFERENCES users (id) ON DELETE SET NULL,
object UUID, object UUID,
action VARCHAR NOT NULL, action VARCHAR NOT NULL,
data JSONB, data JSONB,
created_at TIMESTAMP created_at TIMESTAMP
); );

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,
} }