first commit

This commit is contained in:
Xander Bil 2024-11-11 23:18:08 +01:00
commit 62ff6e321e
No known key found for this signature in database
GPG key ID: EC9706B54A278598
22 changed files with 4284 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
.env
*.db

3680
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

38
Cargo.toml Normal file
View file

@ -0,0 +1,38 @@
[package]
name = "mailauth"
version = "0.1.0"
edition = "2021"
[workspace]
members = [".", "migration"]
[dependencies]
axum = { version = "0.7.5", default-features = false, features = [
"tokio",
"http1",
"tracing",
"query",
"json",
"form"
] }
axum-extra = {version = "0.9.4", features=["cookie-signed"]}
reqwest = { version = "0.12.7", default-features = false, features = [
"json",
"rustls-tls",
] }
dotenvy = "0.15"
jsonwebtoken = "9.3.0"
serde = "1.0.123"
rand= "0.8.5"
tokio = { version = "1.39.3", default-features = false, features = [
"rt-multi-thread",
"macros",
"net",
] }
sea-orm = { version = "1.1.0", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros" ] }
migration = { path = "migration" }
minijinja = "2.4.0"
thiserror = "2.0.3"

18
migration/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "1.1.0"
features = [
"sqlx-sqlite", "runtime-tokio-native-tls"
]

41
migration/README.md Normal file
View file

@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

14
migration/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
pub use sea_orm_migration::prelude::*;
mod m20241106_212353_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20241106_212353_create_table::Migration),
]
}
}

View file

@ -0,0 +1,35 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(pk_auto(User::Id))
.col(string(User::Password))
.col(string(User::Userid))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum User {
Table,
Id,
Userid,
Password,
}

6
migration/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

6
src/appstate.rs Normal file
View file

@ -0,0 +1,6 @@
use sea_orm::DatabaseConnection;
#[derive(Clone)]
pub struct Appstate {
pub conn: DatabaseConnection,
}

45
src/config.rs Normal file
View file

@ -0,0 +1,45 @@
use std::{env, sync::OnceLock};
use axum_extra::extract::cookie::Key;
use dotenvy::dotenv;
static CONFIG: OnceLock<Config> = OnceLock::new();
pub struct Config {
pub zauth_url: String,
pub callback_url: String,
pub host_url: String,
pub zauth_client_id: String,
pub zauth_client_secret: String,
pub database_uri: String,
pub cookies_key: Key,
}
impl Config {
pub fn initialize() {
assert!(CONFIG.get().is_none());
Config::get();
}
pub fn get() -> &'static Config {
CONFIG.get_or_init(|| {
dotenv().ok();
Config {
zauth_url: env::var("ZAUTH_URL").expect("ZAUTH_URL not present"),
callback_url: env::var("ZAUTH_CALLBACK_PATH")
.expect("ZAUTH_CALLBACK_PATH not present"),
host_url: env::var("HOST_URL").expect("HOST_URL not present"),
zauth_client_id: env::var("ZAUTH_CLIENT_ID").expect("ZAUTH_CLIENT_ID not present"),
zauth_client_secret: env::var("ZAUTH_CLIENT_SECRET")
.expect("ZAUTH_CLIENT_SECRET not present"),
database_uri: env::var("DATABASE_URL").expect("DATABASE_URI not present"),
cookies_key: Key::from(
env::var("COOKIES_KEY")
.expect("COOKIES_KEY not present")
.as_ref(),
),
}
})
}
}

5
src/entities/mod.rs Normal file
View file

@ -0,0 +1,5 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub mod prelude;
pub mod user;

3
src/entities/prelude.rs Normal file
View file

@ -0,0 +1,3 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub use super::user::Entity as User;

17
src/entities/user.rs Normal file
View file

@ -0,0 +1,17 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub password: String,
pub userid: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

34
src/error.rs Normal file
View file

@ -0,0 +1,34 @@
use axum::response::{IntoResponse, Response};
use reqwest::StatusCode;
use sea_orm::DbErr;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ThisError {
#[error("Datase error")]
Database(#[from] DbErr),
#[error("Client request error")]
Request(#[from] reqwest::Error),
#[error("Error: {message:?}")]
Generic { code: StatusCode, message: String },
}
impl IntoResponse for ThisError {
fn into_response(self) -> Response {
eprint!("{}", self);
match self {
ThisError::Database(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error".to_string(),
),
ThisError::Request(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"500 Internal Server Error".to_string(),
),
ThisError::Generic { code, .. } => (code, code.to_string()),
}
.into_response()
}
}

75
src/main.rs Normal file
View file

@ -0,0 +1,75 @@
mod appstate;
mod config;
mod entities;
mod error;
mod models;
mod routes;
use appstate::Appstate;
use auth::{callback, login};
use axum::{
response::Html,
routing::{get, post},
Extension, Router,
};
use config::Config;
use migration::{Migrator, MigratorTrait};
use models::user::UserSession;
use routes::{auth, middelware::auth_guard, user::update_password};
use axum::response::IntoResponse;
use sea_orm::Database;
async fn index(Extension(user): Extension<UserSession>) -> impl IntoResponse {
Html(format!(
r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Post Request Form</title>
</head>
<body>
<h1> Good day {} </h1>
<form action="{}/update_password" method="POST">
<input type="text" name="password" placeholder="Enter some text" required />
<button type="submit">Send Data</button>
</form>
</body>
</html>
"#,
user.name,
Config::get().host_url
))
}
#[tokio::main]
async fn main() {
Config::initialize();
let conn = Database::connect(Config::get().database_uri.to_string())
.await
.expect("Database connection failed");
Migrator::up(&conn, None)
.await
.expect("Cound not run migrations");
let state = Appstate { conn };
// build our application with a single route
let app = Router::new()
.route("/", get(index))
.route("/index", get(index))
.route("/update_password", post(update_password))
.route_layer(axum::middleware::from_fn(auth_guard))
.route("/login", get(login))
.route("/oauth/callback", get(callback))
.with_state(state);
// run our app with hyper, listening globally on port 3000
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}

9
src/models/jwt.rs Normal file
View file

@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct JWTPayload {
pub sub: String,
pub iat: usize,
pub exp: usize,
pub preferred_username: String
}

2
src/models/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod jwt;
pub mod user;

4
src/models/user.rs Normal file
View file

@ -0,0 +1,4 @@
#[derive(Clone)]
pub struct UserSession {
pub name: String
}

133
src/routes/auth.rs Normal file
View file

@ -0,0 +1,133 @@
use axum::extract::Query;
use axum::http::HeaderMap;
use axum::response::Redirect;
use axum_extra::extract::cookie::{Cookie, SameSite};
use axum_extra::extract::{CookieJar, SignedCookieJar};
use rand::distributions::{Alphanumeric, DistString};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::ThisError;
#[derive(Deserialize)]
pub struct LoginParams {
redirect: Option<String>,
}
pub async fn login(jar: CookieJar, params: Query<LoginParams>) -> (CookieJar, Redirect) {
let state = Alphanumeric.sample_string(&mut rand::thread_rng(), 16);
// insert state so we can check it in the callback
// redirect to zauth to authenticate
let zauth_url = Config::get().zauth_url.to_string();
let callback_url = Config::get().callback_url.to_string();
let zauth_client_id = Config::get().zauth_client_id.to_string();
let redirect_cookie = if let Some(redirect_uri) = params.redirect.clone() {
Cookie::build(("redirect", redirect_uri))
} else {
Cookie::build(("redirect", "/"))
};
let state_cookie = Cookie::build(("state", state.clone()))
.secure(true)
.http_only(true)
.same_site(SameSite::Lax);
let jar = jar.add(redirect_cookie).add(state_cookie);
(jar, Redirect::to(&format!("{zauth_url}/oauth/authorize?client_id={zauth_client_id}&response_type=code&state={state}&redirect_uri={callback_url}&scope=openid")))
}
#[derive(Deserialize, Debug)]
pub struct Callback {
state: String,
code: String,
}
#[derive(Deserialize, Debug)]
pub struct ZauthToken {
access_token: String,
id_token: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ZauthUser {
id: i32,
username: String,
admin: bool,
}
pub async fn callback(
Query(params): Query<Callback>,
headers: HeaderMap,
jar: CookieJar,
) -> Result<(SignedCookieJar, Redirect), ThisError> {
let zauth_state = jar
.get("state")
.ok_or(ThisError::Generic {
code: StatusCode::INTERNAL_SERVER_ERROR,
message: "failed to get session".to_string(),
})?
.value();
// check if saved state matches returned state
if zauth_state != params.state {
return Err(ThisError::Generic {
code: StatusCode::UNAUTHORIZED,
message: "state does not match".to_string(),
});
}
let client = reqwest::Client::new();
let form = [
("grant_type", "authorization_code"),
("code", &params.code),
("redirect_uri", &Config::get().callback_url),
];
// get token from zauth with code
let token = client
.post(format!("{}/oauth/token", Config::get().zauth_url))
.basic_auth(
Config::get().zauth_client_id.to_string(),
Some(Config::get().zauth_client_secret.to_string()),
)
.form(&form)
.send()
.await?
.error_for_status()?
.json::<ZauthToken>()
.await?;
// get user info from zauth
let zauth_user = client
.get(format!("{}/current_user", Config::get().zauth_url))
.header("Authorization", "Bearer ".to_owned() + &token.access_token)
.send()
.await?
.error_for_status()?
.json::<ZauthUser>()
.await?;
// validate is user is admin
if !zauth_user.admin {
return Err(ThisError::Generic {
code: StatusCode::UNAUTHORIZED,
message: "user is not admin".to_string(),
});
}
let cookie = Cookie::build(("jwt", token.id_token))
.path("/")
.secure(true)
.http_only(true)
.same_site(SameSite::Lax)
.build();
let redirect_uri = jar.get("redirect").map(|c| c.value()).unwrap_or("/");
let signed_jar = SignedCookieJar::from_headers(&headers, Config::get().cookies_key.clone());
Ok((signed_jar.add(cookie), Redirect::to(redirect_uri)))
}

72
src/routes/middelware.rs Normal file
View file

@ -0,0 +1,72 @@
use axum::{
extract::Request,
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Redirect},
};
use axum_extra::extract::SignedCookieJar;
use jsonwebtoken::{decode, decode_header, jwk::Jwk, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use crate::{
config::Config,
error::ThisError,
models::{jwt::JWTPayload, user::UserSession},
};
/// A JWK set
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JwkSet {
pub keys: Vec<Jwk>,
}
pub async fn auth_guard(
headers: HeaderMap,
mut req: Request,
next: Next,
) -> Result<impl IntoResponse, ThisError> {
let redirect = req.uri().clone();
let jar = SignedCookieJar::from_headers(&headers, Config::get().cookies_key.clone());
let token = jar
.get("jwt")
.map(|s| s.value().to_owned())
.ok_or(ThisError::Generic {
code: StatusCode::UNAUTHORIZED,
message: String::from("You are not logged in, please provide token"),
})?;
let client = reqwest::Client::new();
let jwks = client
.get(format!("{}/oauth/jwks", Config::get().zauth_url))
.send()
.await?
.json::<JwkSet>()
.await?;
let header = decode_header(&token).map_err(|e| ThisError::Generic {
code: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("invalid token format: {}", e),
})?;
let mut validation = Validation::new(header.alg);
validation.set_audience(&[Config::get().zauth_client_id.to_string()]);
for jwk in jwks.keys {
let payload = match DecodingKey::from_jwk(&jwk) {
Ok(decode_key) => decode::<JWTPayload>(&token, &decode_key, &validation),
Err(_) => todo!(),
};
if payload.is_ok() {
let user = UserSession {
name: payload.unwrap().claims.preferred_username,
};
req.extensions_mut().insert(user);
return Ok(next.run(req).await);
}
}
Ok(Redirect::to(&format!("/login?redirect={}", redirect)).into_response())
}

3
src/routes/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod middelware;
pub mod user;
pub mod auth;

41
src/routes/user.rs Normal file
View file

@ -0,0 +1,41 @@
use axum::Form;
use axum::{extract::State, Extension};
use sea_orm::{ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use crate::entities::{prelude::*, *};
use crate::error::ThisError;
use crate::{appstate::Appstate, models::user::UserSession};
#[derive(Serialize, Deserialize)]
pub struct PasswordData {
password: String,
}
pub async fn update_password(
state: State<Appstate>,
Extension(session): Extension<UserSession>,
Form(form): Form<PasswordData>,
) -> Result<String, ThisError> {
let user_option = User::find()
.filter(user::Column::Userid.eq(session.name.to_owned()))
.one(&state.conn)
.await?;
match user_option {
Some(user) => {
let mut user: user::ActiveModel = user.into();
user.password = ActiveValue::Set(form.password);
user.update(&state.conn).await?;
}
None => {
let user = user::ActiveModel {
userid: ActiveValue::Set(session.name.to_owned()),
password: ActiveValue::Set(form.password),
..Default::default()
};
user.insert(&state.conn).await?;
}
}
Ok("Password updated".to_string())
}