Add users
New features: - Configuration - Postgresql, migrations - OpenID Connect - Sessions
This commit is contained in:
parent
ef718e49a3
commit
b61bd503e6
22 changed files with 2436 additions and 105 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
||||||
/target
|
/target
|
||||||
|
/config.local.toml
|
||||||
|
|
1860
Cargo.lock
generated
1860
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
@ -5,8 +5,22 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.41.0", features = ["full"] }
|
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"
|
tera = "1.20.0"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
|
tower = "0.5.1"
|
||||||
tower-http = { version = "0.6.1", features = ["fs"] }
|
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"
|
||||||
|
|
|
@ -10,6 +10,7 @@ WORKDIR /usr/src/comfycamp
|
||||||
RUN mkdir -p src/web
|
RUN mkdir -p src/web
|
||||||
COPY src/web/assets src/web/assets
|
COPY src/web/assets src/web/assets
|
||||||
COPY src/web/templates src/web/templates
|
COPY src/web/templates src/web/templates
|
||||||
|
COPY config.default.toml ./
|
||||||
|
|
||||||
COPY --from=builder /usr/local/cargo/bin/comfycamp /usr/local/bin/comfycamp
|
COPY --from=builder /usr/local/cargo/bin/comfycamp /usr/local/bin/comfycamp
|
||||||
CMD ["comfycamp"]
|
CMD ["comfycamp"]
|
||||||
|
|
9
config.default.toml
Normal file
9
config.default.toml
Normal 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
16
docker-compose.yml
Normal 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
41
src/config.rs
Normal 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")
|
||||||
|
};
|
||||||
|
}
|
26
src/internal/db/migrations/0001_users.sql
Normal file
26
src/internal/db/migrations/0001_users.sql
Normal 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
7
src/internal/db/mod.rs
Normal 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
181
src/internal/db/users.rs
Normal 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())
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod db;
|
||||||
|
pub mod openid;
|
92
src/internal/openid.rs
Normal file
92
src/internal/openid.rs
Normal 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(¶ms)
|
||||||
|
.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)
|
||||||
|
}
|
44
src/main.rs
44
src/main.rs
|
@ -1,23 +1,57 @@
|
||||||
use axum::{routing::get, Router};
|
use axum::{
|
||||||
use tower_http::services::ServeDir;
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use log::info;
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::signal;
|
use tokio::signal;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
use crate::config::CONFIG;
|
||||||
|
use crate::internal::db;
|
||||||
use crate::web::controllers;
|
use crate::web::controllers;
|
||||||
|
|
||||||
|
mod config;
|
||||||
mod internal;
|
mod internal;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn 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()
|
let app = Router::new()
|
||||||
.route("/", get(controllers::home))
|
.route("/", get(controllers::home))
|
||||||
.nest_service("/assets", ServeDir::new("src/web/assets"));
|
.route("/openid/signin", post(controllers::signin))
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
|
.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)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.expect("failed to start axum server");
|
||||||
|
|
||||||
|
shared_state.db.close().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown_signal() {
|
async fn shutdown_signal() {
|
||||||
|
|
|
@ -74,3 +74,17 @@ footer .link-row a {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
|
26
src/web/controllers/auth.rs
Normal file
26
src/web/controllers/auth.rs
Normal 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()),
|
||||||
|
}
|
||||||
|
}
|
41
src/web/controllers/ctx.rs
Normal file
41
src/web/controllers/ctx.rs
Normal 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)
|
||||||
|
}
|
29
src/web/controllers/error.rs
Normal file
29
src/web/controllers/error.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
use crate::web::templates;
|
||||||
|
|
||||||
pub async fn home() -> Html<String> {
|
pub async fn home(
|
||||||
templates::render("home.html", &tera::Context::new())
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
88
src/web/controllers/openid.rs
Normal file
88
src/web/controllers/openid.rs
Normal 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("/")))
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use axum::response::Html;
|
use axum::response::Html;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
use tera::Tera;
|
use tera::Tera;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
|
|
|
@ -17,7 +17,16 @@
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Главная</a>
|
<a href="/">Главная</a>
|
||||||
<div class="space"></div>
|
<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>
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
Loading…
Reference in a new issue