session management stuff
This commit is contained in:
parent
4bdb810e19
commit
1d6270c551
15 changed files with 263 additions and 105 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
||||||
/target
|
/target
|
||||||
/docker-data
|
/docker-data
|
||||||
|
/.vscode
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
@ -1,45 +1,45 @@
|
||||||
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,
|
||||||
|
|
1
migrations/20230630080241_add-sessions.down.sql
Normal file
1
migrations/20230630080241_add-sessions.down.sql
Normal file
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE sessions;
|
9
migrations/20230630080241_add-sessions.up.sql
Normal file
9
migrations/20230630080241_add-sessions.up.sql
Normal 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
|
||||||
|
);
|
129
src/auth.rs
129
src/auth.rs
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
|
|
25
src/main.rs
25
src/main.rs
|
@ -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(())
|
||||||
|
|
|
@ -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
52
src/routes/auth.rs
Normal 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 }),
|
||||||
|
))
|
||||||
|
}
|
|
@ -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>>,
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
|
pub mod auth;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
|
|
19
src/state.rs
19
src/state.rs
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue