feat(lib/auth): refresh auth module with handlers, services, share, pow, templates, and routes
ZhenYi committed
Added lib/auth/src/handlers/accept_agreement.rs lib/auth/src/handlers/accept_agreement.rs +42 −0 View lib/auth/src/handlers/accept_agreement.rs
@@ -0,0 +1,42 @@
1use axum::extract::{Path, State};
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use serde::{Deserialize, Serialize};
4use session::Session;
5use utoipa::ToSchema;
6
7use crate::errors::ApiError;
8use crate::share::session::require_uid;
9use crate::services::agreements::AcceptAgreementResp;
10use crate::services::AuthService;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
13pub struct AcceptAgreementCmd {
14 pub version: String,
15}
16
17#[utoipa::path(
18 put,
19 path = "/auth/agreements/{key}",
20 tag = "account",
21 params(
22 ("key" = String, Path, description = "agreement key")
23 ),
24 request_body = AppRequestData<AcceptAgreementCmd>,
25 responses(
26 (status = 200, description = "agreement acceptance recorded", body = AppResponseData<AcceptAgreementResp>)
27 )
28)]
29pub async fn accept_agreement(
30 State(service): State<AuthService>,
31 session: Session,
32 Path(key): Path<String>,
33 payload: AppRequest<AcceptAgreementCmd>,
34) -> Result<AppResponseData<AcceptAgreementResp>, ApiError> {
35 let uid = require_uid(&session)?;
36 let cmd = payload.into_inner();
37 let resp = service
38 .accept_agreement(uid, &key, &cmd.version)
39 .await
40 .map_err(ApiError::from)?;
41 Ok(AppResponseData::ok(resp))
42}
No newline at end of file
Added lib/auth/src/handlers/active_suspension.rs lib/auth/src/handlers/active_suspension.rs +28 −0 View lib/auth/src/handlers/active_suspension.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::suspensions::SuspensionDto;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/suspension",
13 tag = "suspension",
14 responses(
15 (status = 200, description = "active suspension for the current user, none when absent", body = AppResponseData<Option<SuspensionDto>>)
16 )
17)]
18pub async fn active_suspension(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<Option<SuspensionDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service
24 .active_suspension(uid)
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/add_ssh_key.rs lib/auth/src/handlers/add_ssh_key.rs +30 −0 View lib/auth/src/handlers/add_ssh_key.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::ssh_keys::{AddSshKeyCmd, SshKeyDto};
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/ssh-keys",
13 tag = "security",
14 request_body = AppRequestData<AddSshKeyCmd>,
15 responses(
16 (status = 200, description = "ssh key registered", body = AppResponseData<SshKeyDto>)
17 )
18)]
19pub async fn add_ssh_key(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<AddSshKeyCmd>,
23) -> Result<AppResponseData<SshKeyDto>, ApiError> {
24 let uid = require_uid(&session)?;
25 let resp = service
26 .add_ssh_key(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::ok(resp))
30}
No newline at end of file
Added lib/auth/src/handlers/agreement_page.rs lib/auth/src/handlers/agreement_page.rs +27 −0 View lib/auth/src/handlers/agreement_page.rs
@@ -0,0 +1,27 @@
1use axum::extract::{Path, State};
2use axum::response::Html;
3
4use crate::errors::ApiError;
5use crate::services::AuthService;
6use crate::templates;
7
8pub async fn agreement_page(
9 State(service): State<AuthService>,
10 Path(key): Path<String>,
11) -> Result<Html<String>, ApiError> {
12 let doc = service
13 .list_agreements()
14 .into_iter()
15 .find(|d| d.key == key);
16 match doc {
17 Some(doc) => {
18 let html_content = templates::render_markdown(&doc.content_md);
19 Ok(templates::page_agreement(&doc, &html_content))
20 }
21 None => Ok(templates::page_message(
22 false,
23 "Not found",
24 "The requested agreement document does not exist.",
25 )),
26 }
27}
Added lib/auth/src/handlers/block_user.rs lib/auth/src/handlers/block_user.rs +29 −0 View lib/auth/src/handlers/block_user.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 put,
12 path = "/users/{uid}/block",
13 tag = "users",
14 params(
15 ("uid" = Uuid, Path, description = "user id to block")
16 ),
17 responses(
18 (status = 200, description = "block established", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn block_user(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(blocked_uid): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.block(uid, blocked_uid).await.map_err(ApiError::from)?;
28 Ok(AppResponseData::empty())
29}
No newline at end of file
Added lib/auth/src/handlers/cancel_deletion.rs lib/auth/src/handlers/cancel_deletion.rs +24 −0 View lib/auth/src/handlers/cancel_deletion.rs
@@ -0,0 +1,24 @@
1use axum::extract::State;
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/deletion/cancel",
12 tag = "deletion",
13 responses(
14 (status = 200, description = "deletion request cancelled", body = AppResponseData<NoData>)
15 )
16)]
17pub async fn cancel_deletion(
18 State(service): State<AuthService>,
19 session: Session,
20) -> Result<AppResponseData<NoData>, ApiError> {
21 let uid = require_uid(&session)?;
22 service.cancel_deletion(uid).await.map_err(ApiError::from)?;
23 Ok(AppResponseData::empty())
24}
No newline at end of file
Added lib/auth/src/handlers/cancel_email_change.rs lib/auth/src/handlers/cancel_email_change.rs +32 −0 View lib/auth/src/handlers/cancel_email_change.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 delete,
12 path = "/auth/email/change/{ticket_id}",
13 tag = "auth",
14 params(
15 ("ticket_id" = Uuid, Path, description = "email change ticket id")
16 ),
17 responses(
18 (status = 200, description = "email change cancelled", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn cancel_email_change(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(ticket_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .cancel_email_change(uid, ticket_id)
29 .await
30 .map_err(ApiError::from)?;
31 Ok(AppResponseData::empty())
32}
No newline at end of file
Added lib/auth/src/handlers/change_password.rs lib/auth/src/handlers/change_password.rs +30 −0 View lib/auth/src/handlers/change_password.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::password::ChangePasswordCmd;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/password/change",
13 tag = "auth",
14 request_body = AppRequestData<ChangePasswordCmd>,
15 responses(
16 (status = 200, description = "password changed, existing sessions invalidated", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn change_password(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<ChangePasswordCmd>,
23) -> Result<AppResponseData<NoData>, ApiError> {
24 let uid = require_uid(&session)?;
25 service
26 .change_password(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::empty())
30}
No newline at end of file
Added lib/auth/src/handlers/confirm_email_change.rs lib/auth/src/handlers/confirm_email_change.rs +39 −0 View lib/auth/src/handlers/confirm_email_change.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use serde::{Deserialize, Serialize};
4use session::Session;
5use utoipa::ToSchema;
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::share::session::require_uid;
10use crate::services::AuthService;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
13pub struct ConfirmEmailChangeCmd {
14 pub ticket_id: Uuid,
15 pub code: String,
16}
17
18#[utoipa::path(
19 post,
20 path = "/auth/email/change/confirm",
21 tag = "auth",
22 request_body = AppRequestData<ConfirmEmailChangeCmd>,
23 responses(
24 (status = 200, description = "email changed to the ticket address", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn confirm_email_change(
28 State(service): State<AuthService>,
29 session: Session,
30 payload: AppRequest<ConfirmEmailChangeCmd>,
31) -> Result<AppResponseData<NoData>, ApiError> {
32 let uid = require_uid(&session)?;
33 let cmd = payload.into_inner();
34 service
35 .confirm_email_change(uid, cmd.ticket_id, &cmd.code)
36 .await
37 .map_err(ApiError::from)?;
38 Ok(AppResponseData::empty())
39}
No newline at end of file
Added lib/auth/src/handlers/create_access_key.rs lib/auth/src/handlers/create_access_key.rs +30 −0 View lib/auth/src/handlers/create_access_key.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::access_keys::{CreateAccessKeyCmd, CreateAccessKeyResp};
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/access-keys",
13 tag = "security",
14 request_body = AppRequestData<CreateAccessKeyCmd>,
15 responses(
16 (status = 200, description = "access key created, token shown only in this response", body = AppResponseData<CreateAccessKeyResp>)
17 )
18)]
19pub async fn create_access_key(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<CreateAccessKeyCmd>,
23) -> Result<AppResponseData<CreateAccessKeyResp>, ApiError> {
24 let uid = require_uid(&session)?;
25 let resp = service
26 .create_access_key(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::ok(resp))
30}
No newline at end of file
Added lib/auth/src/handlers/create_report.rs lib/auth/src/handlers/create_report.rs +30 −0 View lib/auth/src/handlers/create_report.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::reports::{CreateReportCmd, ReportDto};
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/reports",
13 tag = "reports",
14 request_body = AppRequestData<CreateReportCmd>,
15 responses(
16 (status = 200, description = "report filed", body = AppResponseData<ReportDto>)
17 )
18)]
19pub async fn create_report(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<CreateReportCmd>,
23) -> Result<AppResponseData<ReportDto>, ApiError> {
24 let uid = require_uid(&session)?;
25 let resp = service
26 .create_report(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::ok(resp))
30}
No newline at end of file
Added lib/auth/src/handlers/delete_access_key.rs lib/auth/src/handlers/delete_access_key.rs +32 −0 View lib/auth/src/handlers/delete_access_key.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 delete,
12 path = "/auth/access-keys/{key_id}",
13 tag = "security",
14 params(
15 ("key_id" = Uuid, Path, description = "access key id")
16 ),
17 responses(
18 (status = 200, description = "access key deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_access_key(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(key_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_access_key(uid, key_id)
29 .await
30 .map_err(ApiError::from)?;
31 Ok(AppResponseData::empty())
32}
No newline at end of file
Added lib/auth/src/handlers/delete_device.rs lib/auth/src/handlers/delete_device.rs +32 −0 View lib/auth/src/handlers/delete_device.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 delete,
12 path = "/auth/devices/{device_id}",
13 tag = "security",
14 params(
15 ("device_id" = Uuid, Path, description = "device id")
16 ),
17 responses(
18 (status = 200, description = "device record deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_device(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(device_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_device(uid, device_id)
29 .await
30 .map_err(ApiError::from)?;
31 Ok(AppResponseData::empty())
32}
No newline at end of file
Added lib/auth/src/handlers/delete_notification.rs lib/auth/src/handlers/delete_notification.rs +32 −0 View lib/auth/src/handlers/delete_notification.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 delete,
12 path = "/notifications/{notification_id}",
13 tag = "notifications",
14 params(
15 ("notification_id" = Uuid, Path, description = "notification id")
16 ),
17 responses(
18 (status = 200, description = "notification deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_notification(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(notification_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_notification(uid, notification_id)
29 .await
30 .map_err(ApiError::from)?;
31 Ok(AppResponseData::empty())
32}
No newline at end of file
Added lib/auth/src/handlers/delete_ssh_key.rs lib/auth/src/handlers/delete_ssh_key.rs +32 −0 View lib/auth/src/handlers/delete_ssh_key.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 delete,
12 path = "/auth/ssh-keys/{key_id}",
13 tag = "security",
14 params(
15 ("key_id" = Uuid, Path, description = "ssh key id")
16 ),
17 responses(
18 (status = 200, description = "ssh key deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_ssh_key(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(key_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_ssh_key(uid, key_id)
29 .await
30 .map_err(ApiError::from)?;
31 Ok(AppResponseData::empty())
32}
No newline at end of file
Added lib/auth/src/handlers/deletion_status.rs lib/auth/src/handlers/deletion_status.rs +25 −0 View lib/auth/src/handlers/deletion_status.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::deletion::DeletionDto;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/deletion",
13 tag = "deletion",
14 responses(
15 (status = 200, description = "latest deletion request for the current user", body = AppResponseData<Option<DeletionDto>>)
16 )
17)]
18pub async fn deletion_status(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<Option<DeletionDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service.deletion_status(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(resp))
25}
No newline at end of file
Added lib/auth/src/handlers/follow_user.rs lib/auth/src/handlers/follow_user.rs +29 −0 View lib/auth/src/handlers/follow_user.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 put,
12 path = "/users/{uid}/follow",
13 tag = "users",
14 params(
15 ("uid" = Uuid, Path, description = "user id to follow")
16 ),
17 responses(
18 (status = 200, description = "follow relationship established", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn follow_user(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(followed_uid): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.follow(uid, followed_uid).await.map_err(ApiError::from)?;
28 Ok(AppResponseData::empty())
29}
No newline at end of file
Added lib/auth/src/handlers/forgot_password.rs lib/auth/src/handlers/forgot_password.rs +28 −0 View lib/auth/src/handlers/forgot_password.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::password::{ForgotPasswordCmd, ForgotPasswordResp};
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/auth/password/forgot",
12 tag = "auth",
13 request_body = AppRequestData<ForgotPasswordCmd>,
14 responses(
15 (status = 200, description = "reset token issued, delivery is not disclosed", body = AppResponseData<ForgotPasswordResp>)
16 )
17)]
18pub async fn forgot_password(
19 State(service): State<AuthService>,
20 session: Session,
21 payload: AppRequest<ForgotPasswordCmd>,
22) -> Result<AppResponseData<ForgotPasswordResp>, ApiError> {
23 let resp = service
24 .forgot_password(&session, payload.into_inner())
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/get_preferences.rs lib/auth/src/handlers/get_preferences.rs +25 −0 View lib/auth/src/handlers/get_preferences.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::preferences::UserPreferencesDto;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/auth/preferences",
13 tag = "account",
14 responses(
15 (status = 200, description = "preferences for the current user, defaults when unset", body = AppResponseData<UserPreferencesDto>)
16 )
17)]
18pub async fn get_preferences(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<UserPreferencesDto>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service.preferences(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(resp))
25}
No newline at end of file
Added lib/auth/src/handlers/get_user.rs lib/auth/src/handlers/get_user.rs +26 −0 View lib/auth/src/handlers/get_user.rs
@@ -0,0 +1,26 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use uuid::Uuid;
4
5use crate::errors::ApiError;
6use crate::share::types::UserPublic;
7use crate::services::AuthService;
8
9#[utoipa::path(
10 get,
11 path = "/users/{uid}",
12 tag = "users",
13 params(
14 ("uid" = Uuid, Path, description = "user id")
15 ),
16 responses(
17 (status = 200, description = "public user identity", body = AppResponseData<UserPublic>)
18 )
19)]
20pub async fn get_user(
21 State(service): State<AuthService>,
22 Path(uid): Path<Uuid>,
23) -> Result<AppResponseData<UserPublic>, ApiError> {
24 let user = service.public_user(uid).await.map_err(ApiError::from)?;
25 Ok(AppResponseData::ok(user))
26}
No newline at end of file
Added lib/auth/src/handlers/list_access_keys.rs lib/auth/src/handlers/list_access_keys.rs +25 −0 View lib/auth/src/handlers/list_access_keys.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::access_keys::AccessKeyDto;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/auth/access-keys",
13 tag = "security",
14 responses(
15 (status = 200, description = "access keys for the current user", body = AppResponseData<Vec<AccessKeyDto>>)
16 )
17)]
18pub async fn list_access_keys(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<AccessKeyDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let keys = service.list_access_keys(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(keys))
25}
No newline at end of file
Added lib/auth/src/handlers/list_agreements.rs lib/auth/src/handlers/list_agreements.rs +20 −0 View lib/auth/src/handlers/list_agreements.rs
@@ -0,0 +1,20 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3
4use crate::errors::ApiError;
5use crate::share::types::AgreementDoc;
6use crate::services::AuthService;
7
8#[utoipa::path(
9 get,
10 path = "/auth/agreements",
11 tag = "account",
12 responses(
13 (status = 200, description = "agreement documents from configuration", body = AppResponseData<Vec<AgreementDoc>>)
14 )
15)]
16pub async fn list_agreements(
17 State(service): State<AuthService>,
18) -> Result<AppResponseData<Vec<AgreementDoc>>, ApiError> {
19 Ok(AppResponseData::ok(service.list_agreements()))
20}
No newline at end of file
Added lib/auth/src/handlers/list_blocked_users.rs lib/auth/src/handlers/list_blocked_users.rs +25 −0 View lib/auth/src/handlers/list_blocked_users.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::share::types::UserPublic;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/users/blocks",
13 tag = "users",
14 responses(
15 (status = 200, description = "users blocked by the current user", body = AppResponseData<Vec<UserPublic>>)
16 )
17)]
18pub async fn list_blocked_users(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<UserPublic>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let users = service.blocked_users(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(users))
25}
No newline at end of file
Added lib/auth/src/handlers/list_devices.rs lib/auth/src/handlers/list_devices.rs +25 −0 View lib/auth/src/handlers/list_devices.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::devices::DeviceDto;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/auth/devices",
13 tag = "security",
14 responses(
15 (status = 200, description = "devices known for the current user", body = AppResponseData<Vec<DeviceDto>>)
16 )
17)]
18pub async fn list_devices(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<DeviceDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let devices = service.list_devices(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(devices))
25}
No newline at end of file
Added lib/auth/src/handlers/list_followers.rs lib/auth/src/handlers/list_followers.rs +26 −0 View lib/auth/src/handlers/list_followers.rs
@@ -0,0 +1,26 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use uuid::Uuid;
4
5use crate::errors::ApiError;
6use crate::share::types::UserPublic;
7use crate::services::AuthService;
8
9#[utoipa::path(
10 get,
11 path = "/users/{uid}/followers",
12 tag = "users",
13 params(
14 ("uid" = Uuid, Path, description = "user id")
15 ),
16 responses(
17 (status = 200, description = "users following the given user", body = AppResponseData<Vec<UserPublic>>)
18 )
19)]
20pub async fn list_followers(
21 State(service): State<AuthService>,
22 Path(uid): Path<Uuid>,
23) -> Result<AppResponseData<Vec<UserPublic>>, ApiError> {
24 let users = service.followers(uid).await.map_err(ApiError::from)?;
25 Ok(AppResponseData::ok(users))
26}
No newline at end of file
Added lib/auth/src/handlers/list_following.rs lib/auth/src/handlers/list_following.rs +26 −0 View lib/auth/src/handlers/list_following.rs
@@ -0,0 +1,26 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use uuid::Uuid;
4
5use crate::errors::ApiError;
6use crate::share::types::UserPublic;
7use crate::services::AuthService;
8
9#[utoipa::path(
10 get,
11 path = "/users/{uid}/following",
12 tag = "users",
13 params(
14 ("uid" = Uuid, Path, description = "user id")
15 ),
16 responses(
17 (status = 200, description = "users the given user follows", body = AppResponseData<Vec<UserPublic>>)
18 )
19)]
20pub async fn list_following(
21 State(service): State<AuthService>,
22 Path(uid): Path<Uuid>,
23) -> Result<AppResponseData<Vec<UserPublic>>, ApiError> {
24 let users = service.following(uid).await.map_err(ApiError::from)?;
25 Ok(AppResponseData::ok(users))
26}
No newline at end of file
Added lib/auth/src/handlers/list_notifications.rs lib/auth/src/handlers/list_notifications.rs +38 −0 View lib/auth/src/handlers/list_notifications.rs
@@ -0,0 +1,38 @@
1use axum::extract::{Query, State};
2use export::rest::AppResponseData;
3use serde::Deserialize;
4use session::Session;
5use utoipa::IntoParams;
6
7use crate::errors::ApiError;
8use crate::share::session::require_uid;
9use crate::services::notifications::NotificationDto;
10use crate::services::AuthService;
11
12#[derive(Debug, Clone, Copy, Default, Deserialize, IntoParams)]
13pub struct ListNotificationsQuery {
14 pub limit: Option<u64>,
15 pub offset: Option<u64>,
16}
17
18#[utoipa::path(
19 get,
20 path = "/notifications",
21 tag = "notifications",
22 params(ListNotificationsQuery),
23 responses(
24 (status = 200, description = "notifications for the current user", body = AppResponseData<Vec<NotificationDto>>)
25 )
26)]
27pub async fn list_notifications(
28 State(service): State<AuthService>,
29 session: Session,
30 Query(query): Query<ListNotificationsQuery>,
31) -> Result<AppResponseData<Vec<NotificationDto>>, ApiError> {
32 let uid = require_uid(&session)?;
33 let notifications = service
34 .list_notifications(uid, query.limit, query.offset)
35 .await
36 .map_err(ApiError::from)?;
37 Ok(AppResponseData::ok(notifications))
38}
No newline at end of file
Added lib/auth/src/handlers/list_ssh_keys.rs lib/auth/src/handlers/list_ssh_keys.rs +25 −0 View lib/auth/src/handlers/list_ssh_keys.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::ssh_keys::SshKeyDto;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/auth/ssh-keys",
13 tag = "security",
14 responses(
15 (status = 200, description = "ssh keys for the current user", body = AppResponseData<Vec<SshKeyDto>>)
16 )
17)]
18pub async fn list_ssh_keys(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<SshKeyDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let keys = service.list_ssh_keys(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(keys))
25}
No newline at end of file
Added lib/auth/src/handlers/login.rs lib/auth/src/handlers/login.rs +28 −0 View lib/auth/src/handlers/login.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::login::{LoginCmd, LoginResp};
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/auth/login",
12 tag = "auth",
13 request_body = AppRequestData<LoginCmd>,
14 responses(
15 (status = 200, description = "login accepted, session established", body = AppResponseData<LoginResp>)
16 )
17)]
18pub async fn login(
19 State(service): State<AuthService>,
20 session: Session,
21 payload: AppRequest<LoginCmd>,
22) -> Result<AppResponseData<LoginResp>, ApiError> {
23 let resp = service
24 .login(&session, payload.into_inner())
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/login_2fa.rs lib/auth/src/handlers/login_2fa.rs +28 −0 View lib/auth/src/handlers/login_2fa.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::login::{LoginResp, TwoFactorLoginCmd};
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/auth/login/2fa",
12 tag = "auth",
13 request_body = AppRequestData<TwoFactorLoginCmd>,
14 responses(
15 (status = 200, description = "two-factor challenge accepted, session established", body = AppResponseData<LoginResp>)
16 )
17)]
18pub async fn login_2fa(
19 State(service): State<AuthService>,
20 session: Session,
21 payload: AppRequest<TwoFactorLoginCmd>,
22) -> Result<AppResponseData<LoginResp>, ApiError> {
23 let resp = service
24 .login_2fa(&session, payload.into_inner())
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/logout.rs lib/auth/src/handlers/logout.rs +22 −0 View lib/auth/src/handlers/logout.rs
@@ -0,0 +1,22 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::logout::LogoutResp;
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/auth/logout",
12 tag = "auth",
13 responses(
14 (status = 200, description = "active user logged out of the session", body = AppResponseData<LogoutResp>)
15 )
16)]
17pub async fn logout(
18 State(service): State<AuthService>,
19 session: Session,
20) -> Result<AppResponseData<LogoutResp>, ApiError> {
21 Ok(AppResponseData::ok(service.logout(&session, None)))
22}
No newline at end of file
Added lib/auth/src/handlers/mark_all_notifications_read.rs lib/auth/src/handlers/mark_all_notifications_read.rs +27 −0 View lib/auth/src/handlers/mark_all_notifications_read.rs
@@ -0,0 +1,27 @@
1use axum::extract::State;
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::AuthService;
8
9#[utoipa::path(
10 put,
11 path = "/notifications/read-all",
12 tag = "notifications",
13 responses(
14 (status = 200, description = "all notifications marked as read", body = AppResponseData<NoData>)
15 )
16)]
17pub async fn mark_all_notifications_read(
18 State(service): State<AuthService>,
19 session: Session,
20) -> Result<AppResponseData<NoData>, ApiError> {
21 let uid = require_uid(&session)?;
22 service
23 .mark_all_notifications_read(uid)
24 .await
25 .map_err(ApiError::from)?;
26 Ok(AppResponseData::empty())
27}
No newline at end of file
Added lib/auth/src/handlers/mark_notification_read.rs lib/auth/src/handlers/mark_notification_read.rs +32 −0 View lib/auth/src/handlers/mark_notification_read.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 put,
12 path = "/notifications/{notification_id}/read",
13 tag = "notifications",
14 params(
15 ("notification_id" = Uuid, Path, description = "notification id")
16 ),
17 responses(
18 (status = 200, description = "notification marked as read", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn mark_notification_read(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(notification_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .mark_notification_read(uid, notification_id)
29 .await
30 .map_err(ApiError::from)?;
31 Ok(AppResponseData::empty())
32}
No newline at end of file
Added lib/auth/src/handlers/me.rs lib/auth/src/handlers/me.rs +33 −0 View lib/auth/src/handlers/me.rs
@@ -0,0 +1,33 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use serde::Serialize;
4use session::Session;
5use utoipa::ToSchema;
6
7use crate::errors::ApiError;
8use crate::share::session::require_uid;
9use crate::services::profile::{ProfileDto, UserMeDto};
10use crate::services::AuthService;
11
12#[derive(Debug, Clone, Serialize, ToSchema)]
13pub struct MeResp {
14 pub user: UserMeDto,
15 pub profile: Option<ProfileDto>,
16}
17
18#[utoipa::path(
19 get,
20 path = "/auth/me",
21 tag = "account",
22 responses(
23 (status = 200, description = "identity and profile of the active user", body = AppResponseData<MeResp>)
24 )
25)]
26pub async fn me(
27 State(service): State<AuthService>,
28 session: Session,
29) -> Result<AppResponseData<MeResp>, ApiError> {
30 let uid = require_uid(&session)?;
31 let (user, profile) = service.me(uid).await.map_err(ApiError::from)?;
32 Ok(AppResponseData::ok(MeResp { user, profile }))
33}
No newline at end of file
Added lib/auth/src/handlers/mod.rs lib/auth/src/handlers/mod.rs +57 −0 View lib/auth/src/handlers/mod.rs
@@ -0,0 +1,57 @@
1pub mod accept_agreement;
2pub mod active_suspension;
3pub mod add_ssh_key;
4pub mod agreement_page;
5pub mod block_user;
6pub mod cancel_deletion;
7pub mod cancel_email_change;
8pub mod change_password;
9pub mod confirm_email_change;
10pub mod create_access_key;
11pub mod create_report;
12pub mod delete_access_key;
13pub mod delete_device;
14pub mod delete_notification;
15pub mod delete_ssh_key;
16pub mod deletion_status;
17pub mod follow_user;
18pub mod forgot_password;
19pub mod get_preferences;
20pub mod get_user;
21pub mod list_access_keys;
22pub mod list_agreements;
23pub mod list_blocked_users;
24pub mod list_devices;
25pub mod list_followers;
26pub mod list_following;
27pub mod list_notifications;
28pub mod list_ssh_keys;
29pub mod login;
30pub mod login_2fa;
31pub mod logout;
32pub mod mark_all_notifications_read;
33pub mod mark_notification_read;
34pub mod me;
35pub mod pow_challenge;
36pub mod reclaim;
37pub mod recovery_codes;
38pub mod regenerate_recovery_codes;
39pub mod register;
40pub mod request_deletion;
41pub mod request_email_change;
42pub mod resend_code;
43pub mod reset_password;
44pub mod reset_password_page;
45pub mod totp_disable;
46pub mod totp_enable;
47pub mod totp_setup;
48pub mod totp_status;
49pub mod unblock_user;
50pub mod unfollow_user;
51pub mod unread_notifications_count;
52pub mod update_birthday;
53pub mod update_device;
54pub mod update_preferences;
55pub mod update_profile;
56pub mod verify_email;
57pub mod verify_email_page;
No newline at end of file
Added lib/auth/src/handlers/pow_challenge.rs lib/auth/src/handlers/pow_challenge.rs +39 −0 View lib/auth/src/handlers/pow_challenge.rs
@@ -0,0 +1,39 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use serde::Serialize;
4use session::Session;
5use utoipa::ToSchema;
6
7use crate::errors::ApiError;
8use crate::services::AuthService;
9
10#[derive(Debug, Clone, Serialize, ToSchema)]
11pub struct PowChallengeResp {
12 pub nonce: String,
13 pub difficulty: u32,
14 pub expires_at: i64,
15 pub mac: Option<String>,
16}
17
18#[utoipa::path(
19 get,
20 path = "/auth/pow/challenge",
21 tag = "auth",
22 responses(
23 (status = 200, description = "challenge issued", body = AppResponseData<PowChallengeResp>)
24 )
25)]
26pub async fn pow_challenge(
27 State(service): State<AuthService>,
28 session: Session,
29) -> Result<AppResponseData<PowChallengeResp>, ApiError> {
30 let challenge = service
31 .issue_pow_challenge(&session)
32 .map_err(ApiError::from)?;
33 Ok(AppResponseData::ok(PowChallengeResp {
34 nonce: challenge.nonce,
35 difficulty: challenge.difficulty,
36 expires_at: challenge.expires_at,
37 mac: challenge.mac,
38 }))
39}
No newline at end of file
Added lib/auth/src/handlers/reclaim.rs lib/auth/src/handlers/reclaim.rs +28 −0 View lib/auth/src/handlers/reclaim.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::email::{ReclaimCmd, ReclaimResp};
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/auth/email/reclaim",
12 tag = "auth",
13 request_body = AppRequestData<ReclaimCmd>,
14 responses(
15 (status = 200, description = "inactive account reclaim issued", body = AppResponseData<ReclaimResp>)
16 )
17)]
18pub async fn reclaim(
19 State(service): State<AuthService>,
20 session: Session,
21 payload: AppRequest<ReclaimCmd>,
22) -> Result<AppResponseData<ReclaimResp>, ApiError> {
23 let resp = service
24 .reclaim(&session, payload.into_inner())
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/recovery_codes.rs lib/auth/src/handlers/recovery_codes.rs +25 −0 View lib/auth/src/handlers/recovery_codes.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::two_factor::RecoveryCodesResp;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/auth/2fa/recovery-codes",
13 tag = "auth",
14 responses(
15 (status = 200, description = "recovery code usage summary, codes never returned", body = AppResponseData<RecoveryCodesResp>)
16 )
17)]
18pub async fn recovery_codes(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<RecoveryCodesResp>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service.recovery_codes(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(resp))
25}
No newline at end of file
Added lib/auth/src/handlers/regenerate_recovery_codes.rs lib/auth/src/handlers/regenerate_recovery_codes.rs +28 −0 View lib/auth/src/handlers/regenerate_recovery_codes.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::two_factor::RecoveryCodesResp;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/2fa/recovery-codes",
13 tag = "auth",
14 responses(
15 (status = 200, description = "replacement codes issued, shown only in this response", body = AppResponseData<RecoveryCodesResp>)
16 )
17)]
18pub async fn regenerate_recovery_codes(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<RecoveryCodesResp>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service
24 .regenerate_recovery_codes(uid)
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/register.rs lib/auth/src/handlers/register.rs +28 −0 View lib/auth/src/handlers/register.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::register::{RegisterCmd, RegisterResp};
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/auth/register",
12 tag = "auth",
13 request_body = AppRequestData<RegisterCmd>,
14 responses(
15 (status = 200, description = "registration pending email verification", body = AppResponseData<RegisterResp>)
16 )
17)]
18pub async fn register(
19 State(service): State<AuthService>,
20 session: Session,
21 payload: AppRequest<RegisterCmd>,
22) -> Result<AppResponseData<RegisterResp>, ApiError> {
23 let resp = service
24 .register(&session, payload.into_inner())
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/request_deletion.rs lib/auth/src/handlers/request_deletion.rs +38 −0 View lib/auth/src/handlers/request_deletion.rs
@@ -0,0 +1,38 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use serde::{Deserialize, Serialize};
4use session::Session;
5use utoipa::ToSchema;
6
7use crate::errors::ApiError;
8use crate::share::session::require_uid;
9use crate::services::deletion::DeletionDto;
10use crate::services::AuthService;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
13pub struct RequestDeletionCmd {
14 pub reason: Option<String>,
15}
16
17#[utoipa::path(
18 post,
19 path = "/deletion",
20 tag = "deletion",
21 request_body = AppRequestData<RequestDeletionCmd>,
22 responses(
23 (status = 200, description = "deletion request recorded", body = AppResponseData<DeletionDto>)
24 )
25)]
26pub async fn request_deletion(
27 State(service): State<AuthService>,
28 session: Session,
29 payload: AppRequest<RequestDeletionCmd>,
30) -> Result<AppResponseData<DeletionDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let cmd = payload.into_inner();
33 let resp = service
34 .request_deletion(uid, cmd.reason)
35 .await
36 .map_err(ApiError::from)?;
37 Ok(AppResponseData::ok(resp))
38}
No newline at end of file
Added lib/auth/src/handlers/request_email_change.rs lib/auth/src/handlers/request_email_change.rs +30 −0 View lib/auth/src/handlers/request_email_change.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::email::{EmailChangeTicketDto, RequestEmailChangeCmd};
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/email/change",
13 tag = "auth",
14 request_body = AppRequestData<RequestEmailChangeCmd>,
15 responses(
16 (status = 200, description = "email change ticket created", body = AppResponseData<EmailChangeTicketDto>)
17 )
18)]
19pub async fn request_email_change(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<RequestEmailChangeCmd>,
23) -> Result<AppResponseData<EmailChangeTicketDto>, ApiError> {
24 let uid = require_uid(&session)?;
25 let resp = service
26 .request_email_change(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::ok(resp))
30}
No newline at end of file
Added lib/auth/src/handlers/resend_code.rs lib/auth/src/handlers/resend_code.rs +28 −0 View lib/auth/src/handlers/resend_code.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::email::{ResendCodeCmd, ResendCodeResp};
7use crate::services::AuthService;
8
9#[utoipa::path(
10 post,
11 path = "/auth/email/resend",
12 tag = "auth",
13 request_body = AppRequestData<ResendCodeCmd>,
14 responses(
15 (status = 200, description = "verification code resent", body = AppResponseData<ResendCodeResp>)
16 )
17)]
18pub async fn resend_code(
19 State(service): State<AuthService>,
20 session: Session,
21 payload: AppRequest<ResendCodeCmd>,
22) -> Result<AppResponseData<ResendCodeResp>, ApiError> {
23 let resp = service
24 .resend_code(&session, payload.into_inner())
25 .await
26 .map_err(ApiError::from)?;
27 Ok(AppResponseData::ok(resp))
28}
No newline at end of file
Added lib/auth/src/handlers/reset_password.rs lib/auth/src/handlers/reset_password.rs +26 −0 View lib/auth/src/handlers/reset_password.rs
@@ -0,0 +1,26 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3
4use crate::errors::ApiError;
5use crate::services::password::ResetPasswordCmd;
6use crate::services::AuthService;
7
8#[utoipa::path(
9 post,
10 path = "/auth/password/reset",
11 tag = "auth",
12 request_body = AppRequestData<ResetPasswordCmd>,
13 responses(
14 (status = 200, description = "password replaced, existing sessions invalidated", body = AppResponseData<NoData>)
15 )
16)]
17pub async fn reset_password(
18 State(service): State<AuthService>,
19 payload: AppRequest<ResetPasswordCmd>,
20) -> Result<AppResponseData<NoData>, ApiError> {
21 service
22 .reset_password(payload.into_inner())
23 .await
24 .map_err(ApiError::from)?;
25 Ok(AppResponseData::empty())
26}
No newline at end of file
Added lib/auth/src/handlers/reset_password_page.rs lib/auth/src/handlers/reset_password_page.rs +98 −0 View lib/auth/src/handlers/reset_password_page.rs
@@ -0,0 +1,98 @@
1use axum::extract::{Query, State};
2use axum::response::Html;
3use maud::html;
4use serde::Deserialize;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::AuthService;
9use crate::templates;
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct ResetPasswordPageQuery {
13 pub email: String,
14 pub token: String,
15 pub temp_id: Uuid,
16}
17
18pub async fn reset_password_page(
19 State(_service): State<AuthService>,
20 Query(q): Query<ResetPasswordPageQuery>,
21) -> Result<Html<String>, ApiError> {
22 let display_email = q.email.clone();
23 let display_token = q.token.clone();
24 let display_temp_id = q.temp_id.to_string();
25
26 let body = html! {
27 div class="max-w-md mx-auto px-6 py-20" {
28 div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8" {
29 h1 class="text-2xl font-bold text-gray-900 mb-2" { "Reset your password" }
30 p class="text-sm text-gray-500 mb-6" {
31 "Account: "
32 (display_email)
33 }
34 form id="reset-form" class="space-y-4" {
35 input type="hidden" name="email" value=(display_email);
36 input type="hidden" name="token" value=(display_token);
37 input type="hidden" name="temp_id" value=(display_temp_id);
38 div {
39 label for="new-password" class="block text-sm font-medium text-gray-700 mb-1" {
40 "New password"
41 }
42 input type="password" id="new-password" name="new_password"
43 required
44 class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500";
45 }
46 button type="submit"
47 class="w-full py-2.5 px-4 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition" {
48 "Reset password"
49 }
50 }
51 div id="result-message" class="mt-4 text-sm hidden" {}
52 p class="mt-4 text-sm text-gray-500" {
53 "After resetting, all other sessions will be signed out."
54 }
55 }
56 }
57 script {
58 (maud::PreEscaped(r#"
59document.getElementById('reset-form').addEventListener('submit', async function(e) {
60 e.preventDefault();
61 var btn = this.querySelector('button[type="submit"]');
62 var msg = document.getElementById('result-message');
63 btn.disabled = true;
64 btn.textContent = 'Resetting...';
65 msg.className = 'mt-4 text-sm hidden';
66 try {
67 var payload = {
68 email: this.email.value,
69 token: this.token.value,
70 temp_id: this.temp_id.value,
71 new_password: this.new_password.value
72 };
73 var resp = await fetch('/auth/password/reset', {
74 method: 'POST',
75 headers: {'Content-Type': 'application/json'},
76 body: JSON.stringify({data: payload})
77 });
78 var body = await resp.json();
79 if (resp.ok) {
80 msg.className = 'mt-4 text-sm text-green-700';
81 msg.textContent = 'Password reset successful. You can now log in.';
82 this.classList.add('hidden');
83 } else {
84 msg.className = 'mt-4 text-sm text-red-600';
85 msg.textContent = body.message || 'Reset failed. The link may have expired.';
86 }
87 } catch (_) {
88 msg.className = 'mt-4 text-sm text-red-600';
89 msg.textContent = 'Network error. Please try again.';
90 }
91 btn.disabled = false;
92 btn.textContent = 'Reset password';
93});
94"#))
95 }
96 };
97 Ok(templates::page_shell("Reset your password", body))
98}
Added lib/auth/src/handlers/totp_disable.rs lib/auth/src/handlers/totp_disable.rs +30 −0 View lib/auth/src/handlers/totp_disable.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::two_factor::TotpCodeCmd;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/2fa/disable",
13 tag = "auth",
14 request_body = AppRequestData<TotpCodeCmd>,
15 responses(
16 (status = 200, description = "two-factor authentication disabled and recovery codes removed", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn totp_disable(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<TotpCodeCmd>,
23) -> Result<AppResponseData<NoData>, ApiError> {
24 let uid = require_uid(&session)?;
25 service
26 .totp_disable(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::empty())
30}
No newline at end of file
Added lib/auth/src/handlers/totp_enable.rs lib/auth/src/handlers/totp_enable.rs +30 −0 View lib/auth/src/handlers/totp_enable.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::two_factor::TotpCodeCmd;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/2fa/enable",
13 tag = "auth",
14 request_body = AppRequestData<TotpCodeCmd>,
15 responses(
16 (status = 200, description = "two-factor authentication enabled", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn totp_enable(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<TotpCodeCmd>,
23) -> Result<AppResponseData<NoData>, ApiError> {
24 let uid = require_uid(&session)?;
25 service
26 .totp_enable(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::empty())
30}
No newline at end of file
Added lib/auth/src/handlers/totp_setup.rs lib/auth/src/handlers/totp_setup.rs +25 −0 View lib/auth/src/handlers/totp_setup.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::two_factor::TotpSetupResp;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 post,
12 path = "/auth/2fa/setup",
13 tag = "auth",
14 responses(
15 (status = 200, description = "new secret stored, awaiting enable", body = AppResponseData<TotpSetupResp>)
16 )
17)]
18pub async fn totp_setup(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<TotpSetupResp>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service.totp_setup(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(resp))
25}
No newline at end of file
Added lib/auth/src/handlers/totp_status.rs lib/auth/src/handlers/totp_status.rs +25 −0 View lib/auth/src/handlers/totp_status.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::two_factor::TotpStatusResp;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 get,
12 path = "/auth/2fa/status",
13 tag = "auth",
14 responses(
15 (status = 200, description = "two-factor status for the current user", body = AppResponseData<TotpStatusResp>)
16 )
17)]
18pub async fn totp_status(
19 State(service): State<AuthService>,
20 session: Session,
21) -> Result<AppResponseData<TotpStatusResp>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service.totp_status(uid).await.map_err(ApiError::from)?;
24 Ok(AppResponseData::ok(resp))
25}
No newline at end of file
Added lib/auth/src/handlers/unblock_user.rs lib/auth/src/handlers/unblock_user.rs +29 −0 View lib/auth/src/handlers/unblock_user.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 delete,
12 path = "/users/{uid}/block",
13 tag = "users",
14 params(
15 ("uid" = Uuid, Path, description = "user id to unblock")
16 ),
17 responses(
18 (status = 200, description = "block removed", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn unblock_user(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(blocked_uid): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.unblock(uid, blocked_uid).await.map_err(ApiError::from)?;
28 Ok(AppResponseData::empty())
29}
No newline at end of file
Added lib/auth/src/handlers/unfollow_user.rs lib/auth/src/handlers/unfollow_user.rs +29 −0 View lib/auth/src/handlers/unfollow_user.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 delete,
12 path = "/users/{uid}/follow",
13 tag = "users",
14 params(
15 ("uid" = Uuid, Path, description = "user id to unfollow")
16 ),
17 responses(
18 (status = 200, description = "follow relationship removed", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn unfollow_user(
22 State(service): State<AuthService>,
23 session: Session,
24 Path(followed_uid): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.unfollow(uid, followed_uid).await.map_err(ApiError::from)?;
28 Ok(AppResponseData::empty())
29}
No newline at end of file
Added lib/auth/src/handlers/unread_notifications_count.rs lib/auth/src/handlers/unread_notifications_count.rs +34 −0 View lib/auth/src/handlers/unread_notifications_count.rs
@@ -0,0 +1,34 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use serde::Serialize;
4use session::Session;
5use utoipa::ToSchema;
6
7use crate::errors::ApiError;
8use crate::share::session::require_uid;
9use crate::services::AuthService;
10
11#[derive(Debug, Clone, Serialize, ToSchema)]
12pub struct UnreadCountResp {
13 pub count: u64,
14}
15
16#[utoipa::path(
17 get,
18 path = "/notifications/unread-count",
19 tag = "notifications",
20 responses(
21 (status = 200, description = "number of unread notifications", body = AppResponseData<UnreadCountResp>)
22 )
23)]
24pub async fn unread_notifications_count(
25 State(service): State<AuthService>,
26 session: Session,
27) -> Result<AppResponseData<UnreadCountResp>, ApiError> {
28 let uid = require_uid(&session)?;
29 let count = service
30 .unread_notifications_count(uid)
31 .await
32 .map_err(ApiError::from)?;
33 Ok(AppResponseData::ok(UnreadCountResp { count }))
34}
No newline at end of file
Added lib/auth/src/handlers/update_birthday.rs lib/auth/src/handlers/update_birthday.rs +37 −0 View lib/auth/src/handlers/update_birthday.rs
@@ -0,0 +1,37 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use serde::{Deserialize, Serialize};
4use session::Session;
5use time::Date;
6use utoipa::ToSchema;
7
8use crate::errors::ApiError;
9use crate::share::session::require_uid;
10use crate::services::AuthService;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
13pub struct UpdateBirthdayCmd {
14 pub date_of_birth: Date,
15}
16
17#[utoipa::path(
18 put,
19 path = "/auth/me/birthday",
20 tag = "account",
21 request_body = AppRequestData<UpdateBirthdayCmd>,
22 responses(
23 (status = 200, description = "birthday stored", body = AppResponseData<NoData>)
24 )
25)]
26pub async fn update_birthday(
27 State(service): State<AuthService>,
28 session: Session,
29 payload: AppRequest<UpdateBirthdayCmd>,
30) -> Result<AppResponseData<NoData>, ApiError> {
31 let uid = require_uid(&session)?;
32 service
33 .update_birthday(uid, payload.into_inner().date_of_birth)
34 .await
35 .map_err(ApiError::from)?;
36 Ok(AppResponseData::empty())
37}
No newline at end of file
Added lib/auth/src/handlers/update_device.rs lib/auth/src/handlers/update_device.rs +35 −0 View lib/auth/src/handlers/update_device.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Path, State};
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::share::session::require_uid;
8use crate::services::devices::UpdateDeviceCmd;
9use crate::services::AuthService;
10
11#[utoipa::path(
12 patch,
13 path = "/auth/devices/{device_id}",
14 tag = "security",
15 params(
16 ("device_id" = Uuid, Path, description = "device id")
17 ),
18 request_body = AppRequestData<UpdateDeviceCmd>,
19 responses(
20 (status = 200, description = "device record updated", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_device(
24 State(service): State<AuthService>,
25 session: Session,
26 Path(device_id): Path<Uuid>,
27 payload: AppRequest<UpdateDeviceCmd>,
28) -> Result<AppResponseData<NoData>, ApiError> {
29 let uid = require_uid(&session)?;
30 service
31 .update_device(uid, device_id, payload.into_inner())
32 .await
33 .map_err(ApiError::from)?;
34 Ok(AppResponseData::empty())
35}
No newline at end of file
Added lib/auth/src/handlers/update_preferences.rs lib/auth/src/handlers/update_preferences.rs +30 −0 View lib/auth/src/handlers/update_preferences.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::preferences::UpdatePreferencesCmd;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 patch,
12 path = "/auth/preferences",
13 tag = "account",
14 request_body = AppRequestData<UpdatePreferencesCmd>,
15 responses(
16 (status = 200, description = "preferences updated", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn update_preferences(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<UpdatePreferencesCmd>,
23) -> Result<AppResponseData<NoData>, ApiError> {
24 let uid = require_uid(&session)?;
25 service
26 .update_preferences(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::empty())
30}
No newline at end of file
Added lib/auth/src/handlers/update_profile.rs lib/auth/src/handlers/update_profile.rs +30 −0 View lib/auth/src/handlers/update_profile.rs
@@ -0,0 +1,30 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::profile::UpdateProfileCmd;
8use crate::services::AuthService;
9
10#[utoipa::path(
11 patch,
12 path = "/auth/me",
13 tag = "account",
14 request_body = AppRequestData<UpdateProfileCmd>,
15 responses(
16 (status = 200, description = "profile fields updated", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn update_profile(
20 State(service): State<AuthService>,
21 session: Session,
22 payload: AppRequest<UpdateProfileCmd>,
23) -> Result<AppResponseData<NoData>, ApiError> {
24 let uid = require_uid(&session)?;
25 service
26 .update_profile(uid, payload.into_inner())
27 .await
28 .map_err(ApiError::from)?;
29 Ok(AppResponseData::empty())
30}
No newline at end of file
Added lib/auth/src/handlers/verify_email.rs lib/auth/src/handlers/verify_email.rs +26 −0 View lib/auth/src/handlers/verify_email.rs
@@ -0,0 +1,26 @@
1use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3
4use crate::errors::ApiError;
5use crate::services::email::{VerifyEmailCmd, VerifyEmailResp};
6use crate::services::AuthService;
7
8#[utoipa::path(
9 post,
10 path = "/auth/email/verify",
11 tag = "auth",
12 request_body = AppRequestData<VerifyEmailCmd>,
13 responses(
14 (status = 200, description = "verification code accepted", body = AppResponseData<VerifyEmailResp>)
15 )
16)]
17pub async fn verify_email(
18 State(service): State<AuthService>,
19 payload: AppRequest<VerifyEmailCmd>,
20) -> Result<AppResponseData<VerifyEmailResp>, ApiError> {
21 let resp = service
22 .verify_verification_code(payload.into_inner())
23 .await
24 .map_err(ApiError::from)?;
25 Ok(AppResponseData::ok(resp))
26}
No newline at end of file
Added lib/auth/src/handlers/verify_email_page.rs lib/auth/src/handlers/verify_email_page.rs +44 −0 View lib/auth/src/handlers/verify_email_page.rs
@@ -0,0 +1,44 @@
1use axum::extract::State;
2use axum::response::Html;
3use models::users::user_email_verification_codes::EmailVerificationPurpose;
4use serde::Deserialize;
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::email::VerifyEmailCmd;
9use crate::services::AuthService;
10use crate::templates;
11
12#[derive(Debug, Clone, Deserialize)]
13pub struct VerifyEmailPageQuery {
14 pub email: String,
15 pub code: String,
16 pub temp_id: Uuid,
17 pub purpose: Option<String>,
18}
19
20pub async fn verify_email_page(
21 State(service): State<AuthService>,
22 axum::extract::Query(q): axum::extract::Query<VerifyEmailPageQuery>,
23) -> Result<Html<String>, ApiError> {
24 let purpose = match q.purpose.as_deref() {
25 Some("password_reset") => EmailVerificationPurpose::PasswordReset,
26 _ => EmailVerificationPurpose::Signup,
27 };
28 let cmd = VerifyEmailCmd {
29 email: q.email,
30 purpose,
31 code: q.code,
32 temp_id: q.temp_id,
33 };
34 match service.verify_verification_code(cmd).await {
35 Ok(_) => Ok(templates::page_verify_result(
36 true,
37 "Your email has been verified. You can now continue.",
38 )),
39 Err(_) => Ok(templates::page_verify_result(
40 false,
41 "The verification code is invalid or has expired.",
42 )),
43 }
44}
Added lib/auth/src/pow/mac.rs lib/auth/src/pow/mac.rs +84 −0 View lib/auth/src/pow/mac.rs
@@ -0,0 +1,84 @@
1use base64::engine::general_purpose::STANDARD;
2use base64::Engine;
3use hkdf::Hkdf;
4use hmac::{Hmac, KeyInit, Mac};
5use sha2::Sha256;
6
7const TAG: &[u8] = b"pow:v1\n";
8
9fn derive_key(master: &[u8], nonce: &str) -> Option<[u8; 32]> {
10 let mut okm = [0u8; 32];
11 Hkdf::<Sha256>::new(Some(nonce.as_bytes()), master)
12 .expand(TAG, &mut okm)
13 .ok()?;
14 Some(okm)
15}
16
17pub fn sign_mac(master: &[u8], nonce: &str, difficulty: u32, expires_at: i64) -> Option<String> {
18 let key = derive_key(master, nonce)?;
19 let mut mac = Hmac::<Sha256>::new_from_slice(&key).ok()?;
20 mac.update(TAG);
21 mac.update(difficulty.to_string().as_bytes());
22 mac.update(b"\n");
23 mac.update(expires_at.to_string().as_bytes());
24 Some(STANDARD.encode(mac.finalize().into_bytes()))
25}
26
27pub fn check_mac(master: &[u8], mac_b64: &str, nonce: &str, difficulty: u32, expires_at: i64) -> bool {
28 let Ok(expected) = STANDARD.decode(mac_b64) else {
29 return false;
30 };
31 let Some(key) = derive_key(master, nonce) else {
32 return false;
33 };
34 let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(&key) else {
35 return false;
36 };
37 mac.update(TAG);
38 mac.update(difficulty.to_string().as_bytes());
39 mac.update(b"\n");
40 mac.update(expires_at.to_string().as_bytes());
41 mac.verify_slice(&expected).is_ok()
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47
48 const KEY: &[u8] = b"0123456789abcdef0123456789abcdef";
49
50 #[test]
51 fn sign_is_stable_and_verifies() {
52 let first = sign_mac(KEY, "nonce-1", 16, 1_000_000).unwrap();
53 let second = sign_mac(KEY, "nonce-1", 16, 1_000_000).unwrap();
54 assert_eq!(first, second);
55 assert!(check_mac(KEY, &first, "nonce-1", 16, 1_000_000));
56 }
57
58 #[test]
59 fn different_key_fails() {
60 let other = sign_mac(b"another-key-0123456789abcdefg", "nonce-1", 16, 1_000_000).unwrap();
61 assert!(!check_mac(KEY, &other, "nonce-1", 16, 1_000_000));
62 }
63
64 #[test]
65 fn different_nonce_derives_different_key() {
66 let first = sign_mac(KEY, "nonce-1", 16, 1_000_000).unwrap();
67 let second = sign_mac(KEY, "nonce-2", 16, 1_000_000).unwrap();
68 assert_ne!(first, second);
69 }
70
71 #[test]
72 fn tampered_fields_fail() {
73 let mac = sign_mac(KEY, "nonce-1", 16, 1_000_000).unwrap();
74 assert!(!check_mac(KEY, &mac, "nonce-2", 16, 1_000_000));
75 assert!(!check_mac(KEY, &mac, "nonce-1", 17, 1_000_000));
76 assert!(!check_mac(KEY, &mac, "nonce-1", 16, 1_000_001));
77 }
78
79 #[test]
80 fn invalid_base64_fails() {
81 assert!(!check_mac(KEY, "not-base64!", "nonce-1", 16, 1_000_000));
82 assert!(!check_mac(KEY, "", "nonce-1", 16, 1_000_000));
83 }
84}
No newline at end of file
Added lib/auth/src/pow/mod.rs lib/auth/src/pow/mod.rs +5 −0 View lib/auth/src/pow/mod.rs
@@ -0,0 +1,5 @@
1pub mod mac;
2pub mod store;
3
4pub use pow_solver::Challenge;
5pub use store::{issue, verify, PowError};
No newline at end of file
Added lib/auth/src/pow/store.rs lib/auth/src/pow/store.rs +80 −0 View lib/auth/src/pow/store.rs
@@ -0,0 +1,80 @@
1use std::error::Error;
2use std::fmt;
3
4use config::pow::PowConfig;
5use pow_solver::{check, Challenge};
6use session::Session;
7use uuid::Uuid;
8
9use crate::pow::mac::{check_mac, sign_mac};
10use crate::share::crypto::now_secs;
11
12pub const CHALLENGE_KEY: &str = "pow:challenge";
13
14#[derive(Debug)]
15pub enum PowError {
16 NotConfigured,
17 InvalidKey,
18 Missing,
19 Expired,
20 Malformed,
21 InvalidSignature,
22 InvalidSolution,
23}
24
25impl fmt::Display for PowError {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 Self::NotConfigured => write!(f, "pow.mac_key is not configured"),
29 Self::InvalidKey => write!(f, "pow.mac_key cannot be used for signing"),
30 Self::Missing => write!(f, "no active pow challenge"),
31 Self::Expired => write!(f, "pow challenge expired"),
32 Self::Malformed => write!(f, "malformed pow answer"),
33 Self::InvalidSignature => write!(f, "invalid pow challenge signature"),
34 Self::InvalidSolution => write!(f, "invalid pow solution"),
35 }
36 }
37}
38
39impl Error for PowError {}
40
41pub fn issue(session: &Session, config: &PowConfig) -> Result<Challenge, PowError> {
42 let key = config.mac_key.as_deref().ok_or(PowError::NotConfigured)?;
43 let mut challenge = Challenge {
44 nonce: Uuid::now_v7().to_string(),
45 difficulty: config.difficulty,
46 expires_at: now_secs().saturating_add(config.expires_secs.min(i64::MAX as u64) as i64),
47 mac: None,
48 };
49 challenge.mac = Some(
50 sign_mac(key.as_bytes(), &challenge.nonce, challenge.difficulty, challenge.expires_at)
51 .ok_or(PowError::InvalidKey)?,
52 );
53 session.set(CHALLENGE_KEY, challenge.clone());
54 Ok(challenge)
55}
56
57pub fn verify(session: &Session, config: &PowConfig, answer: &str) -> Result<(), PowError> {
58 let key = config.mac_key.as_deref().ok_or(PowError::NotConfigured)?;
59 let Some(challenge) = session.get::<Challenge>(CHALLENGE_KEY) else {
60 return Err(PowError::Missing);
61 };
62 let verified = challenge
63 .mac
64 .as_deref()
65 .map(|mac| check_mac(key.as_bytes(), mac, &challenge.nonce, challenge.difficulty, challenge.expires_at))
66 .unwrap_or(false);
67 if !verified {
68 return Err(PowError::InvalidSignature);
69 }
70 if challenge.is_expired(now_secs()) {
71 session.remove(CHALLENGE_KEY);
72 return Err(PowError::Expired);
73 }
74 let answer: u64 = answer.trim().parse().map_err(|_| PowError::Malformed)?;
75 if !check(&challenge.nonce, challenge.difficulty, answer) {
76 return Err(PowError::InvalidSolution);
77 }
78 session.remove(CHALLENGE_KEY);
79 Ok(())
80}
No newline at end of file
Added lib/auth/src/services/access_keys.rs lib/auth/src/services/access_keys.rs +141 −0 View lib/auth/src/services/access_keys.rs
@@ -0,0 +1,141 @@
1use models::users::user_access_keys::AccessKeyScope;
2use models::users::{user, user_access_keys};
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use super::AuthService;
9use crate::share::crypto::{constant_time_eq, random_token, sha256_hex};
10use crate::errors::AuthError;
11
12const ACCESS_TOKEN_BYTES: usize = 32;
13
14#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
15pub struct CreateAccessKeyCmd {
16 pub name: String,
17 pub scope: AccessKeyScope,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
21pub struct AccessKeyDto {
22 pub id: Uuid,
23 pub name: String,
24 pub scope: AccessKeyScope,
25 pub created_at: OffsetDateTime,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
29pub struct CreateAccessKeyResp {
30 pub key: AccessKeyDto,
31 pub token: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct VerifiedAccessKey {
36 pub user_id: Uuid,
37 pub scope: AccessKeyScope,
38}
39
40impl AuthService {
41 pub async fn create_access_key(
42 &self,
43 uid: Uuid,
44 cmd: CreateAccessKeyCmd,
45 ) -> Result<CreateAccessKeyResp, AuthError> {
46 let name = cmd.name.trim().to_string();
47 if name.is_empty() || name.len() > 64 {
48 return Err(AuthError::BadRequest(
49 "name must be between 1 and 64 characters".to_string(),
50 ));
51 }
52 let db = self.db();
53 let key_id = Uuid::now_v7();
54 let secret = random_token(ACCESS_TOKEN_BYTES);
55 let token = format!("{key_id}.{secret}");
56 let salt = random_token(8);
57 let now = OffsetDateTime::now_utc();
58 let row = user_access_keys::ActiveModel {
59 id: Set(key_id),
60 user_id: Set(uid),
61 name: Set(name.clone()),
62 token_hash: Set(sha256_hex(format!("{secret}{salt}").as_bytes())),
63 token_salt: Set(salt),
64 token_last_eight: Set(token[token.len().saturating_sub(8)..].to_string()),
65 scope: Set(cmd.scope),
66 version: Set(1),
67 created_at: Set(now),
68 updated_at: Set(now),
69 };
70 let inserted = row.insert(db.write()).await?;
71 Ok(CreateAccessKeyResp {
72 key: AccessKeyDto {
73 id: inserted.id,
74 name,
75 scope: inserted.scope,
76 created_at: inserted.created_at,
77 },
78 token,
79 })
80 }
81
82 pub async fn list_access_keys(&self, uid: Uuid) -> Result<Vec<AccessKeyDto>, AuthError> {
83 let rows = user_access_keys::Entity::find()
84 .filter(user_access_keys::Column::UserId.eq(uid))
85 .order_by_desc(user_access_keys::Column::CreatedAt)
86 .all(self.db().read())
87 .await?;
88 Ok(rows
89 .into_iter()
90 .map(|row| AccessKeyDto {
91 id: row.id,
92 name: row.name,
93 scope: row.scope,
94 created_at: row.created_at,
95 })
96 .collect())
97 }
98
99 pub async fn delete_access_key(&self, uid: Uuid, key_id: Uuid) -> Result<(), AuthError> {
100 let db = self.db();
101 let result = user_access_keys::Entity::delete_many()
102 .filter(user_access_keys::Column::Id.eq(key_id))
103 .filter(user_access_keys::Column::UserId.eq(uid))
104 .exec(db.write())
105 .await?;
106 if result.rows_affected == 0 {
107 return Err(AuthError::NotFound);
108 }
109 Ok(())
110 }
111
112 pub async fn verify_access_token(
113 &self,
114 token: &str,
115 ) -> Result<Option<VerifiedAccessKey>, AuthError> {
116 let Some((key_id, _secret)) = token.split_once('.') else {
117 return Ok(None);
118 };
119 let Ok(key_id) = Uuid::parse_str(key_id) else {
120 return Ok(None);
121 };
122 let db = self.db();
123 let Some(row) = user_access_keys::Entity::find_by_id(key_id).one(db.read()).await? else {
124 return Ok(None);
125 };
126 let Some(user) = user::Entity::find_by_id(row.user_id).one(db.read()).await? else {
127 return Ok(None);
128 };
129 if !user.is_active || user.prohibit_login {
130 return Ok(None);
131 }
132 let secret = token.split_once('.').map(|(_, s)| s).unwrap_or_default();
133 let computed = sha256_hex(format!("{secret}{}", row.token_salt).as_bytes());
134 if !constant_time_eq(computed.as_bytes(), row.token_hash.as_bytes()) {
135 return Ok(None);
136 }
137 let user_id = row.user_id;
138 let scope = row.scope;
139 Ok(Some(VerifiedAccessKey { user_id, scope }))
140 }
141}
No newline at end of file
Added lib/auth/src/services/agreements.rs lib/auth/src/services/agreements.rs +65 −0 View lib/auth/src/services/agreements.rs
@@ -0,0 +1,65 @@
1use models::users::user_agreement_acceptances;
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7use super::AuthService;
8use crate::errors::AuthError;
9use crate::share::types::AgreementDoc;
10
11#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
12pub struct AcceptAgreementResp {
13 pub recorded: bool,
14}
15
16impl AuthService {
17 pub fn list_agreements(&self) -> Vec<AgreementDoc> {
18 self.config()
19 .auth
20 .agreements
21 .iter()
22 .map(|doc| AgreementDoc {
23 key: doc.key.clone(),
24 version: doc.version.clone(),
25 title: doc.title.clone(),
26 content_md: doc.content_md.clone(),
27 })
28 .collect()
29 }
30
31 pub async fn accept_agreement(
32 &self,
33 uid: Uuid,
34 key: &str,
35 version: &str,
36 ) -> Result<AcceptAgreementResp, AuthError> {
37 let Some(doc) = self.config().auth.agreements.iter().find(|doc| doc.key == key) else {
38 return Err(AuthError::NotFound);
39 };
40 if doc.version != version {
41 return Err(AuthError::BadRequest(
42 "agreement version mismatch".to_string(),
43 ));
44 }
45 let db = self.db();
46 let existing = user_agreement_acceptances::Entity::find()
47 .filter(user_agreement_acceptances::Column::UserId.eq(uid))
48 .filter(user_agreement_acceptances::Column::AgreementKey.eq(key))
49 .filter(user_agreement_acceptances::Column::AgreementVersion.eq(version))
50 .one(db.write())
51 .await?;
52 if existing.is_some() {
53 return Ok(AcceptAgreementResp { recorded: true });
54 }
55 let row = user_agreement_acceptances::ActiveModel {
56 id: Set(Uuid::now_v7()),
57 user_id: Set(uid),
58 agreement_key: Set(key.to_string()),
59 agreement_version: Set(version.to_string()),
60 accepted_at: Set(OffsetDateTime::now_utc()),
61 };
62 row.insert(db.write()).await?;
63 Ok(AcceptAgreementResp { recorded: true })
64 }
65}
No newline at end of file
Added lib/auth/src/services/deletion.rs lib/auth/src/services/deletion.rs +118 −0 View lib/auth/src/services/deletion.rs
@@ -0,0 +1,118 @@
1use models::users::user_deletion_requests::UserDeletionRequestStatus;
2use models::users::user_deletion_requests;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use super::AuthService;
11use crate::errors::AuthError;
12
13#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
14pub struct DeletionDto {
15 pub id: Uuid,
16 pub reason: Option<String>,
17 pub status: String,
18 pub scheduled_for: Option<OffsetDateTime>,
19 pub created_at: OffsetDateTime,
20}
21
22fn status_str(status: UserDeletionRequestStatus) -> &'static str {
23 match status {
24 UserDeletionRequestStatus::Pending => "pending",
25 UserDeletionRequestStatus::Scheduled => "scheduled",
26 UserDeletionRequestStatus::Completed => "completed",
27 UserDeletionRequestStatus::Cancelled => "cancelled",
28 }
29}
30
31impl AuthService {
32 pub async fn request_deletion(
33 &self,
34 uid: Uuid,
35 reason: Option<String>,
36 ) -> Result<DeletionDto, AuthError> {
37 let db = self.db();
38 let active = user_deletion_requests::Entity::find()
39 .filter(user_deletion_requests::Column::UserId.eq(uid))
40 .filter(
41 user_deletion_requests::Column::Status
42 .is_in([
43 UserDeletionRequestStatus::Pending,
44 UserDeletionRequestStatus::Scheduled,
45 ]),
46 )
47 .one(db.write())
48 .await?;
49 if active.is_some() {
50 return Err(AuthError::Conflict(
51 "deletion request already exists".to_string(),
52 ));
53 }
54 let now = OffsetDateTime::now_utc();
55 let row = user_deletion_requests::ActiveModel {
56 id: Set(Uuid::now_v7()),
57 user_id: Set(uid),
58 reason: Set(reason),
59 status: Set(UserDeletionRequestStatus::Pending),
60 scheduled_for: Set(None),
61 completed_at: Set(None),
62 cancelled_at: Set(None),
63 created_at: Set(now),
64 updated_at: Set(now),
65 };
66 let inserted = row.insert(db.write()).await?;
67 Ok(DeletionDto {
68 id: inserted.id,
69 reason: inserted.reason,
70 status: status_str(inserted.status).to_string(),
71 scheduled_for: inserted.scheduled_for,
72 created_at: inserted.created_at,
73 })
74 }
75
76 pub async fn cancel_deletion(&self, uid: Uuid) -> Result<(), AuthError> {
77 let db = self.db();
78 let now = OffsetDateTime::now_utc();
79 let result = user_deletion_requests::Entity::update_many()
80 .col_expr(
81 user_deletion_requests::Column::Status,
82 sea_orm::sea_query::Expr::value(UserDeletionRequestStatus::Cancelled),
83 )
84 .col_expr(
85 user_deletion_requests::Column::CancelledAt,
86 sea_orm::sea_query::Expr::value(now),
87 )
88 .filter(user_deletion_requests::Column::UserId.eq(uid))
89 .filter(user_deletion_requests::Column::Status.is_in([
90 UserDeletionRequestStatus::Pending,
91 UserDeletionRequestStatus::Scheduled,
92 ]))
93 .exec(db.write())
94 .await?;
95 if result.rows_affected == 0 {
96 return Err(AuthError::NotFound);
97 }
98 Ok(())
99 }
100
101 pub async fn deletion_status(&self, uid: Uuid) -> Result<Option<DeletionDto>, AuthError> {
102 let Some(row) = user_deletion_requests::Entity::find()
103 .filter(user_deletion_requests::Column::UserId.eq(uid))
104 .order_by_desc(user_deletion_requests::Column::CreatedAt)
105 .one(self.db().read())
106 .await?
107 else {
108 return Ok(None);
109 };
110 Ok(Some(DeletionDto {
111 id: row.id,
112 reason: row.reason,
113 status: status_str(row.status).to_string(),
114 scheduled_for: row.scheduled_for,
115 created_at: row.created_at,
116 }))
117 }
118}
No newline at end of file
Added lib/auth/src/services/devices.rs lib/auth/src/services/devices.rs +89 −0 View lib/auth/src/services/devices.rs
@@ -0,0 +1,89 @@
1use models::users::user_devices;
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7use super::AuthService;
8use crate::errors::AuthError;
9
10#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
11pub struct DeviceDto {
12 pub id: Uuid,
13 pub device_name: Option<String>,
14 pub device_type: Option<String>,
15 pub os: Option<String>,
16 pub is_trusted: bool,
17 pub first_seen_at: OffsetDateTime,
18 pub last_seen_at: OffsetDateTime,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
22pub struct UpdateDeviceCmd {
23 pub device_name: Option<String>,
24 pub device_type: Option<String>,
25 pub is_trusted: Option<bool>,
26}
27
28impl AuthService {
29 pub async fn list_devices(&self, uid: Uuid) -> Result<Vec<DeviceDto>, AuthError> {
30 let rows = user_devices::Entity::find()
31 .filter(user_devices::Column::UserId.eq(uid))
32 .order_by_desc(user_devices::Column::LastSeenAt)
33 .all(self.db().read())
34 .await?;
35 Ok(rows
36 .into_iter()
37 .map(|row| DeviceDto {
38 id: row.id,
39 device_name: row.device_name,
40 device_type: row.device_type,
41 os: row.os,
42 is_trusted: row.is_trusted,
43 first_seen_at: row.first_seen_at,
44 last_seen_at: row.last_seen_at,
45 })
46 .collect())
47 }
48
49 pub async fn update_device(
50 &self,
51 uid: Uuid,
52 device_id: Uuid,
53 cmd: UpdateDeviceCmd,
54 ) -> Result<(), AuthError> {
55 let db = self.db();
56 let Some(device) = user_devices::Entity::find()
57 .filter(user_devices::Column::Id.eq(device_id))
58 .filter(user_devices::Column::UserId.eq(uid))
59 .one(db.write())
60 .await?
61 else {
62 return Err(AuthError::NotFound);
63 };
64 let device_name = cmd.device_name.or(device.device_name.clone());
65 let device_type = cmd.device_type.or(device.device_type.clone());
66 let mut active = device.into_active_model();
67 active.device_name = Set(device_name);
68 active.device_type = Set(device_type);
69 if let Some(trusted) = cmd.is_trusted {
70 active.is_trusted = Set(trusted);
71 }
72 active.updated_at = Set(OffsetDateTime::now_utc());
73 active.update(db.write()).await?;
74 Ok(())
75 }
76
77 pub async fn delete_device(&self, uid: Uuid, device_id: Uuid) -> Result<(), AuthError> {
78 let db = self.db();
79 let result = user_devices::Entity::delete_many()
80 .filter(user_devices::Column::Id.eq(device_id))
81 .filter(user_devices::Column::UserId.eq(uid))
82 .exec(db.write())
83 .await?;
84 if result.rows_affected == 0 {
85 return Err(AuthError::NotFound);
86 }
87 Ok(())
88 }
89}
No newline at end of file
Added lib/auth/src/services/email.rs lib/auth/src/services/email.rs +536 −0 View lib/auth/src/services/email.rs
@@ -0,0 +1,536 @@
1use models::users::user;
2use models::users::user_email_change_tickets::EmailChangeTicketStatus;
3use models::users::user_email_verification_codes::EmailVerificationPurpose;
4use models::users::{user_emails, user_email_change_tickets, user_email_verification_codes};
5use sea_orm::sea_query::Expr;
6use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set};
7use session::Session;
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use super::AuthService;
13use crate::share::crypto::{constant_time_eq, generate_numeric_code, sha256_b64url};
14use crate::errors::AuthError;
15use crate::share::rate::{attempts_reset, attempts_track, rate_limit_once};
16use crate::share::types::TempKey;
17use crate::share::validate::validate_email;
18
19fn purpose_str(purpose: EmailVerificationPurpose) -> &'static str {
20 match purpose {
21 EmailVerificationPurpose::Signup => "signup",
22 EmailVerificationPurpose::PasswordReset => "password_reset",
23 }
24}
25
26fn temp_key(
27 prefix: &str,
28 purpose: Option<EmailVerificationPurpose>,
29 email: &str,
30 value: Uuid,
31 ttl_seconds: u64,
32) -> TempKey {
33 let part = purpose.map(purpose_str).unwrap_or_default();
34 TempKey {
35 name: format!(
36 "auth_{prefix}{part}_{}",
37 email.replace('@', "_at_")
38 ),
39 value: value.to_string(),
40 ttl_seconds,
41 secure: true,
42 http_only: true,
43 same_site: "Strict".to_string(),
44 domain: None,
45 path: "/".to_string(),
46 }
47}
48
49pub(crate) struct IssueCodeArtifacts {
50 pub temp_key: TempKey,
51 pub code: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
55pub struct VerifyEmailCmd {
56 pub email: String,
57 pub purpose: EmailVerificationPurpose,
58 pub code: String,
59 pub temp_id: Uuid,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
63pub struct VerifyEmailResp {
64 pub verified: bool,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
68pub struct ResendCodeCmd {
69 pub email: String,
70 pub purpose: EmailVerificationPurpose,
71 pub pow_answer: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
75pub struct ResendCodeResp {
76 pub sent: bool,
77 pub temp_key: TempKey,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
81pub struct ReclaimCmd {
82 pub email: String,
83 pub pow_answer: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
87pub struct ReclaimResp {
88 pub sent: bool,
89 pub temp_key: TempKey,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
93pub struct EmailChangeTicketDto {
94 pub id: Uuid,
95 pub new_email: String,
96 pub status: String,
97 pub created_at: OffsetDateTime,
98 pub expires_at: OffsetDateTime,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
102pub struct RequestEmailChangeCmd {
103 pub new_email: String,
104}
105
106impl AuthService {
107 pub(crate) async fn issue_code(
108 &self,
109 user_id: Uuid,
110 email: &str,
111 purpose: EmailVerificationPurpose,
112 ) -> Result<IssueCodeArtifacts, AuthError> {
113 let config = &self.config().auth;
114 let lower_email = email.trim().to_lowercase();
115 let purpose_name = purpose_str(purpose);
116 let rate_key = format!("auth:code_rate:{purpose_name}:{lower_email}");
117 rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?;
118
119 let db = self.db();
120 let now = OffsetDateTime::now_utc();
121
122 let stale = user_email_verification_codes::Entity::find()
123 .filter(user_email_verification_codes::Column::Email.eq(&lower_email))
124 .filter(user_email_verification_codes::Column::Purpose.eq(purpose))
125 .filter(user_email_verification_codes::Column::UsedAt.is_null())
126 .all(db.write())
127 .await?;
128 for row in stale {
129 let mut active: user_email_verification_codes::ActiveModel = row.into();
130 active.used_at = Set(Some(now));
131 let _ = active.update(db.write()).await;
132 }
133
134 let code = generate_numeric_code(config.verify_code_len);
135 let temp_id = Uuid::now_v7();
136 let code_key = format!("auth:code:{purpose_name}:{temp_id}");
137 crate::share::redis::set_string(
138 self.redis(),
139 &code_key,
140 &code,
141 config.verify_code_ttl_secs,
142 )
143 .await?;
144
145 let row = user_email_verification_codes::ActiveModel {
146 id: Set(Uuid::now_v7()),
147 user_id: Set(user_id),
148 email: Set(lower_email.clone()),
149 purpose: Set(purpose),
150 token_hash: Set(sha256_b64url(code.as_bytes())),
151 created_at: Set(now),
152 expires_at: Set(now + time::Duration::seconds(config.verify_code_ttl_secs as i64)),
153 used_at: Set(None),
154 };
155 row.insert(db.write()).await?;
156
157 let temp_key = temp_key("", Some(purpose), &lower_email, temp_id, config.verify_code_ttl_secs);
158 Ok(IssueCodeArtifacts { temp_key, code })
159 }
160
161 pub async fn verify_verification_code(
162 &self,
163 cmd: VerifyEmailCmd,
164 ) -> Result<VerifyEmailResp, AuthError> {
165 let config = &self.config().auth;
166 let purpose_name = purpose_str(cmd.purpose);
167 let code_key = format!("auth:code:{purpose_name}:{}", cmd.temp_id);
168 let Some(stored) = crate::share::redis::get_string(self.redis(), &code_key).await? else {
169 return Err(AuthError::BadRequest(
170 "verification code expired or not found".to_string(),
171 ));
172 };
173 let attempts_key = format!("auth:code_attempts:{purpose_name}:{}", cmd.temp_id);
174 if !attempts_track(
175 self.redis(),
176 &attempts_key,
177 config.max_verify_attempts,
178 config.verify_code_ttl_secs,
179 )
180 .await?
181 {
182 crate::share::redis::delete(self.redis(), &code_key).await?;
183 return Err(AuthError::RateLimited);
184 }
185 let provided = cmd.code.trim().to_string();
186 if !constant_time_eq(stored.as_bytes(), provided.as_bytes()) {
187 return Err(AuthError::BadRequest("invalid verification code".to_string()));
188 }
189 crate::share::redis::delete(self.redis(), &code_key).await?;
190 attempts_reset(self.redis(), &attempts_key).await?;
191
192 let db = self.db();
193 let lower_email = cmd.email.trim().to_lowercase();
194 let Some(row) = user_email_verification_codes::Entity::find()
195 .filter(user_email_verification_codes::Column::Email.eq(&lower_email))
196 .filter(user_email_verification_codes::Column::Purpose.eq(cmd.purpose))
197 .filter(
198 user_email_verification_codes::Column::TokenHash
199 .eq(sha256_b64url(provided.as_bytes())),
200 )
201 .filter(user_email_verification_codes::Column::UsedAt.is_null())
202 .one(db.write())
203 .await?
204 else {
205 return Err(AuthError::BadRequest("invalid verification code".to_string()));
206 };
207 if row.expires_at <= OffsetDateTime::now_utc() {
208 return Err(AuthError::BadRequest(
209 "verification code expired".to_string(),
210 ));
211 }
212
213 let now = OffsetDateTime::now_utc();
214 match cmd.purpose {
215 EmailVerificationPurpose::Signup => {
216 let user_id = row.user_id;
217 let email = row.email.clone();
218 let row = row.clone();
219 sea_orm::TransactionTrait::transaction(db.primary(), |txn| {
220 Box::pin(async move {
221 let mut active: user_email_verification_codes::ActiveModel = row.into();
222 active.used_at = Set(Some(now));
223 active.update(txn).await?;
224
225 let _ = user::Entity::update_many()
226 .col_expr(user::Column::IsActive, Expr::value(true))
227 .col_expr(user::Column::UpdatedAt, Expr::value(now))
228 .filter(user::Column::Uid.eq(user_id))
229 .exec(txn)
230 .await?;
231 let _ = user_emails::Entity::update_many()
232 .col_expr(user_emails::Column::IsActivated, Expr::value(true))
233 .col_expr(user_emails::Column::ActivatedAt, Expr::value(now))
234 .filter(user_emails::Column::UserId.eq(user_id))
235 .filter(user_emails::Column::LowerEmail.eq(&email))
236 .exec(txn)
237 .await?;
238 Ok::<(), DbErr>(())
239 })
240 })
241 .await?;
242 }
243 EmailVerificationPurpose::PasswordReset => {
244 let mut active: user_email_verification_codes::ActiveModel = row.into();
245 active.used_at = Set(Some(now));
246 active.update(db.write()).await?;
247 }
248 }
249 Ok(VerifyEmailResp { verified: true })
250 }
251
252 pub async fn resend_code(
253 &self,
254 session: &Session,
255 cmd: ResendCodeCmd,
256 ) -> Result<ResendCodeResp, AuthError> {
257 self.verify_pow(session, &cmd.pow_answer)?;
258 let lower_email = cmd.email.trim().to_lowercase();
259 let placeholder = temp_key("resend_", Some(cmd.purpose), &lower_email, Uuid::nil(), 0);
260 let Some(email_row) = user_emails::Entity::find()
261 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
262 .filter(user_emails::Column::DeletedAt.is_null())
263 .one(self.db().read())
264 .await?
265 else {
266 return Ok(ResendCodeResp {
267 sent: false,
268 temp_key: placeholder,
269 });
270 };
271 let artifacts = self
272 .issue_code(email_row.user_id, &lower_email, cmd.purpose)
273 .await?;
274 let subject = match cmd.purpose {
275 EmailVerificationPurpose::Signup => "Verify your account",
276 EmailVerificationPurpose::PasswordReset => "Reset your password",
277 };
278 let minutes = self.config().auth.verify_code_ttl_secs / 60;
279 let html = crate::templates::mail_verify_code(&artifacts.code, minutes, subject);
280 let body = format!("Your verification code is: {}", artifacts.code);
281 let sent = self
282 .send_email(email_row.email.as_str(), subject, &body, html.0.as_str())
283 .await
284 .map(|_| true)
285 .unwrap_or_else(|error| {
286 tracing::warn!(%error, "failed to send verification email");
287 false
288 });
289 Ok(ResendCodeResp {
290 sent,
291 temp_key: artifacts.temp_key,
292 })
293 }
294
295 pub async fn reclaim(
296 &self,
297 session: &Session,
298 cmd: ReclaimCmd,
299 ) -> Result<ReclaimResp, AuthError> {
300 self.verify_pow(session, &cmd.pow_answer)?;
301 let lower_email = cmd.email.trim().to_lowercase();
302 let Some((email_row, Some(user))) = user_emails::Entity::find()
303 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
304 .filter(user_emails::Column::DeletedAt.is_null())
305 .find_also_related(user::Entity)
306 .one(self.db().read())
307 .await?
308 else {
309 return Ok(ReclaimResp {
310 sent: false,
311 temp_key: temp_key("reclaim_", None, &lower_email, Uuid::nil(), 0),
312 });
313 };
314 if user.is_active {
315 return Ok(ReclaimResp {
316 sent: false,
317 temp_key: temp_key("reclaim_", None, &lower_email, Uuid::nil(), 0),
318 });
319 }
320 let artifacts = self
321 .issue_code(user.uid, &lower_email, EmailVerificationPurpose::Signup)
322 .await?;
323 let minutes = self.config().auth.verify_code_ttl_secs / 60;
324 let html = crate::templates::mail_verify_code(&artifacts.code, minutes, "Recover your account");
325 let body = format!("Recover your account with code: {}", artifacts.code);
326 let sent = self
327 .send_email(
328 email_row.email.as_str(),
329 "Recover your account",
330 &body,
331 html.0.as_str(),
332 )
333 .await
334 .map(|_| true)
335 .unwrap_or_else(|error| {
336 tracing::warn!(%error, "failed to send reclaim email");
337 false
338 });
339 Ok(ReclaimResp {
340 sent,
341 temp_key: artifacts.temp_key,
342 })
343 }
344
345 pub async fn request_email_change(
346 &self,
347 uid: Uuid,
348 cmd: RequestEmailChangeCmd,
349 ) -> Result<EmailChangeTicketDto, AuthError> {
350 let config = &self.config().auth;
351 let new_email = validate_email(&cmd.new_email)?;
352 let db = self.db();
353 let now = OffsetDateTime::now_utc();
354
355 let current = user_emails::Entity::find()
356 .filter(user_emails::Column::UserId.eq(uid))
357 .filter(user_emails::Column::IsPrimary.eq(true))
358 .one(db.read())
359 .await?;
360 let current_email = current
361 .as_ref()
362 .map(|row| row.lower_email.clone())
363 .unwrap_or_default();
364 if current_email == new_email {
365 return Err(AuthError::BadRequest(
366 "new email is the same as the current email".to_string(),
367 ));
368 }
369
370 let taken = user_emails::Entity::find()
371 .filter(user_emails::Column::LowerEmail.eq(&new_email))
372 .filter(user_emails::Column::UserId.ne(uid))
373 .one(db.read())
374 .await?;
375 if taken.is_some() {
376 return Err(AuthError::Conflict("email is already in use".to_string()));
377 }
378
379 let rate_key = format!("auth:code_rate:email_change:{new_email}");
380 rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?;
381
382 let stale = user_email_change_tickets::Entity::find()
383 .filter(user_email_change_tickets::Column::UserId.eq(uid))
384 .filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending))
385 .all(db.write())
386 .await?;
387 for ticket in stale {
388 let mut active: user_email_change_tickets::ActiveModel = ticket.into();
389 active.status = Set(EmailChangeTicketStatus::Cancelled);
390 let _ = active.update(db.write()).await;
391 }
392
393 let code = generate_numeric_code(config.verify_code_len);
394 let ticket_id = Uuid::now_v7();
395 let ticket = user_email_change_tickets::ActiveModel {
396 id: Set(ticket_id),
397 user_id: Set(uid),
398 token_hash: Set(sha256_b64url(code.as_bytes())),
399 new_email: Set(new_email.clone()),
400 original_email: Set(current.map(|row| row.email)),
401 status: Set(EmailChangeTicketStatus::Pending),
402 created_at: Set(now),
403 expires_at: Set(now + time::Duration::seconds(config.verify_code_ttl_secs as i64)),
404 used_at: Set(None),
405 };
406 ticket.insert(db.write()).await?;
407
408 let expires_at = now + time::Duration::seconds(config.verify_code_ttl_secs as i64);
409 let base_url = &self.config().web.web_base_url;
410 let html = crate::templates::mail_email_change_confirm(
411 &new_email,
412 &ticket_id.to_string(),
413 &code,
414 base_url,
415 );
416 let body = format!("Your email change code is: {code}");
417 if let Err(error) = self
418 .send_email(&new_email, "Confirm your new email", &body, html.0.as_str())
419 .await
420 {
421 tracing::warn!(%error, "failed to send email change code");
422 }
423 Ok(EmailChangeTicketDto {
424 id: ticket_id,
425 new_email,
426 status: "pending".to_string(),
427 created_at: now,
428 expires_at,
429 })
430 }
431
432 pub async fn confirm_email_change(
433 &self,
434 uid: Uuid,
435 ticket_id: Uuid,
436 code: &str,
437 ) -> Result<(), AuthError> {
438 let db = self.db();
439 let now = OffsetDateTime::now_utc();
440 let Some(ticket) = user_email_change_tickets::Entity::find()
441 .filter(user_email_change_tickets::Column::Id.eq(ticket_id))
442 .filter(user_email_change_tickets::Column::UserId.eq(uid))
443 .filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending))
444 .one(db.write())
445 .await?
446 else {
447 return Err(AuthError::BadRequest(
448 "email change ticket not found".to_string(),
449 ));
450 };
451 if ticket.expires_at <= now {
452 return Err(AuthError::BadRequest(
453 "email change ticket expired".to_string(),
454 ));
455 }
456 let code_hash = sha256_b64url(code.trim().as_bytes());
457 if !constant_time_eq(code_hash.as_bytes(), ticket.token_hash.as_bytes()) {
458 return Err(AuthError::BadRequest("invalid email change code".to_string()));
459 }
460
461 let conflicting = user_emails::Entity::find()
462 .filter(user_emails::Column::LowerEmail.eq(ticket.new_email.to_lowercase()))
463 .filter(user_emails::Column::UserId.ne(uid))
464 .one(db.write())
465 .await?;
466 if conflicting.is_some() {
467 return Err(AuthError::Conflict("email is already in use".to_string()));
468 }
469
470 let new_email = ticket.new_email.clone();
471 let new_lower = new_email.to_lowercase();
472 sea_orm::TransactionTrait::transaction(db.primary(), |txn| {
473 Box::pin(async move {
474 let _ = user_emails::Entity::update_many()
475 .col_expr(user_emails::Column::IsPrimary, Expr::value(false))
476 .filter(user_emails::Column::UserId.eq(uid))
477 .filter(user_emails::Column::IsPrimary.eq(true))
478 .exec(txn)
479 .await?;
480
481 let existing = user_emails::Entity::find()
482 .filter(user_emails::Column::UserId.eq(uid))
483 .filter(user_emails::Column::LowerEmail.eq(&new_lower))
484 .one(txn)
485 .await?;
486 if let Some(row) = existing {
487 let mut active: user_emails::ActiveModel = row.into();
488 active.is_activated = Set(true);
489 active.is_primary = Set(true);
490 active.activated_at = Set(Some(now));
491 active.updated_at = Set(now);
492 active.update(txn).await?;
493 } else {
494 let row = user_emails::ActiveModel {
495 id: Set(Uuid::now_v7()),
496 user_id: Set(uid),
497 email: Set(new_email.clone()),
498 lower_email: Set(new_lower.clone()),
499 is_activated: Set(true),
500 is_primary: Set(true),
501 created_at: Set(now),
502 updated_at: Set(now),
503 activated_at: Set(Some(now)),
504 deleted_at: Set(None),
505 };
506 row.insert(txn).await?;
507 }
508
509 let mut active: user_email_change_tickets::ActiveModel = ticket.into();
510 active.status = Set(EmailChangeTicketStatus::Completed);
511 active.used_at = Set(Some(now));
512 active.update(txn).await?;
513 Ok::<(), DbErr>(())
514 })
515 })
516 .await?;
517 Ok(())
518 }
519
520 pub async fn cancel_email_change(&self, uid: Uuid, ticket_id: Uuid) -> Result<(), AuthError> {
521 let db = self.db();
522 let Some(ticket) = user_email_change_tickets::Entity::find()
523 .filter(user_email_change_tickets::Column::Id.eq(ticket_id))
524 .filter(user_email_change_tickets::Column::UserId.eq(uid))
525 .filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending))
526 .one(db.write())
527 .await?
528 else {
529 return Err(AuthError::NotFound);
530 };
531 let mut active: user_email_change_tickets::ActiveModel = ticket.into();
532 active.status = Set(EmailChangeTicketStatus::Cancelled);
533 active.update(db.write()).await?;
534 Ok(())
535 }
536}
No newline at end of file
Added lib/auth/src/services/login.rs lib/auth/src/services/login.rs +329 −0 View lib/auth/src/services/login.rs
@@ -0,0 +1,329 @@
1use models::users::user;
2use models::users::user_recovery_codes;
3use models::users::{user_devices, user_emails};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set,
6};
7use session::{AuthUser, Session};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use super::AuthService;
13use crate::share::crypto::{aes_decrypt, now_secs, sha256_b64url};
14use crate::errors::AuthError;
15use crate::share::rate::{attempts_reset, attempts_track};
16use crate::share::totp::verify_totp_code;
17use crate::share::types::UserPublic;
18
19const TWO_FA_KEY: &str = "auth:2fa";
20const TWO_FA_TTL_SECS: i64 = 300;
21const LOGIN_ATTEMPTS_MAX: u64 = 5;
22const LOGIN_ATTEMPTS_WINDOW: u64 = 300;
23
24#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
25pub struct LoginCmd {
26 pub identifier: String,
27 pub password: String,
28 pub pow_answer: String,
29 pub ua_hash: Option<String>,
30 pub ip_hash: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
34pub struct LoginResp {
35 pub user: UserPublic,
36 pub needs_2fa: bool,
37 pub challenge: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
41pub struct TwoFactorLoginCmd {
42 pub code: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46struct TwoFactorChallenge {
47 pub uid: Uuid,
48 pub expires_at: i64,
49}
50
51impl From<&user::Model> for UserPublic {
52 fn from(user: &user::Model) -> Self {
53 Self {
54 uid: user.uid,
55 username: user.username.clone(),
56 display_name: user.display_name.clone(),
57 avatar_url: user.avatar_url.clone(),
58 }
59 }
60}
61
62impl AuthService {
63 pub async fn login(
64 &self,
65 session: &Session,
66 cmd: LoginCmd,
67 ) -> Result<LoginResp, AuthError> {
68 self.verify_pow(session, &cmd.pow_answer)?;
69
70 let db = self.db();
71 let identifier = cmd.identifier.trim().to_ascii_lowercase();
72 let attempts_key = format!("auth:login_attempts:{identifier}");
73 if !attempts_track(
74 self.redis(),
75 &attempts_key,
76 LOGIN_ATTEMPTS_MAX,
77 LOGIN_ATTEMPTS_WINDOW,
78 )
79 .await?
80 {
81 return Err(AuthError::RateLimited);
82 }
83
84 let Some(user) = Self::find_user_by_identifier(db.write(), &identifier).await? else {
85 return Err(AuthError::InvalidCredentials);
86 };
87 if user.prohibit_login {
88 return Err(AuthError::Forbidden);
89 }
90 if user.is_restricted {
91 return Err(AuthError::Forbidden);
92 }
93 if !user.is_active {
94 return Err(AuthError::Forbidden);
95 }
96 let stored = user.passwd_hash.as_deref().ok_or_else(|| {
97 AuthError::BadRequest("account has no password".to_string())
98 })?;
99 if !crate::share::crypto::verify_password(&cmd.password, stored) {
100 return Err(AuthError::InvalidCredentials);
101 }
102 attempts_reset(self.redis(), &attempts_key).await?;
103
104 if user.totp_enabled {
105 let challenge = TwoFactorChallenge {
106 uid: user.uid,
107 expires_at: now_secs() + TWO_FA_TTL_SECS,
108 };
109 session.set(TWO_FA_KEY, challenge);
110 return Ok(LoginResp {
111 user: UserPublic::from(&user),
112 needs_2fa: true,
113 challenge: Some(Uuid::now_v7().to_string()),
114 });
115 }
116
117 self.finish_login(session, &user, cmd.ua_hash, cmd.ip_hash)
118 .await
119 }
120
121 pub async fn login_2fa(
122 &self,
123 session: &Session,
124 cmd: TwoFactorLoginCmd,
125 ) -> Result<LoginResp, AuthError> {
126 let Some(challenge) = session.get::<TwoFactorChallenge>(TWO_FA_KEY) else {
127 return Err(AuthError::BadRequest(
128 "no pending two-factor challenge".to_string(),
129 ));
130 };
131 let attempts_key = format!("auth:2fa_attempts:{}", challenge.uid);
132 if !attempts_track(self.redis(), &attempts_key, 5, 300).await? {
133 return Err(AuthError::RateLimited);
134 }
135
136 session.remove(TWO_FA_KEY);
137 if challenge.expires_at <= now_secs() {
138 return Err(AuthError::BadRequest(
139 "two-factor challenge expired".to_string(),
140 ));
141 }
142
143 let db = self.db();
144 let Some(user) = user::Entity::find_by_id(challenge.uid).one(db.read()).await?
145 else {
146 return Err(AuthError::BadRequest(
147 "invalid two-factor challenge".to_string(),
148 ));
149 };
150 if !user.totp_enabled {
151 return Err(AuthError::BadRequest(
152 "two-factor authentication is not enabled".to_string(),
153 ));
154 }
155
156 self.consume_two_factor_code(&user, &cmd.code).await?;
157 attempts_reset(self.redis(), &attempts_key).await?;
158 self.finish_login(session, &user, None, None).await
159 }
160
161 async fn consume_two_factor_code(
162 &self,
163 user: &user::Model,
164 code: &str,
165 ) -> Result<(), AuthError> {
166 let secret_key = self
167 .config()
168 .auth
169 .totp_encryption_key
170 .as_deref()
171 .ok_or_else(|| AuthError::Internal("totp encryption key not configured".to_string()))?;
172 let stored = user.totp_encrypted_secret.as_deref().ok_or_else(|| {
173 AuthError::Internal("totp secret missing".to_string())
174 })?;
175 let secret = aes_decrypt(secret_key, stored)?;
176 match verify_totp_code(&secret, code, user.totp_last_counter) {
177 Ok((_, counter)) => {
178 let now = OffsetDateTime::now_utc();
179 let mut active = user.clone().into_active_model();
180 active.totp_last_used_at = Set(Some(now));
181 active.totp_last_counter = Set(Some(counter));
182 active.updated_at = Set(now);
183 active.update(self.db().write()).await?;
184 Ok(())
185 }
186 Err(totp_error) => {
187 if let TotpErrorCategory::Invalid = TotpErrorCategory::from(&totp_error) {
188 self.consume_recovery_code(user.uid, code).await
189 } else {
190 Err(AuthError::from(totp_error))
191 }
192 }
193 }
194 }
195
196 async fn consume_recovery_code(&self, uid: Uuid, code: &str) -> Result<(), AuthError> {
197 let hash = sha256_b64url(code.trim().as_bytes());
198 let db = self.db();
199 let Some(row) = user_recovery_codes::Entity::find()
200 .filter(user_recovery_codes::Column::UserId.eq(uid))
201 .filter(user_recovery_codes::Column::CodeHash.eq(&hash))
202 .filter(user_recovery_codes::Column::UsedAt.is_null())
203 .one(db.write())
204 .await?
205 else {
206 return Err(AuthError::BadRequest("invalid or expired code".to_string()));
207 };
208 let mut active = row.into_active_model();
209 active.used_at = Set(Some(OffsetDateTime::now_utc()));
210 active.update(db.write()).await?;
211 Ok(())
212 }
213
214 async fn finish_login(
215 &self,
216 session: &Session,
217 user: &user::Model,
218 ua_hash: Option<String>,
219 ip_hash: Option<String>,
220 ) -> Result<LoginResp, AuthError> {
221 self.assert_not_suspended(user.uid).await?;
222 let db = self.db();
223 let primary = user_emails::Entity::find()
224 .filter(user_emails::Column::UserId.eq(user.uid))
225 .filter(user_emails::Column::IsPrimary.eq(true))
226 .one(db.read())
227 .await?;
228 let email = primary.map(|row| row.email).unwrap_or_default();
229
230 let auth_user = AuthUser {
231 uid: user.uid,
232 username: user.username.clone(),
233 display_name: Some(user.display_name.clone()),
234 avatar_url: Some(user.avatar_url.clone()),
235 email,
236 };
237 crate::share::session::apply_login(session, auth_user, user.passwd_version);
238
239 let now = OffsetDateTime::now_utc();
240 let mut active = user.clone().into_active_model();
241 active.last_login_at = Set(Some(now));
242 active.updated_at = Set(now);
243 let _ = active.update(db.write()).await;
244
245 if let Some(hash) = ua_hash {
246 let _ = self.record_device(user.uid, &hash, ip_hash).await;
247 }
248 Ok(LoginResp {
249 user: UserPublic::from(user),
250 needs_2fa: false,
251 challenge: None,
252 })
253 }
254
255 async fn record_device(
256 &self,
257 uid: Uuid,
258 ua_hash: &str,
259 ip_hash: Option<String>,
260 ) -> Result<(), AuthError> {
261 let db = self.db();
262 let now = OffsetDateTime::now_utc();
263 if let Some(device) = user_devices::Entity::find()
264 .filter(user_devices::Column::UserId.eq(uid))
265 .filter(user_devices::Column::DeviceHash.eq(ua_hash))
266 .one(db.write())
267 .await?
268 {
269 let mut active = device.into_active_model();
270 active.last_seen_at = Set(now);
271 active.last_ip_hash = Set(ip_hash);
272 active.updated_at = Set(now);
273 active.update(db.write()).await?;
274 } else {
275 let row = user_devices::ActiveModel {
276 id: Set(Uuid::now_v7()),
277 user_id: Set(uid),
278 device_name: Set(None),
279 device_type: Set(None),
280 device_hash: Set(ua_hash.to_string()),
281 user_agent_hash: Set(Some(ua_hash.to_string())),
282 os: Set(None),
283 last_ip_hash: Set(ip_hash),
284 last_seen_at: Set(now),
285 first_seen_at: Set(now),
286 is_trusted: Set(false),
287 created_at: Set(now),
288 updated_at: Set(now),
289 };
290 row.insert(db.write()).await?;
291 }
292 Ok(())
293 }
294
295 async fn find_user_by_identifier(
296 db: &sea_orm::DatabaseConnection,
297 identifier: &str,
298 ) -> Result<Option<user::Model>, AuthError> {
299 if identifier.contains('@')
300 && let Some((_email, Some(user))) = user_emails::Entity::find()
301 .filter(user_emails::Column::LowerEmail.eq(identifier))
302 .filter(user_emails::Column::DeletedAt.is_null())
303 .find_also_related(user::Entity)
304 .one(db)
305 .await?
306 {
307 return Ok(Some(user));
308 }
309 Ok(user::Entity::find()
310 .filter(user::Column::LowerName.eq(identifier))
311 .one(db)
312 .await?)
313 }
314 }
315
316 enum TotpErrorCategory {
317 Invalid,
318 MalformedSecret,
319}
320
321impl From<&crate::share::totp::TotpError> for TotpErrorCategory {
322 fn from(error: &crate::share::totp::TotpError) -> Self {
323 match error {
324 crate::share::totp::TotpError::InvalidCode
325 | crate::share::totp::TotpError::InvalidOrExpiredCode => Self::Invalid,
326 crate::share::totp::TotpError::MalformedSecret => Self::MalformedSecret,
327 }
328 }
329}
No newline at end of file
Added lib/auth/src/services/logout.rs lib/auth/src/services/logout.rs +24 −0 View lib/auth/src/services/logout.rs
@@ -0,0 +1,24 @@
1use session::{Session, SessionUserMut};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use super::AuthService;
6
7#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
8pub struct LogoutResp {
9 pub logged_out: bool,
10}
11
12impl AuthService {
13 pub fn logout(&self, session: &Session, uid: Option<Uuid>) -> LogoutResp {
14 match uid {
15 Some(uid) => LogoutResp {
16 logged_out: session.logout_user(uid),
17 },
18 None => {
19 session.clear_uid();
20 LogoutResp { logged_out: true }
21 }
22 }
23 }
24}
No newline at end of file
Added lib/auth/src/services/mod.rs lib/auth/src/services/mod.rs +73 −0 View lib/auth/src/services/mod.rs
@@ -0,0 +1,73 @@
1use std::sync::Arc;
2
3use config::AppConfig;
4use export::{AppDatabase, AppRedis, AppState};
5use mailbaby::Email;
6
7use crate::errors::AuthError;
8
9pub mod access_keys;
10pub mod agreements;
11pub mod deletion;
12pub mod devices;
13pub mod email;
14pub mod login;
15pub mod logout;
16pub mod notifications;
17pub mod password;
18pub mod pow;
19pub mod preferences;
20pub mod profile;
21pub mod register;
22pub mod reports;
23pub mod ssh_keys;
24pub mod suspensions;
25pub mod two_factor;
26pub mod users;
27
28#[derive(Clone)]
29pub struct AuthService {
30 pub state: AppState,
31}
32
33impl AuthService {
34 pub fn new(state: AppState) -> Self {
35 Self { state }
36 }
37
38 pub fn config(&self) -> &AppConfig {
39 &self.state.config
40 }
41
42 pub fn db(&self) -> &Arc<AppDatabase> {
43 &self.state.database
44 }
45
46 pub fn redis(&self) -> &AppRedis {
47 &self.state.redis
48 }
49
50 pub async fn send_email(
51 &self,
52 to: &str,
53 subject: &str,
54 text: &str,
55 html: &str,
56 ) -> Result<(), AuthError> {
57 let builder = Email::builder(subject).to([to]).text_body(text);
58 let builder = if html.is_empty() {
59 builder
60 } else {
61 builder.html_body(html)
62 };
63 let email = builder
64 .build()
65 .map_err(|error| AuthError::Internal(format!("mail build failed: {error}")))?;
66 self.state
67 .email
68 .send(&email)
69 .await
70 .map(|_| ())
71 .map_err(AuthError::from)
72 }
73}
No newline at end of file
Added lib/auth/src/services/notifications.rs lib/auth/src/services/notifications.rs +109 −0 View lib/auth/src/services/notifications.rs
@@ -0,0 +1,109 @@
1use models::users::user_notifications::NotificationType;
2use models::users::user_notifications;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait,
5 QueryFilter, QueryOrder, QuerySelect, Set,
6};
7use serde::{Deserialize, Serialize};
8use serde_json::Value as JsonValue;
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use super::AuthService;
13use crate::errors::AuthError;
14
15#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
16pub struct NotificationDto {
17 pub id: Uuid,
18 pub notification_type: NotificationType,
19 pub title: String,
20 pub body: Option<String>,
21 pub data: Option<JsonValue>,
22 pub actor_uid: Option<Uuid>,
23 pub read_at: Option<OffsetDateTime>,
24 pub created_at: OffsetDateTime,
25}
26
27impl AuthService {
28 pub async fn list_notifications(
29 &self,
30 uid: Uuid,
31 limit: Option<u64>,
32 offset: Option<u64>,
33 ) -> Result<Vec<NotificationDto>, AuthError> {
34 let max_limit = limit.unwrap_or(50).min(100);
35 let mut query = user_notifications::Entity::find()
36 .filter(user_notifications::Column::UserId.eq(uid))
37 .order_by_desc(user_notifications::Column::CreatedAt)
38 .limit(max_limit);
39 if let Some(offset) = offset {
40 query = query.offset(offset);
41 }
42 let rows = query.all(self.db().read()).await?;
43 Ok(rows
44 .into_iter()
45 .map(|row| NotificationDto {
46 id: row.id,
47 notification_type: row.notification_type,
48 title: row.title,
49 body: row.body,
50 data: row.data_json,
51 actor_uid: row.actor_uid,
52 read_at: row.read_at,
53 created_at: row.created_at,
54 })
55 .collect())
56 }
57
58 pub async fn unread_notifications_count(&self, uid: Uuid) -> Result<u64, AuthError> {
59 let count = user_notifications::Entity::find()
60 .filter(user_notifications::Column::UserId.eq(uid))
61 .filter(user_notifications::Column::ReadAt.is_null())
62 .count(self.db().read())
63 .await?;
64 Ok(count)
65 }
66
67 pub async fn mark_notification_read(&self, uid: Uuid, id: Uuid) -> Result<(), AuthError> {
68 let db = self.db();
69 let Some(row) = user_notifications::Entity::find()
70 .filter(user_notifications::Column::Id.eq(id))
71 .filter(user_notifications::Column::UserId.eq(uid))
72 .one(db.write())
73 .await?
74 else {
75 return Err(AuthError::NotFound);
76 };
77 let mut active = row.into_active_model();
78 active.read_at = Set(Some(OffsetDateTime::now_utc()));
79 active.update(db.write()).await?;
80 Ok(())
81 }
82
83 pub async fn mark_all_notifications_read(&self, uid: Uuid) -> Result<(), AuthError> {
84 let db = self.db();
85 user_notifications::Entity::update_many()
86 .col_expr(
87 user_notifications::Column::ReadAt,
88 sea_orm::sea_query::Expr::value(OffsetDateTime::now_utc()),
89 )
90 .filter(user_notifications::Column::UserId.eq(uid))
91 .filter(user_notifications::Column::ReadAt.is_null())
92 .exec(db.write())
93 .await?;
94 Ok(())
95 }
96
97 pub async fn delete_notification(&self, uid: Uuid, id: Uuid) -> Result<(), AuthError> {
98 let db = self.db();
99 let result = user_notifications::Entity::delete_many()
100 .filter(user_notifications::Column::Id.eq(id))
101 .filter(user_notifications::Column::UserId.eq(uid))
102 .exec(db.write())
103 .await?;
104 if result.rows_affected == 0 {
105 return Err(AuthError::NotFound);
106 }
107 Ok(())
108 }
109}
No newline at end of file
Added lib/auth/src/services/password.rs lib/auth/src/services/password.rs +198 −0 View lib/auth/src/services/password.rs
@@ -0,0 +1,198 @@
1use models::users::{user, user_emails};
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set,
4};
5use session::Session;
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use super::AuthService;
11use crate::share::crypto::{
12 constant_time_eq, hash_password, random_token, sha256_b64url, validate_password,
13};
14use crate::errors::AuthError;
15use crate::share::types::TempKey;
16
17#[derive(Serialize, Deserialize)]
18struct StoredResetToken {
19 token_hash: String,
20 email: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
24pub struct ForgotPasswordCmd {
25 pub email: String,
26 pub pow_answer: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
30pub struct ForgotPasswordResp {
31 pub sent: bool,
32 pub temp_key: TempKey,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
36pub struct ResetPasswordCmd {
37 pub email: String,
38 pub token: String,
39 pub new_password: String,
40 pub temp_id: Uuid,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
44pub struct ChangePasswordCmd {
45 pub old_password: String,
46 pub new_password: String,
47}
48
49impl AuthService {
50 pub async fn forgot_password(
51 &self,
52 session: &Session,
53 cmd: ForgotPasswordCmd,
54 ) -> Result<ForgotPasswordResp, AuthError> {
55 self.verify_pow(session, &cmd.pow_answer)?;
56 let config = &self.config().auth;
57 let lower_email = cmd.email.trim().to_lowercase();
58 let temp_id = Uuid::now_v7();
59 let temp_key = TempKey {
60 name: format!("auth_reset_{}", lower_email.replace('@', "_at_")),
61 value: temp_id.to_string(),
62 ttl_seconds: config.reset_token_ttl_secs,
63 secure: true,
64 http_only: true,
65 same_site: "Strict".to_string(),
66 domain: None,
67 path: "/".to_string(),
68 };
69
70 let Some(email_row) = user_emails::Entity::find()
71 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
72 .filter(user_emails::Column::DeletedAt.is_null())
73 .one(self.db().read())
74 .await?
75 else {
76 return Ok(ForgotPasswordResp {
77 sent: true,
78 temp_key,
79 });
80 };
81
82 let token = random_token(config.reset_token_bytes);
83 let reset_key = format!("auth:reset:{temp_id}");
84 let stored = StoredResetToken {
85 token_hash: sha256_b64url(token.as_bytes()),
86 email: lower_email.clone(),
87 };
88 crate::share::redis::set_string(
89 self.redis(),
90 &reset_key,
91 &serde_json::to_string(&stored)
92 .map_err(|error| AuthError::Internal(format!("encode reset token: {error}")))?,
93 config.reset_token_ttl_secs,
94 )
95 .await?;
96
97 let base_url = &self.config().web.web_base_url;
98 let html = crate::templates::mail_reset_link(
99 &lower_email,
100 &token,
101 &temp_key.value,
102 base_url,
103 );
104 let body = format!("Use this reset token within the next minutes: {token}");
105 let sent = self
106 .send_email(
107 email_row.email.as_str(),
108 "Reset your password",
109 &body,
110 html.0.as_str(),
111 )
112 .await
113 .map(|_| true)
114 .unwrap_or_else(|error| {
115 tracing::warn!(%error, "failed to send reset email");
116 false
117 });
118 Ok(ForgotPasswordResp { sent, temp_key })
119 }
120
121 pub async fn reset_password(
122 &self,
123 cmd: ResetPasswordCmd,
124 ) -> Result<(), AuthError> {
125 let config = &self.config().auth;
126 validate_password(&cmd.new_password, &config.password_policy)?;
127
128 let reset_key = format!("auth:reset:{}", cmd.temp_id);
129 let Some(raw) = crate::share::redis::getdel_string(self.redis(), &reset_key).await?
130 else {
131 return Err(AuthError::BadRequest(
132 "reset token expired or not found".to_string(),
133 ));
134 };
135 let stored: StoredResetToken = serde_json::from_str(&raw)
136 .map_err(|error| AuthError::Internal(format!("decode reset token: {error}")))?;
137 let provided = cmd.token.trim();
138 let lower_email = cmd.email.trim().to_lowercase();
139 let valid = constant_time_eq(stored.token_hash.as_bytes(), sha256_b64url(provided.as_bytes()).as_bytes())
140 && stored.email == lower_email;
141 if !valid {
142 return Err(AuthError::BadRequest("invalid reset token".to_string()));
143 }
144
145 let db = self.db();
146 let Some((_email_row, Some(user))) = user_emails::Entity::find()
147 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
148 .filter(user_emails::Column::DeletedAt.is_null())
149 .find_also_related(user::Entity)
150 .one(db.write())
151 .await?
152 else {
153 return Err(AuthError::BadRequest("invalid reset token".to_string()));
154 };
155
156 let password_hash = hash_password(&cmd.new_password)?;
157 let now = OffsetDateTime::now_utc();
158 let next_version = user.passwd_version.unwrap_or(0) + 1;
159 let mut active = user.into_active_model();
160 active.passwd_hash = Set(Some(password_hash));
161 active.passwd_version = Set(Some(next_version));
162 active.password_last_changed_at = Set(Some(now));
163 active.updated_at = Set(now);
164 active.update(db.write()).await?;
165 Ok(())
166 }
167
168 pub async fn change_password(
169 &self,
170 uid: Uuid,
171 cmd: ChangePasswordCmd,
172 ) -> Result<(), AuthError> {
173 let config = &self.config().auth;
174 validate_password(&cmd.new_password, &config.password_policy)?;
175
176 let db = self.db();
177 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
178 return Err(AuthError::NotFound);
179 };
180 let stored = user.passwd_hash.as_deref().ok_or_else(|| {
181 AuthError::BadRequest("account has no password".to_string())
182 })?;
183 if !crate::share::crypto::verify_password(&cmd.old_password, stored) {
184 return Err(AuthError::InvalidCredentials);
185 }
186
187 let password_hash = hash_password(&cmd.new_password)?;
188 let now = OffsetDateTime::now_utc();
189 let next_version = user.passwd_version.unwrap_or(0) + 1;
190 let mut active = user.into_active_model();
191 active.passwd_hash = Set(Some(password_hash));
192 active.passwd_version = Set(Some(next_version));
193 active.password_last_changed_at = Set(Some(now));
194 active.updated_at = Set(now);
195 active.update(db.write()).await?;
196 Ok(())
197 }
198}
No newline at end of file
Added lib/auth/src/services/pow.rs lib/auth/src/services/pow.rs +16 −0 View lib/auth/src/services/pow.rs
@@ -0,0 +1,16 @@
1use pow_solver::Challenge;
2use session::Session;
3
4use super::AuthService;
5use crate::errors::AuthError;
6
7impl AuthService {
8 pub fn issue_pow_challenge(&self, session: &Session) -> Result<Challenge, AuthError> {
9 Ok(crate::pow::issue(session, &self.config().pow)?)
10 }
11
12 pub fn verify_pow(&self, session: &Session, answer: &str) -> Result<(), AuthError> {
13 crate::pow::verify(session, &self.config().pow, answer)?;
14 Ok(())
15 }
16}
No newline at end of file
Added lib/auth/src/services/preferences.rs lib/auth/src/services/preferences.rs +147 −0 View lib/auth/src/services/preferences.rs
@@ -0,0 +1,147 @@
1use models::users::user::Audience;
2use models::users::user_preferences;
3use sea_orm::{
4 ActiveModelTrait, EntityTrait, IntoActiveModel, Set,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use super::AuthService;
11use crate::errors::AuthError;
12
13#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
14pub struct UserPreferencesDto {
15 pub theme_mode: String,
16 pub diff_view_style: Option<String>,
17 pub in_app_flags: i32,
18 pub email_flags: i32,
19 pub push_flags: i32,
20 pub email_digest: bool,
21 pub mute_until: Option<OffsetDateTime>,
22 pub allow_dms_from: Audience,
23 pub allow_mentions_from: Audience,
24 pub show_online_status: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default, utoipa::ToSchema)]
28pub struct UpdatePreferencesCmd {
29 pub theme_mode: Option<String>,
30 pub diff_view_style: Option<String>,
31 pub in_app_flags: Option<i32>,
32 pub email_flags: Option<i32>,
33 pub push_flags: Option<i32>,
34 pub email_digest: Option<bool>,
35 pub mute_until: Option<OffsetDateTime>,
36 pub allow_dms_from: Option<Audience>,
37 pub allow_mentions_from: Option<Audience>,
38 pub show_online_status: Option<bool>,
39}
40
41impl UserPreferencesDto {
42 fn defaults() -> Self {
43 Self {
44 theme_mode: "system".to_string(),
45 diff_view_style: None,
46 in_app_flags: 0,
47 email_flags: 0,
48 push_flags: 0,
49 email_digest: false,
50 mute_until: None,
51 allow_dms_from: Audience::Everyone,
52 allow_mentions_from: Audience::Everyone,
53 show_online_status: false,
54 }
55 }
56}
57
58impl AuthService {
59 pub async fn preferences(&self, uid: Uuid) -> Result<UserPreferencesDto, AuthError> {
60 let Some(row) = user_preferences::Entity::find_by_id(uid).one(self.db().read()).await?
61 else {
62 return Ok(UserPreferencesDto::defaults());
63 };
64 Ok(UserPreferencesDto {
65 theme_mode: row.theme_mode,
66 diff_view_style: row.diff_view_style,
67 in_app_flags: row.in_app_flags,
68 email_flags: row.email_flags,
69 push_flags: row.push_flags,
70 email_digest: row.email_digest,
71 mute_until: row.mute_until,
72 allow_dms_from: row.allow_dms_from,
73 allow_mentions_from: row.allow_mentions_from,
74 show_online_status: row.show_online_status,
75 })
76 }
77
78 pub async fn update_preferences(
79 &self,
80 uid: Uuid,
81 cmd: UpdatePreferencesCmd,
82 ) -> Result<(), AuthError> {
83 let db = self.db();
84 let now = OffsetDateTime::now_utc();
85 let existing = user_preferences::Entity::find_by_id(uid).one(db.write()).await?;
86 if let Some(row) = existing {
87 let mut active = row.into_active_model();
88 if let Some(theme_mode) = cmd.theme_mode {
89 active.theme_mode = Set(theme_mode);
90 }
91 if cmd.diff_view_style.is_some() {
92 active.diff_view_style = Set(cmd.diff_view_style);
93 }
94 if let Some(flags) = cmd.in_app_flags {
95 active.in_app_flags = Set(flags);
96 }
97 if let Some(flags) = cmd.email_flags {
98 active.email_flags = Set(flags);
99 }
100 if let Some(flags) = cmd.push_flags {
101 active.push_flags = Set(flags);
102 }
103 if let Some(digest) = cmd.email_digest {
104 active.email_digest = Set(digest);
105 }
106 if cmd.mute_until.is_some() {
107 active.mute_until = Set(cmd.mute_until);
108 }
109 if let Some(audience) = cmd.allow_dms_from {
110 active.allow_dms_from = Set(audience);
111 }
112 if let Some(audience) = cmd.allow_mentions_from {
113 active.allow_mentions_from = Set(audience);
114 }
115 if let Some(online) = cmd.show_online_status {
116 active.show_online_status = Set(online);
117 }
118 active.updated_at = Set(now);
119 active.update(db.write()).await?;
120 } else {
121 let defaults = UserPreferencesDto::defaults();
122 let row = user_preferences::ActiveModel {
123 user_id: Set(uid),
124 theme_mode: Set(cmd.theme_mode.unwrap_or(defaults.theme_mode)),
125 theme_token: Set(None),
126 theme_version: Set(None),
127 diff_view_style: Set(cmd.diff_view_style.or(defaults.diff_view_style)),
128 in_app_flags: Set(cmd.in_app_flags.unwrap_or(defaults.in_app_flags)),
129 email_flags: Set(cmd.email_flags.unwrap_or(defaults.email_flags)),
130 push_flags: Set(cmd.push_flags.unwrap_or(defaults.push_flags)),
131 email_digest: Set(cmd.email_digest.unwrap_or(defaults.email_digest)),
132 mute_until: Set(cmd.mute_until.or(defaults.mute_until)),
133 allow_dms_from: Set(cmd.allow_dms_from.unwrap_or(defaults.allow_dms_from)),
134 allow_mentions_from: Set(
135 cmd.allow_mentions_from.unwrap_or(defaults.allow_mentions_from),
136 ),
137 show_online_status: Set(
138 cmd.show_online_status.unwrap_or(defaults.show_online_status),
139 ),
140 created_at: Set(now),
141 updated_at: Set(now),
142 };
143 row.insert(db.write()).await?;
144 }
145 Ok(())
146 }
147}
No newline at end of file
Added lib/auth/src/services/profile.rs lib/auth/src/services/profile.rs +252 −0 View lib/auth/src/services/profile.rs
@@ -0,0 +1,252 @@
1use models::users::user::ProfileVisibility;
2use models::users::{user, user_profiles};
3use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set, TransactionTrait};
4use serde::{Deserialize, Serialize};
5use time::{Date, OffsetDateTime};
6use uuid::Uuid;
7
8use super::AuthService;
9use crate::errors::AuthError;
10use crate::share::validate::validate_age;
11
12#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
13pub struct UserMeDto {
14 pub uid: Uuid,
15 pub username: String,
16 pub display_name: String,
17 pub avatar_url: String,
18 pub is_active: bool,
19 pub is_restricted: bool,
20 pub prohibit_login: bool,
21 pub visibility: ProfileVisibility,
22 pub bot: bool,
23 pub system: bool,
24 pub timezone: Option<String>,
25 pub locale: Option<String>,
26 pub language: Option<String>,
27 pub created_at: OffsetDateTime,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, Default, utoipa::ToSchema)]
31pub struct ProfileDto {
32 pub real_name: Option<String>,
33 pub bio: Option<String>,
34 pub pronouns: Option<String>,
35 pub website: Option<String>,
36 pub location: Option<String>,
37 pub company: Option<String>,
38 pub date_of_birth: Option<Date>,
39 pub show_email: bool,
40 pub show_location: bool,
41 pub show_real_name: bool,
42 pub show_activity: bool,
43 pub privacy_agreed_at: Option<OffsetDateTime>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default, utoipa::ToSchema)]
47pub struct UpdateProfileCmd {
48 pub display_name: Option<String>,
49 pub locale: Option<String>,
50 pub language: Option<String>,
51 pub timezone: Option<String>,
52 pub real_name: Option<String>,
53 pub bio: Option<String>,
54 pub pronouns: Option<String>,
55 pub website: Option<String>,
56 pub location: Option<String>,
57 pub company: Option<String>,
58 pub show_email: Option<bool>,
59 pub show_location: Option<bool>,
60 pub show_real_name: Option<bool>,
61 pub show_activity: Option<bool>,
62}
63
64impl AuthService {
65 pub async fn me(&self, uid: Uuid) -> Result<(UserMeDto, Option<ProfileDto>), AuthError> {
66 let db = self.db();
67 let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
68 return Err(AuthError::NotFound);
69 };
70 let profile = user_profiles::Entity::find_by_id(uid).one(db.read()).await?;
71 let me = UserMeDto {
72 uid: user.uid,
73 username: user.username,
74 display_name: user.display_name,
75 avatar_url: user.avatar_url,
76 is_active: user.is_active,
77 is_restricted: user.is_restricted,
78 prohibit_login: user.prohibit_login,
79 visibility: user.visibility,
80 bot: user.bot,
81 system: user.system,
82 timezone: user.timezone,
83 locale: user.locale,
84 language: user.language,
85 created_at: user.created_at,
86 };
87 let profile = profile.map(|row| ProfileDto {
88 real_name: row.real_name,
89 bio: row.bio,
90 pronouns: row.pronouns,
91 website: row.website,
92 location: row.location,
93 company: row.company,
94 date_of_birth: row.date_of_birth,
95 show_email: row.show_email,
96 show_location: row.show_location,
97 show_real_name: row.show_real_name,
98 show_activity: row.show_activity,
99 privacy_agreed_at: row.privacy_agreed_at,
100 });
101 Ok((me, profile))
102 }
103
104 pub async fn update_profile(
105 &self,
106 uid: Uuid,
107 cmd: UpdateProfileCmd,
108 ) -> Result<(), AuthError> {
109 let db = self.db();
110 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
111 return Err(AuthError::NotFound);
112 };
113 let now = OffsetDateTime::now_utc();
114 let mut user_active = user.into_active_model();
115 if let Some(display_name) = cmd.display_name.filter(|name| !name.trim().is_empty()) {
116 let display_name = display_name.trim().to_string();
117 if display_name.len() > 64 {
118 return Err(AuthError::BadRequest(
119 "display_name must be at most 64 characters".to_string(),
120 ));
121 }
122 user_active.display_name = Set(display_name);
123 }
124 if let Some(locale) = cmd.locale {
125 user_active.locale = Set(Some(locale));
126 }
127 if let Some(language) = cmd.language {
128 user_active.language = Set(Some(language));
129 }
130 if let Some(timezone) = cmd.timezone {
131 user_active.timezone = Set(Some(timezone));
132 }
133 user_active.updated_at = Set(now);
134
135 TransactionTrait::transaction(db.primary(), |txn| {
136 Box::pin(async move {
137 user_active.update(txn).await?;
138
139 let existing = user_profiles::Entity::find_by_id(uid).one(txn).await?;
140 if let Some(row) = existing {
141 let mut active = row.into_active_model();
142 if let Some(value) = cmd.real_name {
143 active.real_name = Set(Some(value));
144 }
145 if let Some(value) = cmd.bio {
146 active.bio = Set(Some(value));
147 }
148 if let Some(value) = cmd.pronouns {
149 active.pronouns = Set(Some(value));
150 }
151 if let Some(value) = cmd.website {
152 active.website = Set(Some(value));
153 }
154 if let Some(value) = cmd.location {
155 active.location = Set(Some(value));
156 }
157 if let Some(value) = cmd.company {
158 active.company = Set(Some(value));
159 }
160 if let Some(value) = cmd.show_email {
161 active.show_email = Set(value);
162 }
163 if let Some(value) = cmd.show_location {
164 active.show_location = Set(value);
165 }
166 if let Some(value) = cmd.show_real_name {
167 active.show_real_name = Set(value);
168 }
169 if let Some(value) = cmd.show_activity {
170 active.show_activity = Set(value);
171 }
172 active.updated_at = Set(now);
173 active.update(txn).await?;
174 } else {
175 let row = user_profiles::ActiveModel {
176 user_id: Set(uid),
177 real_name: Set(cmd.real_name),
178 bio: Set(cmd.bio),
179 pronouns: Set(cmd.pronouns),
180 website: Set(cmd.website),
181 location: Set(cmd.location),
182 company: Set(cmd.company),
183 pronouns_privacy: Set(None),
184 date_of_birth: Set(None),
185 banner_url: Set(None),
186 banner_hash: Set(None),
187 banner_color: Set(None),
188 accent_color: Set(None),
189 timezone_privacy_flags: Set(None),
190 custom_status_emoji: Set(None),
191 custom_status_text: Set(None),
192 custom_status_expires_at: Set(None),
193 show_email: Set(cmd.show_email.unwrap_or(false)),
194 show_location: Set(cmd.show_location.unwrap_or(false)),
195 show_real_name: Set(cmd.show_real_name.unwrap_or(false)),
196 show_activity: Set(cmd.show_activity.unwrap_or(false)),
197 privacy_agreed_at: Set(None),
198 created_at: Set(now),
199 updated_at: Set(now),
200 };
201 row.insert(txn).await?;
202 }
203 Ok::<(), sea_orm::DbErr>(())
204 })
205 })
206 .await
207 .map_err(AuthError::from)
208 }
209
210 pub async fn update_birthday(&self, uid: Uuid, date_of_birth: Date) -> Result<bool, AuthError> {
211 validate_age(Some(date_of_birth), self.config().auth.min_age)?;
212 let db = self.db();
213 let now = OffsetDateTime::now_utc();
214 let existing = user_profiles::Entity::find_by_id(uid).one(db.write()).await?;
215 if let Some(row) = existing {
216 let mut active = row.into_active_model();
217 active.date_of_birth = Set(Some(date_of_birth));
218 active.updated_at = Set(now);
219 active.update(db.write()).await?;
220 Ok(true)
221 } else {
222 let row = user_profiles::ActiveModel {
223 user_id: Set(uid),
224 real_name: Set(None),
225 bio: Set(None),
226 pronouns: Set(None),
227 website: Set(None),
228 location: Set(None),
229 company: Set(None),
230 pronouns_privacy: Set(None),
231 date_of_birth: Set(Some(date_of_birth)),
232 banner_url: Set(None),
233 banner_hash: Set(None),
234 banner_color: Set(None),
235 accent_color: Set(None),
236 timezone_privacy_flags: Set(None),
237 custom_status_emoji: Set(None),
238 custom_status_text: Set(None),
239 custom_status_expires_at: Set(None),
240 show_email: Set(false),
241 show_location: Set(false),
242 show_real_name: Set(false),
243 show_activity: Set(false),
244 privacy_agreed_at: Set(None),
245 created_at: Set(now),
246 updated_at: Set(now),
247 };
248 row.insert(db.write()).await?;
249 Ok(false)
250 }
251 }
252}
No newline at end of file
Added lib/auth/src/services/register.rs lib/auth/src/services/register.rs +171 −0 View lib/auth/src/services/register.rs
@@ -0,0 +1,171 @@
1use models::users::user::ProfileVisibility;
2use models::users::{user, user_emails, user_profiles};
3use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set};
4use session::Session;
5use serde::{Deserialize, Serialize};
6use time::{Date, OffsetDateTime};
7use uuid::Uuid;
8
9use super::AuthService;
10use crate::share::crypto::{hash_password, validate_password};
11use crate::errors::AuthError;
12use crate::share::types::TempKey;
13use crate::share::validate::{derive_username, validate_age, validate_email};
14
15#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
16pub struct RegisterCmd {
17 pub username: Option<String>,
18 pub email: String,
19 pub password: String,
20 pub pow_answer: String,
21 pub date_of_birth: Option<Date>,
22 pub accept_agreements: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
26pub struct RegisterResp {
27 pub uid: Uuid,
28 pub pending: bool,
29 pub purpose: String,
30 pub temp_key: TempKey,
31}
32
33impl AuthService {
34 pub async fn register(
35 &self,
36 session: &Session,
37 cmd: RegisterCmd,
38 ) -> Result<RegisterResp, AuthError> {
39 self.verify_pow(session, &cmd.pow_answer)?;
40
41 let policy = &self.config().auth.password_policy;
42 validate_password(&cmd.password, policy)?;
43 let email = validate_email(&cmd.email)?;
44 let username = derive_username(&email, cmd.username.as_deref())?;
45 let lower_name = username.to_lowercase();
46 let lower_email = email.to_lowercase();
47 validate_age(cmd.date_of_birth, self.config().auth.min_age)?;
48
49 let db = self.db();
50 let user_exists = user::Entity::find()
51 .filter(user::Column::LowerName.eq(&lower_name))
52 .one(db.write())
53 .await?;
54 let email_exists = user_emails::Entity::find()
55 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
56 .one(db.write())
57 .await?;
58 if user_exists.is_some() || email_exists.is_some() {
59 return Err(AuthError::Conflict("registration failed".to_string()));
60 }
61
62 let password_hash = hash_password(&cmd.password)?;
63 let uid = Uuid::now_v7();
64 let now = OffsetDateTime::now_utc();
65 let email_for_tx = email.clone();
66 let lower_email_for_tx = lower_email.clone();
67 let username_for_tx = username.clone();
68 sea_orm::TransactionTrait::transaction(db.primary(), |txn| {
69 Box::pin(async move {
70 let user_row = user::ActiveModel {
71 uid: Set(uid),
72 username: Set(username_for_tx.clone()),
73 lower_name: Set(lower_name.clone()),
74 display_name: Set(username_for_tx.clone()),
75 avatar_url: Set(String::new()),
76 is_active: Set(false),
77 is_restricted: Set(false),
78 prohibit_login: Set(false),
79 visibility: Set(ProfileVisibility::Public),
80 bot: Set(false),
81 system: Set(false),
82 last_login_at: Set(None),
83 last_login_ip_hash: Set(None),
84 version: Set(1),
85 passwd_hash: Set(Some(password_hash)),
86 password_last_changed_at: Set(Some(now)),
87 passwd_version: Set(Some(1)),
88 totp_encrypted_secret: Set(None),
89 totp_enabled: Set(false),
90 totp_last_used_at: Set(None),
91 totp_last_counter: Set(None),
92 timezone: Set(None),
93 locale: Set(None),
94 language: Set(None),
95 created_at: Set(now),
96 updated_at: Set(now),
97 };
98 user_row.insert(txn).await?;
99
100 let profile_row = user_profiles::ActiveModel {
101 user_id: Set(uid),
102 real_name: Set(None),
103 bio: Set(None),
104 pronouns: Set(None),
105 website: Set(None),
106 location: Set(None),
107 company: Set(None),
108 pronouns_privacy: Set(None),
109 date_of_birth: Set(cmd.date_of_birth),
110 banner_url: Set(None),
111 banner_hash: Set(None),
112 banner_color: Set(None),
113 accent_color: Set(None),
114 timezone_privacy_flags: Set(None),
115 custom_status_emoji: Set(None),
116 custom_status_text: Set(None),
117 custom_status_expires_at: Set(None),
118 show_email: Set(false),
119 show_location: Set(false),
120 show_real_name: Set(false),
121 show_activity: Set(false),
122 privacy_agreed_at: Set(None),
123 created_at: Set(now),
124 updated_at: Set(now),
125 };
126 profile_row.insert(txn).await?;
127
128 let email_row = user_emails::ActiveModel {
129 id: Set(Uuid::now_v7()),
130 user_id: Set(uid),
131 email: Set(email_for_tx),
132 lower_email: Set(lower_email_for_tx),
133 is_activated: Set(false),
134 is_primary: Set(true),
135 created_at: Set(now),
136 updated_at: Set(now),
137 activated_at: Set(None),
138 deleted_at: Set(None),
139 };
140 email_row.insert(txn).await?;
141 Ok::<(), DbErr>(())
142 })
143 })
144 .await?;
145
146 let artifacts = self
147 .issue_code(uid, &lower_email, models::users::user_email_verification_codes::EmailVerificationPurpose::Signup)
148 .await?;
149 let minutes = self.config().auth.verify_code_ttl_secs / 60;
150 let html = crate::templates::mail_verify_code(&artifacts.code, minutes, "Verify your account");
151 let text = format!("Verify your account with code: {}", artifacts.code);
152 if let Err(error) = self
153 .send_email(&email, "Verify your account", &text, html.0.as_str())
154 .await
155 {
156 tracing::warn!(%error, "failed to send verification email");
157 }
158 for key in &cmd.accept_agreements {
159 if let Some(doc) = self.config().auth.agreements.iter().find(|doc| &doc.key == key) {
160 let _ = self.accept_agreement(uid, &doc.key, &doc.version).await;
161 }
162 }
163
164 Ok(RegisterResp {
165 uid,
166 pending: true,
167 purpose: "signup".to_string(),
168 temp_key: artifacts.temp_key,
169 })
170 }
171}
No newline at end of file
Added lib/auth/src/services/reports.rs lib/auth/src/services/reports.rs +75 −0 View lib/auth/src/services/reports.rs
@@ -0,0 +1,75 @@
1use models::users::user_reports::ReportStatus;
2use models::users::user_reports;
3use sea_orm::{ActiveModelTrait, EntityTrait, Set};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use super::AuthService;
9use crate::errors::AuthError;
10
11#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
12pub struct CreateReportCmd {
13 pub reported_uid: Uuid,
14 pub reason: String,
15 pub evidence: Option<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
19pub struct ReportDto {
20 pub id: Uuid,
21 pub reported_uid: Uuid,
22 pub reason: String,
23 pub evidence: Option<String>,
24 pub status: ReportStatus,
25 pub created_at: OffsetDateTime,
26}
27
28impl AuthService {
29 pub async fn create_report(
30 &self,
31 reporter_uid: Uuid,
32 cmd: CreateReportCmd,
33 ) -> Result<ReportDto, AuthError> {
34 if reporter_uid == cmd.reported_uid {
35 return Err(AuthError::BadRequest(
36 "cannot report yourself".to_string(),
37 ));
38 }
39 let reason = cmd.reason.trim().to_string();
40 if reason.is_empty() || reason.len() > 500 {
41 return Err(AuthError::BadRequest(
42 "reason must be between 1 and 500 characters".to_string(),
43 ));
44 }
45 let db = self.db();
46 let reported = models::users::user::Entity::find_by_id(cmd.reported_uid)
47 .one(db.read())
48 .await?;
49 if reported.is_none() {
50 return Err(AuthError::NotFound);
51 }
52 let now = OffsetDateTime::now_utc();
53 let row = user_reports::ActiveModel {
54 id: Set(Uuid::now_v7()),
55 reporter_uid: Set(Some(reporter_uid)),
56 reported_uid: Set(cmd.reported_uid),
57 reason: Set(reason),
58 evidence: Set(cmd.evidence),
59 status: Set(ReportStatus::Open),
60 resolver_uid: Set(None),
61 resolution_note: Set(None),
62 created_at: Set(now),
63 updated_at: Set(now),
64 };
65 let inserted = row.insert(db.write()).await?;
66 Ok(ReportDto {
67 id: inserted.id,
68 reported_uid: inserted.reported_uid,
69 reason: inserted.reason,
70 evidence: inserted.evidence,
71 status: inserted.status,
72 created_at: inserted.created_at,
73 })
74 }
75}
No newline at end of file
Added lib/auth/src/services/ssh_keys.rs lib/auth/src/services/ssh_keys.rs +122 −0 View lib/auth/src/services/ssh_keys.rs
@@ -0,0 +1,122 @@
1use models::users::user_ssh_keys::SshKeyType;
2use models::users::user_ssh_keys;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
4use serde::{Deserialize, Serialize};
5use ssh_key::{Algorithm, EcdsaCurve, HashAlg};
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use super::AuthService;
10use crate::errors::AuthError;
11
12#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
13pub struct SshKeyDto {
14 pub id: Uuid,
15 pub name: String,
16 pub key_type: SshKeyType,
17 pub fingerprint: String,
18 pub created_at: OffsetDateTime,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
22pub struct AddSshKeyCmd {
23 pub name: String,
24 pub content: String,
25}
26
27fn key_type_of(algorithm: &Algorithm) -> Option<SshKeyType> {
28 match algorithm {
29 Algorithm::Rsa { .. } => Some(SshKeyType::SshRsa),
30 Algorithm::Ed25519 => Some(SshKeyType::SshEd25519),
31 Algorithm::Ecdsa { curve } if *curve == EcdsaCurve::NistP256 => {
32 Some(SshKeyType::EcdsaSha2Nistp256)
33 }
34 _ => None,
35 }
36}
37
38impl AuthService {
39 pub async fn add_ssh_key(
40 &self,
41 uid: Uuid,
42 cmd: AddSshKeyCmd,
43 ) -> Result<SshKeyDto, AuthError> {
44 let name = cmd.name.trim().to_string();
45 if name.is_empty() || name.len() > 64 {
46 return Err(AuthError::BadRequest(
47 "name must be between 1 and 64 characters".to_string(),
48 ));
49 }
50 let parsed = ssh_key::PublicKey::from_openssh(cmd.content.trim())
51 .map_err(|_| AuthError::BadRequest("invalid ssh public key".to_string()))?;
52 let Some(key_type) = key_type_of(&parsed.algorithm()) else {
53 return Err(AuthError::BadRequest(
54 "unsupported ssh key type".to_string(),
55 ));
56 };
57 let fingerprint = parsed.fingerprint(HashAlg::Sha256).to_string();
58
59 let db = self.db();
60 let taken = user_ssh_keys::Entity::find()
61 .filter(user_ssh_keys::Column::Fingerprint.eq(&fingerprint))
62 .one(db.write())
63 .await?;
64 if taken.is_some() {
65 return Err(AuthError::Conflict(
66 "ssh key fingerprint already registered".to_string(),
67 ));
68 }
69
70 let now = OffsetDateTime::now_utc();
71 let row = user_ssh_keys::ActiveModel {
72 id: Set(Uuid::now_v7()),
73 user_id: Set(uid),
74 key_type: Set(key_type),
75 fingerprint: Set(fingerprint.clone()),
76 name: Set(name.clone()),
77 content: Set(cmd.content.trim().to_string()),
78 created_at: Set(now),
79 updated_at: Set(now),
80 last_used_at: Set(None),
81 };
82 let inserted = row.insert(db.write()).await?;
83 Ok(SshKeyDto {
84 id: inserted.id,
85 name,
86 key_type: inserted.key_type,
87 fingerprint,
88 created_at: inserted.created_at,
89 })
90 }
91
92 pub async fn list_ssh_keys(&self, uid: Uuid) -> Result<Vec<SshKeyDto>, AuthError> {
93 let rows = user_ssh_keys::Entity::find()
94 .filter(user_ssh_keys::Column::UserId.eq(uid))
95 .order_by_desc(user_ssh_keys::Column::CreatedAt)
96 .all(self.db().read())
97 .await?;
98 Ok(rows
99 .into_iter()
100 .map(|row| SshKeyDto {
101 id: row.id,
102 name: row.name,
103 key_type: row.key_type,
104 fingerprint: row.fingerprint,
105 created_at: row.created_at,
106 })
107 .collect())
108 }
109
110 pub async fn delete_ssh_key(&self, uid: Uuid, key_id: Uuid) -> Result<(), AuthError> {
111 let db = self.db();
112 let result = user_ssh_keys::Entity::delete_many()
113 .filter(user_ssh_keys::Column::Id.eq(key_id))
114 .filter(user_ssh_keys::Column::UserId.eq(uid))
115 .exec(db.write())
116 .await?;
117 if result.rows_affected == 0 {
118 return Err(AuthError::NotFound);
119 }
120 Ok(())
121 }
122}
No newline at end of file
Added lib/auth/src/services/suspensions.rs lib/auth/src/services/suspensions.rs +56 −0 View lib/auth/src/services/suspensions.rs
@@ -0,0 +1,56 @@
1use models::users::user_suspensions;
2use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, QueryOrder};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7use super::AuthService;
8use crate::errors::AuthError;
9
10#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
11pub struct SuspensionDto {
12 pub id: Uuid,
13 pub suspension_type: String,
14 pub reason: Option<String>,
15 pub starts_at: OffsetDateTime,
16 pub ends_at: Option<OffsetDateTime>,
17}
18
19impl AuthService {
20 pub async fn active_suspension(&self, uid: Uuid) -> Result<Option<SuspensionDto>, AuthError> {
21 let now = OffsetDateTime::now_utc();
22 let Some(row) = user_suspensions::Entity::find()
23 .filter(user_suspensions::Column::UserId.eq(uid))
24 .filter(user_suspensions::Column::RevokedAt.is_null())
25 .filter(user_suspensions::Column::StartsAt.lte(now))
26 .filter(
27 Condition::any()
28 .add(user_suspensions::Column::EndsAt.is_null())
29 .add(user_suspensions::Column::EndsAt.gt(now)),
30 )
31 .order_by_desc(user_suspensions::Column::StartsAt)
32 .one(self.db().read())
33 .await?
34 else {
35 return Ok(None);
36 };
37 Ok(Some(SuspensionDto {
38 id: row.id,
39 suspension_type: match row.suspension_type {
40 user_suspensions::SuspensionType::Temporary => "temporary",
41 user_suspensions::SuspensionType::Permanent => "permanent",
42 }
43 .to_string(),
44 reason: row.reason,
45 starts_at: row.starts_at,
46 ends_at: row.ends_at,
47 }))
48 }
49
50 pub async fn assert_not_suspended(&self, uid: Uuid) -> Result<(), AuthError> {
51 if self.active_suspension(uid).await?.is_some() {
52 return Err(AuthError::Forbidden);
53 }
54 Ok(())
55 }
56}
No newline at end of file
Added lib/auth/src/services/two_factor.rs lib/auth/src/services/two_factor.rs +211 −0 View lib/auth/src/services/two_factor.rs
@@ -0,0 +1,211 @@
1use models::users::user;
2use models::users::user_recovery_codes;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set, TransactionTrait,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use super::AuthService;
11use crate::share::crypto::{aes_decrypt, aes_encrypt, sha256_b64url};
12use crate::errors::AuthError;
13use crate::share::totp::{generate_secret, otpauth_uri, verify_totp_code};
14
15const RECOVERY_CODE_COUNT: usize = 10;
16
17#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
18pub struct TotpStatusResp {
19 pub enabled: bool,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
23pub struct TotpSetupResp {
24 pub secret: String,
25 pub otpauth_uri: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
29pub struct TotpCodeCmd {
30 pub code: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
34pub struct RecoveryCodesResp {
35 pub total: usize,
36 pub used: usize,
37 pub codes: Vec<String>,
38}
39
40fn recovery_code() -> String {
41 let segment = || {
42 let bytes = crate::share::crypto::random_bytes(3);
43 crate::share::totp::base32_encode(&bytes)
44 };
45 format!("{}-{}", segment(), segment())
46}
47
48impl AuthService {
49 pub async fn totp_status(&self, uid: Uuid) -> Result<TotpStatusResp, AuthError> {
50 let Some(user) = user::Entity::find_by_id(uid).one(self.db().read()).await? else {
51 return Err(AuthError::NotFound);
52 };
53 Ok(TotpStatusResp {
54 enabled: user.totp_enabled,
55 })
56 }
57
58 pub async fn totp_setup(&self, uid: Uuid) -> Result<TotpSetupResp, AuthError> {
59 let db = self.db();
60 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
61 return Err(AuthError::NotFound);
62 };
63 if user.totp_enabled {
64 return Err(AuthError::BadRequest(
65 "two-factor authentication is already enabled; disable it first".to_string(),
66 ));
67 }
68 let key = self
69 .config()
70 .auth
71 .totp_encryption_key
72 .as_deref()
73 .ok_or_else(|| {
74 AuthError::Internal("totp encryption key not configured".to_string())
75 })?;
76 let secret = generate_secret();
77 let encrypted = aes_encrypt(key, &secret)?;
78 let otpauth = otpauth_uri(&secret, &user.username);
79 let now = OffsetDateTime::now_utc();
80 let mut active = user.into_active_model();
81 active.totp_encrypted_secret = Set(Some(encrypted));
82 active.totp_enabled = Set(false);
83 active.totp_last_counter = Set(None);
84 active.updated_at = Set(now);
85 active.update(db.write()).await?;
86 Ok(TotpSetupResp {
87 otpauth_uri: otpauth,
88 secret,
89 })
90 }
91
92 pub async fn totp_enable(&self, uid: Uuid, cmd: TotpCodeCmd) -> Result<(), AuthError> {
93 let db = self.db();
94 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
95 return Err(AuthError::NotFound);
96 };
97 let key = self
98 .config()
99 .auth
100 .totp_encryption_key
101 .as_deref()
102 .ok_or_else(|| {
103 AuthError::Internal("totp encryption key not configured".to_string())
104 })?;
105 let stored = user.totp_encrypted_secret.as_deref().ok_or_else(|| {
106 AuthError::BadRequest("run totp setup first".to_string())
107 })?;
108 let secret = aes_decrypt(key, stored)?;
109 let (_, counter) = verify_totp_code(&secret, &cmd.code, None)?;
110 let now = OffsetDateTime::now_utc();
111 let mut active = user.into_active_model();
112 active.totp_enabled = Set(true);
113 active.totp_last_used_at = Set(Some(now));
114 active.totp_last_counter = Set(Some(counter));
115 active.updated_at = Set(now);
116 active.update(db.write()).await?;
117 Ok(())
118 }
119
120 pub async fn totp_disable(&self, uid: Uuid, cmd: TotpCodeCmd) -> Result<(), AuthError> {
121 let db = self.db();
122 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
123 return Err(AuthError::NotFound);
124 };
125 let key = self
126 .config()
127 .auth
128 .totp_encryption_key
129 .as_deref()
130 .ok_or_else(|| {
131 AuthError::Internal("totp encryption key not configured".to_string())
132 })?;
133 let stored = user.totp_encrypted_secret.as_deref().ok_or_else(|| {
134 AuthError::BadRequest("two-factor authentication is not enabled".to_string())
135 })?;
136 let secret = aes_decrypt(key, stored)?;
137 verify_totp_code(&secret, &cmd.code, user.totp_last_counter)?;
138
139 let now = OffsetDateTime::now_utc();
140 TransactionTrait::transaction(db.primary(), |txn| {
141 Box::pin(async move {
142 let mut active = user.into_active_model();
143 active.totp_enabled = Set(false);
144 active.totp_encrypted_secret = Set(None);
145 active.totp_last_used_at = Set(None);
146 active.totp_last_counter = Set(None);
147 active.updated_at = Set(now);
148 active.update(txn).await?;
149
150 user_recovery_codes::Entity::delete_many()
151 .filter(user_recovery_codes::Column::UserId.eq(uid))
152 .exec(txn)
153 .await?;
154 Ok::<(), sea_orm::DbErr>(())
155 })
156 })
157 .await
158 .map_err(AuthError::from)
159 }
160
161 pub async fn recovery_codes(&self, uid: Uuid) -> Result<RecoveryCodesResp, AuthError> {
162 let db = self.db();
163 let rows = user_recovery_codes::Entity::find()
164 .filter(user_recovery_codes::Column::UserId.eq(uid))
165 .all(db.read())
166 .await?;
167 let used = rows.iter().filter(|row| row.used_at.is_some()).count();
168 Ok(RecoveryCodesResp {
169 total: rows.len(),
170 used,
171 codes: Vec::new(),
172 })
173 }
174
175 pub async fn regenerate_recovery_codes(&self, uid: Uuid) -> Result<RecoveryCodesResp, AuthError> {
176 let db = self.db();
177 let now = OffsetDateTime::now_utc();
178 let mut generated = Vec::with_capacity(RECOVERY_CODE_COUNT);
179 for _ in 0..RECOVERY_CODE_COUNT {
180 generated.push(recovery_code());
181 }
182 let codes = generated.clone();
183 TransactionTrait::transaction(db.primary(), |txn| {
184 Box::pin(async move {
185 user_recovery_codes::Entity::delete_many()
186 .filter(user_recovery_codes::Column::UserId.eq(uid))
187 .exec(txn)
188 .await?;
189 for code in &generated {
190 let row = user_recovery_codes::ActiveModel {
191 id: Set(Uuid::now_v7()),
192 user_id: Set(uid),
193 code_hash: Set(sha256_b64url(code.as_bytes())),
194 used_at: Set(None),
195 expires_at: Set(None),
196 created_at: Set(now),
197 };
198 row.insert(txn).await?;
199 }
200 Ok::<(), sea_orm::DbErr>(())
201 })
202 })
203 .await?;
204
205 Ok(RecoveryCodesResp {
206 total: codes.len(),
207 used: 0,
208 codes,
209 })
210 }
211}
No newline at end of file
Added lib/auth/src/services/users.rs lib/auth/src/services/users.rs +145 −0 View lib/auth/src/services/users.rs
@@ -0,0 +1,145 @@
1use models::users::user;
2use models::users::{user_blocks, user_followers};
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7use super::AuthService;
8use crate::errors::AuthError;
9use crate::share::types::UserPublic;
10
11impl AuthService {
12 pub async fn public_user(&self, uid: Uuid) -> Result<UserPublic, AuthError> {
13 let Some(user) = user::Entity::find_by_id(uid).one(self.db().read()).await? else {
14 return Err(AuthError::NotFound);
15 };
16 Ok(UserPublic {
17 uid: user.uid,
18 username: user.username,
19 display_name: user.display_name,
20 avatar_url: user.avatar_url,
21 })
22 }
23
24 pub async fn follow(&self, follower_uid: Uuid, followed_uid: Uuid) -> Result<(), AuthError> {
25 if follower_uid == followed_uid {
26 return Err(AuthError::BadRequest(
27 "cannot follow yourself".to_string(),
28 ));
29 }
30 let db = self.db();
31 let followed_exists = user::Entity::find_by_id(followed_uid).one(db.read()).await?;
32 if followed_exists.is_none() {
33 return Err(AuthError::NotFound);
34 }
35 let existing = user_followers::Entity::find()
36 .filter(user_followers::Column::FollowerId.eq(follower_uid))
37 .filter(user_followers::Column::FollowedId.eq(followed_uid))
38 .one(db.write())
39 .await?;
40 if existing.is_some() {
41 return Ok(());
42 }
43 let row = user_followers::ActiveModel {
44 id: Set(Uuid::now_v7()),
45 follower_id: Set(follower_uid),
46 followed_id: Set(followed_uid),
47 created_at: Set(OffsetDateTime::now_utc()),
48 };
49 row.insert(db.write()).await?;
50 Ok(())
51 }
52
53 pub async fn unfollow(&self, follower_uid: Uuid, followed_uid: Uuid) -> Result<(), AuthError> {
54 let db = self.db();
55 user_followers::Entity::delete_many()
56 .filter(user_followers::Column::FollowerId.eq(follower_uid))
57 .filter(user_followers::Column::FollowedId.eq(followed_uid))
58 .exec(db.write())
59 .await?;
60 Ok(())
61 }
62
63 pub async fn followers(&self, uid: Uuid) -> Result<Vec<UserPublic>, AuthError> {
64 let rows = user_followers::Entity::find()
65 .filter(user_followers::Column::FollowedId.eq(uid))
66 .all(self.db().read())
67 .await?;
68 let ids: Vec<Uuid> = rows.into_iter().map(|row| row.follower_id).collect();
69 self.load_users_public(&ids).await
70 }
71
72 pub async fn following(&self, uid: Uuid) -> Result<Vec<UserPublic>, AuthError> {
73 let rows = user_followers::Entity::find()
74 .filter(user_followers::Column::FollowerId.eq(uid))
75 .all(self.db().read())
76 .await?;
77 let ids: Vec<Uuid> = rows.into_iter().map(|row| row.followed_id).collect();
78 self.load_users_public(&ids).await
79 }
80
81 pub async fn block(&self, blocker_uid: Uuid, blocked_uid: Uuid) -> Result<(), AuthError> {
82 if blocker_uid == blocked_uid {
83 return Err(AuthError::BadRequest("cannot block yourself".to_string()));
84 }
85 let db = self.db();
86 let blocked_exists = user::Entity::find_by_id(blocked_uid).one(db.read()).await?;
87 if blocked_exists.is_none() {
88 return Err(AuthError::NotFound);
89 }
90 let existing = user_blocks::Entity::find()
91 .filter(user_blocks::Column::BlockerId.eq(blocker_uid))
92 .filter(user_blocks::Column::BlockedId.eq(blocked_uid))
93 .one(db.write())
94 .await?;
95 if existing.is_some() {
96 return Ok(());
97 }
98 let row = user_blocks::ActiveModel {
99 id: Set(Uuid::now_v7()),
100 blocker_id: Set(blocker_uid),
101 blocked_id: Set(blocked_uid),
102 created_at: Set(OffsetDateTime::now_utc()),
103 };
104 row.insert(db.write()).await?;
105 Ok(())
106 }
107
108 pub async fn unblock(&self, blocker_uid: Uuid, blocked_uid: Uuid) -> Result<(), AuthError> {
109 let db = self.db();
110 user_blocks::Entity::delete_many()
111 .filter(user_blocks::Column::BlockerId.eq(blocker_uid))
112 .filter(user_blocks::Column::BlockedId.eq(blocked_uid))
113 .exec(db.write())
114 .await?;
115 Ok(())
116 }
117
118 pub async fn blocked_users(&self, uid: Uuid) -> Result<Vec<UserPublic>, AuthError> {
119 let rows = user_blocks::Entity::find()
120 .filter(user_blocks::Column::BlockerId.eq(uid))
121 .all(self.db().read())
122 .await?;
123 let ids: Vec<Uuid> = rows.into_iter().map(|row| row.blocked_id).collect();
124 self.load_users_public(&ids).await
125 }
126
127 async fn load_users_public(&self, ids: &[Uuid]) -> Result<Vec<UserPublic>, AuthError> {
128 if ids.is_empty() {
129 return Ok(Vec::new());
130 }
131 let rows = user::Entity::find()
132 .filter(user::Column::Uid.is_in(ids.iter().copied()))
133 .all(self.db().read())
134 .await?;
135 Ok(rows
136 .into_iter()
137 .map(|user| UserPublic {
138 uid: user.uid,
139 username: user.username,
140 display_name: user.display_name,
141 avatar_url: user.avatar_url,
142 })
143 .collect())
144 }
145}
No newline at end of file
Added lib/auth/src/share/crypto.rs lib/auth/src/share/crypto.rs +149 −0 View lib/auth/src/share/crypto.rs
@@ -0,0 +1,149 @@
1use std::time::{SystemTime, UNIX_EPOCH};
2
3use aes_gcm::aead::Aead;
4use aes_gcm::aead::OsRng;
5use aes_gcm::aead::rand_core::RngCore;
6use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
7use argon2::password_hash::SaltString;
8use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
9use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
10use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
11use base64::Engine;
12use config::auth::PasswordPolicy;
13use rand::Rng;
14use sha2::{Digest, Sha256};
15
16use crate::errors::AuthError;
17
18pub fn hash_password(password: &str) -> Result<String, AuthError> {
19 let salt = SaltString::generate(&mut ArgonOsRng);
20 Argon2::default()
21 .hash_password(password.as_bytes(), &salt)
22 .map(|hash| hash.to_string())
23 .map_err(|error| AuthError::Internal(format!("password hashing failed: {error}")))
24}
25
26pub fn verify_password(password: &str, hash: &str) -> bool {
27 let Ok(parsed) = PasswordHash::new(hash) else {
28 return false;
29 };
30 Argon2::default()
31 .verify_password(password.as_bytes(), &parsed)
32 .is_ok()
33}
34
35pub fn validate_password(password: &str, policy: &PasswordPolicy) -> Result<(), AuthError> {
36 if password.len() < policy.min_length {
37 return Err(AuthError::BadRequest(format!(
38 "password must be at least {} characters",
39 policy.min_length
40 )));
41 }
42 if policy.require_upper && !password.chars().any(|c| c.is_ascii_uppercase()) {
43 return Err(AuthError::BadRequest(
44 "password must contain an uppercase letter".to_string(),
45 ));
46 }
47 if policy.require_lower && !password.chars().any(|c| c.is_ascii_lowercase()) {
48 return Err(AuthError::BadRequest(
49 "password must contain a lowercase letter".to_string(),
50 ));
51 }
52 if policy.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
53 return Err(AuthError::BadRequest(
54 "password must contain a digit".to_string(),
55 ));
56 }
57 if policy.require_symbol
58 && !password.chars().any(|c| !c.is_ascii_alphanumeric())
59 {
60 return Err(AuthError::BadRequest(
61 "password must contain a symbol".to_string(),
62 ));
63 }
64 Ok(())
65}
66
67pub fn generate_numeric_code(len: usize) -> String {
68 let mut rng = rand::thread_rng();
69 (0..len)
70 .map(|_| rng.gen_range(0..10u32).to_string())
71 .collect()
72}
73
74pub fn random_bytes(len: usize) -> Vec<u8> {
75 let mut bytes = vec![0u8; len];
76 rand::thread_rng().fill(&mut bytes[..]);
77 bytes
78}
79
80pub fn random_token(len: usize) -> String {
81 URL_SAFE_NO_PAD.encode(random_bytes(len))
82}
83
84pub fn sha256_b64url(data: &[u8]) -> String {
85 URL_SAFE_NO_PAD.encode(Sha256::digest(data))
86}
87
88pub fn sha256_hex(data: &[u8]) -> String {
89 Sha256::digest(data)
90 .iter()
91 .map(|byte| format!("{byte:02x}"))
92 .collect()
93}
94
95pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
96 if a.len() != b.len() {
97 return false;
98 }
99 let mut diff = 0u8;
100 for (x, y) in a.iter().zip(b.iter()) {
101 diff |= x ^ y;
102 }
103 diff == 0
104}
105
106pub fn aes_encrypt(key_material: &str, plaintext: &str) -> Result<String, AuthError> {
107 let key = aes_key(key_material);
108 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
109 let mut nonce_bytes = [0u8; 12];
110 OsRng.fill_bytes(&mut nonce_bytes);
111 let nonce = Nonce::from_slice(&nonce_bytes);
112 let ciphertext = cipher
113 .encrypt(nonce, plaintext.as_bytes())
114 .map_err(|_| AuthError::Internal("aes encryption failed".to_string()))?;
115 let mut output = Vec::with_capacity(nonce_bytes.len() + ciphertext.len());
116 output.extend_from_slice(&nonce_bytes);
117 output.extend_from_slice(&ciphertext);
118 Ok(STANDARD.encode(output))
119}
120
121pub fn aes_decrypt(key_material: &str, stored: &str) -> Result<String, AuthError> {
122 let bytes = STANDARD
123 .decode(stored)
124 .map_err(|_| AuthError::Internal("malformed encrypted value".to_string()))?;
125 if bytes.len() < 12 {
126 return Err(AuthError::Internal(
127 "malformed encrypted value".to_string(),
128 ));
129 }
130 let (nonce_bytes, ciphertext) = bytes.split_at(12);
131 let key = aes_key(key_material);
132 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
133 let plaintext = cipher
134 .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
135 .map_err(|_| AuthError::Internal("aes decryption failed".to_string()))?;
136 String::from_utf8(plaintext)
137 .map_err(|_| AuthError::Internal("decrypted value is not utf8".to_string()))
138}
139
140fn aes_key(material: &str) -> [u8; 32] {
141 Sha256::digest(material.as_bytes()).into()
142}
143
144pub fn now_secs() -> i64 {
145 SystemTime::now()
146 .duration_since(UNIX_EPOCH)
147 .map(|duration| duration.as_secs() as i64)
148 .unwrap_or(0)
149}
No newline at end of file
Added lib/auth/src/share/mod.rs lib/auth/src/share/mod.rs +9 −0 View lib/auth/src/share/mod.rs
@@ -0,0 +1,9 @@
1pub mod crypto;
2pub mod rate;
3pub mod redis;
4pub mod session;
5pub mod totp;
6pub mod types;
7pub mod validate;
8
9pub use types::{AgreementDoc, TempKey, UserPublic};
No newline at end of file
Added lib/auth/src/share/rate.rs lib/auth/src/share/rate.rs +30 −0 View lib/auth/src/share/rate.rs
@@ -0,0 +1,30 @@
1use export::AppRedis;
2
3use crate::errors::AuthError;
4
5pub async fn rate_limit_once(
6 redis: &AppRedis,
7 key: &str,
8 window_secs: u64,
9) -> Result<(), AuthError> {
10 let is_new = super::redis::set_nx_ex(redis, key, "1", window_secs).await?;
11 if !is_new {
12 return Err(AuthError::RateLimited);
13 }
14 Ok(())
15}
16
17pub async fn attempts_track(
18 redis: &AppRedis,
19 key: &str,
20 max: u64,
21 window_secs: u64,
22) -> Result<bool, AuthError> {
23 let count = super::redis::incr_expire(redis, key, window_secs).await?;
24 Ok(count <= max as i64)
25}
26
27pub async fn attempts_reset(redis: &AppRedis, key: &str) -> Result<(), AuthError> {
28 super::redis::delete(redis, key).await?;
29 Ok(())
30}
No newline at end of file
Added lib/auth/src/share/redis.rs lib/auth/src/share/redis.rs +91 −0 View lib/auth/src/share/redis.rs
@@ -0,0 +1,91 @@
1use export::{AppRedis, RedisConnection};
2use redis::RedisError;
3
4macro_rules! dispatch {
5 ($conn:expr, |$c:ident| $body:expr) => {
6 match &mut $conn {
7 RedisConnection::Standalone($c) => $body,
8 RedisConnection::Cluster($c) => $body,
9 }
10 };
11}
12
13pub async fn get_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
14 let mut conn = redis.connection_clone();
15 dispatch!(conn, |c| redis::cmd("GET").arg(key).query_async(c).await)
16}
17
18pub async fn set_string(
19 redis: &AppRedis,
20 key: &str,
21 value: &str,
22 ttl_secs: u64,
23) -> Result<(), RedisError> {
24 let mut conn = redis.connection_clone();
25 dispatch!(conn, |c| {
26 redis::cmd("SET")
27 .arg(key)
28 .arg(value)
29 .arg("EX")
30 .arg(ttl_secs)
31 .exec_async(c)
32 .await
33 })
34}
35
36pub async fn set_nx_ex(
37 redis: &AppRedis,
38 key: &str,
39 value: &str,
40 ttl_secs: u64,
41) -> Result<bool, RedisError> {
42 let mut conn = redis.connection_clone();
43 dispatch!(conn, |c| {
44 redis::cmd("SET")
45 .arg(key)
46 .arg(value)
47 .arg("NX")
48 .arg("EX")
49 .arg(ttl_secs)
50 .query_async(c)
51 .await
52 })
53}
54
55pub async fn delete(redis: &AppRedis, key: &str) -> Result<(), RedisError> {
56 let mut conn = redis.connection_clone();
57 dispatch!(conn, |c| redis::cmd("DEL").arg(key).exec_async(c).await)
58}
59
60pub async fn exists(redis: &AppRedis, key: &str) -> Result<bool, RedisError> {
61 let mut conn = redis.connection_clone();
62 dispatch!(conn, |c| redis::cmd("EXISTS").arg(key).query_async(c).await)
63}
64
65pub async fn getdel_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
66 let mut conn = redis.connection_clone();
67 dispatch!(conn, |c| redis::cmd("GETDEL").arg(key).query_async(c).await)
68}
69
70const INCR_EXPIRE_SCRIPT: &str = r#"local c = redis.call('INCR', KEYS[1])
71if c == 1 then
72 redis.call('EXPIRE', KEYS[1], ARGV[1])
73end
74return c"#;
75
76pub async fn incr_expire(
77 redis: &AppRedis,
78 key: &str,
79 ttl_secs: u64,
80) -> Result<i64, RedisError> {
81 let mut conn = redis.connection_clone();
82 dispatch!(conn, |c| {
83 redis::cmd("EVAL")
84 .arg(INCR_EXPIRE_SCRIPT)
85 .arg(1)
86 .arg(key)
87 .arg(ttl_secs)
88 .query_async(c)
89 .await
90 })
91}
Added lib/auth/src/share/session.rs lib/auth/src/share/session.rs +37 −0 View lib/auth/src/share/session.rs
@@ -0,0 +1,37 @@
1use session::{AuthUser, Session, SessionUser, SessionUserMut};
2use uuid::Uuid;
3
4use crate::errors::{ApiError, AuthError};
5
6pub const PWD_VERSION_KEY: &str = "auth:pwd_version";
7
8pub fn require_uid(session: &Session) -> Result<Uuid, Box<ApiError>> {
9 match session.uid() {
10 Some(uid) => Ok(uid),
11 None => Err(Box::new(ApiError::from(AuthError::Unauthorized))),
12 }
13}
14
15pub fn apply_login(session: &Session, user: AuthUser, passwd_version: Option<i64>) {
16 session.login_user(user);
17 session.set(PWD_VERSION_KEY, passwd_version.unwrap_or(0));
18}
19
20pub fn pwd_version_matches(session: &Session, current: Option<i64>) -> bool {
21 let stored = session.get::<i64>(PWD_VERSION_KEY);
22 match (stored, current) {
23 (Some(stored_ver), Some(curr_ver)) => stored_ver == curr_ver,
24 (None, None) => true,
25 _ => false,
26 }
27}
28
29pub fn validate_session_pwd_version(session: &Session, current_passwd_version: Option<i64>) -> Result<(), AuthError> {
30 if !pwd_version_matches(session, current_passwd_version) {
31 if let Some(uid) = session.uid() {
32 session.logout_user(uid);
33 }
34 return Err(AuthError::Unauthorized);
35 }
36 Ok(())
37}
No newline at end of file
Added lib/auth/src/share/totp.rs lib/auth/src/share/totp.rs +136 −0 View lib/auth/src/share/totp.rs
@@ -0,0 +1,136 @@
1use std::fmt;
2
3use hmac::{Hmac, KeyInit, Mac};
4use sha1::Sha1;
5
6use super::crypto::{constant_time_eq, random_bytes, now_secs};
7use crate::errors::AuthError;
8
9pub const TOTP_ISSUER: &str = "Untitled";
10const TOTP_PERIOD_SECS: u64 = 30;
11const TOTP_DIGITS: u32 = 6;
12const TOTP_SECRET_BYTES: usize = 20;
13
14#[derive(Debug)]
15pub enum TotpError {
16 InvalidCode,
17 InvalidOrExpiredCode,
18 MalformedSecret,
19}
20
21impl fmt::Display for TotpError {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 Self::InvalidCode => write!(f, "invalid code format"),
25 Self::InvalidOrExpiredCode => write!(f, "invalid or expired code"),
26 Self::MalformedSecret => write!(f, "malformed totp secret"),
27 }
28 }
29}
30
31impl std::error::Error for TotpError {}
32
33impl From<TotpError> for AuthError {
34 fn from(error: TotpError) -> Self {
35 match error {
36 TotpError::InvalidCode | TotpError::InvalidOrExpiredCode => {
37 Self::BadRequest("invalid or expired code".to_string())
38 }
39 TotpError::MalformedSecret => Self::Internal("malformed totp secret".to_string()),
40 }
41 }
42}
43
44const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
45
46pub fn base32_encode(data: &[u8]) -> String {
47 let mut output = String::with_capacity(data.len() * 8 / 5 + 1);
48 let mut buffer = 0u32;
49 let mut bits = 0u32;
50 for &byte in data {
51 buffer = (buffer << 8) | byte as u32;
52 bits += 8;
53 while bits >= 5 {
54 bits -= 5;
55 output.push(BASE32_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
56 }
57 }
58 if bits > 0 {
59 output.push(BASE32_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
60 }
61 output
62}
63
64fn base32_decode(input: &str) -> Option<Vec<u8>> {
65 let mut output = Vec::with_capacity(input.len() * 5 / 8);
66 let mut buffer = 0u32;
67 let mut bits = 0u32;
68 for c in input.chars() {
69 let value = match c.to_ascii_uppercase() {
70 'A'..='Z' => c.to_ascii_uppercase() as u32 - b'A' as u32,
71 '2'..='7' => c as u32 - b'2' as u32 + 26,
72 _ => return None,
73 };
74 buffer = (buffer << 5) | value;
75 bits += 5;
76 if bits >= 8 {
77 bits -= 8;
78 output.push(((buffer >> bits) & 0xff) as u8);
79 }
80 }
81 Some(output)
82}
83
84pub fn generate_secret() -> String {
85 base32_encode(&random_bytes(TOTP_SECRET_BYTES))
86}
87
88fn hotp(secret: &[u8], counter: u64) -> Option<u32> {
89 let mut mac = Hmac::<Sha1>::new_from_slice(secret).ok()?;
90 mac.update(&counter.to_be_bytes());
91 let digest = mac.finalize().into_bytes();
92 let offset = (digest[19] & 0x0f) as usize;
93 Some(
94 ((digest[offset] as u32 & 0x7f) << 24)
95 | ((digest[offset + 1] as u32) << 16)
96 | ((digest[offset + 2] as u32) << 8)
97 | (digest[offset + 3] as u32),
98 )
99}
100
101fn format_code(code: u32) -> String {
102 let truncated = code % 10u32.pow(TOTP_DIGITS);
103 format!("{truncated:0width$}", width = TOTP_DIGITS as usize)
104}
105
106pub fn verify_totp_code(
107 secret: &str,
108 code: &str,
109 last_counter: Option<i64>,
110) -> Result<(u64, i64), TotpError> {
111 if code.len() as u32 != TOTP_DIGITS || !code.bytes().all(|b| b.is_ascii_digit()) {
112 return Err(TotpError::InvalidCode);
113 }
114 let secret_bytes = base32_decode(secret).ok_or(TotpError::MalformedSecret)?;
115 let counter = now_secs().max(0) as u64 / TOTP_PERIOD_SECS;
116 for offset in [0i64, -1, 1] {
117 let candidate = (counter as i64 + offset).max(0) as u64;
118 if last_counter.is_some_and(|last| candidate as i64 <= last) {
119 continue;
120 }
121 let expected = format_code(hotp(&secret_bytes, candidate).ok_or(TotpError::MalformedSecret)?);
122 if constant_time_eq(expected.as_bytes(), code.as_bytes()) {
123 return Ok((candidate, candidate as i64));
124 }
125 }
126 Err(TotpError::InvalidOrExpiredCode)
127}
128
129pub fn otpauth_uri(secret: &str, username: &str) -> String {
130 let encoded_user = username.replace(':', "%3A").replace('?', "%3F").replace('&', "%26").replace('=', "%3D");
131 format!(
132 "otpauth://totp/{issuer}:{user}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30",
133 issuer = TOTP_ISSUER,
134 user = encoded_user
135 )
136}
No newline at end of file
Added lib/auth/src/share/types.rs lib/auth/src/share/types.rs +30 −0 View lib/auth/src/share/types.rs
@@ -0,0 +1,30 @@
1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
5pub struct TempKey {
6 pub name: String,
7 pub value: String,
8 pub ttl_seconds: u64,
9 pub secure: bool,
10 pub http_only: bool,
11 pub same_site: String,
12 pub domain: Option<String>,
13 pub path: String,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
17pub struct UserPublic {
18 pub uid: Uuid,
19 pub username: String,
20 pub display_name: String,
21 pub avatar_url: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
25pub struct AgreementDoc {
26 pub key: String,
27 pub version: String,
28 pub title: String,
29 pub content_md: String,
30}
No newline at end of file
Added lib/auth/src/share/validate.rs lib/auth/src/share/validate.rs +66 −0 View lib/auth/src/share/validate.rs
@@ -0,0 +1,66 @@
1use time::{Date, OffsetDateTime};
2
3use crate::errors::AuthError;
4
5pub fn validate_email(raw: &str) -> Result<String, AuthError> {
6 let email = raw.trim().to_lowercase();
7 if email.len() < 3 || email.len() > 254 {
8 return Err(AuthError::BadRequest("invalid email address".to_string()));
9 }
10 if email.chars().any(|c| c.is_whitespace()) {
11 return Err(AuthError::BadRequest("invalid email address".to_string()));
12 }
13 let Some(at) = email.find('@') else {
14 return Err(AuthError::BadRequest("invalid email address".to_string()));
15 };
16 if at == 0 || at == email.len() - 1 || !email[at + 1..].contains('.') {
17 return Err(AuthError::BadRequest("invalid email address".to_string()));
18 }
19 Ok(email)
20}
21
22pub fn validate_username(name: &str) -> Result<String, AuthError> {
23 if name.is_empty() || name.len() > 64 {
24 return Err(AuthError::BadRequest(
25 "username must be between 1 and 64 characters".to_string(),
26 ));
27 }
28 if !name
29 .chars()
30 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
31 {
32 return Err(AuthError::BadRequest(
33 "username may only contain letters, digits, underscores, dots and dashes".to_string(),
34 ));
35 }
36 Ok(name.to_string())
37}
38
39pub fn derive_username(email: &str, explicit: Option<&str>) -> Result<String, AuthError> {
40 match explicit {
41 Some(name) => validate_username(name),
42 None => {
43 let base = email.split('@').next().unwrap_or_default();
44 validate_username(base)
45 }
46 }
47}
48
49pub fn validate_age(date_of_birth: Option<Date>, min_age: u8) -> Result<(), AuthError> {
50 let Some(dob) = date_of_birth else {
51 return Err(AuthError::BadRequest(
52 "date of birth is required".to_string(),
53 ));
54 };
55 let today = OffsetDateTime::now_utc().date();
56 let mut age = today.year() - dob.year();
57 if (today.month(), today.day()) < (dob.month(), dob.day()) {
58 age -= 1;
59 }
60 if age < min_age as i32 {
61 return Err(AuthError::BadRequest(format!(
62 "you must be at least {min_age} years old"
63 )));
64 }
65 Ok(())
66}
No newline at end of file
Added lib/auth/src/templates/common.rs lib/auth/src/templates/common.rs +105 −0 View lib/auth/src/templates/common.rs
@@ -0,0 +1,105 @@
1use axum::response::Html;
2use maud::{html, Markup, DOCTYPE};
3
4pub(crate) fn page_shell(title: &str, body: Markup) -> Html<String> {
5 let v = html! {
6 (DOCTYPE)
7 html lang="en" {
8 head {
9 meta charset="utf-8";
10 meta name="viewport" content="width=device-width, initial-scale=1";
11 title { (title) }
12 script src="https://cdn.tailwindcss.com" {}
13 }
14 body class="bg-gray-50 min-h-screen text-gray-900 antialiased" {
15 (body)
16 }
17 }
18 };
19 Html(v.into_string())
20}
21
22pub(crate) fn mail_shell(title: &str, body: Markup) -> Html<String> {
23 let v = html! {
24 (DOCTYPE)
25 html lang="en" xmlns="http://www.w3.org/1999/xhtml" {
26 head {
27 meta charset="utf-8";
28 meta name="viewport" content="width=device-width, initial-scale=1";
29 meta http-equiv="X-UA-Compatible" content="IE=edge";
30 title { (title) }
31 style type="text/css" {
32 "body,table,td,a{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}"
33 "table,td{mso-table-lspace:0pt;mso-table-rspace:0pt;}"
34 "img{border:0;height:auto;line-height:100%;outline:none;text-decoration:none;}"
35 "table{border-collapse:collapse!important;}"
36 "body{height:100%!important;margin:0!important;padding:0!important;width:100%!important;}"
37 "a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}"
38 ".btn{display:inline-block;padding:12px 28px;font-size:16px;font-weight:600;"
39 "text-decoration:none;border-radius:8px;color:#ffffff;background-color:#2563eb;"
40 "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;}"
41 ".btn:hover{background-color:#1d4ed8;}"
42 ".container{max-width:600px;margin:0 auto;padding:24px;"
43 "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;}"
44 ".code{font-size:32px;font-weight:700;letter-spacing:8px;color:#111827;"
45 "padding:16px 24px;background:#f3f4f6;border-radius:8px;text-align:center;margin:16px 0;}"
46 ".muted{color:#6b7280;font-size:14px;}"
47 }
48 }
49 body style="background-color:#f9fafb;margin:0;padding:0;" {
50 table role="presentation" width="100%" cellpadding="0" cellspacing="0" {
51 tr {
52 td align="center" style="padding:24px;" {
53 table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.1);" {
54 tr {
55 td style="padding:32px 40px;" {
56 (body)
57 }
58 }
59 }
60 }
61 }
62 }
63 }
64 }
65 };
66 Html(v.into_string())
67}
68
69pub(super) fn url_join(base: &str, path: &str) -> String {
70 let base = base.trim_end_matches('/');
71 let path = path.trim_start_matches('/');
72 format!("{base}/{path}")
73}
74
75pub(super) fn append_query(url: &str, pairs: &[(&str, &str)]) -> String {
76 if pairs.is_empty() {
77 return url.to_string();
78 }
79 let mut s = format!("{url}?");
80 for (i, (k, v)) in pairs.iter().enumerate() {
81 if i > 0 {
82 s.push('&');
83 }
84 s.push_str(&percent_encode(k));
85 s.push('=');
86 s.push_str(&percent_encode(v));
87 }
88 s
89}
90
91fn percent_encode(input: &str) -> String {
92 let mut out = String::with_capacity(input.len());
93 for &b in input.as_bytes() {
94 match b {
95 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
96 out.push(b as char);
97 }
98 _ => {
99 out.push('%');
100 out.push_str(&format!("{b:02X}"));
101 }
102 }
103 }
104 out
105}
No newline at end of file
Added lib/auth/src/templates/mail.rs lib/auth/src/templates/mail.rs +93 −0 View lib/auth/src/templates/mail.rs
@@ -0,0 +1,93 @@
1use axum::response::Html;
2use maud::html;
3
4use super::common::{append_query, mail_shell, url_join};
5
6pub fn mail_verify_code(code: &str, minutes: u64, purpose: &str) -> Html<String> {
7 let body = html! {
8 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { (purpose) }
9 p style="color:#374151;margin:0 0 8px;" {
10 "Enter the following code to continue. It expires in "
11 (minutes)
12 " minutes."
13 }
14 div class="code" { (code) }
15 p class="muted" style="margin:16px 0 0;" {
16 "If you didn't request this, you can safely ignore this email."
17 }
18 };
19 mail_shell(purpose, body)
20}
21
22pub fn mail_reset_link(email: &str, token: &str, temp_id: &str, base_url: &str) -> Html<String> {
23 let link = append_query(
24 &url_join(base_url, "/reset-password"),
25 &[
26 ("email", email),
27 ("token", token),
28 ("temp_id", temp_id),
29 ],
30 );
31 let body = html! {
32 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Reset your password" }
33 p style="color:#374151;margin:0 0 24px;" {
34 "Click the button below to reset your password. This link expires in 30 minutes."
35 }
36 table role="presentation" cellpadding="0" cellspacing="0" {
37 tr {
38 td align="center" {
39 a href=(link) class="btn" { "Reset password" }
40 }
41 }
42 }
43 p class="muted" style="margin:24px 0 0;" {
44 "If the button doesn't work, copy this link into your browser:"
45 }
46 p style="word-break:break-all;font-size:13px;color:#2563eb;margin:4px 0 0;" { (link) }
47 p class="muted" style="margin:24px 0 0;" {
48 "If you didn't request a password reset, ignore this email."
49 }
50 };
51 mail_shell("Reset your password", body)
52}
53
54pub fn mail_email_change_confirm(
55 new_email: &str,
56 ticket_id: &str,
57 code: &str,
58 base_url: &str,
59) -> Html<String> {
60 let confirm_link = append_query(
61 &url_join(base_url, "/email/change/confirm"),
62 &[("ticket_id", ticket_id), ("code", code)],
63 );
64 let cancel_link = append_query(
65 &url_join(base_url, "/email/change/cancel"),
66 &[("ticket_id", ticket_id)],
67 );
68 let body = html! {
69 h1 style="font-size:24px;font-weight:700;margin:0 0 8px;" { "Confirm your new email" }
70 p style="color:#374151;margin:0 0 24px;" {
71 "You requested to change your email to "
72 strong { (new_email) }
73 ". Click the button below to confirm."
74 }
75 table role="presentation" cellpadding="0" cellspacing="0" {
76 tr {
77 td align="center" {
78 a href=(confirm_link) class="btn" { "Confirm email change" }
79 }
80 }
81 }
82 p class="muted" style="margin:16px 0 0;" {
83 "Or enter this code: "
84 strong style="color:#111827;" { (code) }
85 }
86 p class="muted" style="margin:16px 0 0;" {
87 "Didn't request this? "
88 a href=(cancel_link) style="color:#2563eb;" { "Cancel the change" }
89 "."
90 }
91 };
92 mail_shell("Confirm your new email", body)
93}
No newline at end of file
Added lib/auth/src/templates/mod.rs lib/auth/src/templates/mod.rs +7 −0 View lib/auth/src/templates/mod.rs
@@ -0,0 +1,7 @@
1mod common;
2mod mail;
3mod page;
4
5pub(crate) use common::page_shell;
6pub use mail::{mail_email_change_confirm, mail_reset_link, mail_verify_code};
7pub use page::{page_agreement, page_message, page_verify_result, render_markdown};
No newline at end of file
Added lib/auth/src/templates/page.rs lib/auth/src/templates/page.rs +75 −0 View lib/auth/src/templates/page.rs
@@ -0,0 +1,75 @@
1use axum::response::Html;
2use maud::html;
3
4use crate::share::types::AgreementDoc;
5
6use super::common::page_shell;
7
8pub fn page_agreement(doc: &AgreementDoc, html_content: &str) -> Html<String> {
9 let body = html! {
10 div class="max-w-3xl mx-auto px-6 py-12" {
11 div class="mb-8" {
12 h1 class="text-3xl font-bold tracking-tight" { (doc.title) }
13 p class="mt-2 text-sm text-gray-500" {
14 "Version "
15 (doc.version)
16 }
17 }
18 article class="prose prose-lg max-w-none" {
19 (maud::PreEscaped(html_content))
20 }
21 footer class="mt-12 border-t pt-6 text-sm text-gray-500" {
22 "By continuing you agree to this document."
23 }
24 }
25 };
26 page_shell(&doc.title, body)
27}
28
29pub fn page_verify_result(ok: bool, message: &str) -> Html<String> {
30 let title = if ok { "Email verified" } else { "Verification failed" };
31 let (bg, title_color) = if ok {
32 ("bg-green-50", "text-green-800")
33 } else {
34 ("bg-red-50", "text-red-800")
35 };
36 let box_classes = format!("{bg} rounded-xl py-10 px-8");
37 let heading_classes = format!("{title_color} text-2xl font-bold");
38 let body = html! {
39 div class="max-w-md mx-auto px-6 py-20 text-center" {
40 div class=(box_classes) {
41 h1 class=(heading_classes) { (title) }
42 p class="mt-4 text-gray-600" { (message) }
43 }
44 }
45 };
46 page_shell(title, body)
47}
48
49pub fn page_message(ok: bool, title: &str, message: &str) -> Html<String> {
50 let bg = if ok { "bg-green-50" } else { "bg-blue-50" };
51 let title_color = if ok { "text-green-800" } else { "text-gray-900" };
52 let box_classes = format!("{bg} rounded-xl py-10 px-8");
53 let heading_classes = format!("{title_color} text-2xl font-bold");
54 let body = html! {
55 div class="max-w-md mx-auto px-6 py-20 text-center" {
56 div class=(box_classes) {
57 h1 class=(heading_classes) { (title) }
58 p class="mt-4 text-gray-600" { (message) }
59 }
60 }
61 };
62 page_shell(title, body)
63}
64
65pub fn render_markdown(input: &str) -> String {
66 use pulldown_cmark::{html, Options, Parser};
67
68 let mut options = Options::empty();
69 options.insert(Options::ENABLE_TABLES);
70 options.insert(Options::ENABLE_STRIKETHROUGH);
71 let parser = Parser::new_ext(input, options);
72 let mut output = String::new();
73 html::push_html(&mut output, parser);
74 output
75}
No newline at end of file
Added lib/auth/src/errors.rs lib/auth/src/errors.rs +207 −0 View lib/auth/src/errors.rs
@@ -0,0 +1,207 @@
1use std::fmt;
2
3use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::http::StatusCode;
5use axum::response::{IntoResponse, Response};
6use axum::Json;
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
10use crate::pow::PowError;
11
12#[derive(Debug)]
13pub enum AuthError {
14 BadRequest(String),
15 InvalidCredentials,
16 Unauthorized,
17 Forbidden,
18 NotFound,
19 Conflict(String),
20 RateLimited,
21 PowFailed,
22 Internal(String),
23}
24
25impl fmt::Display for AuthError {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 Self::BadRequest(message) => write!(f, "{message}"),
29 Self::InvalidCredentials => write!(f, "invalid credentials"),
30 Self::Unauthorized => write!(f, "unauthorized"),
31 Self::Forbidden => write!(f, "forbidden"),
32 Self::NotFound => write!(f, "not found"),
33 Self::Conflict(message) => write!(f, "{message}"),
34 Self::RateLimited => write!(f, "rate limit exceeded"),
35 Self::PowFailed => write!(f, "proof of work failed"),
36 Self::Internal(message) => write!(f, "{message}"),
37 }
38 }
39}
40
41impl std::error::Error for AuthError {}
42
43impl From<PowError> for AuthError {
44 fn from(error: PowError) -> Self {
45 match error {
46 PowError::NotConfigured | PowError::InvalidKey => {
47 Self::Internal("pow service is not configured".to_string())
48 }
49 PowError::Missing => Self::BadRequest("pow challenge missing".to_string()),
50 PowError::Expired => Self::BadRequest("pow challenge expired".to_string()),
51 PowError::Malformed | PowError::InvalidSolution => {
52 Self::BadRequest("invalid pow solution".to_string())
53 }
54 PowError::InvalidSignature => Self::BadRequest("invalid pow challenge".to_string()),
55 }
56 }
57}
58
59impl From<sea_orm::DbErr> for AuthError {
60 fn from(error: sea_orm::DbErr) -> Self {
61 if is_unique_violation(&error) {
62 return Self::Conflict("resource already exists".to_string());
63 }
64 Self::Internal(format!("database error: {error}"))
65 }
66}
67
68impl From<sea_orm::TransactionError<sea_orm::DbErr>> for AuthError {
69 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
70 match &error {
71 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
72 Self::Conflict("resource already exists".to_string())
73 }
74 _ => Self::Internal(format!("database transaction error: {error}")),
75 }
76 }
77}
78
79fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
80 let msg = err.to_string().to_lowercase();
81 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
82}
83
84impl From<redis::RedisError> for AuthError {
85 fn from(error: redis::RedisError) -> Self {
86 Self::Internal(format!("redis error: {error}"))
87 }
88}
89
90impl From<export::ExportError> for AuthError {
91 fn from(error: export::ExportError) -> Self {
92 Self::Internal(format!("infrastructure error: {error}"))
93 }
94}
95
96impl From<serde_json::Error> for AuthError {
97 fn from(error: serde_json::Error) -> Self {
98 Self::BadRequest(format!("invalid payload: {error}"))
99 }
100}
101
102#[derive(Debug, IntoResponses)]
103pub enum ApiError {
104 #[response(status = 400, description = "request failed validation")]
105 BadRequest(#[to_schema] AppResponseData<NoData>),
106 #[response(status = 400, description = "credentials rejected")]
107 InvalidCredentials(#[to_schema] AppResponseData<NoData>),
108 #[response(status = 401, description = "authentication required")]
109 Unauthorized(#[to_schema] AppResponseData<NoData>),
110 #[response(status = 403, description = "operation not allowed")]
111 Forbidden(#[to_schema] AppResponseData<NoData>),
112 #[response(status = 404, description = "resource not found")]
113 NotFound(#[to_schema] AppResponseData<NoData>),
114 #[response(status = 409, description = "resource conflict")]
115 Conflict(#[to_schema] AppResponseData<NoData>),
116 #[response(status = 429, description = "rate limit exceeded")]
117 RateLimited(#[to_schema] AppResponseData<NoData>),
118 #[response(status = 500, description = "internal server error")]
119 Internal(#[to_schema] AppResponseData<NoData>),
120}
121
122impl ApiError {
123 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
124 AppResponseData::err(code, message)
125 }
126
127 fn decompose(self) -> (u16, AppResponseData<NoData>) {
128 match self {
129 Self::BadRequest(envelope) => (400, envelope),
130 Self::InvalidCredentials(envelope) => (400, envelope),
131 Self::Unauthorized(envelope) => (401, envelope),
132 Self::Forbidden(envelope) => (403, envelope),
133 Self::NotFound(envelope) => (404, envelope),
134 Self::Conflict(envelope) => (409, envelope),
135 Self::RateLimited(envelope) => (429, envelope),
136 Self::Internal(envelope) => (500, envelope),
137 }
138 }
139}
140
141impl IntoResponse for ApiError {
142 fn into_response(self) -> Response {
143 let (code, envelope) = self.decompose();
144 let status = match StatusCode::from_u16(code) {
145 Ok(status) => status,
146 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
147 };
148 (status, Json(envelope)).into_response()
149 }
150}
151
152impl From<AuthError> for ApiError {
153 fn from(error: AuthError) -> Self {
154 match error {
155 AuthError::BadRequest(msg) => {
156 Self::BadRequest(AppResponseData::err(400, msg))
157 }
158 AuthError::PowFailed => {
159 Self::BadRequest(Self::payload(400, "proof of work failed"))
160 }
161 AuthError::InvalidCredentials => {
162 Self::InvalidCredentials(Self::payload(400, "invalid credentials"))
163 }
164 AuthError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized")),
165 AuthError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden")),
166 AuthError::NotFound => Self::NotFound(Self::payload(404, "not found")),
167 AuthError::Conflict(msg) => {
168 Self::Conflict(AppResponseData::err(409, msg))
169 }
170 AuthError::RateLimited => Self::RateLimited(Self::payload(429, "rate limit exceeded")),
171 AuthError::Internal(message) => {
172 tracing::warn!(error = %message, "auth handler failed");
173 Self::Internal(Self::payload(500, "internal server error"))
174 }
175 }
176 }
177}
178
179impl From<Box<ApiError>> for ApiError {
180 fn from(error: Box<ApiError>) -> Self {
181 *error
182 }
183}
184
185impl From<AppResponseData<()>> for ApiError {
186 fn from(_error: AppResponseData<()>) -> Self {
187 Self::BadRequest(Self::payload(400, "bad request"))
188 }
189}
190
191impl From<PathRejection> for ApiError {
192 fn from(_error: PathRejection) -> Self {
193 Self::BadRequest(Self::payload(400, "bad request"))
194 }
195}
196
197impl From<QueryRejection> for ApiError {
198 fn from(_error: QueryRejection) -> Self {
199 Self::BadRequest(Self::payload(400, "bad request"))
200 }
201}
202
203impl From<(StatusCode, &'static str)> for ApiError {
204 fn from(_error: (StatusCode, &'static str)) -> Self {
205 Self::Internal(Self::payload(500, "internal server error"))
206 }
207}
No newline at end of file
Modified lib/auth/src/lib.rs lib/auth/src/lib.rs +8 −0 View lib/auth/src/lib.rs
@@ -0,0 +1,8 @@
1pub mod services;
2pub mod handlers;
3pub mod pow;
4pub mod errors;
5pub mod share;
6pub mod templates;
7pub mod openapi;
8pub mod routes;
No newline at end of file
Added lib/auth/src/openapi.rs lib/auth/src/openapi.rs +161 −0 View lib/auth/src/openapi.rs
@@ -0,0 +1,161 @@
1use export::rest::NoData;
2use models::users::user::{Audience, ProfileVisibility};
3use models::users::user_access_keys::AccessKeyScope;
4use models::users::user_email_verification_codes::EmailVerificationPurpose;
5use models::users::user_notifications::NotificationType;
6use models::users::user_reports::ReportStatus;
7use models::users::user_ssh_keys::SshKeyType;
8use utoipa::OpenApi;
9
10use crate::handlers;
11use crate::handlers::accept_agreement::AcceptAgreementCmd;
12use crate::handlers::confirm_email_change::ConfirmEmailChangeCmd;
13use crate::handlers::me::MeResp;
14use crate::handlers::pow_challenge::PowChallengeResp;
15use crate::handlers::request_deletion::RequestDeletionCmd;
16use crate::handlers::unread_notifications_count::UnreadCountResp;
17use crate::handlers::update_birthday::UpdateBirthdayCmd;
18use crate::services::access_keys::{AccessKeyDto, CreateAccessKeyCmd, CreateAccessKeyResp};
19use crate::services::agreements::AcceptAgreementResp;
20use crate::services::deletion::DeletionDto;
21use crate::services::devices::{DeviceDto, UpdateDeviceCmd};
22use crate::services::email::{
23 EmailChangeTicketDto, ReclaimCmd, ReclaimResp, RequestEmailChangeCmd, ResendCodeCmd,
24 ResendCodeResp, VerifyEmailCmd, VerifyEmailResp,
25};
26use crate::services::login::{LoginCmd, LoginResp, TwoFactorLoginCmd};
27use crate::services::logout::LogoutResp;
28use crate::services::notifications::NotificationDto;
29use crate::services::password::{
30 ChangePasswordCmd, ForgotPasswordCmd, ForgotPasswordResp, ResetPasswordCmd,
31};
32use crate::services::preferences::{UpdatePreferencesCmd, UserPreferencesDto};
33use crate::services::profile::{ProfileDto, UpdateProfileCmd, UserMeDto};
34use crate::services::register::{RegisterCmd, RegisterResp};
35use crate::services::reports::{CreateReportCmd, ReportDto};
36use crate::services::ssh_keys::{AddSshKeyCmd, SshKeyDto};
37use crate::services::suspensions::SuspensionDto;
38use crate::services::two_factor::{RecoveryCodesResp, TotpCodeCmd, TotpSetupResp, TotpStatusResp};
39use crate::share::types::{AgreementDoc, TempKey, UserPublic};
40
41#[derive(OpenApi)]
42#[openapi(
43 info(title = "gitdata auth api", version = "1.0"),
44 paths(
45 handlers::accept_agreement::accept_agreement,
46 handlers::active_suspension::active_suspension,
47 handlers::add_ssh_key::add_ssh_key,
48 handlers::block_user::block_user,
49 handlers::cancel_deletion::cancel_deletion,
50 handlers::cancel_email_change::cancel_email_change,
51 handlers::change_password::change_password,
52 handlers::confirm_email_change::confirm_email_change,
53 handlers::create_access_key::create_access_key,
54 handlers::create_report::create_report,
55 handlers::delete_access_key::delete_access_key,
56 handlers::delete_device::delete_device,
57 handlers::delete_notification::delete_notification,
58 handlers::delete_ssh_key::delete_ssh_key,
59 handlers::deletion_status::deletion_status,
60 handlers::follow_user::follow_user,
61 handlers::forgot_password::forgot_password,
62 handlers::get_preferences::get_preferences,
63 handlers::get_user::get_user,
64 handlers::list_access_keys::list_access_keys,
65 handlers::list_agreements::list_agreements,
66 handlers::list_blocked_users::list_blocked_users,
67 handlers::list_devices::list_devices,
68 handlers::list_followers::list_followers,
69 handlers::list_following::list_following,
70 handlers::list_notifications::list_notifications,
71 handlers::list_ssh_keys::list_ssh_keys,
72 handlers::login::login,
73 handlers::login_2fa::login_2fa,
74 handlers::logout::logout,
75 handlers::mark_all_notifications_read::mark_all_notifications_read,
76 handlers::mark_notification_read::mark_notification_read,
77 handlers::me::me,
78 handlers::pow_challenge::pow_challenge,
79 handlers::reclaim::reclaim,
80 handlers::recovery_codes::recovery_codes,
81 handlers::regenerate_recovery_codes::regenerate_recovery_codes,
82 handlers::register::register,
83 handlers::request_deletion::request_deletion,
84 handlers::request_email_change::request_email_change,
85 handlers::resend_code::resend_code,
86 handlers::reset_password::reset_password,
87 handlers::totp_disable::totp_disable,
88 handlers::totp_enable::totp_enable,
89 handlers::totp_setup::totp_setup,
90 handlers::totp_status::totp_status,
91 handlers::unblock_user::unblock_user,
92 handlers::unfollow_user::unfollow_user,
93 handlers::unread_notifications_count::unread_notifications_count,
94 handlers::update_birthday::update_birthday,
95 handlers::update_device::update_device,
96 handlers::update_preferences::update_preferences,
97 handlers::update_profile::update_profile,
98 handlers::verify_email::verify_email,
99 ),
100 components(schemas(
101 NoData,
102 TempKey,
103 UserPublic,
104 AgreementDoc,
105 RegisterCmd,
106 RegisterResp,
107 LoginCmd,
108 LoginResp,
109 TwoFactorLoginCmd,
110 LogoutResp,
111 VerifyEmailCmd,
112 VerifyEmailResp,
113 ResendCodeCmd,
114 ResendCodeResp,
115 ReclaimCmd,
116 ReclaimResp,
117 EmailChangeTicketDto,
118 RequestEmailChangeCmd,
119 ConfirmEmailChangeCmd,
120 ForgotPasswordCmd,
121 ForgotPasswordResp,
122 ResetPasswordCmd,
123 ChangePasswordCmd,
124 TotpStatusResp,
125 TotpSetupResp,
126 TotpCodeCmd,
127 RecoveryCodesResp,
128 DeviceDto,
129 UpdateDeviceCmd,
130 CreateAccessKeyCmd,
131 AccessKeyDto,
132 CreateAccessKeyResp,
133 SshKeyDto,
134 AddSshKeyCmd,
135 UserMeDto,
136 ProfileDto,
137 UpdateProfileCmd,
138 UpdateBirthdayCmd,
139 UserPreferencesDto,
140 UpdatePreferencesCmd,
141 AcceptAgreementCmd,
142 AcceptAgreementResp,
143 MeResp,
144 PowChallengeResp,
145 NotificationDto,
146 CreateReportCmd,
147 ReportDto,
148 UnreadCountResp,
149 DeletionDto,
150 RequestDeletionCmd,
151 SuspensionDto,
152 ProfileVisibility,
153 Audience,
154 AccessKeyScope,
155 SshKeyType,
156 NotificationType,
157 ReportStatus,
158 EmailVerificationPurpose,
159 ))
160)]
161pub struct AuthApiDoc;
No newline at end of file
Added lib/auth/src/routes.rs lib/auth/src/routes.rs +134 −0 View lib/auth/src/routes.rs
@@ -0,0 +1,134 @@
1use axum::routing::{delete, get, patch, post, put};
2use axum::Router;
3
4use crate::handlers::accept_agreement::accept_agreement;
5use crate::handlers::active_suspension::active_suspension;
6use crate::handlers::add_ssh_key::add_ssh_key;
7use crate::handlers::agreement_page::agreement_page;
8use crate::handlers::block_user::block_user;
9use crate::handlers::cancel_deletion::cancel_deletion;
10use crate::handlers::cancel_email_change::cancel_email_change;
11use crate::handlers::change_password::change_password;
12use crate::handlers::confirm_email_change::confirm_email_change;
13use crate::handlers::create_access_key::create_access_key;
14use crate::handlers::create_report::create_report;
15use crate::handlers::delete_access_key::delete_access_key;
16use crate::handlers::delete_device::delete_device;
17use crate::handlers::delete_notification::delete_notification;
18use crate::handlers::delete_ssh_key::delete_ssh_key;
19use crate::handlers::deletion_status::deletion_status;
20use crate::handlers::follow_user::follow_user;
21use crate::handlers::forgot_password::forgot_password;
22use crate::handlers::get_preferences::get_preferences;
23use crate::handlers::get_user::get_user;
24use crate::handlers::list_access_keys::list_access_keys;
25use crate::handlers::list_agreements::list_agreements;
26use crate::handlers::list_blocked_users::list_blocked_users;
27use crate::handlers::list_devices::list_devices;
28use crate::handlers::list_followers::list_followers;
29use crate::handlers::list_following::list_following;
30use crate::handlers::list_notifications::list_notifications;
31use crate::handlers::list_ssh_keys::list_ssh_keys;
32use crate::handlers::login::login;
33use crate::handlers::login_2fa::login_2fa;
34use crate::handlers::logout::logout;
35use crate::handlers::mark_all_notifications_read::mark_all_notifications_read;
36use crate::handlers::mark_notification_read::mark_notification_read;
37use crate::handlers::me::me;
38use crate::handlers::pow_challenge::pow_challenge;
39use crate::handlers::reclaim::reclaim;
40use crate::handlers::recovery_codes::recovery_codes;
41use crate::handlers::regenerate_recovery_codes::regenerate_recovery_codes;
42use crate::handlers::register::register;
43use crate::handlers::request_deletion::request_deletion;
44use crate::handlers::request_email_change::request_email_change;
45use crate::handlers::resend_code::resend_code;
46use crate::handlers::reset_password::reset_password;
47use crate::handlers::reset_password_page::reset_password_page;
48use crate::handlers::totp_disable::totp_disable;
49use crate::handlers::totp_enable::totp_enable;
50use crate::handlers::totp_setup::totp_setup;
51use crate::handlers::totp_status::totp_status;
52use crate::handlers::unblock_user::unblock_user;
53use crate::handlers::unfollow_user::unfollow_user;
54use crate::handlers::unread_notifications_count::unread_notifications_count;
55use crate::handlers::update_birthday::update_birthday;
56use crate::handlers::update_device::update_device;
57use crate::handlers::update_preferences::update_preferences;
58use crate::handlers::update_profile::update_profile;
59use crate::handlers::verify_email::verify_email;
60use crate::handlers::verify_email_page::verify_email_page;
61use crate::services::AuthService;
62
63pub fn router() -> Router<AuthService> {
64 Router::new()
65 .route("/auth/pow/challenge", get(pow_challenge))
66 .route("/auth/register", post(register))
67 .route("/auth/login", post(login))
68 .route("/auth/login/2fa", post(login_2fa))
69 .route("/auth/logout", post(logout))
70 .route("/auth/email/verify", post(verify_email))
71 .route("/auth/email/verify/page", get(verify_email_page))
72 .route("/auth/email/resend", post(resend_code))
73 .route("/auth/email/reclaim", post(reclaim))
74 .route("/auth/email/change", post(request_email_change))
75 .route("/auth/email/change/confirm", post(confirm_email_change))
76 .route("/auth/email/change/{ticket_id}", delete(cancel_email_change))
77 .route("/auth/password/forgot", post(forgot_password))
78 .route("/auth/password/reset", post(reset_password))
79 .route("/auth/password/reset/page", get(reset_password_page))
80 .route("/auth/password/change", post(change_password))
81 .route("/auth/2fa/status", get(totp_status))
82 .route("/auth/2fa/setup", post(totp_setup))
83 .route("/auth/2fa/enable", post(totp_enable))
84 .route("/auth/2fa/disable", post(totp_disable))
85 .route(
86 "/auth/2fa/recovery-codes",
87 get(recovery_codes).post(regenerate_recovery_codes),
88 )
89 .route("/auth/devices", get(list_devices))
90 .route(
91 "/auth/devices/{device_id}",
92 patch(update_device).delete(delete_device),
93 )
94 .route(
95 "/auth/access-keys",
96 get(list_access_keys).post(create_access_key),
97 )
98 .route("/auth/access-keys/{key_id}", delete(delete_access_key))
99 .route("/auth/ssh-keys", get(list_ssh_keys).post(add_ssh_key))
100 .route("/auth/ssh-keys/{key_id}", delete(delete_ssh_key))
101 .route("/auth/me", get(me).patch(update_profile))
102 .route("/auth/me/birthday", put(update_birthday))
103 .route(
104 "/auth/preferences",
105 get(get_preferences).patch(update_preferences),
106 )
107 .route("/auth/agreements", get(list_agreements))
108 .route("/auth/agreements/{key}", put(accept_agreement))
109 .route("/auth/agreements/{key}/page", get(agreement_page))
110 .route("/users/{uid}", get(get_user))
111 .route("/users/{uid}/follow", put(follow_user).delete(unfollow_user))
112 .route("/users/{uid}/followers", get(list_followers))
113 .route("/users/{uid}/following", get(list_following))
114 .route("/users/{uid}/block", put(block_user).delete(unblock_user))
115 .route("/users/blocks", get(list_blocked_users))
116 .route("/notifications", get(list_notifications))
117 .route(
118 "/notifications/unread-count",
119 get(unread_notifications_count),
120 )
121 .route(
122 "/notifications/read-all",
123 put(mark_all_notifications_read),
124 )
125 .route("/notifications/{notification_id}", delete(delete_notification))
126 .route(
127 "/notifications/{notification_id}/read",
128 put(mark_notification_read),
129 )
130 .route("/reports", post(create_report))
131 .route("/deletion", get(deletion_status).post(request_deletion))
132 .route("/deletion/cancel", post(cancel_deletion))
133 .route("/suspension", get(active_suspension))
134}
No newline at end of file
Modified lib/auth/Cargo.toml lib/auth/Cargo.toml +26 −0 View lib/auth/Cargo.toml
@@ -4,3 +4,29 @@
44edition.workspace = true
55
66[dependencies]
7config = { path = "../config" }
8session = { path = "../session" }
9export = { path = "../export" }
10models = { path = "../models" }
11pow-solver = { path = "../pow-solver" }
12axum = { workspace = true }
13sea-orm = { workspace = true }
14redis = { workspace = true }
15serde = { workspace = true, features = ["derive"] }
16utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] }
17serde_json = { workspace = true }
18base64 = { workspace = true }
19hmac = "0.13.0"
20sha2 = "0.11.0"
21sha1 = "0.11.0"
22uuid = { version = "1", features = ["v7", "serde"] }
23hkdf = "0.13.0"
24time = { version = "0.3", features = ["serde"] }
25argon2 = "0.5"
26aes-gcm = "0.10"
27rand = "0.8"
28ssh-key = "0.6"
29tracing = { workspace = true }
30mailbaby = { git = "https://github.com/mailbabys/mailbaby-rs", rev = "e408acf48a19e4fd554b020a865d144706f7c2de", features = ["rest", "grpc"] }
31maud = { workspace = true }
32pulldown-cmark = { workspace = true }
No newline at end of file