Initial Commit

This commit is contained in:
2021-07-16 00:12:14 +02:00
commit 827c724c46
10 changed files with 2079 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1222
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "gamma_basic_auth_proxy"
version = "1.0.0"
authors = ["Joakim Hulthe <joakim@hulthe.net>"]
edition = "2018"
[dependencies]
hyper = { version = "0.14", features = ["full"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
ron = "0.6.4"
compound-error = "0.1.2"
log = "0.4"
femme = "2"
kv-log-macro = "1"
base64 = "0.13"
reqwest = { version = "0.11.4", default-features = false, features = ["rustls-tls", "cookies", "multipart", "json"] }

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
###################
### BUILD STAGE ###
###################
FROM rust:1.52 as build_stage
# Install build dependencies
RUN rustup target add x86_64-unknown-linux-musl
# Build project
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN strip target/x86_64-unknown-linux-musl/release/auth_proxy
########################
### PRODUCTION STAGE ###
########################
FROM scratch
EXPOSE 3000
WORKDIR /
# Copy application binary
COPY --from=build_stage /app/target/x86_64-unknown-linux-musl/release/auth_proxy auth_proxy
CMD ["/auth_proxy", "--help"]

3
README.md Normal file
View File

@ -0,0 +1,3 @@
auth\_proxy
========
_An HTTP based reverse proxy for adding basic auth to your stack_

91
src/cache.rs Normal file
View File

@ -0,0 +1,91 @@
use std::collections::HashMap;
use reqwest::Client;
use tokio::{sync::{RwLock, RwLockReadGuard, RwLockWriteGuard, Mutex}, time::{Duration, Instant}};
use std::sync::Arc;
use crate::{Opt, gamma::{self, Credentials, User}};
struct CacheEntry {
last_checked: Instant,
login_result: Result<User, String>,
http_client: Client,
}
pub struct UserCache {
map: Mutex<HashMap<Credentials, Arc<RwLock<Option<CacheEntry>>>>>,
}
// Arbitraty cache time, should be configurable
const CACHE_TIME: Duration = Duration::from_secs(10);
impl UserCache {
pub(crate) fn new() -> Self {
UserCache {
map: Mutex::new(HashMap::new()),
}
}
pub(crate) async fn login(&self, opt: &Opt, credentials: &Credentials) -> Result<User, String> {
let mut map = self.map.lock().await;
// if an entry already exists
if let Some(user_state) = map.get(credentials) {
// clone it
let user_state = Arc::clone(user_state);
drop(map); // release the map lock
loop {
let entry = RwLockReadGuard::map(user_state.read().await, |option| option.as_ref().expect("already initialized"));
let last_checked = entry.last_checked;
// if the entry has not expired
if last_checked.elapsed() < CACHE_TIME {
// take the fast path and just assume we are still logged in
break entry.login_result.clone();
} else {
// otherwise try to upgrade to a write lock so that we can refresh the session
drop(entry);
let mut entry = RwLockWriteGuard::map(user_state.write().await, |option| option.as_mut().expect("already initialized"));
// if the entry was updated when we didn't have the lock, retry
if entry.last_checked != last_checked {
continue;
}
let login_result = gamma::login(&mut entry.http_client, opt, credentials).await;
entry.last_checked = Instant::now();
entry.login_result = login_result.clone();
break login_result;
}
}
} else {
// if the entry did not exist, then we must log in
// create an empty lock and take the write handle
let lock = Arc::new(RwLock::new(None));
let mut entry = lock.write().await;
// then put it in the map so that we can release the map as fast as possible
map.insert(credentials.clone(), Arc::clone(&lock));
drop(map); // release the map lock
let mut client = Client::builder()
.cookie_store(true)
.timeout(Duration::from_secs(10 /* TODO: configure timeout */))
.build()
.expect("http client");
let login_result = gamma::login(&mut client, opt, credentials).await;
let last_checked = Instant::now();
// try to log in and cache the result
let new_entry = CacheEntry {
login_result: login_result.clone(),
last_checked,
http_client: client,
};
*entry = Some(new_entry);
login_result
}
}
}

86
src/gamma.rs Normal file
View File

@ -0,0 +1,86 @@
use kv_log_macro::debug;
use reqwest::{Client, Response};
use serde::{Deserialize, Serialize};
use crate::Opt;
#[derive(Clone, PartialEq, Eq, Hash, Serialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[derive(Clone, Deserialize)]
pub struct User {
pub cid: String,
pub username: String,
pub groups: Vec<Group>,
}
#[derive(Clone, Deserialize)]
pub struct Group {
pub name: String,
#[serde(rename = "superGroup")]
pub super_group: SuperGroup,
}
#[derive(Clone, Deserialize)]
pub struct SuperGroup {
pub name: String,
}
fn check_status(resp: &Response) -> Result<(), String> {
if resp.status().is_server_error() {
Err(format!("Gamma Error: {}", resp.status()))
} else if resp.status().is_client_error() {
Err("Invalid credentials".to_string())
} else {
Ok(())
}
}
pub(crate) async fn login(client: &mut Client, opt: &Opt, credentials: &Credentials) -> Result<User, String> {
let login_uri = format!("{}{}", opt.gamma, "/api/login");
let login_resp = client
.post(&login_uri)
.form(credentials)
.send()
.await
.map_err(|e| format!("gamma: login failed: {}", e))?;
debug!("gamma: tried logging in", {
username: credentials.username.as_str(),
uri: login_uri.as_str(),
response: format!("{:?}", login_resp).as_str(),
});
check_status(&login_resp)?;
get_me(client, opt, &credentials.username).await
}
pub (crate) async fn get_me(client: &mut Client, opt: &Opt, username: &str) -> Result<User, String> {
let me_uri = format!("{}{}", opt.gamma, "/api/users/me");
let me_resp = client
.get(&me_uri)
.send()
.await
.map_err(|e| format!("gamma: get user info failed: {}", e))?;
debug!("gamma: tried getting user info", {
username: username,
uri: me_uri.as_str(),
response: format!("{:?}", me_resp).as_str(),
});
check_status(&me_resp)?;
let user: User = me_resp
.json()
.await
.map_err(|e| format!("gamma: failed to deserialize json: {}", e))?;
Ok(user)
}

317
src/main.rs Normal file
View File

@ -0,0 +1,317 @@
mod cache;
mod gamma;
mod rules;
use compound_error::CompoundError;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Client, Request, Response, Server, Uri};
use kv_log_macro::{debug, error, info, warn};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use cache::UserCache;
use gamma::{Credentials, User};
use rules::{Method, Permission, Rule};
#[derive(Debug)]
struct Opt {
/// Example: http://my-server:80/
proxy: String,
/// Example: https://gamma.chalmers.it
gamma: String,
/// Example: "My special place"
realm: String,
rules: HashMap<String, Rule>,
}
#[derive(Debug, CompoundError)]
enum Error {
Hyper(hyper::Error),
IO(std::io::Error),
}
struct State {
opt: Opt,
cache: UserCache,
}
async fn proxy_pass(mut req: Request<Body>, state: &State) -> Result<Response<Body>, Error> {
let req_uri = req.uri().clone();
info!("{:#?}", req);
let unauthorized = |msg| {
info!("respoinding with 401 Unauthorized due to {}", msg);
Ok(Response::builder()
.status(401)
.header(
"WWW-Authenticate",
format!(r#"Basic realm="{}", charset="UTF-8""#, state.opt.realm),
)
.header("Docker-Distribution-Api-Version", "registry/2.0")
.body(Body::from("Unauthorized"))
.expect("infallible response"))
};
let forbidden = |msg| {
info!("respoinding with 403 Forbidden due to {}", msg);
Ok(Response::builder()
.status(403)
.body(Body::from("Forbidden"))
.expect("infallible response"))
};
let login = match req.headers_mut().remove("Authorization") {
None => {
info!("request received", {
uri: format!("{}", req_uri).as_str(),
method: req.method().as_str(),
provided_auth: false,
});
None
}
Some(authorization) => {
info!("request received", {
uri: format!("{}", req_uri).as_str(),
method: req.method().as_str(),
provided_auth: true,
});
let credentials = authorization
.to_str()
.ok()
.and_then(|s| s.strip_prefix("Basic "))
.and_then(|s| base64::decode(s).ok())
.and_then(|b| String::from_utf8(b).ok());
match credentials.as_ref().and_then(|s| s.split_once(":")) {
Some((user, pass)) => {
let credentials = Credentials {
username: user.to_string(),
password: pass.to_string(),
};
match state.cache.login(&state.opt, &credentials).await {
Ok(user) => Some(user),
Err(e) => {
warn!("{}", e);
return unauthorized("invalid login");
}
}
}
None => {
warn!("client did not provide valid a \"Authorization\" header");
None
}
}
}
};
match validate(&state.opt, &req, &login) {
Validation::Allowed => {
info!("success"); /* success! continue to proxy */
}
Validation::NotAllowed => return forbidden("failed validation"),
Validation::RequiresLogin => return unauthorized("not logged in"),
}
let proxy_uri: Uri = state.opt.proxy.parse().expect("proxy uri");
let mut new_uri = Uri::builder().authority(proxy_uri.authority().unwrap().clone());
new_uri = new_uri.scheme(proxy_uri.scheme_str().unwrap_or("http"));
if let Some(paq) = req_uri.path_and_query().cloned() {
new_uri = new_uri.path_and_query(paq);
}
*req.uri_mut() = new_uri.build().expect("uri");
let client = Client::new();
let mut error = None;
let response = match client.request(req).await {
Ok(response) => response,
Err(e) => {
error = Some(format!("{:?}", e));
Response::builder()
.status(503)
.body("503 Service Unavailable".into())
.expect("infallible response")
}
};
if let Some(e) = error {
warn!("{}", e);
}
Ok(response)
}
fn get_opt() -> Result<Opt, String> {
let env = |name| std::env::var(name).map_err(|e| format!("{}: {}", name, e));
Ok(Opt {
proxy: env("PROXY_HOST")?,
realm: env("AUTH_REALM")?,
gamma: env("GAMMA_HOST")?,
rules: std::env::vars()
.filter(|(name, _)| name.starts_with("AUTH_RULE_"))
.map(|(name, rule)| Rule::parse(&rule).map(|rule| (name, rule)))
.collect::<Result<_, _>>()?,
})
}
enum Validation {
/// Validataion succeeded, client pay proceed
Allowed,
/// The client is not allowed to proceed, even if it provided credentials
NotAllowed,
/// The client should provide credentials and try again
RequiresLogin,
}
fn validate(opt: &Opt, req: &Request<Body>, login: &Option<User>) -> Validation {
// filter irrelevant rules
let mut applicable_rules: Vec<_> = opt
.rules
.iter()
.filter(|(_, rule)| {
let req_path: PathBuf = req.uri().path().into();
req_path.starts_with(&rule.path)
})
.filter(|(_, rule)| match rule.method {
Method::Any => true,
Method::GET => req.method() == hyper::Method::GET,
Method::POST => req.method() == hyper::Method::POST,
Method::PUT => req.method() == hyper::Method::PUT,
Method::DELETE => req.method() == hyper::Method::DELETE,
Method::HEAD => req.method() == hyper::Method::HEAD,
Method::OPTIONS => req.method() == hyper::Method::OPTIONS,
Method::CONNECT => req.method() == hyper::Method::CONNECT,
Method::PATCH => req.method() == hyper::Method::PATCH,
Method::TRACE => req.method() == hyper::Method::TRACE,
})
.collect();
// find the most relevant rule
applicable_rules.sort_by(|&(a_name, a), &(b_name, b)| {
// example priorities:
// 1. /foo/bar/baz/ POST
// 2. /foo/bar/baz/ Any
// 3. /foo/bar Any
let a_len = a.path.components().count();
let b_len = b.path.components().count();
a_len.cmp(&b_len).then_with(|| match (a.method, b.method) {
(ma, mb) if ma == mb => panic!("conflicting rules, {} and {}", a_name, b_name),
(Method::Any, _) => Ordering::Less,
(_, Method::Any) => Ordering::Greater,
_ => unreachable!("Rules for different methods can't conflict"),
})
});
debug!("list of applicable rules: {:?}", applicable_rules);
let (name, rule) = match applicable_rules.last() {
Some(&last) => last,
// No rules exist, so we default to not allowed
None => return Validation::NotAllowed,
};
info!("checking connecting against rule {}", name);
fn check_logged_in(login: &Option<User>) -> Validation {
if login.is_some() {
Validation::Allowed
} else {
Validation::RequiresLogin
}
}
fn check_is_group(group: &str, login: &Option<User>) -> Validation {
match login.as_ref() {
Some(user) => {
info!("check_is_group", { user: user.username.as_str(), group: group });
let mut groups = user
.groups
.iter()
.flat_map(|group| [&group.name, &group.super_group.name]);
if groups.find(|&user_group| user_group == group).is_some() {
Validation::Allowed
} else {
Validation::NotAllowed
}
}
None => Validation::RequiresLogin,
}
}
fn check_permission(permission: &Permission, login: &Option<User>) -> Validation {
let recurse = |perm| check_permission(perm, login);
match permission {
Permission::AllowAll => Validation::Allowed,
Permission::AnyUser => check_logged_in(login),
Permission::Group(group) => check_is_group(&group, login),
Permission::And(a, b) => match (recurse(a), recurse(b)) {
(Validation::Allowed, Validation::Allowed) => Validation::Allowed,
(Validation::NotAllowed, _) | (_, Validation::NotAllowed) => Validation::NotAllowed,
(Validation::RequiresLogin, _) | (_, Validation::RequiresLogin) => {
Validation::RequiresLogin
}
},
Permission::Or(a, b) => match (recurse(a), recurse(b)) {
(Validation::NotAllowed, Validation::NotAllowed) => Validation::NotAllowed,
(Validation::Allowed, _) | (_, Validation::Allowed) => Validation::Allowed,
(Validation::RequiresLogin, _) | (_, Validation::RequiresLogin) => {
Validation::RequiresLogin
}
},
Permission::Not(rule) => match recurse(rule) {
Validation::Allowed => Validation::NotAllowed,
Validation::NotAllowed | Validation::RequiresLogin => Validation::Allowed,
},
}
}
// validate request against the rule
check_permission(&rule.permission, login)
}
#[tokio::main]
async fn main() {
//femme::start();
femme::with_level(femme::LevelFilter::Debug);
let opt: Opt = match get_opt() {
Ok(opt) => opt,
Err(e) => {
error!("{}", e);
std::process::exit(1);
}
};
info!("{:#?}", opt);
let cache = UserCache::new();
let state: &'static State = Box::leak(Box::new(State { opt, cache }));
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
info!("Listening on 0.0.0.0:3000");
let make_svc = make_service_fn(|_conn| async move {
Ok::<_, Error>(service_fn(move |r| proxy_pass(r, state)))
});
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
error!("server error: {}", e);
}
}

225
src/me.json Normal file
View File

@ -0,0 +1,225 @@
{
"id": "e514bbda-97c8-4879-b62a-3e2c350f8032",
"cid": "hulthe",
"nick": "Tux🐧",
"firstName": "Joakim",
"lastName": "Hulthe",
"email": "joakim@hulthe.net",
"phone": "703644214",
"language": "sv",
"avatarUrl": "https://gamma.chalmers.it/api/uploads/8314961ee67fbfa57f603fff17ffbecc58bbf490.jpg",
"gdpr": true,
"userAgreement": true,
"accountLocked": false,
"acceptanceYear": 2016,
"authorities": [],
"activated": true,
"enabled": true,
"username": "hulthe",
"accountNonLocked": true,
"accountNonExpired": true,
"credentialsNonExpired": true,
"groups": [
{
"id": "b6be176b-5705-49f5-8ac2-8646aec748ba",
"becomesActive": 1483142400000,
"becomesInactive": 1514678400000,
"description": {
"sv": "digIT 17/18",
"en": "digIT 17/18"
},
"email": "digit17@chalmers.it",
"function": {
"sv": "digIT 17/18",
"en": "digIT 17/18"
},
"name": "digit17",
"prettyName": "digIT 17/18",
"avatarURL": null,
"superGroup": {
"id": "e2eb1ee6-d1c9-4ec6-bbd1-fdd5910060c7",
"name": "didit",
"prettyName": "didIT",
"type": "ALUMNI",
"email": "didit@chalmers.it"
},
"active": false
},
{
"id": "6e0f5714-65b9-46e1-8d6d-0273487bea48",
"becomesActive": 1568764800000,
"becomesInactive": 1603065600000,
"description": {
"sv": "Dataskyddsombud 2019",
"en": "Dataskyddsombud 2019"
},
"email": "dpo19@chalmers.it",
"function": {
"sv": "Dataskyddsombud 2019",
"en": "Dataskyddsombud 2019"
},
"name": "dpo19",
"prettyName": "Dataskyddsombud 2019",
"avatarURL": null,
"superGroup": {
"id": "6586de15-94a3-4c7a-bd31-e50854d0b5eb",
"name": "dpo",
"prettyName": "dpo",
"type": "COMMITTEE",
"email": "dpo@chalmers.it"
},
"active": false
},
{
"id": "d45861bb-938a-47bd-a4ce-d5152c697278",
"becomesActive": 1582329600000,
"becomesInactive": 1617321600000,
"description": {
"sv": "DrawIT 20",
"en": "DrawIT 20"
},
"email": "drawit20@chalmers.it",
"function": {
"sv": "DrawIT 20",
"en": "DrawIT 20"
},
"name": "drawit20",
"prettyName": "DrawIT 20",
"avatarURL": null,
"superGroup": {
"id": "25502aca-9f41-4110-a29d-04aeeed93411",
"name": "dragit",
"prettyName": "DragIT",
"type": "ALUMNI",
"email": "dragit@chalmers.it"
},
"active": false
},
{
"id": "4da2800d-72e9-4052-a857-f95cca89fca5",
"becomesActive": 1451692800000,
"becomesInactive": 1483315200000,
"description": {
"sv": "LaggIT 2016",
"en": "LaggIT 2016"
},
"email": "laggit16@chalmers.it",
"function": {
"sv": "LaggIT 2016",
"en": "LaggIT 2016"
},
"name": "laggit16",
"prettyName": "LaggIT 16",
"avatarURL": null,
"superGroup": {
"id": "d7bb514c-31e9-4af3-9119-3fa03f643ea5",
"name": "ragequit",
"prettyName": "ragequIT",
"type": "ALUMNI",
"email": "ragequit@chalmers.it"
},
"active": false
},
{
"id": "8015807b-8680-4f0f-8bd1-d2bf1ffbc130",
"becomesActive": 1514851200000,
"becomesInactive": 1546387200000,
"description": {
"sv": "laggIT 2018",
"en": "laggIT 2018"
},
"email": "laggit18@chalmers.it",
"function": {
"sv": "laggIT 2018",
"en": "laggIT 2018"
},
"name": "laggit18",
"prettyName": "laggIT 18",
"avatarURL": null,
"superGroup": {
"id": "d7bb514c-31e9-4af3-9119-3fa03f643ea5",
"name": "ragequit",
"prettyName": "ragequIT",
"type": "ALUMNI",
"email": "ragequit@chalmers.it"
},
"active": false
},
{
"id": "ef3054b0-78cb-494a-8352-2c2ee62b54b0",
"becomesActive": 1483228800000,
"becomesInactive": 1514764800000,
"description": {
"sv": "LaggIT 17",
"en": "LaggIT 17"
},
"email": "laggit17@chalmers.it",
"function": {
"sv": "LaggIT 17",
"en": "LaggIT 17"
},
"name": "laggit17",
"prettyName": "LaggIT 17",
"avatarURL": null,
"superGroup": {
"id": "d7bb514c-31e9-4af3-9119-3fa03f643ea5",
"name": "ragequit",
"prettyName": "ragequIT",
"type": "ALUMNI",
"email": "ragequit@chalmers.it"
},
"active": false
},
{
"id": "c246af65-531d-49e5-9b3a-9e97cb5b32b0",
"becomesActive": 1616803200000,
"becomesInactive": 1651363200000,
"description": {
"sv": "",
"en": ""
},
"email": "drawit21@chalmers.it",
"function": {
"sv": "",
"en": ""
},
"name": "drawit 21",
"prettyName": "DrawIT 21",
"avatarURL": null,
"superGroup": {
"id": "09728fbb-fead-4f12-aec9-7bfe39c8cc6f",
"name": "drawit",
"prettyName": "DrawIT",
"type": "SOCIETY",
"email": "drawit@chalmers.it"
},
"active": true
},
{
"id": "b6756bd1-8fab-48e6-9204-1bf0b097c4e1",
"becomesActive": 1546214400000,
"becomesInactive": 1577750400000,
"description": {
"sv": "DrawIT 19",
"en": "DrawIT 19"
},
"email": "drawit19@chalmers.it",
"function": {
"sv": "DrawIT 19",
"en": "DrawIT 19"
},
"name": "drawit19",
"prettyName": "DrawIT 19",
"avatarURL": null,
"superGroup": {
"id": "25502aca-9f41-4110-a29d-04aeeed93411",
"name": "dragit",
"prettyName": "DragIT",
"type": "ALUMNI",
"email": "dragit@chalmers.it"
},
"active": false
}
],
"websiteURLs": null
}

90
src/rules.rs Normal file
View File

@ -0,0 +1,90 @@
//! Rules for configuring who has access to what endpoints
//!
//! ## Examples:
//! ```
//! Rule { path: "/v2/", method: GET, permission: And(AnyUser, Not(Group("styrit"))) }
//! Rule { path: "/v2/_catalog", permission: AllowAll }
//! Rule { path: "/v2/image/*/push", permission: Or(Group("didit"), Group("digit")) }
//! ```
use serde::{Deserialize, Serialize};
use std::fmt::{self, Display, Formatter};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct Rule {
pub path: PathBuf,
#[serde(default)]
pub method: Method,
pub permission: Permission,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum Method {
GET,
POST,
PUT,
DELETE,
HEAD,
OPTIONS,
CONNECT,
PATCH,
TRACE,
Any,
}
impl Default for Method {
fn default() -> Self {
Method::Any
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Permission {
/// No authorization required
AllowAll,
/// Required the user to be logged in
AnyUser,
/// Requires the user to be logged in and part of the specific group
Group(String),
Or(Box<Permission>, Box<Permission>),
And(Box<Permission>, Box<Permission>),
Not(Box<Permission>),
}
impl Rule {
pub fn parse(s: &str) -> Result<Rule, String> {
ron::from_str(s).map_err(|e| format!("Failed to parse Rule: {}", e))
}
}
impl Display for Rule {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self.method {
Method::Any => {}
_ => write!(f, "{:?} ", self.method)?,
}
write!(f, "{:?} ", self.path)?;
write!(f, "permission: {}", self.permission)?;
Ok(())
}
}
impl Display for Permission {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Permission::AllowAll => write!(f, "allow all"),
Permission::Group(group) => write!(f, "is group '{}'", group),
Permission::AnyUser => write!(f, "is logged in"),
Permission::Or(a, b) => write!(f, "({}) or ({})", a, b),
Permission::And(a, b) => write!(f, "({}) and ({})", a, b),
Permission::Not(perm) => write!(f, "not {}", perm),
}
}
}