mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-11-14 20:56:49 +00:00
feat: Implement a web-based account management dashboard
This commit is contained in:
72 files changed
+2568
-681
No files matched your search
@@ -0,0 +1,144 @@
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use ruma::{OwnedUserId, UserId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::{ROUTE_PREFIX, WebError};
|
||||
|
||||
pub(crate) mod store;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub(crate) struct LoginQuery {
|
||||
#[serde(flatten)]
|
||||
pub next: LoginTarget,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub reauthenticate: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(tag = "next", rename_all = "snake_case")]
|
||||
pub(crate) enum LoginTarget {
|
||||
#[default]
|
||||
Account,
|
||||
ChangePassword,
|
||||
ChangeEmail,
|
||||
CrossSigningReset,
|
||||
Deactivate,
|
||||
}
|
||||
|
||||
impl LoginTarget {
|
||||
pub(crate) fn target_path(&self) -> String {
|
||||
let path = match self {
|
||||
| Self::Account => "account/",
|
||||
| Self::ChangePassword => "account/password/change",
|
||||
| Self::ChangeEmail => "account/email/change/",
|
||||
| Self::CrossSigningReset => "account/cross_signing_reset",
|
||||
| Self::Deactivate => "account/deactivate",
|
||||
};
|
||||
|
||||
format!("{ROUTE_PREFIX}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// An extractor that fetches the authenticated user.
|
||||
pub(crate) struct User(Option<UserSession>);
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct UserSession {
|
||||
pub user_id: OwnedUserId,
|
||||
pub last_login: SystemTime,
|
||||
}
|
||||
|
||||
impl UserSession {
|
||||
const RECENT_LOGIN_THRESHOLD: Duration = Duration::from_mins(10);
|
||||
|
||||
pub(crate) fn is_recent(&self) -> bool {
|
||||
let now = SystemTime::now();
|
||||
|
||||
if let Ok(duration) = now.duration_since(self.last_login) {
|
||||
duration < Self::RECENT_LOGIN_THRESHOLD
|
||||
} else {
|
||||
// Clock drift might cause the last login time to be later than the current
|
||||
// system time. We play it safe and say the session isn't recent if that
|
||||
// happens.
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub(crate) const KEY: &str = "session";
|
||||
|
||||
/// Consume this extractor and return the user's session information.
|
||||
pub(crate) fn into_session(self) -> Option<UserSession> { self.0 }
|
||||
|
||||
/// Extract the user ID, redirecting to the login page if the user isn't
|
||||
/// logged in.
|
||||
pub(crate) fn expect(self, or_else: LoginTarget) -> Result<OwnedUserId, WebError> {
|
||||
if let Some(session) = self.0 {
|
||||
Ok(session.user_id)
|
||||
} else {
|
||||
Err(WebError::LoginRequired(LoginQuery { next: or_else, reauthenticate: false }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the user ID, redirecting to the login page if the user isn't
|
||||
/// logged in or if they haven't logged in recently.
|
||||
pub(crate) fn expect_recent(self, or_else: LoginTarget) -> Result<OwnedUserId, WebError> {
|
||||
if let Some(session) = self.0 {
|
||||
if session.is_recent() {
|
||||
Ok(session.user_id)
|
||||
} else {
|
||||
Err(WebError::LoginRequired(LoginQuery { next: or_else, reauthenticate: true }))
|
||||
}
|
||||
} else {
|
||||
Err(WebError::LoginRequired(LoginQuery { next: or_else, reauthenticate: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<crate::State> for User {
|
||||
type Rejection = WebError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
services: &crate::State,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let session_store = Session::from_request_parts(parts, services)
|
||||
.await
|
||||
.expect("should be able to extract session");
|
||||
|
||||
let session = session_store
|
||||
.get::<UserSession>(Self::KEY)
|
||||
.await
|
||||
.expect("should be able to deserialize session");
|
||||
|
||||
if let Some(session) = &session {
|
||||
require_active(services, &session.user_id).await?;
|
||||
}
|
||||
|
||||
Ok(Self(session))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn require_active(
|
||||
services: &crate::State,
|
||||
user_id: &UserId,
|
||||
) -> Result<(), WebError> {
|
||||
if !services.users.is_active(user_id).await {
|
||||
return Err(WebError::Forbidden("Your account is deactivated.".to_owned()));
|
||||
}
|
||||
|
||||
if services
|
||||
.users
|
||||
.is_locked(user_id)
|
||||
.await
|
||||
.expect("should be able to check lock state")
|
||||
{
|
||||
return Err(WebError::Forbidden("Your account is locked.".to_owned()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use conduwuit_core::utils::stream::TryIgnore;
|
||||
use conduwuit_database::{Database, Deserialized, Json, Map};
|
||||
use futures::StreamExt;
|
||||
use tower_sessions::{
|
||||
ExpiredDeletion, SessionStore,
|
||||
cookie::time::OffsetDateTime,
|
||||
session::{Id, Record},
|
||||
session_store::Error,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RocksDbSessionStore {
|
||||
websessionid_session: Arc<Map>,
|
||||
}
|
||||
|
||||
impl RocksDbSessionStore {
|
||||
pub(crate) fn new(db: &Database) -> Self {
|
||||
Self {
|
||||
websessionid_session: db["websessionid_session"].clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionStore for RocksDbSessionStore {
|
||||
async fn save(&self, session: &Record) -> Result<(), Error> {
|
||||
self.websessionid_session
|
||||
.raw_put(session.id.0.to_be_bytes(), Json(session));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load(&self, session_id: &Id) -> Result<Option<Record>, Error> {
|
||||
let Some(session) = self
|
||||
.websessionid_session
|
||||
.get(&session_id.0.to_be_bytes())
|
||||
.await
|
||||
.deserialized()
|
||||
.ok()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
async fn delete(&self, session_id: &Id) -> Result<(), Error> {
|
||||
self.websessionid_session
|
||||
.remove(&session_id.0.to_be_bytes());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ExpiredDeletion for RocksDbSessionStore {
|
||||
async fn delete_expired(&self) -> Result<(), Error> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
self.websessionid_session
|
||||
.stream()
|
||||
.ignore_err()
|
||||
.for_each(async |(id, session): (&[u8], Record)| {
|
||||
if session.expiry_date < now {
|
||||
self.websessionid_session.remove(id);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user