diff --git a/crates/database/migrations/20230426153143_totp_login_requests.down.sql b/crates/database/migrations/20230426153143_totp_login_requests.down.sql new file mode 100644 index 0000000..9a64fe7 --- /dev/null +++ b/crates/database/migrations/20230426153143_totp_login_requests.down.sql @@ -0,0 +1 @@ +drop table if exists totp_login_requests; diff --git a/crates/database/migrations/20230426153143_totp_login_requests.up.sql b/crates/database/migrations/20230426153143_totp_login_requests.up.sql new file mode 100644 index 0000000..5862fd0 --- /dev/null +++ b/crates/database/migrations/20230426153143_totp_login_requests.up.sql @@ -0,0 +1,11 @@ +create table if not exists totp_login_requests +( + -- info + token TEXT not null primary key, + user TEXT not null references users (id), + + -- timings + created_at TEXT not null default CURRENT_TIMESTAMP, + expires_at TEXT not null, + used_at TEXT +); diff --git a/crates/database/queries/totp_login_requests/get_one.sql b/crates/database/queries/totp_login_requests/get_one.sql new file mode 100644 index 0000000..709ef26 --- /dev/null +++ b/crates/database/queries/totp_login_requests/get_one.sql @@ -0,0 +1,12 @@ +select + -- info + token, + user, + + -- timings + created_at as "created_at: DateTime", + expires_at as "expires_at: DateTime", + used_at as "used_at: DateTime" +from totp_login_requests + +where token is (?) \ No newline at end of file diff --git a/crates/database/queries/totp_login_requests/insert.sql b/crates/database/queries/totp_login_requests/insert.sql new file mode 100644 index 0000000..38af967 --- /dev/null +++ b/crates/database/queries/totp_login_requests/insert.sql @@ -0,0 +1,2 @@ +insert into totp_login_requests (token, user, expires_at) +values (?, ?, datetime(?, 'unixepoch')) diff --git a/crates/database/queries/totp_login_requests/use_token.sql b/crates/database/queries/totp_login_requests/use_token.sql new file mode 100644 index 0000000..95caca0 --- /dev/null +++ b/crates/database/queries/totp_login_requests/use_token.sql @@ -0,0 +1,5 @@ +update totp_login_requests + +set used_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 f9e28a9..b2ed816 100644 --- a/crates/database/sqlx-data.json +++ b/crates/database/sqlx-data.json @@ -506,6 +506,16 @@ }, "query": "insert or ignore into settings(id)\nvalues (0);" }, + "645f583812c4d71570d1e84b866e85c5a012d71cd745494049a8d9bebab3ed61": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 1 + } + }, + "query": "update totp_login_requests\n\nset used_at = CURRENT_TIMESTAMP\n\nwhere token is ?" + }, "64cf880633d3ee5c18f6e7c2a865470442f1ba4b1019806a580ec384329dc32e": { "describe": { "columns": [ @@ -922,6 +932,58 @@ }, "query": "update refresh_tokens\n\nset revoked_at = CURRENT_TIMESTAMP\n\nwhere revoked_at is null" }, + "a0dd1ddcc7d58fad78ed6c5027a9559428be9d0d59df6409ca9ec7cd728f6001": { + "describe": { + "columns": [ + { + "name": "token", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "expires_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "used_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + true + ], + "parameters": { + "Right": 1 + } + }, + "query": "select\n -- info\n token,\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\"\nfrom totp_login_requests\n\nwhere token is (?)" + }, + "a2bf546b2cf6a53a3a127c08fbb7f3dfcf0fe7a30364b1fb57c3e6b757093578": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 3 + } + }, + "query": "insert into totp_login_requests (token, user, expires_at)\nvalues (?, ?, datetime(?, 'unixepoch'))\n" + }, "a55b17a3a70e6445517f19536220f0dafc78a0e8b69221dee4715f84841839da": { "describe": { "columns": [], diff --git a/crates/database/src/tables.rs b/crates/database/src/tables.rs index d52ac8a..2f8c2e6 100644 --- a/crates/database/src/tables.rs +++ b/crates/database/src/tables.rs @@ -3,6 +3,7 @@ mod authorization_codes; mod keys; mod refresh_tokens; mod settings; +mod totp_login_requests; mod users; pub use apps::Apps; @@ -10,4 +11,5 @@ pub use authorization_codes::AuthorizationCodes; pub use keys::Keys; pub use refresh_tokens::RefreshTokens; pub use settings::Settings; +pub use totp_login_requests::TotpLoginRequests; pub use users::Users; diff --git a/crates/database/src/tables/totp_login_requests.rs b/crates/database/src/tables/totp_login_requests.rs new file mode 100644 index 0000000..f4d93e2 --- /dev/null +++ b/crates/database/src/tables/totp_login_requests.rs @@ -0,0 +1,60 @@ +use crate::error::{handle_error, Error}; +use sqlx::sqlite::SqliteQueryResult; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::{FromRow, SqliteExecutor}; + +#[derive(FromRow)] +pub struct TotpLoginRequests { + // Info + pub token: String, + pub user: String, + + // Timings + pub created_at: DateTime, + pub expires_at: DateTime, + pub used_at: Option>, +} + +impl TotpLoginRequests { + pub async fn insert( + conn: impl SqliteExecutor<'_>, + token: &str, + user: &str, + expires_at: i64, + ) -> Result, Error> { + let query: SqliteQueryResult = sqlx::query_file!( + "queries/totp_login_requests/insert.sql", + token, + user, + expires_at + ) + .execute(conn) + .await + .map_err(handle_error)?; + + 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/totp_login_requests/get_one.sql", token) + .fetch_optional(conn) + .await + .map_err(handle_error) + } + + pub async fn use_token( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + let query: SqliteQueryResult = + sqlx::query_file!("queries/totp_login_requests/use_token.sql", token) + .execute(conn) + .await + .map_err(handle_error)?; + + Ok((query.rows_affected() == 1).then_some(())) + } +} diff --git a/crates/users/src/lib.rs b/crates/users/src/lib.rs index 4c6c49a..1247cd2 100644 --- a/crates/users/src/lib.rs +++ b/crates/users/src/lib.rs @@ -1,6 +1,7 @@ mod database; mod error; pub mod password_reset; +pub mod totp_login_request; use chrono::{DateTime, Utc}; use id::UserID; diff --git a/crates/users/src/totp_login_request.rs b/crates/users/src/totp_login_request.rs new file mode 100644 index 0000000..35edb98 --- /dev/null +++ b/crates/users/src/totp_login_request.rs @@ -0,0 +1,73 @@ +use crate::Error; +use chrono::{DateTime, Duration, Utc}; +use database::sqlx::SqliteExecutor; +use database::TotpLoginRequests as DatabaseTotpLoginRequests; +use id::UserID; + +pub const TOTP_REQUEST_COOKIE_NAME: &str = "totp_request"; +pub const TOTP_REQUEST_LEN: usize = 25; + +pub struct TotpLoginRequest { + // Info + token: String, + user: UserID, + + // Timings + created_at: DateTime, + expires_at: DateTime, + used_at: Option>, +} + +impl TotpLoginRequest { + pub fn token(&self) -> &str { + &self.token + } + pub fn user(&self) -> &UserID { + &self.user + } + pub fn has_expired(&self) -> bool { + self.expires_at < Utc::now() + } + pub fn used_at(&self) -> Option> { + self.used_at + } +} + +impl From for TotpLoginRequest { + fn from(db: DatabaseTotpLoginRequests) -> Self { + Self { + token: db.token, + user: UserID(db.user), + created_at: db.created_at, + expires_at: db.expires_at, + used_at: db.used_at, + } + } +} + +impl TotpLoginRequest { + pub async fn insert( + conn: impl SqliteExecutor<'_>, + token: &str, + user: &UserID, + validity_minutes: i64, + ) -> Result, Error> { + let expires_at = Utc::now() + Duration::minutes(validity_minutes); + + Ok(DatabaseTotpLoginRequests::insert(conn, token, &user.0, expires_at.timestamp()).await?) + } + + pub async fn get_one( + conn: impl SqliteExecutor<'_>, + token: &str, + ) -> Result, Error> { + Ok(DatabaseTotpLoginRequests::get_one(conn, token) + .await? + .map(Self::from)) + } + + /// Consume and mark as used + pub async fn use_code(self, conn: impl SqliteExecutor<'_>) -> Result, Error> { + Ok(DatabaseTotpLoginRequests::use_token(conn, &self.token).await?) + } +}