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 3033132ulid::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 stufflet 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 stufflet 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, internallyfn 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
#V2DEFINE 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;/**/qqUSE 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 userDROPDELETE 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",]