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
|
||||
/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]
|
||||
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"
|
||||
|
|
|
@ -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
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 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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
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;
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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 lazy_static::lazy_static;
|
||||
use tera::Tera;
|
||||
|
||||
lazy_static! {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue