diff --git a/Cargo.lock b/Cargo.lock index 1ad0c89..2140954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,11 +238,21 @@ dependencies = [ "js-sys", "num-traits", "serde", - "time", + "time 0.1.45", "wasm-bindgen", "winapi", ] +[[package]] +name = "cookie" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24" +dependencies = [ + "time 0.3.22", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.4" @@ -696,6 +706,7 @@ dependencies = [ "axum", "axum-macros", "chrono", + "cookie", "figment", "serde", "serde_json", @@ -703,6 +714,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -1402,6 +1414,33 @@ dependencies = [ "winapi", ] +[[package]] +name = "time" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea9e1b3cf1243ae005d9e74085d4d542f3125458f3a81af210d901dcd7411efd" +dependencies = [ + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 8c523bd..a9a984d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] axum = "0.6" axum-macros = "0.3" +cookie = "0.17" tracing = "0.1" tracing-subscriber = "0.3" tokio = { version = "1.28", features = ["full"] } @@ -17,6 +18,7 @@ figment = { version = "0.10", features = ["toml", "env"] } chrono = { version = "0.4", features = ["serde", "clock"] } anyhow = "1.0" argon2 = "0.5" +url = "2.4" [profile.dev.package.sqlx-macros] opt-level = 3 \ No newline at end of file diff --git a/src/auth.rs b/src/auth.rs index b936291..a166f78 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -3,10 +3,7 @@ use argon2::{ password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHash, PasswordHasher, PasswordVerifier, }; -use chrono::{ - serde::{ts_seconds, ts_seconds_option}, - DateTime, Duration, Utc, -}; +use chrono::{Duration, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Pool, Postgres}; use uuid::Uuid; @@ -35,12 +32,9 @@ pub struct User { pub roles: Vec, /// Times - #[serde(with = "ts_seconds")] - pub created_at: DateTime, - #[serde(with = "ts_seconds_option")] - pub modified_at: Option>, - #[serde(with = "ts_seconds_option")] - pub deleted_at: Option>, + pub created_at: NaiveDateTime, + pub modified_at: Option, + pub deleted_at: Option, } impl Default for User { @@ -79,7 +73,7 @@ impl User { id: result.id, name: username.to_owned(), roles: roles.to_owned(), - created_at: result.created_at.and_utc(), + created_at: result.created_at, ..Default::default() }) } @@ -113,23 +107,21 @@ pub struct Session { pub secret: String, /// Times - #[serde(with = "ts_seconds")] - pub created_at: DateTime, - #[serde(with = "ts_seconds")] - pub expires_at: DateTime, + pub created_at: NaiveDateTime, + pub expires_at: NaiveDateTime, } impl Session { pub async fn create(pool: &Pool, user_id: Uuid, duration: Duration) -> Result { - let now = Utc::now(); + let now = Utc::now().naive_utc(); let expires = now + duration; let secret = random(); let result = sqlx::query!( "INSERT INTO sessions (actor, secret, created_at, expires_at) VALUES ($1, $2, $3, $4) RETURNING id", user_id, secret, - now.naive_utc(), - expires.naive_utc() + now, + expires ) .fetch_one(pool) .await?; @@ -150,13 +142,13 @@ impl Session { } pub async fn refresh(self: Self, pool: &Pool, duration: Duration) -> Result { - let expires_at = Utc::now() + duration; + let expires_at = (Utc::now() + duration).naive_utc(); let secret = random(); sqlx::query!( "UPDATE sessions SET secret = $1, expires_at = $2 WHERE id = $3 RETURNING id", secret, - expires_at.naive_utc(), + expires_at, self.id ) .fetch_one(pool) diff --git a/src/error.rs b/src/error.rs index 297d227..bf94491 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,3 @@ -use std::error::Error; - use axum::{ http::StatusCode, response::{IntoResponse, Response}, diff --git a/src/main.rs b/src/main.rs index 0fc7000..2c243f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ async fn main() -> Result<()> { let app = Router::new() .route("/auth/login", post(routes::auth::login)) + .route("/me", get(routes::auth::me)) .route("/pages/:site/:slug", get(routes::content::page)) .route("/admin/bootstrap", post(routes::admin::bootstrap)) .with_state(shared_state); diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 80e89ef..36217ea 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1,7 +1,8 @@ use axum::extract::State; use axum::http::StatusCode; +use axum::response::IntoResponse; use axum::Json; -use serde_json::{json, Value}; +use serde_json::json; use std::sync::Arc; use crate::auth::{random, User}; @@ -9,16 +10,17 @@ use crate::error::AppError; use crate::roles::ROLE_SUPERADMIN; use crate::state::AppState; -pub async fn bootstrap(State(state): State>) -> Result, AppError> { +pub async fn bootstrap(State(state): State>) -> impl IntoResponse { // Only allow this request if the user table is completely empty! let empty = sqlx::query!( "SELECT CASE WHEN EXISTS(SELECT 1 FROM users) THEN false ELSE true END AS empty;" ) .map(|row| row.empty.unwrap_or(true)) .fetch_one(&state.database) - .await?; + .await + .map_err(AppError::from)?; - if empty { + if !empty { return Err(AppError::ClientError { status: StatusCode::BAD_REQUEST, code: "already-setup".into(), @@ -35,7 +37,8 @@ pub async fn bootstrap(State(state): State>) -> Result &password, &[ROLE_SUPERADMIN].to_vec(), ) - .await?; + .await + .map_err(AppError::from)?; Ok(Json(json!({"username": username, "password": password}))) } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 4e8171c..ee5a860 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -1,7 +1,13 @@ -use axum::{extract::State, http::StatusCode, Json}; +use axum::{ + extract::State, + http::{header::SET_COOKIE, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; use chrono::Duration; +use cookie::Cookie; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::json; use std::sync::Arc; use crate::{ @@ -19,8 +25,10 @@ pub struct LoginRequest { pub async fn login( State(state): State>, Json(payload): Json, -) -> Result, AppError> { - let user = User::find(&state.database, payload.username.as_str()).await?; +) -> impl IntoResponse { + let user = User::find(&state.database, payload.username.as_str()) + .await + .map_err(AppError::from)?; let invalid = || -> AppError { AppError::ClientError { @@ -33,7 +41,7 @@ pub async fn login( let user = user.ok_or_else(invalid)?; let plaintext = user.password.ok_or_else(invalid)?; - if !verify(&payload.password, &plaintext)? { + if !verify(&payload.password, &plaintext).map_err(AppError::from)? { return Err(invalid()); } @@ -42,9 +50,28 @@ pub async fn login( user.id, Duration::seconds(state.config.session_duration.into()), ) - .await?; + .await + .map_err(AppError::from)?; - Ok(Json( - json!({ "session_token": session.token(), "expires_at": session.expires_at }), - )) + let token = session.token(); + let mut response: Response = + Json(json!({ "session_token": token, "expires_at": session.expires_at })).into_response(); + + let secure = state.config.secure(); + let cookie = Cookie::build("session", token) + .domain(state.config.domain()) + .secure(secure) + .http_only(!secure) + .path("/") + .finish(); + + response + .headers_mut() + .insert(SET_COOKIE, cookie.to_string().parse()?); + + Ok(response) +} + +pub async fn me() -> String { + "test".into() } diff --git a/src/state.rs b/src/state.rs index ef5fa16..891ec07 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,19 +1,37 @@ use serde::{Deserialize, Serialize}; use sqlx::{Pool, Postgres}; +use url::Url; #[derive(Deserialize, Serialize, Clone)] pub struct Config { pub bind: String, pub database_url: String, pub session_duration: i32, // in seconds + pub base_url: String, +} + +impl Config { + pub fn secure(&self) -> bool { + Url::parse(&self.base_url) + .map(|x| x.scheme().to_owned()) + .unwrap_or("http".to_owned()) + == "https" + } + + pub fn domain(&self) -> String { + Url::parse(&self.base_url) + .map(|x| x.domain().unwrap_or("localhost").to_owned()) + .unwrap_or("localhost".to_owned()) + } } impl Default for Config { fn default() -> Self { Config { - bind: "127.0.0.1:3000".to_owned(), - database_url: "postgres://artificiale:changeme@localhost/artificiale".to_owned(), + bind: "127.0.0.1:3000".into(), + database_url: "postgres://artificiale:changeme@localhost/artificiale".into(), session_duration: 1440, // 24min + base_url: "http://localhost".into(), } } }