From 2dc896ef046a52aabda43285450ab3b621e72094 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Thu, 29 Jul 2021 12:29:01 -0600 Subject: [PATCH] authentication --- appmgr/migrations/20210629193146_Init.sql | 12 +- appmgr/src/auth.rs | 196 ++++++++++++++++++++++ appmgr/src/inspect.rs | 2 +- appmgr/src/lib.rs | 1 + appmgr/src/middleware/auth.rs | 134 +++++++-------- appmgr/src/middleware/cors.rs | 11 +- appmgr/src/util.rs | 2 +- 7 files changed, 277 insertions(+), 81 deletions(-) create mode 100644 appmgr/src/auth.rs diff --git a/appmgr/migrations/20210629193146_Init.sql b/appmgr/migrations/20210629193146_Init.sql index 6b2878b2b..0744a69e4 100644 --- a/appmgr/migrations/20210629193146_Init.sql +++ b/appmgr/migrations/20210629193146_Init.sql @@ -9,7 +9,13 @@ CREATE TABLE IF NOT EXISTS tor CREATE TABLE IF NOT EXISTS session ( id TEXT NOT NULL PRIMARY KEY, - created_at TIMESTAMP NOT NULL, - expires_at TIMESTAMP NOT NULL, - metadata JSON + logged_in TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + logged_out TIMESTAMP, + last_active TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + user_agent TEXT, + metadata TEXT NOT NULL DEFAULT 'null' +); +CREATE TABLE IF NOT EXISTS password +( + hash TEXT NOT NULL PRIMARY KEY ); \ No newline at end of file diff --git a/appmgr/src/auth.rs b/appmgr/src/auth.rs new file mode 100644 index 000000000..dba5167c5 --- /dev/null +++ b/appmgr/src/auth.rs @@ -0,0 +1,196 @@ +use anyhow::anyhow; +use basic_cookies::Cookie; +use chrono::NaiveDateTime; +use clap::ArgMatches; +use http::header::COOKIE; +use http::HeaderValue; +use indexmap::IndexMap; +use rpc_toolkit::command; +use rpc_toolkit::command_helpers::prelude::{RequestParts, ResponseParts}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::context::EitherContext; +use crate::middleware::auth::{get_id, hash_token}; +use crate::util::{display_none, display_serializable, IoFormat}; +use crate::{Error, ResultExt}; + +#[command(subcommands(login, logout))] +pub fn auth(#[context] ctx: EitherContext) -> Result { + Ok(ctx) +} + +pub fn parse_metadata(_: &str, _: &ArgMatches<'_>) -> Result { + Ok(serde_json::json!({ + "platforms": ["cli"], + })) +} + +#[command(display(display_none), metadata(authenticated = false))] +pub async fn login( + #[context] ctx: EitherContext, + #[request] req: &RequestParts, + #[response] res: &mut ResponseParts, + #[arg] password: String, + #[arg( + parse(parse_metadata), + default = "", + help = "RPC Only: This value cannot be overidden from the cli" + )] + metadata: Value, +) -> Result<(), Error> { + let rpc_ctx = ctx.as_rpc().unwrap(); + let mut handle = rpc_ctx.secret_store.acquire().await?; + let pw_hash = sqlx::query!("SELECT hash FROM password") + .fetch_one(&mut handle) + .await? + .hash; + argon2::verify_encoded(&pw_hash, password.as_bytes()).map_err(|_| { + Error::new( + anyhow!("Password Incorrect"), + crate::ErrorKind::Authorization, + ) + })?; + let token = base32::encode( + base32::Alphabet::RFC4648 { padding: false }, + &rand::random::<[u8; 16]>(), + ) + .to_lowercase(); + let id = hash_token(&token); + let user_agent = req.headers.get("user-agent").and_then(|h| h.to_str().ok()); + let metadata = serde_json::to_string(&metadata).with_kind(crate::ErrorKind::Database)?; + sqlx::query!( + "INSERT INTO session (id, user_agent, metadata) VALUES (?, ?, ?)", + id, + user_agent, + metadata, + ) + .execute(&mut handle) + .await?; + res.headers.insert( + "set-cookie", + HeaderValue::from_str(&format!("session={}; HttpOnly; SameSite=Strict", token)) + .with_kind(crate::ErrorKind::Unknown)?, // Should be impossible, but don't want to panic + ); + + Ok(()) +} + +#[command(display(display_none), metadata(authenticated = false))] +pub async fn logout( + #[context] ctx: EitherContext, + #[request] req: &RequestParts, +) -> Result<(), Error> { + if let Some(cookie_header) = req.headers.get(COOKIE) { + let cookies = Cookie::parse( + cookie_header + .to_str() + .with_kind(crate::ErrorKind::Authorization)?, + ) + .with_kind(crate::ErrorKind::Authorization)?; + if let Some(session) = cookies.iter().find(|c| c.get_name() == "session") { + let token = session.get_value(); + let id = hash_token(token); + kill(ctx, id).await?; + } + } + Ok(()) +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Session { + logged_in: NaiveDateTime, + last_active: NaiveDateTime, + user_agent: Option, + metadata: Value, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct SessionList { + current: String, + sessions: IndexMap, +} + +#[command(subcommands(list, kill))] +pub async fn session(#[context] ctx: EitherContext) -> Result { + Ok(ctx) +} + +fn display_sessions(arg: SessionList, matches: &ArgMatches<'_>) { + use prettytable::*; + + if matches.is_present("format") { + return display_serializable(arg, matches); + } + + let mut table = Table::new(); + table.add_row(row![bc => + "ID", + "LOGGED IN", + "LAST ACTIVE", + "USER AGENT", + "METADATA", + ]); + for (id, session) in arg.sessions { + let mut row = row![ + &id, + &format!("{}", session.logged_in), + &format!("{}", session.last_active), + session.user_agent.as_deref().unwrap_or("N/A"), + &format!("{}", session.metadata), + ]; + if id == arg.current { + row.iter_mut() + .map(|c| c.style(Attr::ForegroundColor(color::GREEN))) + .collect::<()>() + } + table.add_row(row); + } + table.print_tty(false); +} + +#[command(display(display_sessions))] +pub async fn list( + #[context] ctx: EitherContext, + #[request] req: &RequestParts, + #[allow(unused_variables)] + #[arg(long = "format")] + format: Option, +) -> Result { + Ok(SessionList { + current: get_id(req)?, + sessions: sqlx::query!( + "SELECT * FROM session WHERE logged_out IS NULL OR logged_out > CURRENT_TIMESTAMP" + ) + .fetch_all(&mut ctx.as_rpc().unwrap().secret_store.acquire().await?) + .await? + .into_iter() + .map(|row| { + Ok(( + row.id, + Session { + logged_in: row.logged_in, + last_active: row.last_active, + user_agent: row.user_agent, + metadata: serde_json::from_str(&row.metadata) + .with_kind(crate::ErrorKind::Database)?, + }, + )) + }) + .collect::>()?, + }) +} + +#[command(display(display_none))] +pub async fn kill(#[context] ctx: EitherContext, #[arg] id: String) -> Result<(), Error> { + let rpc_ctx = ctx.as_rpc().unwrap(); + sqlx::query!( + "UPDATE session SET logged_out = CURRENT_TIMESTAMP WHERE id = ?", + id + ) + .execute(&mut rpc_ctx.secret_store.acquire().await?) + .await?; + Ok(()) +} diff --git a/appmgr/src/inspect.rs b/appmgr/src/inspect.rs index e9c63ae68..78a24096f 100644 --- a/appmgr/src/inspect.rs +++ b/appmgr/src/inspect.rs @@ -9,7 +9,7 @@ use crate::util::{display_none, display_serializable, IoFormat}; use crate::Error; #[command(subcommands(hash, manifest, license, icon, instructions, docker_images))] -pub fn inspect(#[context] ctx: EitherContext) -> Result<(), Error> { +pub fn inspect(#[context] _ctx: EitherContext) -> Result<(), Error> { Ok(()) } diff --git a/appmgr/src/lib.rs b/appmgr/src/lib.rs index b9d551755..5cbe0b69c 100644 --- a/appmgr/src/lib.rs +++ b/appmgr/src/lib.rs @@ -17,6 +17,7 @@ lazy_static::lazy_static! { } pub mod action; +pub mod auth; pub mod backup; pub mod config; pub mod context; diff --git a/appmgr/src/middleware/auth.rs b/appmgr/src/middleware/auth.rs index 3a0ed5b92..eb1bac82e 100644 --- a/appmgr/src/middleware/auth.rs +++ b/appmgr/src/middleware/auth.rs @@ -1,22 +1,25 @@ use anyhow::anyhow; use basic_cookies::Cookie; use chrono::Utc; +use digest::Digest; use futures::future::BoxFuture; use futures::FutureExt; +use rpc_toolkit::command_helpers::prelude::RequestParts; use rpc_toolkit::hyper::header::COOKIE; use rpc_toolkit::hyper::http::Error as HttpError; -use rpc_toolkit::hyper::{Body, Request, Response, StatusCode}; +use rpc_toolkit::hyper::{Body, Request, Response}; use rpc_toolkit::rpc_server_helpers::{ - noop2, noop3, DynMiddleware, DynMiddlewareStage2, DynMiddlewareStage3, + noop3, noop4, DynMiddleware, DynMiddlewareStage2, DynMiddlewareStage3, }; use rpc_toolkit::yajrc::RpcMethod; use rpc_toolkit::Metadata; -use serde::Deserialize; +use sha2::Sha256; use crate::context::RpcContext; use crate::{Error, ResultExt}; -async fn is_authed(ctx: &RpcContext, req: &Request) -> Result { - if let Some(cookie_header) = req.headers().get(COOKIE) { + +pub fn get_id(req: &RequestParts) -> Result { + if let Some(cookie_header) = req.headers.get(COOKIE) { let cookies = Cookie::parse( cookie_header .to_str() @@ -24,80 +27,69 @@ async fn is_authed(ctx: &RpcContext, req: &Request) -> Result ) .with_kind(crate::ErrorKind::Authorization)?; if let Some(session) = cookies.iter().find(|c| c.get_name() == "session") { - let id = session.get_value(); - let exp = sqlx::query!("SELECT expires_at FROM session WHERE id = ?", id) - .fetch_one(&mut ctx.secret_store.acquire().await?) - .await?; - if exp.expires_at < Utc::now().naive_utc() { - return Ok(true); - } + return Ok(hash_token(session.get_value())); } } - Ok(false) + Err(Error::new( + anyhow!("UNAUTHORIZED"), + crate::ErrorKind::Authorization, + )) } -pub async fn auth Deserialize<'de> + 'static, M: Metadata>( - ctx: RpcContext, -) -> DynMiddleware { +pub fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + base32::encode( + base32::Alphabet::RFC4648 { padding: false }, + hasher.finalize().as_slice(), + ) + .to_lowercase() +} + +async fn is_authed(ctx: &RpcContext, req: &RequestParts) -> Result<(), Error> { + let id = get_id(req)?; + let exp = sqlx::query!("SELECT logged_out FROM session WHERE id = ?", id) + .fetch_one(&mut ctx.secret_store.acquire().await?) + .await?; + match exp.logged_out { + Some(exp) if exp >= Utc::now().naive_utc() => Err(Error::new( + anyhow!("UNAUTHORIZED"), + crate::ErrorKind::Authorization, + )), + _ => Ok(()), + } +} + +pub async fn auth(ctx: RpcContext) -> DynMiddleware { Box::new( |req: &mut Request, metadata: M| - -> BoxFuture< - Result, Response>, HttpError>, - > { + -> BoxFuture>, HttpError>> { async move { - match is_authed(&ctx, req).await { - Ok(true) => Ok(Ok(noop2())), - Ok(false) => Ok(Ok({ - let mut fake_req = Request::new(Body::empty()); - *fake_req.headers_mut() = req.headers().clone(); - let m2: DynMiddlewareStage2 = - Box::new(move |rpc_req| { - let method = rpc_req.method.as_str(); - let res: Result< - Result>, - HttpError, - > = if metadata.get(method, "login").unwrap_or(false) { - todo!("set cookie on success") - } else if !metadata.get(method, "authenticated").unwrap_or(true) { - Ok(Ok(noop3())) - } else { - rpc_toolkit::rpc_server_helpers::to_response( - &fake_req, - Ok(( - rpc_req.id.clone(), - Err(Error::new( - anyhow!("UNAUTHORIZED"), - crate::ErrorKind::Authorization, - ) - .into()), - )), - |_| StatusCode::OK, - ) - .map(|a| Err(a)) - }; - async { res }.boxed() - }); - m2 - })), - Err(e) => Ok(Ok({ - let mut fake_req = Request::new(Body::empty()); - *fake_req.headers_mut() = req.headers().clone(); - let m2: DynMiddlewareStage2 = Box::new(move |rpc_req| { - let res: Result< - Result>, - HttpError, - > = rpc_toolkit::rpc_server_helpers::to_response( - &fake_req, - Ok((rpc_req.id.clone(), Err(e.into()))), - |_| StatusCode::OK, - ) - .map(|a| Err(a)); - async { res }.boxed() - }); - m2 - })), - } + let mut header_stub = Request::new(Body::empty()); + *header_stub.headers_mut() = req.headers().clone(); + let m2: DynMiddlewareStage2 = Box::new(move |req, rpc_req| { + async move { + if metadata + .get(rpc_req.method.as_str(), "authenticated") + .unwrap_or(true) + { + if let Err(e) = is_authed(&ctx, req).await { + let m3: DynMiddlewareStage3 = Box::new(|_, rpc_res| { + async move { + *rpc_res = Err(e.into()); + Ok(Ok(noop4())) + } + .boxed() + }); + return Ok(Ok(m3)); + } + } + Ok(Ok(noop3())) + } + .boxed() + }); + Ok(Ok(m2)) } .boxed() }, diff --git a/appmgr/src/middleware/cors.rs b/appmgr/src/middleware/cors.rs index 0fa860f1d..132a2385f 100644 --- a/appmgr/src/middleware/cors.rs +++ b/appmgr/src/middleware/cors.rs @@ -4,11 +4,12 @@ use rpc_toolkit::hyper::{Body, Method, Request, Response}; use rpc_toolkit::rpc_server_helpers::{ DynMiddlewareStage2, DynMiddlewareStage3, DynMiddlewareStage4, }; -use serde::Deserialize; +use rpc_toolkit::Metadata; -pub async fn cors Deserialize<'de> + 'static, Metadata>( +pub async fn cors( req: &mut Request, -) -> Result, Response>, HttpError> { + _metadata: M, +) -> Result>, HttpError> { if req.method() == Method::OPTIONS { Ok(Err(Response::builder() .header( @@ -24,9 +25,9 @@ pub async fn cors Deserialize<'de> + 'static, Metadata>( .header("Access-Control-Allow-Credentials", "true") .body(Body::empty())?)) } else { - Ok(Ok(Box::new(|_| { + Ok(Ok(Box::new(|_, _| { async move { - let res: DynMiddlewareStage3 = Box::new(|_| { + let res: DynMiddlewareStage3 = Box::new(|_, _| { async move { let res: DynMiddlewareStage4 = Box::new(|res| { async move { diff --git a/appmgr/src/util.rs b/appmgr/src/util.rs index a3883be69..eef8cbcac 100644 --- a/appmgr/src/util.rs +++ b/appmgr/src/util.rs @@ -9,7 +9,7 @@ use std::time::Duration; use anyhow::anyhow; use async_trait::async_trait; -use clap::ArgMatches; +use clap::{Arg, ArgMatches}; use digest::Digest; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value;