From 1d6270c5510c256fa1b98fa0eb28850d701811f2 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Fri, 30 Jun 2023 15:02:20 +0200 Subject: [PATCH] session management stuff --- .gitignore | 3 +- Cargo.toml | 2 +- .../20230628223219_create-base.down.sql | 2 +- migrations/20230628223219_create-base.up.sql | 72 +++++----- .../20230630080241_add-sessions.down.sql | 1 + migrations/20230630080241_add-sessions.up.sql | 9 ++ src/auth.rs | 129 ++++++++++++++++++ src/content.rs | 41 ------ src/error.rs | 2 + src/main.rs | 25 +--- src/routes/admin.rs | 7 +- src/routes/auth.rs | 52 +++++++ src/routes/content.rs | 3 +- src/routes/mod.rs | 1 + src/state.rs | 19 +++ 15 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 migrations/20230630080241_add-sessions.down.sql create mode 100644 migrations/20230630080241_add-sessions.up.sql create mode 100644 src/routes/auth.rs diff --git a/.gitignore b/.gitignore index 04b3cdf..75488aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -/docker-data \ No newline at end of file +/docker-data +/.vscode \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 93f55e6..8c523bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/migrations/20230628223219_create-base.down.sql b/migrations/20230628223219_create-base.down.sql index a20f899..f1f6d2c 100644 --- a/migrations/20230628223219_create-base.down.sql +++ b/migrations/20230628223219_create-base.down.sql @@ -1,4 +1,4 @@ DROP TABLE pages; DROP TABLE sites; DROP TABLE audit; -DROP TABLE users; \ No newline at end of file +DROP TABLE users; diff --git a/migrations/20230628223219_create-base.up.sql b/migrations/20230628223219_create-base.up.sql index b84df1c..7c54461 100644 --- a/migrations/20230628223219_create-base.up.sql +++ b/migrations/20230628223219_create-base.up.sql @@ -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 -); \ No newline at end of file + actor UUID REFERENCES users (id) ON DELETE SET NULL, + object UUID, + action VARCHAR NOT NULL, + data JSONB, + created_at TIMESTAMP +); diff --git a/migrations/20230630080241_add-sessions.down.sql b/migrations/20230630080241_add-sessions.down.sql new file mode 100644 index 0000000..180974d --- /dev/null +++ b/migrations/20230630080241_add-sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE sessions; diff --git a/migrations/20230630080241_add-sessions.up.sql b/migrations/20230630080241_add-sessions.up.sql new file mode 100644 index 0000000..e397136 --- /dev/null +++ b/migrations/20230630080241_add-sessions.up.sql @@ -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 +); diff --git a/src/auth.rs b/src/auth.rs index 72ae0cc..ca4deb2 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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, + + /// Hashed password + pub password: Option>, + + /// User's chosen displayed name + pub display_name: Option, + + /// Biography / User description + pub bio: Option, + + /// User roles (as role IDs) + pub roles: Vec, + + /// Times + #[serde(with = "ts_seconds")] + pub created_at: DateTime, + #[serde(with = "ts_seconds_option")] + pub modified_at: Option>, + #[serde(with = "ts_seconds_option")] + pub deleted_at: Option>, +} + +#[derive(Deserialize, Serialize, FromRow)] +pub struct Role { + /// Role ID + pub id: Uuid, + + /// Role scopes (permissions) + pub scopes: Vec, +} + +#[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, + #[serde(with = "ts_seconds")] + pub expires_at: DateTime, +} + +impl Session { + pub async fn create(pool: &Pool, user_id: Uuid, duration: Duration) -> Result { + 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, id: Uuid) -> Result> { + Ok(sqlx::query_as("SELECT * FROM sessions WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?) + } + + pub async fn refresh(self: Self, pool: &Pool, duration: Duration) -> Result { + 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::()?), + token_str.to_string(), + )) + } +} pub fn random() -> String { SaltString::generate(&mut OsRng).to_string() diff --git a/src/content.rs b/src/content.rs index 288e4dd..c80cd36 100644 --- a/src/content.rs +++ b/src/content.rs @@ -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, - - /// Hashed password - pub password: Option>, - - /// User's chosen displayed name - pub display_name: Option, - - /// Biography / User description - pub bio: Option, - - /// User roles (as role IDs) - pub roles: Vec, - - /// Times - #[serde(with = "ts_seconds")] - pub created_at: DateTime, - #[serde(with = "ts_seconds_option")] - pub modified_at: Option>, - #[serde(with = "ts_seconds_option")] - pub deleted_at: Option>, -} - -#[derive(Deserialize, Serialize, FromRow)] -pub struct Role { - /// Role ID - pub id: Uuid, - - /// Role scopes (permissions) - pub scopes: Vec, -} - #[derive(Deserialize, Serialize, FromRow)] pub struct Site { /// Site internal ID diff --git a/src/error.rs b/src/error.rs index bf94491..297d227 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +use std::error::Error; + use axum::{ http::StatusCode, response::{IntoResponse, Response}, diff --git a/src/main.rs b/src/main.rs index 1004ff7..0fc7000 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(()) diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 2eebfb0..73c1353 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -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>) -> Result 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(), }); } diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..5cb5584 --- /dev/null +++ b/src/routes/auth.rs @@ -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>, + Json(payload): Json, +) -> Result, 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 }), + )) +} diff --git a/src/routes/content.rs b/src/routes/content.rs index 940ce1d..909756a 100644 --- a/src/routes/content.rs +++ b/src/routes/content.rs @@ -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>, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9eebbe2..e0cc12a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,2 +1,3 @@ pub mod admin; +pub mod auth; pub mod content; diff --git a/src/state.rs b/src/state.rs index 79dd374..ef5fa16 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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, + pub config: Config, }