From 3990228403da0652481079333780361be6e425a7 Mon Sep 17 00:00:00 2001 From: Alex Janka Date: Sat, 25 May 2024 16:15:53 +1000 Subject: [PATCH] get forwarded auth --- Cargo.lock | 6 +- Cargo.toml | 7 ++- src/api_interface.rs | 10 +++- src/errors.rs | 42 +++++++++++++- src/main.rs | 6 +- src/server/authentication.rs | 76 ++++++++++++++++++++++++++ src/server/mod.rs | 103 ++++++++++++++++++++++++++++++++++- webapp/index.html | 1 + 8 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 src/server/authentication.rs diff --git a/Cargo.lock b/Cargo.lock index a639f5b..7e042a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2849,7 +2849,7 @@ dependencies = [ [[package]] name = "tesla-charge-controller" -version = "1.3.3" +version = "1.4.0" dependencies = [ "chrono", "clap 4.5.4", @@ -2861,6 +2861,8 @@ dependencies = [ "log 0.4.21", "notify-debouncer-mini", "prometheus", + "rand 0.8.5", + "reqwest", "rocket", "ron", "serde", @@ -2875,7 +2877,7 @@ dependencies = [ [[package]] name = "tesla-common" version = "0.3.1" -source = "git+https://git.alexjanka.com/alex/tesla-common#6c6cb913c33a94b6246d21af1525fad02b5cd3d8" +source = "git+https://git.alexjanka.com/alex/tesla-common#74859ebe6f1c96ae6a62d9156beffd8df46dea37" dependencies = [ "ron", "serde", diff --git a/Cargo.toml b/Cargo.toml index f4ba1a9..88dfeb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tesla-charge-controller" -version = "1.3.3" +version = "1.4.0" edition = "2021" license = "MITNFA" description = "Controls Tesla charge rate based on solar charge data" @@ -32,3 +32,8 @@ libmodbus-rs = "0.8.3" if_chain = "1.0.2" notify-debouncer-mini = { version = "0.4.1", default-features = false } lazy_static = "1.4" +rand = "0.8" +reqwest = "0.12.4" + +# [patch."https://git.alexjanka.com/alex/tesla-common"] +# tesla-common = { path = "../tesla-common" } diff --git a/src/api_interface.rs b/src/api_interface.rs index 419a026..dea18b8 100644 --- a/src/api_interface.rs +++ b/src/api_interface.rs @@ -261,12 +261,13 @@ struct MonitoredValues { climate_keeper: String, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] // these are the messages that the webserver can send the api thread pub enum InterfaceRequest { FlashLights, StopCharge, SetChargeRate(i64), + NewAuth(AuthInfo), } const KEY_REFRESH_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); @@ -346,6 +347,13 @@ impl TeslaInterface { Ok(_) => {} Err(e) => log::error!("Error setting charge rate: {e:?}"), }, + InterfaceRequest::NewAuth(new) => { + self.api.access_token = new.access_token; + self.api.refresh_token = new.refresh_token; + if let Err(e) = self.save_key() { + log::error!("failed to save key: {e:?}"); + } + } } } diff --git a/src/errors.rs b/src/errors.rs index 084a008..3f4276b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -38,6 +38,18 @@ pub enum ServerError { InvalidParameters, #[error("prometheus")] Prometheus(#[from] prometheus::Error), + #[error("uri")] + Uri, + #[error(transparent)] + Auth(#[from] AuthKeyError), + #[error(transparent)] + Channel(#[from] tokio::sync::mpsc::error::SendError), +} + +impl From> for ServerError { + fn from(_: rocket::http::uri::Error<'_>) -> Self { + Self::Uri + } } impl From> for ServerError { @@ -49,10 +61,13 @@ impl From> for ServerError { impl<'a> Responder<'a, 'a> for ServerError { fn respond_to(self, _: &'a rocket::Request<'_>) -> rocket::response::Result<'a> { Err(match self { - ServerError::Lock => rocket::http::Status::InternalServerError, ServerError::NoData => rocket::http::Status::ServiceUnavailable, - Self::InvalidParameters => rocket::http::Status::BadRequest, - ServerError::Prometheus(_) => rocket::http::Status::InternalServerError, + ServerError::InvalidParameters => rocket::http::Status::BadRequest, + ServerError::Auth(_) => rocket::http::Status::Unauthorized, + ServerError::Channel(_) + | ServerError::Uri + | ServerError::Lock + | ServerError::Prometheus(_) => rocket::http::Status::InternalServerError, }) } } @@ -106,3 +121,24 @@ pub enum ConfigError { #[error("io")] Io(#[from] std::io::Error), } + +#[derive(Debug, thiserror::Error)] +pub(crate) enum AuthKeyError { + #[error("Error returned from Tesla")] + TeslaError(TeslaError), + #[error(transparent)] + SerdeError(#[from] serde_json::Error), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error("Secrets not registered")] + Secrets, +} + +#[derive(serde::Deserialize, Debug)] +#[allow(dead_code)] +pub(crate) struct TeslaError { + error: String, + error_description: String, + #[serde(rename = "referenceID")] + reference_id: String, +} diff --git a/src/main.rs b/src/main.rs index a0a28dc..a1a18d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,14 +167,14 @@ async fn main() { .map(|(_, tcrc)| tcrc.tcrc_state.clone()) .unwrap_or_default(); - let server_handle = server::launch_server(server::ServerState { + let server_handle = server::launch_server(server::ServerState::new( car_state, - pl_state, tcrc_state, + pl_state, api_requests, pli_requests, tcrc_requests, - }); + )); if let Some((mut interface, mut tesla_charge_rate_controller)) = interface_and_tcrc { // spawn the api / charge rate control loop diff --git a/src/server/authentication.rs b/src/server/authentication.rs new file mode 100644 index 0000000..b258de3 --- /dev/null +++ b/src/server/authentication.rs @@ -0,0 +1,76 @@ +use tesla_common::AuthInfo; + +use crate::errors::AuthKeyError; + +#[derive(serde::Deserialize, Debug)] +#[allow(dead_code)] +struct AuthKeySuccess { + access_token: String, + refresh_token: String, + id_token: String, + expires_in: usize, + state: String, + token_type: String, +} + +impl AuthKeySuccess { + fn get_keys(&self) -> AuthInfo { + AuthInfo { + access_token: tesla_common::AccessToken(self.access_token.clone()), + refresh_token: Some(tesla_common::RefreshToken(self.refresh_token.clone())), + } + } +} + +#[derive(serde::Deserialize, Debug)] +#[serde(untagged)] +enum AuthKeyResponse { + Ok(AuthKeySuccess), + Error(crate::errors::TeslaError), +} + +pub async fn register_auth_key(code: String) -> Result { + if let Some(secrets) = super::SECRETS.as_ref() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "Content-Type", + "application/x-www-form-urlencoded".parse().unwrap(), + ); + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let res = client + .post("https://auth.tesla.com/oauth2/v3/token") + .headers(headers) + .body( + [ + "grant_type=authorization_code&client_id=", + &secrets.client_id, + "&client_secret=", + &secrets.client_secret, + "&code=", + &code, + "&audience=", + &secrets.audience, + "&redirect_uri=", + &secrets.redirect_uri, + ] + .concat(), + ) + .send() + .await? + .text() + .await?; + let keys = match serde_json::from_str(&res) { + Ok(AuthKeyResponse::Ok(v)) => Ok(v), + Ok(AuthKeyResponse::Error(e)) => Err(AuthKeyError::TeslaError(e)), + Err(e) => Err(AuthKeyError::SerdeError(e)), + }?; + + Ok(keys.get_keys()) + } else { + Err(AuthKeyError::Secrets) + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index e60a23e..5acdd76 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,5 +1,6 @@ use std::sync::{Arc, RwLock}; +use rand::Rng; use rocket::{ fairing::{Fairing, Info, Kind}, http::Header, @@ -30,6 +31,28 @@ pub struct ServerState { pub api_requests: UnboundedSender, pub pli_requests: UnboundedSender, pub tcrc_requests: UnboundedSender, + waiting_for_auth: std::sync::Mutex>, +} + +impl ServerState { + pub fn new( + car_state: Arc>, + tcrc_state: Arc>, + pl_state: Option>>, + api_requests: UnboundedSender, + pli_requests: UnboundedSender, + tcrc_requests: UnboundedSender, + ) -> Self { + Self { + car_state, + tcrc_state, + pl_state, + api_requests, + pli_requests, + tcrc_requests, + waiting_for_auth: std::sync::Mutex::new(None), + } + } } pub async fn launch_server(state: ServerState) { @@ -77,11 +100,89 @@ fn rocket(state: ServerState) -> rocket::Rocket { sync_time, read_ram, read_eeprom, - set_regulator_state + set_regulator_state, + reauthenticate, + auth ], ) } +#[derive(Debug)] +struct Secrets { + client_id: String, + client_secret: String, + audience: String, + redirect_uri: String, +} + +impl Secrets { + fn load() -> Option { + Some(Self { + client_id: std::env::var("CLIENT_ID").ok()?, + client_secret: std::env::var("CLIENT_SECRET").ok()?, + audience: std::env::var("AUDIENCE").ok()?, + redirect_uri: std::env::var("REDIRECT_URI").ok()?, + }) + } +} + +lazy_static::lazy_static! { + static ref SECRETS: Option = Secrets::load(); +} + +mod authentication; + +#[get("/reauthenticate")] +async fn reauthenticate( + state: &State, +) -> Result { + let state_random: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(16) + .map(char::from) + .collect(); + + let mut auth = state.waiting_for_auth.lock()?; + + let secrets = SECRETS.as_ref().ok_or(ServerError::Lock)?; + + let uri = rocket::http::uri::Absolute::parse_owned(format!( + "https://auth.tesla.com/oauth2/v3/authorize?&client_id={}&locale=en-US&prompt=login&redirect_uri={}&response_type=code&scope=openid%20vehicle_device_data%20offline_access%20user_data%20vehicle_cmds%20vehicle_charging_cmds&state={state_random}", + secrets.client_id, + secrets.redirect_uri + ))?; + + *auth = Some((state_random.clone(), std::time::Instant::now())); + log::info!("set auth {auth:?}"); + Ok(rocket::response::Redirect::to(uri)) +} + +#[post("/auth", format = "application/json", data = "")] +async fn auth( + data: rocket::serde::json::Json, + state: &State, +) -> Result<(), ServerError> { + log::info!("got reauthentication data"); + let data = data.0; + let code = { + let mut guard = state.waiting_for_auth.lock()?; + if guard.as_ref().is_some_and(|(state_random, time)| { + *state_random == data.state + && std::time::Instant::now().duration_since(*time) + < std::time::Duration::from_secs(30 * 60) + }) { + guard.take(); + log::info!("matched reauthentication link"); + Ok(data.code) + } else { + Err(ServerError::NoData) + }? + }; + let new = authentication::register_auth_key(code).await?; + state.api_requests.send(InterfaceRequest::NewAuth(new))?; + Ok(()) +} + #[get("/home")] async fn home(state: &State) -> Result, ServerError> { let location_data = &state diff --git a/webapp/index.html b/webapp/index.html index e0da197..a5e8c04 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -37,6 +37,7 @@ Regulator control→ PID control variables→ Shutoff voltage control→ + Reauthenticate→