diff --git a/crates/database/queries/refresh_tokens/get_one.sql b/crates/database/queries/refresh_tokens/get_one.sql new file mode 100644 index 0000000..f43ec1a --- /dev/null +++ b/crates/database/queries/refresh_tokens/get_one.sql @@ -0,0 +1,14 @@ +select + -- info + token, + ip_address, + user, + + -- timings + created_at as "created_at: DateTime", + expires_at as "expires_at: DateTime", + used_at as "used_at: DateTime", + revoked_at as "revoked_at: DateTime" +from refresh_tokens + +where token is (?) \ No newline at end of file diff --git a/crates/database/queries/refresh_tokens/revoke.sql b/crates/database/queries/refresh_tokens/revoke.sql new file mode 100644 index 0000000..c1d5924 --- /dev/null +++ b/crates/database/queries/refresh_tokens/revoke.sql @@ -0,0 +1,5 @@ +update refresh_tokens + +set revoked_at = CURRENT_TIMESTAMP + +where token is ? \ No newline at end of file diff --git a/crates/database/sqlx-data.json b/crates/database/sqlx-data.json index 1e842c9..32d452e 100644 --- a/crates/database/sqlx-data.json +++ b/crates/database/sqlx-data.json @@ -322,6 +322,60 @@ }, "query": "insert into authorization_codes (code, app, user, expires_at)\nvalues (?, ?, ?, datetime(?, 'unixepoch'))\n" }, + "a7405a0479b551ce8e3ea7451fd781214e049a0f12551146ace1e9a2f2f0c06d": { + "describe": { + "columns": [ + { + "name": "token", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "ip_address", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "expires_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "used_at: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "revoked_at: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ], + "parameters": { + "Right": 1 + } + }, + "query": "select\n -- info\n token,\n ip_address,\n user,\n\n -- timings\n created_at as \"created_at: DateTime\",\n expires_at as \"expires_at: DateTime\",\n used_at as \"used_at: DateTime\",\n revoked_at as \"revoked_at: DateTime\"\nfrom refresh_tokens\n\nwhere token is (?)" + }, "aa88eb27d38ba4cfb539e4b4d7a86770c24221109e8fcc188a7d38f41e674817": { "describe": { "columns": [], @@ -498,6 +552,16 @@ }, "query": "select u.id,\n u.created_at as \"created_at: DateTime\",\n u.updated_at as \"updated_at: DateTime\",\n u.is_admin as \"is_admin: bool\",\n u.username,\n u.name,\n u.email,\n u.password,\n u.password_recover,\n u.paper_key,\n u.is_archived as \"is_archived: bool\"\nfrom users u\n\n inner join settings s on u.id = s.first_admin\n\nwhere u.is_admin is 1\n and u.is_archived is 0\n and u.id is s.first_admin\n\nlimit 1" }, + "c6157ec3928527ec0ac5f493a5a91faff7e3668204a179e827a87d6279a02c40": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "update refresh_tokens\n\nset revoked_at = CURRENT_TIMESTAMP\n\nwhere token is ?" + }, "cf624c4e122477228e3bab09f7cd0dedf4776f73e7a86f19e06772a0adf83406": { "describe": { "columns": [ diff --git a/crates/database/src/tables/refresh_tokens.rs b/crates/database/src/tables/refresh_tokens.rs index 53f240c..2cc2d4c 100644 --- a/crates/database/src/tables/refresh_tokens.rs +++ b/crates/database/src/tables/refresh_tokens.rs @@ -38,4 +38,24 @@ impl RefreshTokens { Ok((query.rows_affected() == 1).then_some(())) } + + pub async fn get_one( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + sqlx::query_file_as!(Self, "queries/refresh_tokens/get_one.sql", token) + .fetch_optional(conn) + .await + .map_err(handle_error) + } + + pub async fn revoke(conn: impl SqliteExecutor<'_>, token: &str) -> Result, Error> { + let query: SqliteQueryResult = + sqlx::query_file!("queries/refresh_tokens/revoke.sql", token) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } } diff --git a/crates/ezidam/src/guards.rs b/crates/ezidam/src/guards.rs index 4fdf242..d59cbb2 100644 --- a/crates/ezidam/src/guards.rs +++ b/crates/ezidam/src/guards.rs @@ -1,5 +1,7 @@ mod completed_setup; mod need_setup; +mod refresh_token; pub use completed_setup::CompletedSetup; pub use need_setup::NeedSetup; +pub use refresh_token::RefreshToken; diff --git a/crates/ezidam/src/guards/refresh_token.rs b/crates/ezidam/src/guards/refresh_token.rs new file mode 100644 index 0000000..ecfadb9 --- /dev/null +++ b/crates/ezidam/src/guards/refresh_token.rs @@ -0,0 +1,24 @@ +use rocket::request::{FromRequest, Outcome}; +use rocket::Request; + +pub struct RefreshToken(pub String); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for RefreshToken { + type Error = (); + + async fn from_request(request: &'r Request<'_>) -> Outcome { + match request.cookies().get("refresh_token") { + Some(cookie) => { + let value = cookie.value(); + + if value.len() == 64 { + Outcome::Success(Self(value.to_string())) + } else { + Outcome::Forward(()) + } + } + None => Outcome::Forward(()), + } + } +} diff --git a/crates/ezidam/src/routes/root.rs b/crates/ezidam/src/routes/root.rs index 645aa57..428609f 100644 --- a/crates/ezidam/src/routes/root.rs +++ b/crates/ezidam/src/routes/root.rs @@ -1,10 +1,11 @@ use super::prelude::*; -use rocket::get; +use rocket::http::{Cookie, CookieJar}; +use rocket::{get, post}; use settings::Settings; use users::User; pub fn routes() -> Vec { - routes![logo, avatar, homepage, redirect_to_setup] + routes![logo, avatar, homepage, redirect_to_setup, logout] } #[get("/logo")] @@ -91,3 +92,46 @@ async fn homepage() -> Page { abc: "string".to_string(), }) } + +#[post("/logout")] +async fn logout( + mut db: Connection, + refresh_token: RefreshToken, + cookie_jar: &CookieJar<'_>, +) -> Result { + let mut transaction = db.begin().await?; + + let refresh_token = refresh_tokens::RefreshToken::get_one(&mut transaction, &refresh_token.0) + .await? + .ok_or_else(|| Error::not_found("Unknown refresh token"))?; + + // If refresh token has already been used + if refresh_token.has_been_used() { + // TODO: Revoke all tokens for user + // user.revoke_all_refresh_tokens(&mut transaction).await?; + // transaction.commit().await?; + + return Err(Error::forbidden("This refresh token has already been used")); + } + + // If token has been revoked + if refresh_token.is_revoked() { + return Err(Error::forbidden("This refresh token has been revoked")); + } + + // If token has expired + if refresh_token.has_expired() { + return Err(Error::forbidden("This refresh token has expired")); + } + + // Revoke token + refresh_token.revoke(&mut transaction).await?; + + transaction.commit().await?; + + // Delete cookies + cookie_jar.remove(Cookie::named("access_token")); + cookie_jar.remove(Cookie::named("refresh_token")); + + Ok(Redirect::to(uri!(homepage))) +} diff --git a/crates/ezidam/templates/shell/header.html.tera b/crates/ezidam/templates/shell/header.html.tera index 5d132d0..01753af 100644 --- a/crates/ezidam/templates/shell/header.html.tera +++ b/crates/ezidam/templates/shell/header.html.tera @@ -30,7 +30,9 @@ diff --git a/crates/refresh_tokens/src/database.rs b/crates/refresh_tokens/src/database.rs index 1dd5dae..6f0cc45 100644 --- a/crates/refresh_tokens/src/database.rs +++ b/crates/refresh_tokens/src/database.rs @@ -41,4 +41,17 @@ impl RefreshToken { ) .await?) } + + pub async fn get_one( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + Ok(DatabaseRefreshTokens::get_one(conn, token) + .await? + .map(Self::from)) + } + + pub async fn revoke(self, conn: impl SqliteExecutor<'_>) -> Result, Error> { + Ok(DatabaseRefreshTokens::revoke(conn, &self.token).await?) + } } diff --git a/crates/refresh_tokens/src/lib.rs b/crates/refresh_tokens/src/lib.rs index 6421dff..992aa12 100644 --- a/crates/refresh_tokens/src/lib.rs +++ b/crates/refresh_tokens/src/lib.rs @@ -19,3 +19,17 @@ pub struct RefreshToken { used_at: Option>, revoked_at: Option>, } + +impl RefreshToken { + pub fn has_expired(&self) -> bool { + self.expires_at < Utc::now() + } + + pub fn has_been_used(&self) -> bool { + self.used_at.is_some() + } + + pub fn is_revoked(&self) -> bool { + self.revoked_at.is_some() + } +}