Z2GITYCX4GLFIH2NUNPMWNPJXKMNRLSL4FAN7YTDIFRSH6DJU2JAC
use std::collections::HashSet;
use axum::async_trait;
use axum_login::{AuthUser, AuthnBackend, AuthzBackend, UserId};
use password_auth::{generate_hash, verify_password, VerifyError};
use secrecy::{CloneableSecret, DebugSecret, ExposeSecret, Secret, SerializableSecret, Zeroize};
use serde::{Deserialize, Serialize};
use surrealdb::{engine::remote::ws::Client, sql::Thing, Surreal};
// A wrapper around a string meant for password hashes
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Password(String);
impl Password {
pub fn new<S: AsRef<str>>(password: S) -> Self {
Self(generate_hash(password.as_ref()))
}
pub fn verify<S: AsRef<str>>(&self, attempt: S) -> bool {
match verify_password(attempt.as_ref(), &self.0) {
Ok(()) => true,
Err(VerifyError::PasswordInvalid) => false,
Err(VerifyError::Parse(_)) => false,
}
}
}
impl Zeroize for Password {
fn zeroize(&mut self) {
self.0.zeroize();
}
}
impl SerializableSecret for Password {}
impl CloneableSecret for Password {}
impl DebugSecret for Password {}
pub type SecretPassword = Secret<Password>;
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct User {
id: Thing,
email: String,
pub username: String,
password: SecretPassword,
}
impl User {
pub fn code(&self) -> u64 {
// Assume if we aren't parseable as a ULID, that our "code" is 3033132
ulid::Ulid::from_string(&self.id.to_string())
.map(|ulid| ulid.timestamp_ms() % 999999)
.unwrap_or(3033132)
}
}
impl AuthUser for User {
type Id = Thing;
fn id(&self) -> Self::Id {
self.id.clone()
}
fn session_auth_hash(&self) -> &[u8] {
self.password.expose_secret().0.as_bytes()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
/// Where do we want to redirect to?
pub next: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Backend {
db: Surreal<Client>,
}
impl Backend {
pub fn new(db: Surreal<Client>) -> Self {
Self { db }
}
}
#[async_trait]
impl AuthnBackend for Backend {
type User = User;
type Credentials = Credentials;
type Error = surrealdb::Error;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user: Option<User> = self
.db
.query("SELECT email, username, password, id, code FROM user WHERE email = <string> $email")
.bind(("email", creds.email))
.await?
.take(0)?;
Ok(user.filter(|user| user.password.expose_secret().verify(creds.password)))
}
async fn get_user(&self, user_id: &UserId<Backend>) -> Result<Option<Self::User>, Self::Error> {
let user: Option<User> = self
.db
.query("SELECT username, password, email, code, id FROM user WHERE id = $id")
.bind(("id", user_id))
.await?
.take(0)?;
Ok(user)
}
}
#[derive(Hash, PartialEq, Eq, Debug, Clone, Serialize, Deserialize)]
pub struct Permission {
id: String,
aspect: String,
}
#[async_trait]
impl AuthzBackend for Backend {
type Permission = Permission;
async fn get_user_permissions(
&self,
user: &Self::User,
) -> Result<HashSet<Permission>, Self::Error> {
let user_permissions: Vec<Permission> = self.db.query(r#"SELECT id, aspect FROM permission WHERE ->granted->user == type::thing("user", $id)"#).bind(("id", user.id.clone())).await?.take(0)?;
Ok(user_permissions.into_iter().collect())
}
async fn get_group_permissions(
&self,
_user: &Self::User,
) -> Result<HashSet<Permission>, Self::Error> {
Ok(HashSet::new())
}
}
pub type AuthSession = axum_login::AuthSession<Backend>;
let session_config = SessionConfig::default().with_table_name("sessions");
let auth_config = AuthConfig::<i64>::default().with_anonymous_user_id(Some(1));
let session_store =
SessionStore::<SessionPool>::new(Some(db.clone().into()), session_config).await?;
// Session stuff
let session_store = SurrealSessionStore::new(db.clone(), "sessions".to_string());
let expired_session_cleanup_interval: u64 = 1;
tokio::task::spawn(session_store.clone().continuously_delete_expired(
tokio::time::Duration::from_secs(60 * expired_session_cleanup_interval),
));
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
.with_expiry(Expiry::OnInactivity(Duration::hours(1)));
// Auth stuff
let backend = Backend::new(db.clone());
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
#[async_trait]
impl Authentication<User, i64, Surreal<Client>> for User {
async fn load_user(userid: i64, _pool: Option<&Surreal<Client>>) -> anyhow::Result<User> {
todo!()
}
// Used to determine if logged in or not, internally
fn is_authenticated(&self) -> bool {
!self.anonymous
}
fn is_active(&self) -> bool {
!self.anonymous
}
fn is_anonymous(&self) -> bool {
self.anonymous
}
}
}
AppError::DbError(dbe) => {
tracing::error!(err_source = "surrealdb", "Database error: {dbe}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"db-error",
"a database error occured".to_string(),
)
}
AppError::InvalidCredentials => (
StatusCode::UNAUTHORIZED,
"invalid-credentials",
"Invalid credentials given.".to_string(),
),
AppError::Arbitrary(code, message) => {
(StatusCode::INTERNAL_SERVER_ERROR, code, message)
impl From<surrealdb::Error> for AppError {
fn from(value: surrealdb::Error) -> Self {
Self::DbError(value)
}
}
use axum::{
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use secrecy::Secret;
use serde::{Deserialize, Serialize};
use surrealdb::{engine::remote::ws::Client, Surreal};
use crate::{
error::AppError,
user::{AuthSession, Credentials, Password, SecretPassword, User},
AppJson, AppState,
};
// This allows us to extract the "next" field from the query string. We use this
// to redirect after log in.
#[derive(Debug, Deserialize)]
struct NextUrl {
next: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum UserStatus {
Anonymous,
LoggedIn { username: String, code: u64 },
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login).get(status))
.route("/logout", get(logout))
.route("/signup", post(new_user))
}
#[derive(Debug, Deserialize, Serialize)]
struct Signup {
email: String,
username: String,
password: SecretPassword,
}
#[tracing::instrument(skip(db, auth_session))]
async fn new_user(
mut auth_session: AuthSession,
State(db): State<Surreal<Client>>,
AppJson(signup): AppJson<Signup>,
) -> crate::error::Result<impl IntoResponse> {
let users: Vec<User> = db.create("user").content(signup).await?;
dbg!(&users);
assert!(users.len() == 1);
let Some(user) = users.into_iter().next() else {
return Err(AppError::Arbitrary(
"could-not-create-user",
"could not create user in db".to_string(),
));
};
if let Err(e) = auth_session.login(&user).await {
tracing::error!("Failed to log in {}: {}", user.username, e);
return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response());
}
Ok(StatusCode::NO_CONTENT.into_response())
}
#[tracing::instrument(skip(auth_session))]
async fn login(
mut auth_session: AuthSession,
Query(NextUrl { next }): Query<NextUrl>,
Form(creds): Form<Credentials>,
) -> impl IntoResponse {
let user = match auth_session.authenticate(creds.clone()).await {
Ok(Some(user)) => user,
Ok(None) => return AppError::InvalidCredentials.into_response(),
Err(e) => {
tracing::error!("{e}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if let Err(e) = auth_session.login(&user).await {
tracing::error!("Failed to log in {}: {}", user.username, e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
if let Some(ref next) = creds.next.or(next) {
Redirect::to(next).into_response()
} else {
Redirect::to("/").into_response()
}
}
async fn status(auth_session: AuthSession) -> AppJson<UserStatus> {
match auth_session.user {
Some(user) => AppJson(UserStatus::LoggedIn { code: user.code(), username: user.username }),
None => AppJson(UserStatus::Anonymous),
}
}
#[tracing::instrument(skip(auth_session))]
async fn logout(mut auth_session: AuthSession) -> impl IntoResponse {
match auth_session.logout().await {
Ok(_) => Redirect::to("/").into_response(),
Err(e) => {
tracing::error!("Failed to log out user: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
shell:
surreal sql -uroot -proot --database noteboek --namespace noteboek
#V2
DEFINE TABLE user SCHEMAFULL;
DEFINE TABLE permission SCHEMAFULL;
DEFINE TABLE granted SCHEMAFULL;
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE TABLE permission SCHEMAFULL;
DEFINE TABLE granted SCHEMAFULL;
/**/q
q
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD username ON TABLE user TYPE string;
DEFINE FIELD code ON TABLE user TYPE int DEFAULT rand::int(0, 9999999);
DEFINE FIELD password ON TABLE user TYPE string;
DEFINE INDEX unique_id ON TABLE user COLUMNS username, code UNIQUE;
DEFINE TABLE permission SCHEMAFULL;
DEFINE FIELD id ON TABLE permission TYPE string;
DEFINE FIELD aspect ON TABLE permission TYPE string;
DEFINE TABLE granted SCHEMAFULL;
DEFINE FIELD in ON TABLE granted TYPE record<user>;
DEFINE FIELD out ON TABLE granted TYPE record<permission>;
DEFINE FIELD granted ON TABLE granted TYPE datetime DEFAULT time::now();
DEFINE INDEX unique_permissions ON TABLE granted COLUMNS in, out UNIQUE;
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD username ON TABLE user TYPE string;
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD username ON TABLE user TYPE string;
DEFINE FIELD code ON TABLE user TYPE int DEFAULT rand::ulid();
DEFINE FIELD password ON TABLE user TYPE string;
DEFINE INDEX unique_id ON TABLE user COLUMNS username, code UNIQUE;
DEFINE TABLE permission SCHEMAFULL;
DEFINE FIELD id ON TABLE permission TYPE string;
DEFINE FIELD aspect ON TABLE permission TYPE string;
DEFINE TABLE granted SCHEMAFULL;
DEFINE FIELD in ON TABLE granted TYPE record<user>;
DEFINE FIELD out ON TABLE granted TYPE record<permission>;
DEFINE FIELD granted ON TABLE granted TYPE datetime DEFAULT time::now();
DEFINE INDEX unique_permissions ON TABLE granted COLUMNS in, out UNIQUE;
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD username ON TABLE user TYPE string;
DEFINE FIELD id ON TABLE user TYPE string DEFAULT rand::ulid();
DEFINE FIELD password ON TABLE user TYPE string;
DEFINE INDEX unique_id ON TABLE user COLUMNS username, id UNIQUE;
DEFINE TABLE permission SCHEMAFULL;
DEFINE FIELD id ON TABLE permission TYPE string;
DEFINE FIELD aspect ON TABLE permission TYPE string;
DEFINE TABLE granted SCHEMAFULL;
DEFINE FIELD in ON TABLE granted TYPE record<user>;
DEFINE FIELD out ON TABLE granted TYPE record<permission>;
DEFINE FIELD granted ON TABLE granted TYPE datetime DEFAULT time::now();
DEFINE INDEX unique_permissions ON TABLE granted COLUMNS in, out UNIQUE;
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD username ON TABLE user TYPE string;
DEFINE FIELD id ON TABLE user TYPE string DEFAULT rand::ulid();
DEFINE FIELD password ON TABLE user TYPE string;
DEFINE INDEX unique_id ON TABLE user COLUMNS username, id UNIQUE;
DEFINE TABLE permission SCHEMAFULL;
DEFINE FIELD id ON TABLE permission TYPE string;
DEFINE FIELD aspect ON TABLE permission TYPE string;
DEFINE TABLE granted SCHEMAFULL;
DEFINE FIELD in ON TABLE granted TYPE record<user>;
DEFINE FIELD out ON TABLE granted TYPE record<permission>;
DEFINE FIELD granted ON TABLE granted TYPE datetime DEFAULT time::now();
DEFINE INDEX unique_permissions ON TABLE granted COLUMNS in, out UNIQUE;
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD username ON TABLE user TYPE string;
DEFINE FIELD id ON TABLE user TYPE string DEFAULT rand::ulid();
DEFINE FIELD password ON TABLE user TYPE string;
DEFINE INDEX unique_id ON TABLE user COLUMNS username, id UNIQUE;
DEFINE TABLE permission SCHEMAFULL;
DEFINE FIELD id ON TABLE permission TYPE string;
DEFINE FIELD aspect ON TABLE permission TYPE string;
DEFINE TABLE granted SCHEMAFULL;
DEFINE FIELD in ON TABLE granted TYPE record<user>;
DEFINE FIELD out ON TABLE granted TYPE record<permission>;
DEFINE FIELD granted ON TABLE granted TYPE datetime DEFAULT time::now();
DEFINE INDEX unique_permissions ON TABLE granted COLUMNS in, out UNIQUE;
INFO FOR TABLE user
DROP
DELETE user.code;
REMOVE FIELD code ON user;
INFO FOR TABLE user
USE NAMESPACE noteboek;
USE DATABASE noteboek;
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);
DEFINE FIELD username ON TABLE user TYPE string;
DEFINE FIELD id ON TABLE user TYPE string DEFAULT rand::ulid();
DEFINE FIELD password ON TABLE user TYPE string;
DEFINE INDEX unique_id ON TABLE user COLUMNS username, id UNIQUE;
DEFINE TABLE permission SCHEMAFULL;
DEFINE FIELD id ON TABLE permission TYPE string;
DEFINE FIELD aspect ON TABLE permission TYPE string;
DEFINE TABLE granted SCHEMAFULL;
DEFINE FIELD in ON TABLE granted TYPE record<user>;
DEFINE FIELD out ON TABLE granted TYPE record<permission>;
DEFINE FIELD granted ON TABLE granted TYPE datetime DEFAULT time::now();
DEFINE INDEX unique_permissions ON TABLE granted COLUMNS in, out UNIQUE;
ulid = "1.1.1"
}
}
}
}
#[derive(Deserialize, Debug)]
#[serde(tag = "status", rename_all = "snake_case")]
enum UserStatus {
Anonymous,
LoggedIn { username: String, code: u64 },
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct CredentialsState {
email: RwSignal<String>,
password: RwSignal<String>,
}
impl Default for CredentialsState {
fn default() -> Self {
Self { email: create_rw_signal("".to_string()), password: create_rw_signal("".to_string()) }
}
}
#[derive(Serialize)]
struct Signup {
email: String,
password: String,
username: String,
}
async fn fetch_user_status() -> Result<UserStatus, gloo_net::Error> {
Ok(gloo_net::http::Request::get("/api/v1/login").send().await?.json().await?)
}
async fn create_new_user(
signup: &Signup,
) -> Result<std::collections::HashMap<String, String>, String> {
Ok(gloo_net::http::Request::post("/api/v1/signup")
.json(signup)
.map_err(|e| format!("JSON creation error: {e}"))?
.send()
.await
.map_err(|e| format!("Send error: {e}"))?
.json()
.await
.map_err(|e| format!("JSON read error: {e}"))?)
}
#[component]
fn LoginView() -> impl IntoView {
let user_status = expect_context::<UserStatusResource>().0;
let (username, set_username) = create_signal("".to_string());
let (email, set_email) = create_signal("".to_string());
let (password, set_password) = create_signal("".to_string());
let signup = create_action(move |(username, email, password): &(String, String, String)| {
let signup =
Signup { username: username.clone(), email: email.clone(), password: password.clone() };
async move {
let ret = create_new_user(&signup).await;
if ret.is_ok() {
user_status.refetch()
}
ret
}
});
mview! {
[user_status.with(|user| match user {
Some(Ok(status)) => match status {
UserStatus::Anonymous => "not logged in".to_string(),
UserStatus::LoggedIn { username, code } => format!("logged in as {username}#{code}")
}.into_view(),
Some(Err(e)) =>{
let e = format!("{}", e);
mview! {
div class="text-error bg-slate-500" { [e.clone()] }
}.into_view()
},
None => "Loading...".into_view()
})]
Form action="/api/v1/login" method="POST" {
input type="email" name="email";
input type="password" name="password";
button type="submit" {
"Login"
}
}
h2 { "Signup" }
Form action="" method="POST" {
input type="username" name="username" prop:value=[username.get()] on:input={move |ev| set_username.set(event_target_value(&ev))};
input type="email" name="email" prop:value=[email.get()] on:input={move |ev| set_email.set(event_target_value(&ev))};
input type="password" name="password" prop:value=[password.get()] on:input={move |ev| set_password.set(event_target_value(&ev))};
button type="submit" on:click={move |ev| {
ev.prevent_default();
signup.dispatch((username.get(), email.get(), password.get()));
}} {
"Login"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
"anyhow",
"async-recursion",
"async-trait",
"axum-core",
"axum_session",
"bytes",
"chrono",
"dashmap",
"futures",
"http 1.0.0",
"http-body 1.0.0",
"serde",
"tokio",
"tower-layer",
"tower-service",
"tracing",
"heck",
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "tower-cookies"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d"
dependencies = [
"async-trait",
"axum-core",
"cookie",
"futures-util",
"http 1.0.0",
"parking_lot",
"pin-project-lite",
"tower-layer",
"tower-service",
[[package]]
name = "tower-sessions"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16c5f8194d71a7a61275e3c6dd0685b94b378df4c6be31678d697194719949eb"
dependencies = [
"async-trait",
"http 1.0.0",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tower-sessions-memory-store",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1582edecb18d4bfadb598edc1c3c70d06a2206a50ae1860cf41bee81ae9c8cc9"
dependencies = [
"async-trait",
"axum-core",
"base64",
"futures",
"http 1.0.0",
"parking_lot",
"rand",
"serde",
"serde_json",
"thiserror",
"time",
"tokio",
"tracing",
]
[[package]]
name = "tower-sessions-memory-store"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "476cc714e59e6aeee9f11d7ffd3b1dad5fb749440ec80050f0198766909c9423"
dependencies = [
"async-trait",
"time",
"tokio",
"tower-sessions-core",
]
[[package]]
name = "tower-sessions-surrealdb-store"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37314ab815e01a80e53481a446bcfbbfa81f7f3ca19b4dfef296cc7d1215809a"
dependencies = [
"async-trait",
"rmp-serde",
"serde",
"surrealdb",
"thiserror",
"tower-sessions-core",
"tracing",
]