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
/docker-data
/docker-data
/.vscode

View file

@ -14,7 +14,7 @@ uuid = { version = "1.3", features = ["v4", "fast-rng", "serde"] }
serde = { version = "1" }
serde_json = { version = "1", features = ["raw_value"] }
figment = { version = "0.10", features = ["toml", "env"] }
chrono = { version = "0.4", features = ["serde"] }
chrono = { version = "0.4", features = ["serde", "clock"] }
anyhow = "1.0"
argon2 = "0.5"

View file

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

View file

@ -3,47 +3,6 @@ use serde::{Deserialize, Serialize};
use sqlx::{types::Json, FromRow};
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 Site {
/// Site internal ID

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,24 @@
use serde::{Deserialize, Serialize};
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 database: Pool<Postgres>,
pub config: Config,
}