feat: got sessions working
This commit is contained in:
+145
-73
@@ -1,3 +1,11 @@
|
||||
use leptos::{prelude::expect_context, server_fn::error::NoCustomError};
|
||||
use leptos_axum::{extract, ResponseOptions};
|
||||
use leptos::prelude::ServerFnError;
|
||||
use pbkdf2::{Pbkdf2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, rand_core::{OsRng, RngCore}, SaltString}};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct User {
|
||||
pub user_id: i64,
|
||||
@@ -6,13 +14,6 @@ pub struct User {
|
||||
pub last_active: Option<i64>
|
||||
}
|
||||
|
||||
use async_trait::async_trait;
|
||||
use pbkdf2::{Pbkdf2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, rand_core::OsRng, SaltString}};
|
||||
use axum_login::{AuthUser, AuthnBackend, UserId};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
impl std::fmt::Debug for User {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("User")
|
||||
@@ -23,27 +24,16 @@ impl std::fmt::Debug for User {
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUser for User {
|
||||
type Id = i64;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.user_id
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
self.password_hash.as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
async fn hash_password(pass: &[u8]) -> Result<String, Error> {
|
||||
Ok(tokio::task::spawn_blocking({
|
||||
let pass = pass.to_owned();
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
move || Pbkdf2.hash_password(
|
||||
&*pass,
|
||||
&salt,
|
||||
).map(|hash| hash.to_string())
|
||||
move ||
|
||||
Pbkdf2.hash_password(
|
||||
&*pass,
|
||||
&salt,
|
||||
).map(|hash| hash.serialize().as_str().to_string())
|
||||
}).await??)
|
||||
}
|
||||
|
||||
@@ -114,67 +104,149 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Backend(SqlitePool);
|
||||
const SESSION_ID_KEY: &'static str = "session_id";
|
||||
const SESSION_AGE: i64 = 30 * 60;
|
||||
|
||||
impl Backend {
|
||||
pub fn new(db: SqlitePool) -> Self {
|
||||
Self(db)
|
||||
}
|
||||
}
|
||||
pub async fn create_auth_session(username: String, password: String) -> Result<(), ServerFnError> {
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use axum::http::{header, HeaderValue};
|
||||
|
||||
#[async_trait]
|
||||
impl AuthnBackend for Backend {
|
||||
type User = User;
|
||||
type Credentials = (String, String);
|
||||
type Error = Error;
|
||||
let db = expect_context::<SqlitePool>();
|
||||
let resp = expect_context::<ResponseOptions>();
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
creds: Self::Credentials
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user: Option<Self::User> = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT * FROM users WHERE user_name = ?",
|
||||
creds.0
|
||||
let user: Option<User> = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT * FROM users WHERE user_name = ?",
|
||||
username
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let Some(user) = user else {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string()));
|
||||
};
|
||||
|
||||
let good_hash = verify_password(
|
||||
&password,
|
||||
&user.password_hash
|
||||
).await?;
|
||||
|
||||
if good_hash {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let expires = now + SESSION_AGE;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE users SET last_active = ?",
|
||||
now
|
||||
)
|
||||
.fetch_optional(&self.0)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let Some(user) = user else { return Ok(None); };
|
||||
let session_id: String = tokio::task::spawn_blocking(|| {
|
||||
let mut key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
hex::encode(&key[..])
|
||||
}).await?;
|
||||
|
||||
let good_hash = verify_password(
|
||||
&user.password_hash,
|
||||
&creds.1
|
||||
).await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO sessions (session_id, user_id, expires) VALUES (?, ?, ?)",
|
||||
session_id,
|
||||
user.user_id,
|
||||
expires
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
if good_hash {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let cookie = Cookie::build((SESSION_ID_KEY, &session_id))
|
||||
.http_only(true)
|
||||
.path("/")
|
||||
.same_site(SameSite::Lax);
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE users SET last_active = ?",
|
||||
now
|
||||
)
|
||||
.execute(&self.0)
|
||||
.await?;
|
||||
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
|
||||
resp.insert_header(header::SET_COOKIE, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user: Option<Self::User> = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT * FROM users WHERE user_id = ?",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&self.0)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError("Invalid credentials".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthSession = axum_login::AuthSession<Backend>;
|
||||
pub async fn destroy_auth_session() -> Result<(), ServerFnError> {
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
let db = expect_context::<SqlitePool>();
|
||||
let jar = extract::<CookieJar>().await?;
|
||||
|
||||
let Some(cookie) = jar.get(SESSION_ID_KEY) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM sessions WHERE session_id = ?",
|
||||
session_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_auth_session() -> Result<Option<User>, ServerFnError> {
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
let db = expect_context::<SqlitePool>();
|
||||
let jar = extract::<CookieJar>().await?;
|
||||
|
||||
let Some(cookie) = jar.get(SESSION_ID_KEY) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let session_id = cookie.value();
|
||||
|
||||
let user = sqlx::query_as!(
|
||||
User,
|
||||
"SELECT users.user_id, user_name, password_hash, last_active \
|
||||
FROM users \
|
||||
INNER JOIN sessions \
|
||||
WHERE session_id = ? \
|
||||
AND expires > ?",
|
||||
session_id,
|
||||
now
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
if let Some(u) = &user {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let expires = now + SESSION_AGE;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE users SET last_active = ? WHERE user_id = ?",
|
||||
now,
|
||||
u.user_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE sessions SET expires = ? WHERE session_id = ?",
|
||||
expires,
|
||||
session_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM sessions WHERE expires < ?",
|
||||
now
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user