use crate::{schema, user}; use microrm::prelude::*; use serde::Deserialize; use tide::http::Cookie; pub(super) struct SessionHelper<'l> { qi: &'l microrm::QueryInterface<'static>, tmpl: &'l handlebars::Handlebars<'l>, realm_str: &'l str, } type Request = tide::Request; const SESSION_COOKIE_NAME: &'static str = "uauth_session"; impl<'l> SessionHelper<'l> { pub fn new(req: &'l Request) -> Self { Self { qi: req.state().core.pool.query_interface(), tmpl: &req.state().core.templates, realm_str: req.param("realm").expect("no realm param?"), } } pub fn get_realm(&self) -> tide::Result { self.qi .get() .by(schema::Realm::Shortname, self.realm_str) .one() .expect("couldn't query db") .map(|r| r.id()) .ok_or(tide::Error::from_str(404, "No such realm")) } fn build_session( &self, ) -> tide::Result<(schema::SessionID, Option>)> { let rng = ring::rand::SystemRandom::new(); let session_id: [u8; 32] = ring::rand::generate(&rng) .map_err(|_| tide::Error::from_str(500, "Failed to generate session ID"))? .expose(); let session_id = base64::encode_config(session_id, base64::URL_SAFE_NO_PAD); let maybe_id = self.qi.add(&schema::Session { key: session_id.clone(), }); let session_cookie = Cookie::build(SESSION_COOKIE_NAME, session_id) .path("/") .finish(); Ok(( maybe_id.ok().ok_or(tide::Error::from_str( 500, "Failed to store session in database", ))?, Some(session_cookie), )) } pub fn verify_session(&self, req: &Request) -> Option<(schema::RealmID, schema::UserID)> { self.get_or_build_session(req) .ok() .zip(self.get_realm().ok()) .and_then(|((sid, _cookie), realm)| { self.get_auth_for_session(realm, sid).and_then(|auth| { if auth.challenges_left.len() == 0 { Some((realm, auth.user)) } else { None } }) }) } pub fn get_session(&self, req: &Request) -> Option { req.cookie(SESSION_COOKIE_NAME) .and_then(|sid| { self.qi .get() .by(schema::Session::Key, sid.value()) .one() .expect("couldn't query db") }) .and_then(|session| Some(session.id())) } pub fn get_or_build_session( &self, req: &Request, ) -> tide::Result<(schema::SessionID, Option>)> { match self.get_session(&req) { Some(sid) => Ok((sid, None)), None => self.build_session(), } } pub fn get_auth_for_session( &self, realm: schema::RealmID, session: schema::SessionID, ) -> Option> { use schema::SessionAuthentication as SAC; self.qi .get() .by(SAC::Realm, &realm) .by(SAC::Session, &session) .one() .expect("couldn't query db") } pub fn destroy_auth(&self, realm: schema::RealmID, session: schema::SessionID) { use schema::SessionAuthentication as SAC; self.qi .delete() .by(SAC::Realm, &realm) .by(SAC::Session, &session) .exec() .expect("couldn't query db") } } impl<'l> SessionHelper<'l> { fn render_login_from_auth( &self, mut response: tide::Response, redirect: String, auth: Option, error_msg: Option, ) -> tide::Response { log::info!("rendering login response... auth is {:?}", auth); let to_present: Option = match auth { None => Some(schema::AuthChallengeType::Username), Some(auth) => auth.challenges_left.first().copied(), }; if to_present.is_none() { response.set_status(302); tide::Redirect::new("../..").into() } else { self.render_login_page(response, redirect, to_present.unwrap(), error_msg) } } fn render_login_page( &self, mut response: tide::Response, redirect: String, to_present: schema::AuthChallengeType, error_msg: Option, ) -> tide::Response { let do_challenge = |ty, ch| { self.tmpl .render( "id_v1_login", &serde_json::json!( { "challenge": format!(r#"
{}
{}
"#, to_present, ty, ch), "redirect": redirect, "error_msg": error_msg.iter().collect::>() } ), ) .unwrap() }; response.set_content_type("text/html"); match to_present { schema::AuthChallengeType::Username => { response.set_body(do_challenge( "Username", r#""#, )); } schema::AuthChallengeType::Password => { response.set_body(do_challenge( "Password", r#""#, )); } _ => todo!(), } response } } async fn v1_login(req: Request) -> tide::Result { let mut response = tide::Response::builder(200).build(); let shelper = SessionHelper::new(&req); let realm = shelper.get_realm()?; let (session_id, cookie) = shelper.get_or_build_session(&req)?; cookie.map(|c| response.insert_cookie(c)); let auth = shelper.get_auth_for_session(realm, session_id); #[derive(serde::Deserialize)] struct LoginQuery { redirect: Option, } let query: LoginQuery = req.query().unwrap(); Ok(shelper.render_login_from_auth( response, query.redirect.unwrap_or_else(|| "../..".to_string()), auth.map(|a| a.wrapped()), None, )) } async fn v1_login_post(mut req: Request) -> tide::Result { let mut response = tide::Response::builder(200).build(); #[derive(Deserialize)] struct ResponseBody { challenge_type: String, challenge: String, reset: Option, redirect: String, } let body: ResponseBody = req.body_form().await?; let shelper = SessionHelper::new(&req); let realm = shelper.get_realm()?; let (session_id, cookie) = shelper.get_or_build_session(&req)?; cookie.map(|c| response.insert_cookie(c)); let mut auth = shelper.get_auth_for_session(realm, session_id); // check if a login reset was requested; if so, we start again from the top if body.reset.is_some() { if let Some(_) = auth { shelper.destroy_auth(realm, session_id); response.set_status(302); return Ok(tide::Redirect::new("login").into()); } } use schema::AuthChallengeType as ChallengeType; let challenge: schema::AuthChallengeType = match body.challenge_type.as_str() { "Username" => ChallengeType::Username, "Password" => ChallengeType::Password, _ => Err(tide::Error::from_str(400, "Unknown challenge type"))?, }; let mut error = None; // check that the response matches what we're expecting next let to_be_presented: Option = match &auth { None => Some(schema::AuthChallengeType::Username), Some(auth) => auth.challenges_left.first().copied(), }; if to_be_presented != Some(challenge) { Err(tide::Error::from_str(400, "Unexpected challenge type"))? } match challenge { ChallengeType::Username => { let qi = req.state().core.pool.query_interface(); shelper.destroy_auth(realm, session_id); let user = qi .get() .by(schema::User::Realm, &realm) .by(schema::User::Username, &body.challenge) .one() .expect("couldn't query db"); if user.is_none() { error = Some(format!("No such user {}", body.challenge)); } else { let user = user.unwrap(); // TODO: set list of challenges to be whatever else this user has set up let sa = schema::SessionAuthentication { session: session_id, realm: realm, user: user.id(), challenges_left: vec![schema::AuthChallengeType::Password], }; let id = qi.add(&sa).unwrap(); auth = Some(microrm::WithID::new(sa, id)); } } ct => { if let Some(auth) = &mut auth { let qi = req.state().core.pool.query_interface(); let user = qi.get().by_id(&auth.user).one().expect("couldn't query db"); if let Some(user) = user { let user = user::User::from_model(user); let verification = user.verify_challenge(&qi, ct, body.challenge.as_bytes()); match verification { Some(true) => { auth.challenges_left.remove(0); qi.update() .to(auth.as_ref()) .by_id(&auth.id()) .exec() .expect("couldn't update auth status?"); } Some(false) => { error = Some("Incorrect response. Please try again".into()); } None => { error = Some( "User no longer exists. Please contact an administrator.".into(), ); } } } else { error = Some(format!("User is not configured correctly: either it was deleted or it lacks a required authentication challenge type. Please contact an administrator.")); } } else { error = Some(format!("Please restart login process.")); } } }; Ok(shelper.render_login_from_auth(response, body.redirect, auth.map(|a| a.wrapped()), error)) } async fn v1_logout(req: Request) -> tide::Result { let shelper = SessionHelper::new(&req); #[derive(serde::Deserialize)] struct LogoutQuery { redirect: Option, } let query: LogoutQuery = req.query().unwrap(); let realm = shelper.get_realm()?; shelper .get_session(&req) .map(|sid| shelper.destroy_auth(realm, sid)); Ok(tide::Redirect::new(query.redirect.unwrap_or_else(|| "../..".into())).into()) } pub(super) fn session_v1_server(mut route: tide::Route) { route.at("login").get(v1_login).post(v1_login_post); route.at("logout").get(v1_logout); }