diff --git a/.sqlx/query-03dc28f03696a476a1597ccf5934286f0e619c91eda87bdcf223265dfbf26b62.json b/.sqlx/query-03dc28f03696a476a1597ccf5934286f0e619c91eda87bdcf223265dfbf26b62.json new file mode 100644 index 0000000..107b4b0 --- /dev/null +++ b/.sqlx/query-03dc28f03696a476a1597ccf5934286f0e619c91eda87bdcf223265dfbf26b62.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id as \"id!\",\n user_id,\n family_id,\n expires_at as \"expires_at!: DateTime\",\n used_at as \"used_at: DateTime\",\n revoked_at as \"revoked_at: DateTime\"\n FROM refresh_tokens WHERE token_hash = ?", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "refresh_tokens", + "name": "id" + } + } + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "refresh_tokens", + "name": "user_id" + } + } + }, + { + "name": "family_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "refresh_tokens", + "name": "family_id" + } + } + }, + { + "name": "expires_at!: DateTime", + "ordinal": 3, + "type_info": "Datetime", + "origin": { + "Table": { + "table": "refresh_tokens", + "name": "expires_at" + } + } + }, + { + "name": "used_at: DateTime", + "ordinal": 4, + "type_info": "Datetime", + "origin": { + "Table": { + "table": "refresh_tokens", + "name": "used_at" + } + } + }, + { + "name": "revoked_at: DateTime", + "ordinal": 5, + "type_info": "Datetime", + "origin": { + "Table": { + "table": "refresh_tokens", + "name": "revoked_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "03dc28f03696a476a1597ccf5934286f0e619c91eda87bdcf223265dfbf26b62" +} diff --git a/.sqlx/query-08d1cbe6f1f290ce41eadeffaf35ce9e7f373b5a65c76de6ae1cbc558bbda824.json b/.sqlx/query-08d1cbe6f1f290ce41eadeffaf35ce9e7f373b5a65c76de6ae1cbc558bbda824.json new file mode 100644 index 0000000..aca8afe --- /dev/null +++ b/.sqlx/query-08d1cbe6f1f290ce41eadeffaf35ce9e7f373b5a65c76de6ae1cbc558bbda824.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE refresh_tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "08d1cbe6f1f290ce41eadeffaf35ce9e7f373b5a65c76de6ae1cbc558bbda824" +} diff --git a/.sqlx/query-12b63b8a0bf462cdcf57971227f4adf1cd86a5c56f4844934e83aa53e6792bc8.json b/.sqlx/query-12b63b8a0bf462cdcf57971227f4adf1cd86a5c56f4844934e83aa53e6792bc8.json new file mode 100644 index 0000000..0e478ea --- /dev/null +++ b/.sqlx/query-12b63b8a0bf462cdcf57971227f4adf1cd86a5c56f4844934e83aa53e6792bc8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE refresh_tokens SET used_at = ? WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "12b63b8a0bf462cdcf57971227f4adf1cd86a5c56f4844934e83aa53e6792bc8" +} diff --git a/.sqlx/query-60cf46c03a2b4bfb52045d83bad2e463ce91ec5ece94810fffee666539b5cb61.json b/.sqlx/query-60cf46c03a2b4bfb52045d83bad2e463ce91ec5ece94810fffee666539b5cb61.json new file mode 100644 index 0000000..8f939fa --- /dev/null +++ b/.sqlx/query-60cf46c03a2b4bfb52045d83bad2e463ce91ec5ece94810fffee666539b5cb61.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM refresh_tokens WHERE expires_at <= ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "60cf46c03a2b4bfb52045d83bad2e463ce91ec5ece94810fffee666539b5cb61" +} diff --git a/.sqlx/query-c3d9e4e563d8ff54b6c31459f3f1b19c659132bc8e9dcbaa9356217844656fa9.json b/.sqlx/query-c3d9e4e563d8ff54b6c31459f3f1b19c659132bc8e9dcbaa9356217844656fa9.json new file mode 100644 index 0000000..90601cf --- /dev/null +++ b/.sqlx/query-c3d9e4e563d8ff54b6c31459f3f1b19c659132bc8e9dcbaa9356217844656fa9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO refresh_tokens (user_id, family_id, token_hash, expires_at)\n SELECT user_id, family_id, ?, ? FROM refresh_tokens WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "c3d9e4e563d8ff54b6c31459f3f1b19c659132bc8e9dcbaa9356217844656fa9" +} diff --git a/.sqlx/query-f2bf34b5133287e4a397794dff9d4f456ab1d33884bf471555c06c8461d5b81a.json b/.sqlx/query-f2bf34b5133287e4a397794dff9d4f456ab1d33884bf471555c06c8461d5b81a.json new file mode 100644 index 0000000..b470a25 --- /dev/null +++ b/.sqlx/query-f2bf34b5133287e4a397794dff9d4f456ab1d33884bf471555c06c8461d5b81a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO refresh_tokens (user_id, family_id, token_hash, expires_at) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "f2bf34b5133287e4a397794dff9d4f456ab1d33884bf471555c06c8461d5b81a" +} diff --git a/README.md b/README.md index ae5bdfa..0fe1d32 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,22 @@ curl -X POST http://localhost:8080/v1/login \ Send the returned `access_token` to authenticated endpoints as `Authorization: Bearer `. +The login response also carries a `refresh_token`, valid for 30 days or until +the account expires. Exchange it for a new access token instead of storing the +password: + +```sh +curl -X POST http://localhost:8080/v1/token/refresh \ + -H 'Content-Type: application/json' \ + -d '{"refresh_token":""}' +``` + +Every exchange returns a new `refresh_token` and invalidates the one sent, so +clients must store the new one. Presenting an invalidated token again more than +30 seconds after it was exchanged revokes the whole login session, because it +means the token was copied. `POST /v1/logout` with the same body revokes the +session explicitly. Only a SHA-256 digest of each refresh token is stored. + Every authenticated request reloads the account from the database. Role changes, regular-user account expiry and account deletion therefore apply to existing tokens. Admin routes accept only admins; regular-user routes accept only active diff --git a/crates/shaide-common/src/api/users.rs b/crates/shaide-common/src/api/users.rs index 3b72c4b..ab46fdc 100644 --- a/crates/shaide-common/src/api/users.rs +++ b/crates/shaide-common/src/api/users.rs @@ -63,7 +63,16 @@ pub struct AccessTokenResponse { pub access_token: String, pub token_type: String, pub token_expires_in: u64, + /// Exchanged at `/v1/token/refresh` for a new access token. Single use: every exchange + /// returns a new refresh token, and presenting a used one ends the session. + pub refresh_token: String, + pub refresh_token_expires_in: u64, #[schema(value_type = String, format = DateTime)] pub account_expires_at: DateTime, pub role: String, } + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} diff --git a/crates/shaide-db/migrations/0005_refresh_tokens.down.sql b/crates/shaide-db/migrations/0005_refresh_tokens.down.sql new file mode 100644 index 0000000..7f0336a --- /dev/null +++ b/crates/shaide-db/migrations/0005_refresh_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE refresh_tokens; diff --git a/crates/shaide-db/migrations/0005_refresh_tokens.up.sql b/crates/shaide-db/migrations/0005_refresh_tokens.up.sql new file mode 100644 index 0000000..76642e0 --- /dev/null +++ b/crates/shaide-db/migrations/0005_refresh_tokens.up.sql @@ -0,0 +1,18 @@ +-- Refresh tokens let a client renew its access token without the user's password. Only a +-- SHA-256 digest of each token is stored. Every token rotated out of one login shares a +-- `family_id`, so presenting an already rotated token can revoke the whole session. +CREATE TABLE refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + family_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + -- Set when the token is exchanged for its successor. + used_at TIMESTAMP, + -- Set when the token's family is revoked, by logout or detected reuse. + revoked_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT (DATETIME('now')) +); + +CREATE INDEX refresh_tokens_family_id ON refresh_tokens (family_id); +CREATE INDEX refresh_tokens_user_id ON refresh_tokens (user_id); diff --git a/crates/shaide-db/src/lib.rs b/crates/shaide-db/src/lib.rs index 8b0e1a5..a9a7d02 100644 --- a/crates/shaide-db/src/lib.rs +++ b/crates/shaide-db/src/lib.rs @@ -7,6 +7,7 @@ pub mod error; pub mod files; pub mod models; pub mod organizations; +mod refresh_tokens; pub mod upload_lifecycle; mod users; @@ -14,6 +15,7 @@ use std::path::Path; use anyhow::Result; pub use models::{InsertModelDAO, ModelDAO}; +pub use refresh_tokens::RefreshTokenDAO; use sqlx::{Pool, Sqlite, SqlitePool, sqlite::SqliteConnectOptions}; pub use users::{UserDAO, UserRole}; diff --git a/crates/shaide-db/src/refresh_tokens.rs b/crates/shaide-db/src/refresh_tokens.rs new file mode 100644 index 0000000..1bf3e39 --- /dev/null +++ b/crates/shaide-db/src/refresh_tokens.rs @@ -0,0 +1,261 @@ +use chrono::{DateTime, Utc}; +use sqlx::{FromRow, query, query_as}; + +use super::DbConn; +use crate::error::ShaideDBError; + +#[derive(FromRow, Debug, Clone)] +pub struct RefreshTokenDAO { + pub id: i64, + pub user_id: i64, + pub family_id: String, + pub expires_at: DateTime, + pub used_at: Option>, + pub revoked_at: Option>, +} + +/// db read/write operations for `refresh_tokens` table +impl DbConn { + pub async fn insert_refresh_token( + &self, + user_id: i64, + family_id: &str, + token_hash: &str, + expires_at: DateTime, + ) -> Result { + let res = query!( + "INSERT INTO refresh_tokens (user_id, family_id, token_hash, expires_at) VALUES (?, ?, ?, ?)", + user_id, + family_id, + token_hash, + expires_at + ) + .execute(&self.pool) + .await?; + Ok(res.last_insert_rowid()) + } + + pub async fn get_refresh_token_by_hash( + &self, + token_hash: &str, + ) -> Result, ShaideDBError> { + let token = query_as!( + RefreshTokenDAO, + r#" + SELECT + id as "id!", + user_id, + family_id, + expires_at as "expires_at!: DateTime", + used_at as "used_at: DateTime", + revoked_at as "revoked_at: DateTime" + FROM refresh_tokens WHERE token_hash = ?"#, + token_hash + ) + .fetch_optional(&self.pool) + .await?; + Ok(token) + } + + /// Marks `token_id` as used and stores its successor in the same family, atomically. + /// + /// Returns `false` without storing the successor when `token_id` was already used or revoked, + /// which happens when two requests race to rotate the same token. + pub async fn rotate_refresh_token( + &self, + token_id: i64, + successor_hash: &str, + successor_expires_at: DateTime, + ) -> Result { + let now = Utc::now(); + let mut transaction = self.pool.begin().await?; + let claimed = query!( + "UPDATE refresh_tokens SET used_at = ? WHERE id = ? AND used_at IS NULL AND revoked_at IS NULL", + now, + token_id + ) + .execute(&mut *transaction) + .await? + .rows_affected(); + if claimed != 1 { + return Ok(false); + } + query!( + r#" + INSERT INTO refresh_tokens (user_id, family_id, token_hash, expires_at) + SELECT user_id, family_id, ?, ? FROM refresh_tokens WHERE id = ?"#, + successor_hash, + successor_expires_at, + token_id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(true) + } + + /// Revokes every token of a login session that is not revoked yet. + pub async fn revoke_refresh_token_family(&self, family_id: &str) -> Result<(), ShaideDBError> { + let now = Utc::now(); + query!( + "UPDATE refresh_tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL", + now, + family_id + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Deletes tokens that can no longer be exchanged. Kept until expiry even once used or + /// revoked, so that presenting them is still recognised as reuse of a known token. + pub async fn delete_expired_refresh_tokens(&self) -> Result { + let now = Utc::now(); + let res = query!("DELETE FROM refresh_tokens WHERE expires_at <= ?", now) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } +} + +#[cfg(test)] +mod tests { + use chrono::{Duration, Utc}; + use temp_testdir::TempDir; + + use crate::DbConn; + + async fn db_with_user(temp_dir: &TempDir) -> (DbConn, i64) { + let db = DbConn::new(&temp_dir.join("shaide-test.sqlite")) + .await + .expect("test database should be created"); + let user_id = db + .create_user( + "ada".to_owned(), + "hash".to_owned(), + Utc::now() + Duration::days(1), + ) + .await + .unwrap(); + (db, user_id) + } + + #[tokio::test] + async fn rotation_marks_the_token_used_and_stores_the_successor_in_the_same_family() { + let temp_dir = TempDir::default(); + let (db, user_id) = db_with_user(&temp_dir).await; + let expires_at = Utc::now() + Duration::days(30); + let token_id = db + .insert_refresh_token(user_id, "family-1", "hash-1", expires_at) + .await + .unwrap(); + + assert!( + db.rotate_refresh_token(token_id, "hash-2", expires_at) + .await + .unwrap() + ); + + let rotated = db + .get_refresh_token_by_hash("hash-1") + .await + .unwrap() + .unwrap(); + assert!(rotated.used_at.is_some()); + let successor = db + .get_refresh_token_by_hash("hash-2") + .await + .unwrap() + .unwrap(); + assert_eq!(successor.family_id, "family-1"); + assert_eq!(successor.user_id, user_id); + assert!(successor.used_at.is_none()); + } + + #[tokio::test] + async fn a_token_can_be_rotated_only_once() { + let temp_dir = TempDir::default(); + let (db, user_id) = db_with_user(&temp_dir).await; + let expires_at = Utc::now() + Duration::days(30); + let token_id = db + .insert_refresh_token(user_id, "family-1", "hash-1", expires_at) + .await + .unwrap(); + + assert!( + db.rotate_refresh_token(token_id, "hash-2", expires_at) + .await + .unwrap() + ); + assert!( + !db.rotate_refresh_token(token_id, "hash-3", expires_at) + .await + .unwrap() + ); + assert!( + db.get_refresh_token_by_hash("hash-3") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn revoking_a_family_blocks_rotation_and_leaves_other_families_alone() { + let temp_dir = TempDir::default(); + let (db, user_id) = db_with_user(&temp_dir).await; + let expires_at = Utc::now() + Duration::days(30); + let revoked_id = db + .insert_refresh_token(user_id, "family-1", "hash-1", expires_at) + .await + .unwrap(); + db.insert_refresh_token(user_id, "family-2", "hash-2", expires_at) + .await + .unwrap(); + + db.revoke_refresh_token_family("family-1").await.unwrap(); + + assert!( + !db.rotate_refresh_token(revoked_id, "hash-3", expires_at) + .await + .unwrap() + ); + let other = db + .get_refresh_token_by_hash("hash-2") + .await + .unwrap() + .unwrap(); + assert!(other.revoked_at.is_none()); + } + + #[tokio::test] + async fn only_expired_tokens_are_deleted() { + let temp_dir = TempDir::default(); + let (db, user_id) = db_with_user(&temp_dir).await; + db.insert_refresh_token( + user_id, + "family-1", + "expired", + Utc::now() - Duration::seconds(1), + ) + .await + .unwrap(); + db.insert_refresh_token(user_id, "family-2", "valid", Utc::now() + Duration::days(1)) + .await + .unwrap(); + + assert_eq!(db.delete_expired_refresh_tokens().await.unwrap(), 1); + assert!( + db.get_refresh_token_by_hash("expired") + .await + .unwrap() + .is_none() + ); + assert!( + db.get_refresh_token_by_hash("valid") + .await + .unwrap() + .is_some() + ); + } +} diff --git a/crates/shaide/src/openapi.rs b/crates/shaide/src/openapi.rs index 53113ce..ffb23f8 100644 --- a/crates/shaide/src/openapi.rs +++ b/crates/shaide/src/openapi.rs @@ -22,6 +22,8 @@ use utoipa_swagger_ui::SwaggerUi; crate::routes::health::health, crate::routes::trial::is_trial, crate::routes::users::login, + crate::routes::users::refresh_token, + crate::routes::users::logout, crate::routes::users::get_users, crate::routes::users::create_user, crate::routes::users::generate_users, diff --git a/crates/shaide/src/routes/users.rs b/crates/shaide/src/routes/users.rs index ae06fe7..23ddc6f 100644 --- a/crates/shaide/src/routes/users.rs +++ b/crates/shaide/src/routes/users.rs @@ -1,18 +1,24 @@ use anyhow::Result; -use axum::{Json, Router, extract::State, routing}; -use chrono::{TimeZone, Utc}; +use axum::{Json, Router, extract::State, http::StatusCode, routing}; +use chrono::{Duration, TimeZone, Utc}; use shaide_common::api::users::{ AccessTokenResponse, CreateUserRequest, CreateUserResponse, GenerateUsersRequest, GenerateUsersResponse, GeneratedUserResponse, ListUser, ListUsersResponse, LoginRequest, + RefreshTokenRequest, }; -use shaide_db::{DbConn, UserRole}; +use shaide_db::{DbConn, UserDAO, UserRole, error::ShaideDBError}; +use tracing::{debug, warn}; use crate::{ error::ShaideError, middlewares::{AccessRequirement, Admin, ensure_access}, - services::auth::get_auth_service, + services::auth::{get_auth_service, hash_refresh_token}, }; +/// How long after a refresh token was rotated presenting it again counts as a benign race (two +/// tabs refreshing at once) rather than reuse of a stolen token, which revokes the session. +const REFRESH_TOKEN_REUSE_GRACE: Duration = Duration::seconds(30); + #[utoipa::path( get, path = "/v1/users", @@ -112,28 +118,372 @@ pub async fn login( Json(request): Json, ) -> Result, ShaideError> { let user = db.get_user_by_username(&request.username).await?; - let account_expires_at = user.expiry; get_auth_service() .verify_password(request.password, user.password_hash.clone()) .await?; ensure_access(&user, AccessRequirement::Any)?; - let account_expiry = (user.role == UserRole::User).then_some(user.expiry); + + // Housekeeping only: a failure must not block the sign-in. + match db.delete_expired_refresh_tokens().await { + Ok(deleted) if deleted > 0 => debug!(deleted, "Deleted expired refresh tokens"), + Ok(_) => {} + Err(error) => warn!(%error, "Could not delete expired refresh tokens"), + } + + let refresh_token = get_auth_service().issue_refresh_token(account_expiry(&user))?; + let family_id = uuid::Uuid::new_v4().to_string(); + db.insert_refresh_token( + user.id, + &family_id, + &refresh_token.hash, + refresh_token.expires_at, + ) + .await?; + Ok(Json(token_response( + &user, + refresh_token.token, + refresh_token.expires_in, + )?)) +} + +#[utoipa::path( + post, + path = "/v1/token/refresh", + tag = "auth", + request_body = RefreshTokenRequest, + responses( + (status = 200, description = "New access token and rotated refresh token", body = AccessTokenResponse), + (status = 401, description = "Refresh token is invalid, expired, used or revoked") + ) +)] +pub async fn refresh_token( + State(db): State, + Json(request): Json, +) -> Result, ShaideError> { + let Some(stored) = db + .get_refresh_token_by_hash(&hash_refresh_token(&request.refresh_token)) + .await? + else { + return Err(invalid_refresh_token()); + }; + + if stored.revoked_at.is_some() { + return Err(invalid_refresh_token()); + } + if let Some(used_at) = stored.used_at { + if Utc::now() - used_at > REFRESH_TOKEN_REUSE_GRACE { + warn!( + user_id = stored.user_id, + family_id = stored.family_id, + "Rotated refresh token presented again, revoking its session" + ); + db.revoke_refresh_token_family(&stored.family_id).await?; + } + return Err(invalid_refresh_token()); + } + if stored.expires_at <= Utc::now() { + return Err(invalid_refresh_token()); + } + + let user = match db.get_user_by_id(stored.user_id).await { + Ok(user) => user, + Err(ShaideDBError::NotFound(_)) => return Err(invalid_refresh_token()), + Err(error) => return Err(error.into()), + }; + if let Err(error) = ensure_access(&user, AccessRequirement::Any) { + db.revoke_refresh_token_family(&stored.family_id).await?; + return Err(error); + } + + let successor = get_auth_service().issue_refresh_token(account_expiry(&user))?; + if !db + .rotate_refresh_token(stored.id, &successor.hash, successor.expires_at) + .await? + { + // A concurrent request rotated this token first. + return Err(invalid_refresh_token()); + } + Ok(Json(token_response( + &user, + successor.token, + successor.expires_in, + )?)) +} + +#[utoipa::path( + post, + path = "/v1/logout", + tag = "auth", + request_body = RefreshTokenRequest, + responses((status = 204, description = "The refresh token's session is revoked")) +)] +pub async fn logout( + State(db): State, + Json(request): Json, +) -> Result { + // Answers the same for unknown tokens, so the endpoint does not reveal which tokens exist. + if let Some(stored) = db + .get_refresh_token_by_hash(&hash_refresh_token(&request.refresh_token)) + .await? + { + db.revoke_refresh_token_family(&stored.family_id).await?; + } + Ok(StatusCode::NO_CONTENT) +} + +/// Admin accounts do not expire, so only a user's expiry caps the tokens issued to them. +fn account_expiry(user: &UserDAO) -> Option> { + (user.role == UserRole::User).then_some(user.expiry) +} + +fn token_response( + user: &UserDAO, + refresh_token: String, + refresh_token_expires_in: u64, +) -> Result { let (access_token, token_expires_in) = - get_auth_service().issue_access_token(user.id, account_expiry)?; - Ok(Json(AccessTokenResponse { + get_auth_service().issue_access_token(user.id, account_expiry(user))?; + Ok(AccessTokenResponse { access_token, token_type: "Bearer".to_owned(), token_expires_in, - account_expires_at, + refresh_token, + refresh_token_expires_in, + account_expires_at: user.expiry, role: user.role.into(), - })) + }) +} + +fn invalid_refresh_token() -> ShaideError { + ShaideError::unauthorized("Invalid or expired refresh token".to_owned()) } pub fn users_router(db: DbConn) -> Router { Router::new() .route("/v1/login", routing::post(login)) + .route("/v1/token/refresh", routing::post(refresh_token)) + .route("/v1/logout", routing::post(logout)) .route("/v1/users", routing::get(get_users)) .route("/v1/user", routing::post(create_user)) .route("/v1/generate-users", routing::post(generate_users)) .with_state(db) } + +#[cfg(test)] +mod tests { + use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; + use chrono::{Duration, Utc}; + use shaide_common::api::users::{AccessTokenResponse, LoginRequest, RefreshTokenRequest}; + use shaide_db::DbConn; + use temp_testdir::TempDir; + + use super::{login, logout, refresh_token}; + use crate::services::auth::{AuthService, init_test_auth_service}; + + const PASSWORD: &str = "correct horse battery staple"; + + async fn db_with_user(temp_dir: &TempDir, expiry: chrono::DateTime) -> DbConn { + let auth = init_test_auth_service(); + let db = DbConn::new(&temp_dir.join("shaide-test.sqlite")) + .await + .expect("test database should be created"); + let password_hash = auth.hash_password(PASSWORD.to_owned()).await.unwrap(); + db.create_user("ada".to_owned(), password_hash, expiry) + .await + .unwrap(); + db + } + + async fn sign_in(db: &DbConn) -> AccessTokenResponse { + let Json(response) = login( + State(db.clone()), + Json(LoginRequest { + username: "ada".to_owned(), + password: PASSWORD.to_owned(), + }), + ) + .await + .expect("sign-in should succeed"); + response + } + + async fn refresh(db: &DbConn, token: &str) -> Result { + refresh_token( + State(db.clone()), + Json(RefreshTokenRequest { + refresh_token: token.to_owned(), + }), + ) + .await + .map(|Json(response)| response) + .map_err(|error| error.into_response().status()) + } + + /// Moves a token's rotation far enough into the past that presenting it again is reuse. + async fn age_rotation(db: &DbConn, token: &str) { + let used_at = Utc::now() - Duration::minutes(5); + let token_hash = super::hash_refresh_token(token); + sqlx::query("UPDATE refresh_tokens SET used_at = ? WHERE token_hash = ?") + .bind(used_at) + .bind(token_hash) + .execute(&db.pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn a_refresh_token_is_exchanged_for_a_new_access_token_and_a_rotated_refresh_token() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + let session = sign_in(&db).await; + + let refreshed = refresh(&db, &session.refresh_token).await.unwrap(); + + assert_ne!(refreshed.refresh_token, session.refresh_token); + assert_eq!(refreshed.token_type, "Bearer"); + let user_id = init_test_auth_service() + .validate_access_token(&refreshed.access_token) + .unwrap(); + assert_eq!( + user_id, + db.get_user_by_username("ada").await.unwrap().id, + "the new access token should belong to the signed-in user" + ); + assert!(refresh(&db, &refreshed.refresh_token).await.is_ok()); + } + + #[tokio::test] + async fn a_token_presented_again_within_the_grace_period_is_rejected_without_ending_the_session() + { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + let session = sign_in(&db).await; + let refreshed = refresh(&db, &session.refresh_token).await.unwrap(); + + assert_eq!( + refresh(&db, &session.refresh_token).await.unwrap_err(), + StatusCode::UNAUTHORIZED + ); + assert!( + refresh(&db, &refreshed.refresh_token).await.is_ok(), + "the request that won the race should keep its session" + ); + } + + #[tokio::test] + async fn reusing_a_rotated_token_after_the_grace_period_revokes_the_whole_session() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + let session = sign_in(&db).await; + let refreshed = refresh(&db, &session.refresh_token).await.unwrap(); + age_rotation(&db, &session.refresh_token).await; + + assert_eq!( + refresh(&db, &session.refresh_token).await.unwrap_err(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + refresh(&db, &refreshed.refresh_token).await.unwrap_err(), + StatusCode::UNAUTHORIZED, + "the successor of a reused token should be revoked too" + ); + } + + #[tokio::test] + async fn reuse_only_revokes_the_session_it_belongs_to() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + let compromised = sign_in(&db).await; + let other_device = sign_in(&db).await; + refresh(&db, &compromised.refresh_token).await.unwrap(); + age_rotation(&db, &compromised.refresh_token).await; + + refresh(&db, &compromised.refresh_token).await.unwrap_err(); + + assert!(refresh(&db, &other_device.refresh_token).await.is_ok()); + } + + #[tokio::test] + async fn logout_revokes_the_session() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + let session = sign_in(&db).await; + + let status = logout( + State(db.clone()), + Json(RefreshTokenRequest { + refresh_token: session.refresh_token.clone(), + }), + ) + .await + .unwrap(); + + assert_eq!(status, StatusCode::NO_CONTENT); + assert_eq!( + refresh(&db, &session.refresh_token).await.unwrap_err(), + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn logout_answers_the_same_for_an_unknown_token() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + + let status = logout( + State(db), + Json(RefreshTokenRequest { + refresh_token: "never-issued".to_owned(), + }), + ) + .await + .unwrap(); + + assert_eq!(status, StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn an_unknown_token_is_rejected() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + + assert_eq!( + refresh(&db, "never-issued").await.unwrap_err(), + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn refreshing_fails_and_revokes_the_session_once_the_account_has_expired() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::days(365)).await; + let session = sign_in(&db).await; + sqlx::query("UPDATE users SET expiry = ? WHERE username = 'ada'") + .bind(Utc::now() - Duration::seconds(1)) + .execute(&db.pool) + .await + .unwrap(); + + assert_eq!( + refresh(&db, &session.refresh_token).await.unwrap_err(), + StatusCode::UNAUTHORIZED + ); + let stored = db + .get_refresh_token_by_hash(&super::hash_refresh_token(&session.refresh_token)) + .await + .unwrap() + .unwrap(); + assert!(stored.revoked_at.is_some()); + } + + #[tokio::test] + async fn the_refresh_token_does_not_outlive_the_account() { + let temp_dir = TempDir::default(); + let db = db_with_user(&temp_dir, Utc::now() + Duration::hours(2)).await; + + let session = sign_in(&db).await; + + assert!(session.refresh_token_expires_in <= 2 * 60 * 60); + assert!(session.refresh_token_expires_in < AuthService::REFRESH_TOKEN_LIFETIME_SECONDS); + } +} diff --git a/crates/shaide/src/services/auth.rs b/crates/shaide/src/services/auth.rs index 2976441..32b34bd 100644 --- a/crates/shaide/src/services/auth.rs +++ b/crates/shaide/src/services/auth.rs @@ -4,6 +4,11 @@ use argon2::{ Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::{Error as PasswordHashError, SaltString}, }; +use aws_lc_rs::{ + digest::{SHA256, digest}, + rand::{SecureRandom, SystemRandom}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; use jsonwebtoken::{ Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, get_current_timestamp, @@ -15,6 +20,8 @@ use crate::{config::get_environment_config, error::ShaideError}; const JWT_ISSUER: &str = "shaide"; const JWT_AUDIENCE: &str = "shaide-api"; +/// 256 bits, so a refresh token cannot be guessed and a plain digest is enough to store it. +const REFRESH_TOKEN_BYTES: usize = 32; #[derive(Debug, Clone, Serialize, Deserialize)] struct UserClaims { @@ -25,6 +32,14 @@ struct UserClaims { exp: u64, } +/// A newly minted refresh token. `token` is handed to the client once; only `hash` is stored. +pub struct IssuedRefreshToken { + pub token: String, + pub hash: String, + pub expires_at: DateTime, + pub expires_in: u64, +} + pub struct AuthService { encoding_key: EncodingKey, decoding_key: DecodingKey, @@ -33,6 +48,7 @@ pub struct AuthService { impl AuthService { pub const TOKEN_LIFETIME_SECONDS: u64 = 60 * 60; + pub const REFRESH_TOKEN_LIFETIME_SECONDS: u64 = 30 * 24 * 60 * 60; pub fn new(jwt_secret: &str) -> Self { let mut validation = Validation::new(Algorithm::HS256); @@ -86,16 +102,7 @@ impl AuthService { account_expiry: Option>, ) -> Result<(String, u64), ShaideError> { let issued_at = get_current_timestamp(); - let expires_at = account_expiry - .map(|expiry| { - u64::try_from(expiry.timestamp()).map_err(|_| { - ShaideError::internal_server_error("Invalid account expiry".to_owned()) - }) - }) - .transpose()? - .map_or(issued_at + Self::TOKEN_LIFETIME_SECONDS, |expiry| { - expiry.min(issued_at + Self::TOKEN_LIFETIME_SECONDS) - }); + let expires_at = capped_expiry(issued_at, Self::TOKEN_LIFETIME_SECONDS, account_expiry)?; let claims = UserClaims { sub: user_id.to_string(), iss: JWT_ISSUER.to_owned(), @@ -108,6 +115,36 @@ impl AuthService { Ok((token, expires_at.saturating_sub(issued_at))) } + /// Mints a refresh token that expires after [`Self::REFRESH_TOKEN_LIFETIME_SECONDS`], or at + /// `account_expiry` when that comes first. + pub fn issue_refresh_token( + &self, + account_expiry: Option>, + ) -> Result { + let mut bytes = [0u8; REFRESH_TOKEN_BYTES]; + SystemRandom::new().fill(&mut bytes).map_err(|_| { + ShaideError::internal_server_error("Could not generate a refresh token".to_owned()) + })?; + let token = URL_SAFE_NO_PAD.encode(bytes); + let issued_at = get_current_timestamp(); + let expires_at = capped_expiry( + issued_at, + Self::REFRESH_TOKEN_LIFETIME_SECONDS, + account_expiry, + )?; + Ok(IssuedRefreshToken { + hash: hash_refresh_token(&token), + token, + expires_at: i64::try_from(expires_at) + .ok() + .and_then(|seconds| DateTime::from_timestamp(seconds, 0)) + .ok_or_else(|| { + ShaideError::internal_server_error("Invalid refresh token expiry".to_owned()) + })?, + expires_in: expires_at.saturating_sub(issued_at), + }) + } + pub fn validate_access_token(&self, token: &str) -> Result { let claims = decode::(token, &self.decoding_key, &self.validation) .map_err(|_| ShaideError::unauthorized("Invalid or expired access token".to_owned()))? @@ -119,6 +156,26 @@ impl AuthService { } } +/// Digest under which a refresh token is stored and looked up. +pub fn hash_refresh_token(token: &str) -> String { + URL_SAFE_NO_PAD.encode(digest(&SHA256, token.as_bytes())) +} + +/// `issued_at + lifetime`, or the account expiry when that comes first, as a Unix timestamp. +fn capped_expiry( + issued_at: u64, + lifetime: u64, + account_expiry: Option>, +) -> Result { + let expires_at = issued_at + lifetime; + let Some(account_expiry) = account_expiry else { + return Ok(expires_at); + }; + let account_expiry = u64::try_from(account_expiry.timestamp()) + .map_err(|_| ShaideError::internal_server_error("Invalid account expiry".to_owned()))?; + Ok(expires_at.min(account_expiry)) +} + static AUTH_SERVICE: OnceLock = OnceLock::new(); pub fn get_auth_service() -> &'static AuthService {