Add users

New features:
- Configuration
- Postgresql, migrations
- OpenID Connect
- Sessions
This commit is contained in:
Ivan R. 2024-11-01 15:21:55 +05:00
parent ef718e49a3
commit b61bd503e6
Signed by: lumin
GPG key ID: 9B2CA5D12844D4D0
22 changed files with 2436 additions and 105 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target
/config.local.toml

1860
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,22 @@ edition = "2021"
[dependencies]
tokio = { version = "1.41.0", features = ["full"] }
axum = "0.7.7"
axum = { version = "0.7.7", features = [ "macros" ] }
axum-extra = { version = "0.9.4", features = [ "cookie" ] }
tera = "1.20.0"
lazy_static = "1.5.0"
tower = "0.5.1"
tower-http = { version = "0.6.1", features = ["fs"] }
notify = "7.0.0"
sqlx = { version = "0.8.2", features = [ "runtime-tokio", "postgres", "chrono" ] }
url = { version = "2.5.2", features = [ "serde" ] }
serde = { version = "1.0.214", features = [ "derive" ] }
config = "0.14.1"
reqwest = { version = "0.12", features = [ "json" ] }
log = "0.4.22"
env_logger = "0.11.5"
anyhow = "1.0.91"
jsonwebtoken = "9.3.0"
rand = "0.8.5"
rand_core = { version = "0.6.4", features = [ "getrandom" ] }
base64 = "0.22.1"
chrono = "0.4.38"

View file

@ -10,6 +10,7 @@ WORKDIR /usr/src/comfycamp
RUN mkdir -p src/web
COPY src/web/assets src/web/assets
COPY src/web/templates src/web/templates
COPY config.default.toml ./
COPY --from=builder /usr/local/cargo/bin/comfycamp /usr/local/bin/comfycamp
CMD ["comfycamp"]

9
config.default.toml Normal file
View file

@ -0,0 +1,9 @@
url = "http://localhost:4000"
port = 4000
[database]
url = "postgres://comfycamp:simple-password@localhost/comfycamp"
max_connections = 5
[openid]
scopes = "openid email profile"

16
docker-compose.yml Normal file
View file

@ -0,0 +1,16 @@
version: '3.8'
services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: comfycamp
POSTGRES_USER: comfycamp
POSTGRES_PASSWORD: simple-password
ports:
- 5432:5432
volumes:
- rustycamp-postgres-dev:/var/lib/postgresql/data
volumes:
rustycamp-postgres-dev:

41
src/config.rs Normal file
View file

@ -0,0 +1,41 @@
use config::{Config, Environment, File};
use lazy_static::lazy_static;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct OpenIdConfig {
pub discovery_url: String,
pub client_id: String,
pub client_secret: String,
/// Requested scopes separated by space.
pub scopes: String,
}
#[derive(Deserialize)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
}
#[derive(Deserialize)]
pub struct AppConfig {
/// Website url without path, e.g. `https://example.com`.
pub url: url::Url,
/// Port for the web server.
pub port: u16,
pub openid: OpenIdConfig,
pub database: DatabaseConfig,
}
lazy_static! {
pub static ref CONFIG: AppConfig = {
let config = Config::builder()
.add_source(File::with_name("config.default.toml"))
.add_source(File::with_name("config.local.toml").required(false))
.add_source(Environment::default())
.build()
.expect("failed to initialize config");
config.try_deserialize().expect("failed to parse config")
};
}

View file

@ -0,0 +1,26 @@
CREATE TABLE users(
username TEXT NOT NULL PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE external_users(
internal_username TEXT NOT NULL
REFERENCES users(username)
ON DELETE CASCADE,
external_id TEXT NOT NULL
);
CREATE UNIQUE INDEX external_user_idx ON external_users(external_id);
CREATE TABLE tokens(
context TEXT NOT NULL,
value BYTEA NOT NULL,
username TEXT
REFERENCES users(username)
ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX token_idx ON tokens(context, value);

7
src/internal/db/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod users;
pub async fn apply_migrations(pool: &sqlx::PgPool) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./src/internal/db/migrations")
.run(pool)
.await
}

181
src/internal/db/users.rs Normal file
View file

@ -0,0 +1,181 @@
use chrono::prelude::*;
use rand_core::{RngCore, SeedableRng};
use serde::Serialize;
use sqlx::PgPool;
const TOKEN_CONTEXT_AUTH: &str = "auth";
const TOKEN_CONTEXT_OPENID: &str = "openid";
pub const AUTH_TOKEN_LIFETIME_IN_SECONDS: u32 = 60 * 60 * 24 * 30;
pub struct NewUser<'a> {
pub username: &'a str,
pub email: &'a str,
pub display_name: &'a str,
pub external_id: &'a str,
}
#[derive(Clone, Serialize, sqlx::FromRow)]
pub struct User {
pub username: String,
pub email: String,
pub display_name: String,
}
/// Create a new user using external auth provider.
pub async fn create_user(pool: &PgPool, user: NewUser<'_>) -> Result<(), sqlx::Error> {
let mut tx = pool.begin().await?;
sqlx::query(
"
INSERT INTO users(username, email, display_name)
VALUES($1, $2, $3)
",
)
.bind(user.username)
.bind(user.email)
.bind(user.display_name)
.execute(&mut *tx)
.await?;
sqlx::query(
"
INSERT INTO external_users(internal_username, external_id)
VALUES($1, $2)
",
)
.bind(user.username)
.bind(user.external_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Get internal user by ID from external auth provider.
pub async fn get_user_by_external_id(
pool: &PgPool,
external_id: &str,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"
SELECT users.*
FROM users
INNER JOIN external_users
ON external_users.internal_username = users.username
WHERE external_id = $1
",
)
.bind(external_id)
.fetch_optional(pool)
.await
}
/// Generate a token that may be used to authorize specified user
/// and store it in the database.
pub async fn generate_auth_token(pool: &PgPool, username: &str) -> Result<[u8; 64], sqlx::Error> {
let mut rng = rand::rngs::StdRng::from_entropy();
let mut value = [0u8; 64];
rng.fill_bytes(&mut value);
sqlx::query(
"
INSERT INTO tokens(context, value, username)
VALUES($1, $2, $3)
",
)
.bind(TOKEN_CONTEXT_AUTH)
.bind(value)
.bind(username)
.execute(pool)
.await?;
Ok(value)
}
/// Try to find a user referenced by provided token.
pub async fn get_user_by_auth_token(
pool: &PgPool,
value: Vec<u8>,
) -> Result<Option<User>, sqlx::Error> {
let min_created_at = Local::now().checked_sub_signed(chrono::TimeDelta::seconds(
AUTH_TOKEN_LIFETIME_IN_SECONDS.into(),
));
sqlx::query_as::<_, User>(
"
SELECT users.*
FROM users
INNER JOIN tokens
ON tokens.username = users.username
WHERE tokens.context = $1
AND tokens.value = $2
AND tokens.created_at >= $3
",
)
.bind(TOKEN_CONTEXT_AUTH)
.bind(value)
.bind(min_created_at)
.fetch_optional(pool)
.await
}
/// Delete authorization token (logout).
pub async fn delete_auth_token(pool: &PgPool, value: Vec<u8>) -> Result<(), sqlx::Error> {
sqlx::query(
"
DELETE FROM tokens
WHERE context = $1
AND value = $2
RETURNING 1
",
)
.bind(TOKEN_CONTEXT_AUTH)
.bind(value)
.fetch_one(pool)
.await?;
Ok(())
}
/// Create and save random token in the database.
/// This token must be returned by openid authorization endpoint.
pub async fn generate_openid_state(pool: &PgPool) -> Result<[u8; 16], anyhow::Error> {
let mut rng = rand::rngs::StdRng::from_entropy();
let mut raw_value = [0u8; 16];
rng.fill_bytes(&mut raw_value);
sqlx::query(
"
INSERT INTO tokens(context, value)
VALUES ($1, $2)
",
)
.bind(TOKEN_CONTEXT_OPENID)
.bind(raw_value)
.execute(pool)
.await?;
Ok(raw_value)
}
/// Check state returned by openid authorization endpoint.
/// Returns true if provided state was found and deleted.
pub async fn delete_openid_state(pool: &PgPool, state: Vec<u8>) -> Result<bool, anyhow::Error> {
let res = sqlx::query_as::<_, (i32,)>(
"
DELETE FROM tokens
WHERE context = $1
AND value = $2
RETURNING 1
",
)
.bind(TOKEN_CONTEXT_OPENID)
.bind(state)
.fetch_optional(pool)
.await?;
Ok(res.is_some())
}

View file

@ -0,0 +1,2 @@
pub mod db;
pub mod openid;

92
src/internal/openid.rs Normal file
View file

@ -0,0 +1,92 @@
use jsonwebtoken::{jwk::JwkSet, Algorithm, Validation};
use serde::Deserialize;
use url::Url;
use crate::config::CONFIG;
#[derive(Deserialize)]
pub struct Config {
pub authorization_endpoint: String,
pub token_endpoint: String,
pub userinfo_endpoint: String,
pub end_session_endpoint: String,
pub jwks_uri: String,
}
#[derive(Debug, Deserialize)]
pub struct Credentials {
pub token_type: String,
pub access_token: String,
pub expires_in: u64,
pub id_token: String,
}
#[derive(Debug, Deserialize)]
pub struct IdToken {
pub sub: String,
pub email: String,
pub email_verified: bool,
pub preferred_username: String,
pub name: String,
}
pub async fn fetch_openid_config() -> Result<Config, reqwest::Error> {
reqwest::get(&CONFIG.openid.discovery_url)
.await?
.json::<Config>()
.await
}
pub async fn fetch_jwk_set() -> Result<JwkSet, anyhow::Error> {
let openid_config = fetch_openid_config().await?;
let jwkset = reqwest::get(&openid_config.jwks_uri)
.await?
.json::<JwkSet>()
.await?;
Ok(jwkset)
}
/// Exchange temporary authorization code for an ID token.
pub async fn exchange_code(code: &str, redirect_uri: &str) -> Result<Credentials, anyhow::Error> {
let openid_config = fetch_openid_config().await?;
let url = Url::parse(&openid_config.token_endpoint)?;
let params = [
("code", code),
("client_id", &CONFIG.openid.client_id),
("client_secret", &CONFIG.openid.client_secret),
("redirect_uri", redirect_uri),
("grant_type", "authorization_code"),
];
let client = reqwest::Client::new();
let resp = client
.post(url.as_str())
.form(&params)
.send()
.await?
.json::<Credentials>()
.await?;
Ok(resp)
}
/// Validate and parse ID token.
pub async fn parse_id_token(token: String) -> Result<IdToken, anyhow::Error> {
let header = jsonwebtoken::decode_header(&token)?;
let kid = header.kid.ok_or(anyhow::anyhow!("kid is missing"))?;
let jwkset = fetch_jwk_set().await?;
let signing_key = &jwkset
.find(&kid)
.ok_or(anyhow::anyhow!("can't find signing key"))?;
let decoding_key = jsonwebtoken::DecodingKey::from_jwk(signing_key)?;
let mut validation = Validation::new(Algorithm::ES256);
validation.set_audience(&[&CONFIG.openid.client_id]);
let jwt = jsonwebtoken::decode::<IdToken>(&token, &decoding_key, &validation)?;
Ok(jwt.claims)
}

View file

@ -1,23 +1,57 @@
use axum::{routing::get, Router};
use tower_http::services::ServeDir;
use axum::{
routing::{get, post},
Router,
};
use log::info;
use sqlx::postgres::PgPoolOptions;
use std::sync::Arc;
use tokio::signal;
use tower_http::services::ServeDir;
use crate::config::CONFIG;
use crate::internal::db;
use crate::web::controllers;
mod config;
mod internal;
mod web;
#[tokio::main]
async fn main() {
env_logger::init();
let pool = PgPoolOptions::new()
.max_connections(CONFIG.database.max_connections)
.connect(&CONFIG.database.url)
.await
.expect("failed to connect to postgresql");
db::apply_migrations(&pool)
.await
.expect("failed to apply migrations");
let shared_state = Arc::new(controllers::AppState { db: pool });
let app = Router::new()
.route("/", get(controllers::home))
.nest_service("/assets", ServeDir::new("src/web/assets"));
let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
.route("/openid/signin", post(controllers::signin))
.route("/openid/exchange-code", get(controllers::exchange_code))
.route("/logout", post(controllers::logout))
.nest_service("/assets", ServeDir::new("src/web/assets"))
.with_state(shared_state.clone());
let addr = format!("0.0.0.0:{}", CONFIG.port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.expect("failed to start a tcp listener");
info!("Starting a web server on {}", addr);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
.expect("failed to start axum server");
shared_state.db.close().await;
}
async fn shutdown_signal() {

View file

@ -74,3 +74,17 @@ footer .link-row a {
align-items: center;
gap: 8px;
}
.pseudo-link {
background-color: transparent;
border: none;
padding: 0;
color: var(--accent);
font-size: inherit;
font-family: inherit;
cursor: pointer;
}
.pseudo-link:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,26 @@
use axum::{
extract::State,
http::header::SET_COOKIE,
response::{AppendHeaders, IntoResponse, Redirect},
};
use axum_extra::extract::CookieJar;
use std::sync::Arc;
use crate::internal::db::users;
pub async fn logout(
cookies: CookieJar,
State(state): State<Arc<super::AppState>>,
) -> Result<impl IntoResponse, super::ControllerError> {
let token = super::ctx::extract_auth_token(cookies)?;
match token {
Some(token) => {
users::delete_auth_token(&state.db, token).await?;
let headers = AppendHeaders([(SET_COOKIE, "comfy-session=deleted; Max-Age=0;")]);
Ok((headers, Redirect::to("/")))
}
None => Err(anyhow::anyhow!("can't find session token").into()),
}
}

View file

@ -0,0 +1,41 @@
use axum_extra::extract::cookie::CookieJar;
use base64::prelude::*;
use crate::internal::db::users;
pub fn extract_auth_token(cookies: CookieJar) -> Result<Option<Vec<u8>>, anyhow::Error> {
let cookie = match cookies.get("comfy-session") {
Some(cookie) => cookie,
None => {
return Ok(None);
}
};
let token = BASE64_STANDARD.decode(cookie.value())?;
Ok(Some(token))
}
/// Get current user by looking at request cookies.
pub async fn get_user(
pool: &sqlx::PgPool,
cookies: CookieJar,
) -> Result<Option<users::User>, anyhow::Error> {
let token = extract_auth_token(cookies)?;
match token {
Some(token) => {
let user = users::get_user_by_auth_token(pool, token).await?;
Ok(user)
}
None => Ok(None),
}
}
/// Get context for templates with user field set.
pub async fn get_context(
pool: &sqlx::PgPool,
cookies: CookieJar,
) -> Result<tera::Context, anyhow::Error> {
let user = get_user(pool, cookies).await?;
let mut ctx = tera::Context::new();
ctx.insert("user", &user);
Ok(ctx)
}

View file

@ -0,0 +1,29 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use log::error;
pub struct ControllerError(anyhow::Error);
impl IntoResponse for ControllerError {
fn into_response(self) -> Response {
error!("{}", self.0);
(
StatusCode::INTERNAL_SERVER_ERROR,
"something went wrong, see logs for details",
)
.into_response()
}
}
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into `Result<_, AppError>`.
// That way you don't need to do that manually.
impl<E> From<E> for ControllerError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

View file

@ -1,6 +1,14 @@
use axum::response::Html;
use axum::{extract::State, response::Html};
use axum_extra::extract::CookieJar;
use std::sync::Arc;
use crate::web::templates;
pub async fn home() -> Html<String> {
templates::render("home.html", &tera::Context::new())
pub async fn home(
cookies: CookieJar,
State(state): State<Arc<super::AppState>>,
) -> Result<Html<String>, super::ControllerError> {
let ctx = super::get_context(&state.db, cookies).await?;
let resp = templates::render("home.html", &ctx);
Ok(resp)
}

View file

@ -1,3 +1,17 @@
pub mod home;
mod auth;
mod ctx;
mod error;
mod home;
mod openid;
pub use home::home;
pub use auth::*;
pub use home::*;
pub use openid::*;
use ctx::*;
use error::*;
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::PgPool,
}

View file

@ -0,0 +1,88 @@
use axum::{
extract::{Query, State},
http::header::SET_COOKIE,
response::{AppendHeaders, IntoResponse, Redirect},
};
use base64::prelude::*;
use std::{collections::HashMap, sync::Arc};
use url::Url;
use crate::config::CONFIG;
use crate::internal::db::users;
use crate::internal::openid;
fn gen_redirect_uri() -> String {
let mut url = CONFIG.url.clone();
url.set_path("/openid/exchange-code");
url.to_string()
}
/// Redirect the user to the authorization page.
pub async fn signin(
State(state): State<Arc<super::AppState>>,
) -> Result<Redirect, super::ControllerError> {
let raw_openid_state = users::generate_openid_state(&state.db).await?;
let openid_state = BASE64_URL_SAFE.encode(raw_openid_state);
// TODO: cache result.
let openid_config = openid::fetch_openid_config().await?;
let url = Url::parse_with_params(
&openid_config.authorization_endpoint,
&[
("response_type", "code"),
("client_id", &CONFIG.openid.client_id),
("redirect_uri", &gen_redirect_uri()),
("scope", &CONFIG.openid.scopes),
("state", &openid_state),
],
)?;
let resp = Redirect::to(url.as_str());
Ok(resp)
}
pub async fn exchange_code(
Query(params): Query<HashMap<String, String>>,
State(state): State<Arc<super::AppState>>,
) -> Result<impl IntoResponse, super::ControllerError> {
let openid_state = params
.get("state")
.ok_or(anyhow::anyhow!("state is missing"))?;
let raw_openid_state = BASE64_URL_SAFE.decode(openid_state)?;
let state_found = users::delete_openid_state(&state.db, raw_openid_state).await?;
if !state_found {
return Err(anyhow::anyhow!("state was not found").into());
}
let code = params
.get("code")
.ok_or(anyhow::anyhow!("authorization code is missing"))?;
let creds = openid::exchange_code(code, &gen_redirect_uri()).await?;
let token = openid::parse_id_token(creds.id_token).await?;
let user = users::get_user_by_external_id(&state.db, &token.sub).await?;
if user.is_none() {
let new_user = users::NewUser {
username: &token.preferred_username,
email: &token.email,
display_name: &token.name,
external_id: &token.sub,
};
users::create_user(&state.db, new_user).await?;
}
let auth_token = users::generate_auth_token(&state.db, &token.preferred_username).await?;
let encoded_token = BASE64_STANDARD.encode(auth_token);
let headers = AppendHeaders([(
SET_COOKIE,
format!(
"comfy-session={}; Max-Age={}; HttpOnly; Path=/; SameSite=lax",
encoded_token,
users::AUTH_TOKEN_LIFETIME_IN_SECONDS
),
)]);
Ok((headers, Redirect::to("/")))
}

View file

@ -1,5 +1,5 @@
use lazy_static::lazy_static;
use axum::response::Html;
use lazy_static::lazy_static;
use tera::Tera;
lazy_static! {

View file

@ -17,7 +17,16 @@
<nav>
<a href="/">Главная</a>
<div class="space"></div>
<a href="https://auth.comfycamp.space/if/flow/enrollment/">Зарегистрироваться</a>
{% if user %}
Привет, {{ user.display_name }}!
<form method="POST" action="/logout">
<button type="submit" class="pseudo-link">Выйти</button>
</form>
{% else %}
<form method="POST" action="/openid/signin">
<button type="submit" class="pseudo-link">Зарегистрироваться или войти</button>
</form>
{% endif %}
</nav>
<main>