user update
This commit is contained in:
parent
d7a55f6579
commit
e5fe40e4be
8 changed files with 205 additions and 77 deletions
19
README.md
19
README.md
|
@ -9,24 +9,7 @@ Based is a micro framework providing web dev primitives.
|
||||||
- Templates (Shell)
|
- Templates (Shell)
|
||||||
|
|
||||||
## User Auth
|
## User Auth
|
||||||
To use the user auth feature, make sure a migration has added the following to your PostgresDB:
|
To use the user auth feature, make sure a migration has added [these tables](src/auth/auth.sql) to your PostgresDB:
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TYPE user_role AS ENUM ('regular', 'admin');
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
username VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
||||||
"password" text NOT NULL,
|
|
||||||
user_role user_role NOT NULL DEFAULT 'regular'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_session (
|
|
||||||
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
token text NOT NULL,
|
|
||||||
"user" varchar(255) NOT NULL,
|
|
||||||
FOREIGN KEY("user") REFERENCES users(username)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## HTMX
|
## HTMX
|
||||||
Based has a route for serving HTMX at `based::htmx::htmx_script_route` which you can include in your rocket `routes!`. The HTMX script will be available at `/assets/htmx.min.js`.
|
Based has a route for serving HTMX at `based::htmx::htmx_script_route` which you can include in your rocket `routes!`. The HTMX script will be available at `/assets/htmx.min.js`.
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
CREATE TYPE user_role AS ENUM ('regular', 'admin');
|
CREATE TYPE user_role AS ENUM ('regular', 'admin');
|
||||||
|
CREATE TYPE session_kind AS ENUM ('api', 'user');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
username VARCHAR(255) NOT NULL PRIMARY KEY,
|
username VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
@ -11,5 +12,13 @@ CREATE TABLE IF NOT EXISTS user_session (
|
||||||
token text NOT NULL,
|
token text NOT NULL,
|
||||||
"user" varchar(255) NOT NULL,
|
"user" varchar(255) NOT NULL,
|
||||||
"created" timestamptz NOT NULL DEFAULT NOW(),
|
"created" timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
"csrf" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"name" varchar(255)
|
||||||
|
kind session_kind NOT NULL DEFAULT 'user',
|
||||||
FOREIGN KEY("user") REFERENCES users(username)
|
FOREIGN KEY("user") REFERENCES users(username)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profile_pic (
|
||||||
|
username VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
"image" bytea NOT NULL
|
||||||
|
);
|
36
src/auth/csrf.rs
Normal file
36
src/auth/csrf.rs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
use super::User;
|
||||||
|
use crate::get_pg;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
pub trait CSRF {
|
||||||
|
fn get_csrf(&self) -> impl std::future::Future<Output = uuid::Uuid>;
|
||||||
|
fn verify_csrf(&self, csrf: &str) -> impl std::future::Future<Output = bool>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CSRF for User {
|
||||||
|
/// Get CSRF Token for the current session
|
||||||
|
async fn get_csrf(&self) -> uuid::Uuid {
|
||||||
|
let res: (uuid::Uuid,) = sqlx::query_as("SELECT csrf FROM user_session WHERE token = $1")
|
||||||
|
.bind(&self.session)
|
||||||
|
.fetch_one(get_pg!())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
res.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify CSRF and generate a new one
|
||||||
|
async fn verify_csrf(&self, csrf: &str) -> bool {
|
||||||
|
if self.get_csrf().await == uuid::Uuid::from_str(csrf).unwrap() {
|
||||||
|
sqlx::query("UPDATE user_session SET csrf = gen_random_uuid() WHERE token = $1")
|
||||||
|
.bind(&self.session)
|
||||||
|
.execute(get_pg!())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
use chrono::Utc;
|
|
||||||
use data_encoding::HEXUPPER;
|
use data_encoding::HEXUPPER;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::FromRow;
|
|
||||||
|
|
||||||
|
pub mod csrf;
|
||||||
|
pub mod profile_pic;
|
||||||
|
mod session;
|
||||||
mod user;
|
mod user;
|
||||||
|
pub use session::Session;
|
||||||
|
pub use session::Sessions;
|
||||||
pub use user::AdminUser;
|
pub use user::AdminUser;
|
||||||
pub use user::MaybeUser;
|
pub use user::MaybeUser;
|
||||||
pub use user::User;
|
pub use user::User;
|
||||||
|
@ -18,18 +20,6 @@ fn gen_token(token_length: usize) -> String {
|
||||||
HEXUPPER.encode(&token_bytes)
|
HEXUPPER.encode(&token_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
|
||||||
pub struct Session {
|
|
||||||
/// The unique ID of the session token
|
|
||||||
pub id: uuid::Uuid,
|
|
||||||
/// The generated session token
|
|
||||||
pub token: String,
|
|
||||||
/// The username associated with the session token
|
|
||||||
pub user: String,
|
|
||||||
/// Session creation time
|
|
||||||
pub created: chrono::DateTime<Utc>
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A macro to check if a user has admin privileges.
|
/// A macro to check if a user has admin privileges.
|
||||||
///
|
///
|
||||||
/// This macro checks whether the provided user has admin privileges by calling the `is_admin` method on it.
|
/// This macro checks whether the provided user has admin privileges by calling the `is_admin` method on it.
|
||||||
|
|
31
src/auth/profile_pic.rs
Normal file
31
src/auth/profile_pic.rs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
use crate::get_pg;
|
||||||
|
|
||||||
|
use super::User;
|
||||||
|
pub trait ProfilePic {
|
||||||
|
fn profile_pic(&self) -> impl std::future::Future<Output = Option<Vec<u8>>>;
|
||||||
|
fn set_profile_pic(&self, image: Vec<u8>) -> impl std::future::Future<Output = ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfilePic for User {
|
||||||
|
/// Get a user's profile picture from the database
|
||||||
|
async fn profile_pic(&self) -> Option<Vec<u8>> {
|
||||||
|
let res: Option<(Vec<u8>,)> =
|
||||||
|
sqlx::query_as("SELECT image FROM user_profile_pic WHERE username = $1;")
|
||||||
|
.bind(&self.username)
|
||||||
|
.fetch_optional(get_pg!())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
res.map(|x| x.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a user's profile picture in the database
|
||||||
|
async fn set_profile_pic(&self, image: Vec<u8>) {
|
||||||
|
sqlx::query("INSERT INTO user_profile_pic (username, image) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET image = EXCLUDED.image",
|
||||||
|
)
|
||||||
|
.bind(&self.username)
|
||||||
|
.bind(&image)
|
||||||
|
.execute(get_pg!())
|
||||||
|
.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
116
src/auth/session.rs
Normal file
116
src/auth/session.rs
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
use crate::get_pg;
|
||||||
|
|
||||||
|
use super::{User, UserRole, gen_token};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Session {
|
||||||
|
/// The unique ID of the session token
|
||||||
|
pub id: uuid::Uuid,
|
||||||
|
/// The generated session token
|
||||||
|
pub token: String,
|
||||||
|
/// The username associated with the session token
|
||||||
|
pub user: String,
|
||||||
|
/// Session creation time
|
||||||
|
pub created: chrono::DateTime<Utc>,
|
||||||
|
/// Internal CSRF value
|
||||||
|
csrf: uuid::Uuid,
|
||||||
|
/// Named session value
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Kind of session
|
||||||
|
pub kind: SessionKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
#[sqlx(type_name = "session_kind", rename_all = "lowercase")]
|
||||||
|
pub enum SessionKind {
|
||||||
|
API,
|
||||||
|
USER,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Sessions {
|
||||||
|
fn from_session(session: &str) -> impl std::future::Future<Output = Option<User>>;
|
||||||
|
fn login(
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> impl std::future::Future<Output = Option<(Session, UserRole)>>;
|
||||||
|
fn api_key(&self, name: &str) -> impl std::future::Future<Output = Session>;
|
||||||
|
fn session(&self) -> impl std::future::Future<Output = Session>;
|
||||||
|
fn list_sessions(&self) -> impl std::future::Future<Output = Vec<Session>>;
|
||||||
|
fn end_session(&self) -> impl std::future::Future<Output = ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sessions for User {
|
||||||
|
/// Generate a new API Key session
|
||||||
|
async fn api_key(&self, name: &str) -> Session {
|
||||||
|
sqlx::query_as(
|
||||||
|
"INSERT INTO user_session (token, \"user\", kind, name) VALUES ($1, $2, $3) RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(gen_token(64))
|
||||||
|
.bind(&self.username)
|
||||||
|
.bind(SessionKind::API)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_one(get_pg!())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a user session
|
||||||
|
async fn end_session(&self) -> () {
|
||||||
|
sqlx::query("DELETE FROM user_session WHERE token = $1")
|
||||||
|
.bind(&self.session)
|
||||||
|
.execute(get_pg!())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all sessions for a user
|
||||||
|
async fn list_sessions(&self) -> Vec<Session> {
|
||||||
|
sqlx::query_as("SELECT * FROM user_session WHERE user = $1")
|
||||||
|
.bind(&self.username)
|
||||||
|
.fetch_all(get_pg!())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a user from session ID
|
||||||
|
async fn from_session(session: &str) -> Option<User> {
|
||||||
|
let user: Option<Self> = sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(session).fetch_optional(get_pg!()).await.unwrap();
|
||||||
|
|
||||||
|
if let Some(mut user) = user {
|
||||||
|
user.session = session.to_string();
|
||||||
|
return Some(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login a user with the given username and password
|
||||||
|
async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> {
|
||||||
|
let u = Self::find(username).await?;
|
||||||
|
|
||||||
|
if !u.verify_pw(password) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((u.session().await, u.user_role))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new session token for the user
|
||||||
|
///
|
||||||
|
/// Returns a Session instance containing the generated token and associated user
|
||||||
|
async fn session(&self) -> Session {
|
||||||
|
sqlx::query_as(
|
||||||
|
"INSERT INTO user_session (token, \"user\", kind) VALUES ($1, $2, $3) RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(gen_token(64))
|
||||||
|
.bind(&self.username)
|
||||||
|
.bind(SessionKind::USER)
|
||||||
|
.fetch_one(get_pg!())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,9 +3,11 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
|
||||||
use super::{Session, gen_token};
|
use super::Sessions;
|
||||||
use crate::{get_pg, request::api::ToAPI};
|
use crate::{get_pg, request::api::ToAPI};
|
||||||
|
|
||||||
|
// TODO : 2FA
|
||||||
|
|
||||||
/// User
|
/// User
|
||||||
///
|
///
|
||||||
/// # Example:
|
/// # Example:
|
||||||
|
@ -27,7 +29,7 @@ pub struct User {
|
||||||
/// The role of the user
|
/// The role of the user
|
||||||
pub user_role: UserRole,
|
pub user_role: UserRole,
|
||||||
#[sqlx(default)]
|
#[sqlx(default)]
|
||||||
pub session: String
|
pub session: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
||||||
|
@ -40,18 +42,6 @@ pub enum UserRole {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
// Get a user from session ID
|
|
||||||
pub async fn from_session(session: &str) -> Option<Self> {
|
|
||||||
let user: Option<Self> = sqlx::query_as("SELECT * FROM users WHERE username = (SELECT \"user\" FROM user_session WHERE token = $1)").bind(session).fetch_optional(get_pg!()).await.unwrap();
|
|
||||||
|
|
||||||
if let Some(mut user) = user {
|
|
||||||
user.session = session.to_string();
|
|
||||||
return Some(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find a user by their username
|
/// Find a user by their username
|
||||||
pub async fn find(username: &str) -> Option<Self> {
|
pub async fn find(username: &str) -> Option<Self> {
|
||||||
sqlx::query_as("SELECT * FROM users WHERE username = $1")
|
sqlx::query_as("SELECT * FROM users WHERE username = $1")
|
||||||
|
@ -74,6 +64,7 @@ impl User {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(),
|
password: bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap(),
|
||||||
user_role: role,
|
user_role: role,
|
||||||
|
session: String::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sqlx::query("INSERT INTO users (username, \"password\", user_role) VALUES ($1, $2, $3)")
|
sqlx::query("INSERT INTO users (username, \"password\", user_role) VALUES ($1, $2, $3)")
|
||||||
|
@ -87,17 +78,6 @@ impl User {
|
||||||
Some(u)
|
Some(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Login a user with the given username and password
|
|
||||||
pub async fn login(username: &str, password: &str) -> Option<(Session, UserRole)> {
|
|
||||||
let u = Self::find(username).await?;
|
|
||||||
|
|
||||||
if !u.verify_pw(password) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((u.session().await, u.user_role))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Change the password of a User
|
/// Change the password of a User
|
||||||
///
|
///
|
||||||
/// Returns a Result indicating whether the password change was successful or not
|
/// Returns a Result indicating whether the password change was successful or not
|
||||||
|
@ -124,20 +104,6 @@ impl User {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new session token for the user
|
|
||||||
///
|
|
||||||
/// Returns a Session instance containing the generated token and associated user
|
|
||||||
pub async fn session(&self) -> Session {
|
|
||||||
sqlx::query_as(
|
|
||||||
"INSERT INTO user_session (token, \"user\") VALUES ($1, $2) RETURNING id, token, \"user\"",
|
|
||||||
)
|
|
||||||
.bind(gen_token(64))
|
|
||||||
.bind(&self.username)
|
|
||||||
.fetch_one(get_pg!())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the user is an admin
|
/// Check if the user is an admin
|
||||||
pub const fn is_admin(&self) -> bool {
|
pub const fn is_admin(&self) -> bool {
|
||||||
matches!(self.user_role, UserRole::Admin)
|
matches!(self.user_role, UserRole::Admin)
|
||||||
|
@ -160,6 +126,7 @@ impl ToAPI for User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// extracts a user from a request with `session` cookie
|
||||||
async fn extract_user<'r>(request: &'r Request<'_>) -> Option<User> {
|
async fn extract_user<'r>(request: &'r Request<'_>) -> Option<User> {
|
||||||
if let Some(session_id) = request.cookies().get("session") {
|
if let Some(session_id) = request.cookies().get("session") {
|
||||||
if let Some(user) = User::from_session(session_id.value()).await {
|
if let Some(user) = User::from_session(session_id.value()).await {
|
||||||
|
|
|
@ -2,17 +2,13 @@ use tokio::sync::OnceCell;
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
|
pub mod htmx;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod result;
|
pub mod result;
|
||||||
pub mod htmx;
|
|
||||||
|
|
||||||
// TODO : Cache Headers
|
|
||||||
// TODO : Refactor Responders
|
|
||||||
// TODO : Streaming Responses + Ranges
|
|
||||||
// TODO : API Pagination?
|
// TODO : API Pagination?
|
||||||
// TODO : CORS?
|
// TODO : CORS?
|
||||||
// TODO : CSRF?
|
|
||||||
|
|
||||||
// Postgres
|
// Postgres
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue