cookie handling

This commit is contained in:
Hamcha 2023-07-01 15:02:40 +02:00
parent 50e85f7de9
commit b3380648bb
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
8 changed files with 119 additions and 39 deletions

41
Cargo.lock generated
View file

@ -238,11 +238,21 @@ dependencies = [
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde", "serde",
"time", "time 0.1.45",
"wasm-bindgen", "wasm-bindgen",
"winapi", "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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.4" version = "0.8.4"
@ -696,6 +706,7 @@ dependencies = [
"axum", "axum",
"axum-macros", "axum-macros",
"chrono", "chrono",
"cookie",
"figment", "figment",
"serde", "serde",
"serde_json", "serde_json",
@ -703,6 +714,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url",
"uuid", "uuid",
] ]
@ -1402,6 +1414,33 @@ dependencies = [
"winapi", "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]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.6.0" version = "1.6.0"

View file

@ -6,6 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
axum = "0.6" axum = "0.6"
axum-macros = "0.3" axum-macros = "0.3"
cookie = "0.17"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
tokio = { version = "1.28", features = ["full"] } tokio = { version = "1.28", features = ["full"] }
@ -17,6 +18,7 @@ figment = { version = "0.10", features = ["toml", "env"] }
chrono = { version = "0.4", features = ["serde", "clock"] } chrono = { version = "0.4", features = ["serde", "clock"] }
anyhow = "1.0" anyhow = "1.0"
argon2 = "0.5" argon2 = "0.5"
url = "2.4"
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3

View file

@ -3,10 +3,7 @@ use argon2::{
password_hash::{rand_core::OsRng, SaltString}, password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier, Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
}; };
use chrono::{ use chrono::{Duration, NaiveDateTime, Utc};
serde::{ts_seconds, ts_seconds_option},
DateTime, Duration, Utc,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Pool, Postgres}; use sqlx::{FromRow, Pool, Postgres};
use uuid::Uuid; use uuid::Uuid;
@ -35,12 +32,9 @@ pub struct User {
pub roles: Vec<Uuid>, pub roles: Vec<Uuid>,
/// Times /// Times
#[serde(with = "ts_seconds")] pub created_at: NaiveDateTime,
pub created_at: DateTime<Utc>, pub modified_at: Option<NaiveDateTime>,
#[serde(with = "ts_seconds_option")] pub deleted_at: Option<NaiveDateTime>,
pub modified_at: Option<DateTime<Utc>>,
#[serde(with = "ts_seconds_option")]
pub deleted_at: Option<DateTime<Utc>>,
} }
impl Default for User { impl Default for User {
@ -79,7 +73,7 @@ impl User {
id: result.id, id: result.id,
name: username.to_owned(), name: username.to_owned(),
roles: roles.to_owned(), roles: roles.to_owned(),
created_at: result.created_at.and_utc(), created_at: result.created_at,
..Default::default() ..Default::default()
}) })
} }
@ -113,23 +107,21 @@ pub struct Session {
pub secret: String, pub secret: String,
/// Times /// Times
#[serde(with = "ts_seconds")] pub created_at: NaiveDateTime,
pub created_at: DateTime<Utc>, pub expires_at: NaiveDateTime,
#[serde(with = "ts_seconds")]
pub expires_at: DateTime<Utc>,
} }
impl Session { impl Session {
pub async fn create(pool: &Pool<Postgres>, user_id: Uuid, duration: Duration) -> Result<Self> { pub async fn create(pool: &Pool<Postgres>, user_id: Uuid, duration: Duration) -> Result<Self> {
let now = Utc::now(); let now = Utc::now().naive_utc();
let expires = now + duration; let expires = now + duration;
let secret = random(); let secret = random();
let result = sqlx::query!( let result = sqlx::query!(
"INSERT INTO sessions (actor, secret, created_at, expires_at) VALUES ($1, $2, $3, $4) RETURNING id", "INSERT INTO sessions (actor, secret, created_at, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
user_id, user_id,
secret, secret,
now.naive_utc(), now,
expires.naive_utc() expires
) )
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -150,13 +142,13 @@ impl Session {
} }
pub async fn refresh(self: Self, pool: &Pool<Postgres>, duration: Duration) -> Result<Self> { pub async fn refresh(self: Self, pool: &Pool<Postgres>, duration: Duration) -> Result<Self> {
let expires_at = Utc::now() + duration; let expires_at = (Utc::now() + duration).naive_utc();
let secret = random(); let secret = random();
sqlx::query!( sqlx::query!(
"UPDATE sessions SET secret = $1, expires_at = $2 WHERE id = $3 RETURNING id", "UPDATE sessions SET secret = $1, expires_at = $2 WHERE id = $3 RETURNING id",
secret, secret,
expires_at.naive_utc(), expires_at,
self.id self.id
) )
.fetch_one(pool) .fetch_one(pool)

View file

@ -1,5 +1,3 @@
use std::error::Error;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},

View file

@ -41,6 +41,7 @@ async fn main() -> Result<()> {
let app = Router::new() let app = Router::new()
.route("/auth/login", post(routes::auth::login)) .route("/auth/login", post(routes::auth::login))
.route("/me", get(routes::auth::me))
.route("/pages/:site/:slug", get(routes::content::page)) .route("/pages/:site/:slug", get(routes::content::page))
.route("/admin/bootstrap", post(routes::admin::bootstrap)) .route("/admin/bootstrap", post(routes::admin::bootstrap))
.with_state(shared_state); .with_state(shared_state);

View file

@ -1,7 +1,8 @@
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json; use axum::Json;
use serde_json::{json, Value}; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::auth::{random, User}; use crate::auth::{random, User};
@ -9,16 +10,17 @@ use crate::error::AppError;
use crate::roles::ROLE_SUPERADMIN; use crate::roles::ROLE_SUPERADMIN;
use crate::state::AppState; use crate::state::AppState;
pub async fn bootstrap(State(state): State<Arc<AppState>>) -> Result<Json<Value>, AppError> { pub async fn bootstrap(State(state): State<Arc<AppState>>) -> impl IntoResponse {
// Only allow this request if the user table is completely empty! // Only allow this request if the user table is completely empty!
let empty = sqlx::query!( let empty = sqlx::query!(
"SELECT CASE WHEN EXISTS(SELECT 1 FROM users) THEN false ELSE true END AS empty;" "SELECT CASE WHEN EXISTS(SELECT 1 FROM users) THEN false ELSE true END AS empty;"
) )
.map(|row| row.empty.unwrap_or(true)) .map(|row| row.empty.unwrap_or(true))
.fetch_one(&state.database) .fetch_one(&state.database)
.await?; .await
.map_err(AppError::from)?;
if empty { if !empty {
return Err(AppError::ClientError { return Err(AppError::ClientError {
status: StatusCode::BAD_REQUEST, status: StatusCode::BAD_REQUEST,
code: "already-setup".into(), code: "already-setup".into(),
@ -35,7 +37,8 @@ pub async fn bootstrap(State(state): State<Arc<AppState>>) -> Result<Json<Value>
&password, &password,
&[ROLE_SUPERADMIN].to_vec(), &[ROLE_SUPERADMIN].to_vec(),
) )
.await?; .await
.map_err(AppError::from)?;
Ok(Json(json!({"username": username, "password": password}))) Ok(Json(json!({"username": username, "password": password})))
} }

View file

@ -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 chrono::Duration;
use cookie::Cookie;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
@ -19,8 +25,10 @@ pub struct LoginRequest {
pub async fn login( pub async fn login(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> Result<Json<Value>, AppError> { ) -> impl IntoResponse {
let user = User::find(&state.database, payload.username.as_str()).await?; let user = User::find(&state.database, payload.username.as_str())
.await
.map_err(AppError::from)?;
let invalid = || -> AppError { let invalid = || -> AppError {
AppError::ClientError { AppError::ClientError {
@ -33,7 +41,7 @@ pub async fn login(
let user = user.ok_or_else(invalid)?; let user = user.ok_or_else(invalid)?;
let plaintext = user.password.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()); return Err(invalid());
} }
@ -42,9 +50,28 @@ pub async fn login(
user.id, user.id,
Duration::seconds(state.config.session_duration.into()), Duration::seconds(state.config.session_duration.into()),
) )
.await?; .await
.map_err(AppError::from)?;
Ok(Json( let token = session.token();
json!({ "session_token": session.token(), "expires_at": session.expires_at }), 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()
} }

View file

@ -1,19 +1,37 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres}; use sqlx::{Pool, Postgres};
use url::Url;
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
pub struct Config { pub struct Config {
pub bind: String, pub bind: String,
pub database_url: String, pub database_url: String,
pub session_duration: i32, // in seconds 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 { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Config { Config {
bind: "127.0.0.1:3000".to_owned(), bind: "127.0.0.1:3000".into(),
database_url: "postgres://artificiale:changeme@localhost/artificiale".to_owned(), database_url: "postgres://artificiale:changeme@localhost/artificiale".into(),
session_duration: 1440, // 24min session_duration: 1440, // 24min
base_url: "http://localhost".into(),
} }
} }
} }