feat(lib/auth): refresh handlers, services, share, errors, and openapi
ZhenYi committed
Modified lib/auth/src/handlers/list_access_keys.rs lib/auth/src/handlers/list_access_keys.rs +5 −2 View lib/auth/src/handlers/list_access_keys.rs
@@ -1,9 +1,10 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::share::session::require_uid;
7use crate::share::types::ListQuery;
78use crate::services::access_keys::AccessKeyDto;
89use crate::services::AuthService;
910
@@ -11,6 +12,7 @@
1112 get,
1213 path = "/auth/access-keys",
1314 tag = "security",
15 params(ListQuery),
1416 responses(
1517 (status = 200, description = "access keys for the current user", body = AppResponseData<Vec<AccessKeyDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn list_access_keys(
1921 State(service): State<AuthService>,
2022 session: Session,
23 Query(query): Query<ListQuery>,
2124) -> Result<AppResponseData<Vec<AccessKeyDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let keys = service.list_access_keys(uid).await.map_err(ApiError::from)?;
26 let keys = service.list_access_keys(uid, query).await.map_err(ApiError::from)?;
2427 Ok(AppResponseData::ok(keys))
2528}
No newline at end of file
Modified lib/auth/src/handlers/list_blocked_users.rs lib/auth/src/handlers/list_blocked_users.rs +5 −3 View lib/auth/src/handlers/list_blocked_users.rs
@@ -1,16 +1,17 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::share::session::require_uid;
7use crate::share::types::UserPublic;
7use crate::share::types::{ListQuery, UserPublic};
88use crate::services::AuthService;
99
1010#[utoipa::path(
1111 get,
1212 path = "/users/blocks",
1313 tag = "users",
14 params(ListQuery),
1415 responses(
1516 (status = 200, description = "users blocked by the current user", body = AppResponseData<Vec<UserPublic>>)
1617 )
@@ -18,8 +19,9 @@
1819pub async fn list_blocked_users(
1920 State(service): State<AuthService>,
2021 session: Session,
22 Query(query): Query<ListQuery>,
2123) -> Result<AppResponseData<Vec<UserPublic>>, ApiError> {
2224 let uid = require_uid(&session)?;
23 let users = service.blocked_users(uid).await.map_err(ApiError::from)?;
25 let users = service.blocked_users(uid, query).await.map_err(ApiError::from)?;
2426 Ok(AppResponseData::ok(users))
2527}
No newline at end of file
Modified lib/auth/src/handlers/list_devices.rs lib/auth/src/handlers/list_devices.rs +5 −2 View lib/auth/src/handlers/list_devices.rs
@@ -1,9 +1,10 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::share::session::require_uid;
7use crate::share::types::ListQuery;
78use crate::services::devices::DeviceDto;
89use crate::services::AuthService;
910
@@ -11,6 +12,7 @@
1112 get,
1213 path = "/auth/devices",
1314 tag = "security",
15 params(ListQuery),
1416 responses(
1517 (status = 200, description = "devices known for the current user", body = AppResponseData<Vec<DeviceDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn list_devices(
1921 State(service): State<AuthService>,
2022 session: Session,
23 Query(query): Query<ListQuery>,
2124) -> Result<AppResponseData<Vec<DeviceDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let devices = service.list_devices(uid).await.map_err(ApiError::from)?;
26 let devices = service.list_devices(uid, query).await.map_err(ApiError::from)?;
2427 Ok(AppResponseData::ok(devices))
2528}
No newline at end of file
Modified lib/auth/src/handlers/list_followers.rs lib/auth/src/handlers/list_followers.rs +6 −4 View lib/auth/src/handlers/list_followers.rs
@@ -1,9 +1,9 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
33use uuid::Uuid;
44
55use crate::errors::ApiError;
6use crate::share::types::UserPublic;
6use crate::share::types::{ListQuery, UserPublic};
77use crate::services::AuthService;
88
99#[utoipa::path(
@@ -11,7 +11,8 @@
1111 path = "/users/{uid}/followers",
1212 tag = "users",
1313 params(
14 ("uid" = Uuid, Path, description = "user id")
14 ("uid" = Uuid, Path, description = "user id"),
15 ListQuery
1516 ),
1617 responses(
1718 (status = 200, description = "users following the given user", body = AppResponseData<Vec<UserPublic>>)
@@ -20,7 +21,8 @@
2021pub async fn list_followers(
2122 State(service): State<AuthService>,
2223 Path(uid): Path<Uuid>,
24 Query(query): Query<ListQuery>,
2325) -> Result<AppResponseData<Vec<UserPublic>>, ApiError> {
24 let users = service.followers(uid).await.map_err(ApiError::from)?;
26 let users = service.followers(uid, query).await.map_err(ApiError::from)?;
2527 Ok(AppResponseData::ok(users))
2628}
No newline at end of file
Modified lib/auth/src/handlers/list_following.rs lib/auth/src/handlers/list_following.rs +6 −4 View lib/auth/src/handlers/list_following.rs
@@ -1,9 +1,9 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
33use uuid::Uuid;
44
55use crate::errors::ApiError;
6use crate::share::types::UserPublic;
6use crate::share::types::{ListQuery, UserPublic};
77use crate::services::AuthService;
88
99#[utoipa::path(
@@ -11,7 +11,8 @@
1111 path = "/users/{uid}/following",
1212 tag = "users",
1313 params(
14 ("uid" = Uuid, Path, description = "user id")
14 ("uid" = Uuid, Path, description = "user id"),
15 ListQuery
1516 ),
1617 responses(
1718 (status = 200, description = "users the given user follows", body = AppResponseData<Vec<UserPublic>>)
@@ -20,7 +21,8 @@
2021pub async fn list_following(
2122 State(service): State<AuthService>,
2223 Path(uid): Path<Uuid>,
24 Query(query): Query<ListQuery>,
2325) -> Result<AppResponseData<Vec<UserPublic>>, ApiError> {
24 let users = service.following(uid).await.map_err(ApiError::from)?;
26 let users = service.following(uid, query).await.map_err(ApiError::from)?;
2527 Ok(AppResponseData::ok(users))
2628}
No newline at end of file
Modified lib/auth/src/handlers/list_ssh_keys.rs lib/auth/src/handlers/list_ssh_keys.rs +5 −2 View lib/auth/src/handlers/list_ssh_keys.rs
@@ -1,9 +1,10 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::share::session::require_uid;
7use crate::share::types::ListQuery;
78use crate::services::ssh_keys::SshKeyDto;
89use crate::services::AuthService;
910
@@ -11,6 +12,7 @@
1112 get,
1213 path = "/auth/ssh-keys",
1314 tag = "security",
15 params(ListQuery),
1416 responses(
1517 (status = 200, description = "ssh keys for the current user", body = AppResponseData<Vec<SshKeyDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn list_ssh_keys(
1921 State(service): State<AuthService>,
2022 session: Session,
23 Query(query): Query<ListQuery>,
2124) -> Result<AppResponseData<Vec<SshKeyDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let keys = service.list_ssh_keys(uid).await.map_err(ApiError::from)?;
26 let keys = service.list_ssh_keys(uid, query).await.map_err(ApiError::from)?;
2427 Ok(AppResponseData::ok(keys))
2528}
No newline at end of file
Modified lib/auth/src/handlers/regenerate_recovery_codes.rs lib/auth/src/handlers/regenerate_recovery_codes.rs +5 −3 View lib/auth/src/handlers/regenerate_recovery_codes.rs
@@ -1,16 +1,17 @@
11use axum::extract::State;
2use export::rest::AppResponseData;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::share::session::require_uid;
7use crate::services::two_factor::RecoveryCodesResp;
7use crate::services::two_factor::{RecoveryCodesResp, RegenerateRecoveryCodesCmd};
88use crate::services::AuthService;
99
1010#[utoipa::path(
1111 post,
1212 path = "/auth/2fa/recovery-codes",
1313 tag = "auth",
14 request_body = AppRequestData<RegenerateRecoveryCodesCmd>,
1415 responses(
1516 (status = 200, description = "replacement codes issued, shown only in this response", body = AppResponseData<RecoveryCodesResp>)
1617 )
@@ -18,10 +19,11 @@
1819pub async fn regenerate_recovery_codes(
1920 State(service): State<AuthService>,
2021 session: Session,
22 payload: AppRequest<RegenerateRecoveryCodesCmd>,
2123) -> Result<AppResponseData<RecoveryCodesResp>, ApiError> {
2224 let uid = require_uid(&session)?;
2325 let resp = service
24 .regenerate_recovery_codes(uid)
26 .regenerate_recovery_codes(uid, payload.into_inner())
2527 .await
2628 .map_err(ApiError::from)?;
2729 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/totp_enable.rs lib/auth/src/handlers/totp_enable.rs +9 −9 View lib/auth/src/handlers/totp_enable.rs
@@ -1,30 +1,30 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
33use session::Session;
44
55use crate::errors::ApiError;
6use crate::share::session::require_uid;
7use crate::services::two_factor::TotpCodeCmd;
6use crate::services::two_factor::{RecoveryCodesResp, TotpEnableCmd};
87use crate::services::AuthService;
8use crate::share::session::require_uid;
99
1010#[utoipa::path(
1111 post,
1212 path = "/auth/2fa/enable",
1313 tag = "auth",
14 request_body = AppRequestData<TotpCodeCmd>,
14 request_body = AppRequestData<TotpEnableCmd>,
1515 responses(
16 (status = 200, description = "two-factor authentication enabled", body = AppResponseData<NoData>)
16 (status = 200, description = "two-factor authentication enabled and recovery codes generated", body = AppResponseData<RecoveryCodesResp>)
1717 )
1818)]
1919pub async fn totp_enable(
2020 State(service): State<AuthService>,
2121 session: Session,
22 payload: AppRequest<TotpCodeCmd>,
23) -> Result<AppResponseData<NoData>, ApiError> {
22 payload: AppRequest<TotpEnableCmd>,
23) -> Result<AppResponseData<RecoveryCodesResp>, ApiError> {
2424 let uid = require_uid(&session)?;
25 service
25 let resp = service
2626 .totp_enable(uid, payload.into_inner())
2727 .await
2828 .map_err(ApiError::from)?;
29 Ok(AppResponseData::empty())
29 Ok(AppResponseData::ok(resp))
3030}
No newline at end of file
Modified lib/auth/src/services/access_keys.rs lib/auth/src/services/access_keys.rs +13 −3 View lib/auth/src/services/access_keys.rs
@@ -1,6 +1,8 @@
11use models::users::user_access_keys::AccessKeyScope;
22use models::users::{user, user_access_keys};
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
5};
46use serde::{Deserialize, Serialize};
57use time::OffsetDateTime;
68use uuid::Uuid;
@@ -8,6 +10,7 @@
810use super::AuthService;
911use crate::share::crypto::{constant_time_eq, random_token, sha256_hex};
1012use crate::errors::AuthError;
13use crate::share::types::ListQuery;
1114
1215const ACCESS_TOKEN_BYTES: usize = 32;
1316
@@ -79,10 +82,17 @@
7982 })
8083 }
8184
82 pub async fn list_access_keys(&self, uid: Uuid) -> Result<Vec<AccessKeyDto>, AuthError> {
85 pub async fn list_access_keys(
86 &self,
87 uid: Uuid,
88 query: ListQuery,
89 ) -> Result<Vec<AccessKeyDto>, AuthError> {
90 let (limit, offset) = query.page();
8391 let rows = user_access_keys::Entity::find()
8492 .filter(user_access_keys::Column::UserId.eq(uid))
8593 .order_by_desc(user_access_keys::Column::CreatedAt)
94 .limit(limit)
95 .offset(offset)
8696 .all(self.db().read())
8797 .await?;
8898 Ok(rows
@@ -126,7 +136,7 @@
126136 let Some(user) = user::Entity::find_by_id(row.user_id).one(db.read()).await? else {
127137 return Ok(None);
128138 };
129 if !user.is_active || user.prohibit_login {
139 if !user.is_active || user.prohibit_login || user.is_restricted {
130140 return Ok(None);
131141 }
132142 let secret = token.split_once('.').map(|(_, s)| s).unwrap_or_default();
Modified lib/auth/src/services/devices.rs lib/auth/src/services/devices.rs +34 −6 View lib/auth/src/services/devices.rs
@@ -1,11 +1,16 @@
1use models::users::user_devices;
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
1use models::users::{user_devices, user_sessions};
2use sea_orm::sea_query::Expr;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder,
5 QuerySelect, Set, TransactionTrait,
6};
37use serde::{Deserialize, Serialize};
48use time::OffsetDateTime;
59use uuid::Uuid;
610
711use super::AuthService;
812use crate::errors::AuthError;
13use crate::share::types::ListQuery;
914
1015#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1116pub struct DeviceDto {
@@ -26,10 +31,17 @@
2631}
2732
2833impl AuthService {
29 pub async fn list_devices(&self, uid: Uuid) -> Result<Vec<DeviceDto>, AuthError> {
34 pub async fn list_devices(
35 &self,
36 uid: Uuid,
37 query: ListQuery,
38 ) -> Result<Vec<DeviceDto>, AuthError> {
39 let (limit, offset) = query.page();
3040 let rows = user_devices::Entity::find()
3141 .filter(user_devices::Column::UserId.eq(uid))
3242 .order_by_desc(user_devices::Column::LastSeenAt)
43 .limit(limit)
44 .offset(offset)
3345 .all(self.db().read())
3446 .await?;
3547 Ok(rows
@@ -76,14 +88,30 @@
7688
7789 pub async fn delete_device(&self, uid: Uuid, device_id: Uuid) -> Result<(), AuthError> {
7890 let db = self.db();
79 let result = user_devices::Entity::delete_many()
91 let device_exists = user_devices::Entity::find()
8092 .filter(user_devices::Column::Id.eq(device_id))
8193 .filter(user_devices::Column::UserId.eq(uid))
82 .exec(db.write())
94 .one(db.write())
8395 .await?;
84 if result.rows_affected == 0 {
96 if device_exists.is_none() {
8597 return Err(AuthError::NotFound);
8698 }
99 let now = OffsetDateTime::now_utc();
100 TransactionTrait::transaction(db.primary(), |txn| {
101 Box::pin(async move {
102 user_devices::Entity::delete_by_id(device_id).exec(txn).await?;
103 user_sessions::Entity::update_many()
104 .filter(user_sessions::Column::UserId.eq(uid))
105 .filter(user_sessions::Column::DeviceId.eq(Some(device_id)))
106 .col_expr(user_sessions::Column::IsRevoked, Expr::value(true))
107 .col_expr(user_sessions::Column::RevokedAt, Expr::value(Some(now)))
108 .col_expr(user_sessions::Column::UpdatedAt, Expr::value(now))
109 .exec(txn)
110 .await?;
111 Ok::<(), DbErr>(())
112 })
113 })
114 .await?;
87115 Ok(())
88116 }
89117}
No newline at end of file
Modified lib/auth/src/services/email.rs lib/auth/src/services/email.rs +52 −11 View lib/auth/src/services/email.rs
@@ -20,6 +20,7 @@
2020 match purpose {
2121 EmailVerificationPurpose::Signup => "signup",
2222 EmailVerificationPurpose::PasswordReset => "password_reset",
23 EmailVerificationPurpose::AddEmail => "add_email",
2324 }
2425}
2526
@@ -101,6 +102,7 @@
101102#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
102103pub struct RequestEmailChangeCmd {
103104 pub new_email: String,
105 pub password: String,
104106}
105107
106108impl AuthService {
@@ -245,6 +247,11 @@
245247 active.used_at = Set(Some(now));
246248 active.update(db.write()).await?;
247249 }
250 EmailVerificationPurpose::AddEmail => {
251 return Err(AuthError::BadRequest(
252 "add_email verification is handled by the user service".to_string(),
253 ));
254 }
248255 }
249256 Ok(VerifyEmailResp { verified: true })
250257 }
@@ -256,7 +263,6 @@
256263 ) -> Result<ResendCodeResp, AuthError> {
257264 self.verify_pow(session, &cmd.pow_answer)?;
258265 let lower_email = cmd.email.trim().to_lowercase();
259 let placeholder = temp_key("resend_", Some(cmd.purpose), &lower_email, Uuid::nil(), 0);
260266 let Some(email_row) = user_emails::Entity::find()
261267 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
262268 .filter(user_emails::Column::DeletedAt.is_null())
@@ -264,8 +270,14 @@
264270 .await?
265271 else {
266272 return Ok(ResendCodeResp {
267 sent: false,
268 temp_key: placeholder,
273 sent: true,
274 temp_key: temp_key(
275 "resend_",
276 Some(cmd.purpose),
277 &lower_email,
278 Uuid::now_v7(),
279 self.config().auth.verify_code_ttl_secs,
280 ),
269281 });
270282 };
271283 let artifacts = self
@@ -274,6 +286,7 @@
274286 let subject = match cmd.purpose {
275287 EmailVerificationPurpose::Signup => "Verify your account",
276288 EmailVerificationPurpose::PasswordReset => "Reset your password",
289 EmailVerificationPurpose::AddEmail => "Add your email",
277290 };
278291 let minutes = self.config().auth.verify_code_ttl_secs / 60;
279292 let html = crate::templates::mail_verify_code(&artifacts.code, minutes, subject);
@@ -299,6 +312,16 @@
299312 ) -> Result<ReclaimResp, AuthError> {
300313 self.verify_pow(session, &cmd.pow_answer)?;
301314 let lower_email = cmd.email.trim().to_lowercase();
315 let placeholder = || ReclaimResp {
316 sent: true,
317 temp_key: temp_key(
318 "reclaim_",
319 None,
320 &lower_email,
321 Uuid::now_v7(),
322 self.config().auth.verify_code_ttl_secs,
323 ),
324 };
302325 let Some((email_row, Some(user))) = user_emails::Entity::find()
303326 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
304327 .filter(user_emails::Column::DeletedAt.is_null())
@@ -306,16 +329,10 @@
306329 .one(self.db().read())
307330 .await?
308331 else {
309 return Ok(ReclaimResp {
310 sent: false,
311 temp_key: temp_key("reclaim_", None, &lower_email, Uuid::nil(), 0),
312 });
332 return Ok(placeholder());
313333 };
314334 if user.is_active {
315 return Ok(ReclaimResp {
316 sent: false,
317 temp_key: temp_key("reclaim_", None, &lower_email, Uuid::nil(), 0),
318 });
335 return Ok(placeholder());
319336 }
320337 let artifacts = self
321338 .issue_code(user.uid, &lower_email, EmailVerificationPurpose::Signup)
@@ -352,6 +369,16 @@
352369 let db = self.db();
353370 let now = OffsetDateTime::now_utc();
354371
372 let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
373 return Err(AuthError::NotFound);
374 };
375 let stored = user.passwd_hash.as_deref().ok_or_else(|| {
376 AuthError::BadRequest("account has no password".to_string())
377 })?;
378 if !crate::share::crypto::verify_password(&cmd.password, stored) {
379 return Err(AuthError::InvalidCredentials);
380 }
381
355382 let current = user_emails::Entity::find()
356383 .filter(user_emails::Column::UserId.eq(uid))
357384 .filter(user_emails::Column::IsPrimary.eq(true))
@@ -435,6 +462,19 @@
435462 ticket_id: Uuid,
436463 code: &str,
437464 ) -> Result<(), AuthError> {
465 let config = &self.config().auth;
466 let attempts_key = format!("auth:email_change_attempts:{ticket_id}");
467 if !attempts_track(
468 self.redis(),
469 &attempts_key,
470 config.max_verify_attempts,
471 config.verify_code_ttl_secs,
472 )
473 .await?
474 {
475 return Err(AuthError::RateLimited);
476 }
477
438478 let db = self.db();
439479 let now = OffsetDateTime::now_utc();
440480 let Some(ticket) = user_email_change_tickets::Entity::find()
@@ -457,6 +497,7 @@
457497 if !constant_time_eq(code_hash.as_bytes(), ticket.token_hash.as_bytes()) {
458498 return Err(AuthError::BadRequest("invalid email change code".to_string()));
459499 }
500 attempts_reset(self.redis(), &attempts_key).await?;
460501
461502 let conflicting = user_emails::Entity::find()
462503 .filter(user_emails::Column::LowerEmail.eq(ticket.new_email.to_lowercase()))
Modified lib/auth/src/services/login.rs lib/auth/src/services/login.rs +5 −1 View lib/auth/src/services/login.rs
@@ -134,11 +134,12 @@
134134 };
135135 let attempts_key = format!("auth:2fa_attempts:{}", challenge.uid);
136136 if !attempts_track(self.redis(), &attempts_key, 5, 300).await? {
137 session.remove(TWO_FA_KEY);
137138 return Err(AuthError::RateLimited);
138139 }
139140
140 session.remove(TWO_FA_KEY);
141141 if challenge.expires_at <= now_secs() {
142 session.remove(TWO_FA_KEY);
142143 return Err(AuthError::BadRequest(
143144 "two-factor challenge expired".to_string(),
144145 ));
@@ -147,17 +148,20 @@
147148 let db = self.db();
148149 let Some(user) = user::Entity::find_by_id(challenge.uid).one(db.read()).await?
149150 else {
151 session.remove(TWO_FA_KEY);
150152 return Err(AuthError::BadRequest(
151153 "invalid two-factor challenge".to_string(),
152154 ));
153155 };
154156 if !user.totp_enabled {
157 session.remove(TWO_FA_KEY);
155158 return Err(AuthError::BadRequest(
156159 "two-factor authentication is not enabled".to_string(),
157160 ));
158161 }
159162
160163 self.consume_two_factor_code(&user, &cmd.code).await?;
164 session.remove(TWO_FA_KEY);
161165 attempts_reset(self.redis(), &attempts_key).await?;
162166 self.finish_login(session, &user, challenge.ua_hash, challenge.ip_hash)
163167 .await
Modified lib/auth/src/services/password.rs lib/auth/src/services/password.rs +3 −0 View lib/auth/src/services/password.rs
@@ -12,6 +12,7 @@
1212 constant_time_eq, hash_password, random_token, sha256_b64url, validate_password,
1313};
1414use crate::errors::AuthError;
15use crate::share::rate::rate_limit_once;
1516use crate::share::types::TempKey;
1617
1718#[derive(Serialize, Deserialize)]
@@ -55,6 +56,8 @@
5556 self.verify_pow(session, &cmd.pow_answer)?;
5657 let config = &self.config().auth;
5758 let lower_email = cmd.email.trim().to_lowercase();
59 let rate_key = format!("auth:code_rate:forgot_pwd:{lower_email}");
60 rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?;
5861 let temp_id = Uuid::now_v7();
5962 let temp_key = TempKey {
6063 name: format!("auth_reset_{}", lower_email.replace('@', "_at_")),
Modified lib/auth/src/services/ssh_keys.rs lib/auth/src/services/ssh_keys.rs +12 −2 View lib/auth/src/services/ssh_keys.rs
@@ -1,6 +1,8 @@
11use models::users::user_ssh_keys::SshKeyType;
22use models::users::user_ssh_keys;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
5};
46use serde::{Deserialize, Serialize};
57use ssh_key::{Algorithm, EcdsaCurve, HashAlg};
68use time::OffsetDateTime;
@@ -8,6 +10,7 @@
810
911use super::AuthService;
1012use crate::errors::AuthError;
13use crate::share::types::ListQuery;
1114
1215#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1316pub struct SshKeyDto {
@@ -89,10 +92,17 @@
8992 })
9093 }
9194
92 pub async fn list_ssh_keys(&self, uid: Uuid) -> Result<Vec<SshKeyDto>, AuthError> {
95 pub async fn list_ssh_keys(
96 &self,
97 uid: Uuid,
98 query: ListQuery,
99 ) -> Result<Vec<SshKeyDto>, AuthError> {
100 let (limit, offset) = query.page();
93101 let rows = user_ssh_keys::Entity::find()
94102 .filter(user_ssh_keys::Column::UserId.eq(uid))
95103 .order_by_desc(user_ssh_keys::Column::CreatedAt)
104 .limit(limit)
105 .offset(offset)
96106 .all(self.db().read())
97107 .await?;
98108 Ok(rows
Modified lib/auth/src/services/two_factor.rs lib/auth/src/services/two_factor.rs +97 −10 View lib/auth/src/services/two_factor.rs
@@ -31,6 +31,17 @@
3131}
3232
3333#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
34pub struct TotpEnableCmd {
35 pub code: String,
36 pub password: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
40pub struct RegenerateRecoveryCodesCmd {
41 pub password: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
3445pub struct RecoveryCodesResp {
3546 pub total: usize,
3647 pub used: usize,
@@ -89,11 +100,21 @@
89100 })
90101 }
91102
92 pub async fn totp_enable(&self, uid: Uuid, cmd: TotpCodeCmd) -> Result<(), AuthError> {
103 pub async fn totp_enable(
104 &self,
105 uid: Uuid,
106 cmd: TotpEnableCmd,
107 ) -> Result<RecoveryCodesResp, AuthError> {
93108 let db = self.db();
94109 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
95110 return Err(AuthError::NotFound);
96111 };
112 let stored_password = user.passwd_hash.as_deref().ok_or_else(|| {
113 AuthError::BadRequest("account has no password".to_string())
114 })?;
115 if !crate::share::crypto::verify_password(&cmd.password, stored_password) {
116 return Err(AuthError::InvalidCredentials);
117 }
97118 let key = self
98119 .config()
99120 .auth
@@ -108,13 +129,47 @@
108129 let secret = aes_decrypt(key, stored)?;
109130 let (_, counter) = verify_totp_code(&secret, &cmd.code, None)?;
110131 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(())
132
133 let mut generated = Vec::with_capacity(RECOVERY_CODE_COUNT);
134 for _ in 0..RECOVERY_CODE_COUNT {
135 generated.push(recovery_code());
136 }
137 let codes = generated.clone();
138
139 TransactionTrait::transaction(db.primary(), |txn| {
140 Box::pin(async move {
141 let mut active = user.into_active_model();
142 active.totp_enabled = Set(true);
143 active.totp_last_used_at = Set(Some(now));
144 active.totp_last_counter = Set(Some(counter));
145 active.updated_at = Set(now);
146 active.update(txn).await?;
147
148 user_recovery_codes::Entity::delete_many()
149 .filter(user_recovery_codes::Column::UserId.eq(uid))
150 .exec(txn)
151 .await?;
152 for code in &generated {
153 let row = user_recovery_codes::ActiveModel {
154 id: Set(Uuid::now_v7()),
155 user_id: Set(uid),
156 code_hash: Set(sha256_b64url(code.as_bytes())),
157 used_at: Set(None),
158 expires_at: Set(None),
159 created_at: Set(now),
160 };
161 row.insert(txn).await?;
162 }
163 Ok::<(), sea_orm::DbErr>(())
164 })
165 })
166 .await?;
167
168 Ok(RecoveryCodesResp {
169 total: codes.len(),
170 used: 0,
171 codes,
172 })
118173 }
119174
120175 pub async fn totp_disable(&self, uid: Uuid, cmd: TotpCodeCmd) -> Result<(), AuthError> {
@@ -122,6 +177,11 @@
122177 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
123178 return Err(AuthError::NotFound);
124179 };
180 if !user.totp_enabled {
181 return Err(AuthError::BadRequest(
182 "two-factor authentication is not enabled".to_string(),
183 ));
184 }
125185 let key = self
126186 .config()
127187 .auth
@@ -134,8 +194,22 @@
134194 AuthError::BadRequest("two-factor authentication is not enabled".to_string())
135195 })?;
136196 let secret = aes_decrypt(key, stored)?;
137 verify_totp_code(&secret, &cmd.code, user.totp_last_counter)?;
138197
198 let is_valid_totp = verify_totp_code(&secret, &cmd.code, user.totp_last_counter).is_ok();
199 if !is_valid_totp {
200 let hash = sha256_b64url(cmd.code.trim().as_bytes());
201 let recovery_valid = user_recovery_codes::Entity::find()
202 .filter(user_recovery_codes::Column::UserId.eq(uid))
203 .filter(user_recovery_codes::Column::CodeHash.eq(&hash))
204 .filter(user_recovery_codes::Column::UsedAt.is_null())
205 .one(db.read())
206 .await?
207 .is_some();
208 if !recovery_valid {
209 return Err(AuthError::BadRequest("invalid code".to_string()));
210 }
211 }
212
139213 let now = OffsetDateTime::now_utc();
140214 TransactionTrait::transaction(db.primary(), |txn| {
141215 Box::pin(async move {
@@ -172,8 +246,21 @@
172246 })
173247 }
174248
175 pub async fn regenerate_recovery_codes(&self, uid: Uuid) -> Result<RecoveryCodesResp, AuthError> {
249 pub async fn regenerate_recovery_codes(
250 &self,
251 uid: Uuid,
252 cmd: RegenerateRecoveryCodesCmd,
253 ) -> Result<RecoveryCodesResp, AuthError> {
176254 let db = self.db();
255 let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
256 return Err(AuthError::NotFound);
257 };
258 let stored = user.passwd_hash.as_deref().ok_or_else(|| {
259 AuthError::BadRequest("account has no password".to_string())
260 })?;
261 if !crate::share::crypto::verify_password(&cmd.password, stored) {
262 return Err(AuthError::InvalidCredentials);
263 }
177264 let now = OffsetDateTime::now_utc();
178265 let mut generated = Vec::with_capacity(RECOVERY_CODE_COUNT);
179266 for _ in 0..RECOVERY_CODE_COUNT {
Modified lib/auth/src/services/users.rs lib/auth/src/services/users.rs +50 −12 View lib/auth/src/services/users.rs
@@ -1,12 +1,15 @@
11use models::users::user;
22use models::users::{user_blocks, user_followers};
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, QueryFilter, QuerySelect, Set,
5 TransactionTrait,
6};
47use time::OffsetDateTime;
58use uuid::Uuid;
69
710use super::AuthService;
811use crate::errors::AuthError;
9use crate::share::types::UserPublic;
12use crate::share::types::{ListQuery, UserPublic};
1013
1114impl AuthService {
1215 pub async fn public_user(&self, uid: Uuid) -> Result<UserPublic, AuthError> {
@@ -60,18 +63,24 @@
6063 Ok(())
6164 }
6265
63 pub async fn followers(&self, uid: Uuid) -> Result<Vec<UserPublic>, AuthError> {
66 pub async fn followers(&self, uid: Uuid, query: ListQuery) -> Result<Vec<UserPublic>, AuthError> {
67 let (limit, offset) = query.page();
6468 let rows = user_followers::Entity::find()
6569 .filter(user_followers::Column::FollowedId.eq(uid))
70 .limit(limit)
71 .offset(offset)
6672 .all(self.db().read())
6773 .await?;
6874 let ids: Vec<Uuid> = rows.into_iter().map(|row| row.follower_id).collect();
6975 self.load_users_public(&ids).await
7076 }
7177
72 pub async fn following(&self, uid: Uuid) -> Result<Vec<UserPublic>, AuthError> {
78 pub async fn following(&self, uid: Uuid, query: ListQuery) -> Result<Vec<UserPublic>, AuthError> {
79 let (limit, offset) = query.page();
7380 let rows = user_followers::Entity::find()
7481 .filter(user_followers::Column::FollowerId.eq(uid))
82 .limit(limit)
83 .offset(offset)
7584 .all(self.db().read())
7685 .await?;
7786 let ids: Vec<Uuid> = rows.into_iter().map(|row| row.followed_id).collect();
@@ -95,13 +104,39 @@
95104 if existing.is_some() {
96105 return Ok(());
97106 }
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?;
107 let now = OffsetDateTime::now_utc();
108 TransactionTrait::transaction(db.primary(), |txn| {
109 Box::pin(async move {
110 let row = user_blocks::ActiveModel {
111 id: Set(Uuid::now_v7()),
112 blocker_id: Set(blocker_uid),
113 blocked_id: Set(blocked_uid),
114 created_at: Set(now),
115 };
116 row.insert(txn).await?;
117
118 user_followers::Entity::delete_many()
119 .filter(
120 Condition::any()
121 .add(
122 Condition::all()
123 .add(user_followers::Column::FollowerId.eq(blocker_uid))
124 .add(user_followers::Column::FollowedId.eq(blocked_uid)),
125 )
126 .add(
127 Condition::all()
128 .add(user_followers::Column::FollowerId.eq(blocked_uid))
129 .add(user_followers::Column::FollowedId.eq(blocker_uid)),
130 ),
131 )
132 .exec(txn)
133 .await?;
134
135 Ok::<(), sea_orm::DbErr>(())
136 })
137 })
138 .await
139 .map_err(AuthError::from)?;
105140 Ok(())
106141 }
107142
@@ -115,9 +150,12 @@
115150 Ok(())
116151 }
117152
118 pub async fn blocked_users(&self, uid: Uuid) -> Result<Vec<UserPublic>, AuthError> {
153 pub async fn blocked_users(&self, uid: Uuid, query: ListQuery) -> Result<Vec<UserPublic>, AuthError> {
154 let (limit, offset) = query.page();
119155 let rows = user_blocks::Entity::find()
120156 .filter(user_blocks::Column::BlockerId.eq(uid))
157 .limit(limit)
158 .offset(offset)
121159 .all(self.db().read())
122160 .await?;
123161 let ids: Vec<Uuid> = rows.into_iter().map(|row| row.blocked_id).collect();
Modified lib/auth/src/share/crypto.rs lib/auth/src/share/crypto.rs +12 −13 View lib/auth/src/share/crypto.rs
@@ -1,9 +1,7 @@
11use std::time::{SystemTime, UNIX_EPOCH};
22
3use aes_gcm::aead::Aead;
4use aes_gcm::aead::OsRng;
5use aes_gcm::aead::rand_core::RngCore;
6use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
3use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
4use aes_gcm::Aes256Gcm;
75use argon2::password_hash::SaltString;
86use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
97use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
@@ -105,15 +103,13 @@
105103
106104pub fn aes_encrypt(key_material: &str, plaintext: &str) -> Result<String, AuthError> {
107105 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);
106 let cipher = Aes256Gcm::new((&key).into());
107 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
112108 let ciphertext = cipher
113 .encrypt(nonce, plaintext.as_bytes())
109 .encrypt(&nonce, plaintext.as_bytes())
114110 .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);
111 let mut output = Vec::with_capacity(nonce.len() + ciphertext.len());
112 output.extend_from_slice(&nonce);
117113 output.extend_from_slice(&ciphertext);
118114 Ok(STANDARD.encode(output))
119115}
@@ -128,10 +124,13 @@
128124 ));
129125 }
130126 let (nonce_bytes, ciphertext) = bytes.split_at(12);
127 let Ok(nonce_arr): Result<&[u8; 12], _> = nonce_bytes.try_into() else {
128 return Err(AuthError::Internal("malformed encrypted value".to_string()));
129 };
131130 let key = aes_key(key_material);
132 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
131 let cipher = Aes256Gcm::new((&key).into());
133132 let plaintext = cipher
134 .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
133 .decrypt(nonce_arr.into(), ciphertext)
135134 .map_err(|_| AuthError::Internal("aes decryption failed".to_string()))?;
136135 String::from_utf8(plaintext)
137136 .map_err(|_| AuthError::Internal("decrypted value is not utf8".to_string()))
Modified lib/auth/src/share/guard.rs lib/auth/src/share/guard.rs +3 −0 View lib/auth/src/share/guard.rs
@@ -32,6 +32,9 @@
3232 Ok(Some((row, Some(current)))) => {
3333 !row.is_revoked
3434 && row.expires_at > OffsetDateTime::now_utc()
35 && current.is_active
36 && !current.prohibit_login
37 && !current.is_restricted
3538 && pwd_version_matches(&session, current.passwd_version)
3639 }
3740 Ok(_) => false,
Modified lib/auth/src/share/types.rs lib/auth/src/share/types.rs +15 −0 View lib/auth/src/share/types.rs
@@ -1,4 +1,5 @@
11use serde::{Deserialize, Serialize};
2use utoipa::IntoParams;
23use uuid::Uuid;
34
45#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -21,6 +22,20 @@
2122 pub avatar_url: String,
2223}
2324
25#[derive(Debug, Clone, Deserialize, IntoParams)]
26pub struct ListQuery {
27 pub limit: Option<u64>,
28 pub offset: Option<u64>,
29}
30
31impl ListQuery {
32 pub fn page(&self) -> (u64, u64) {
33 let limit = self.limit.unwrap_or(50).clamp(1, 100);
34 let offset = self.offset.unwrap_or(0);
35 (limit, offset)
36 }
37}
38
2439#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
2540pub struct AgreementDoc {
2641 pub key: String,
Modified lib/auth/src/share/validate.rs lib/auth/src/share/validate.rs +2 −1 View lib/auth/src/share/validate.rs
@@ -40,7 +40,8 @@
4040 match explicit {
4141 Some(name) => validate_username(name),
4242 None => {
43 let base = email.split('@').next().unwrap_or_default();
43 let local_part = email.split('@').next().unwrap_or_default();
44 let base = local_part.split('+').next().unwrap_or_default();
4445 validate_username(base)
4546 }
4647 }
Modified lib/auth/src/errors.rs lib/auth/src/errors.rs +1 −4 View lib/auth/src/errors.rs
@@ -141,10 +141,7 @@
141141impl IntoResponse for ApiError {
142142 fn into_response(self) -> Response {
143143 let (code, envelope) = self.decompose();
144 let status = match StatusCode::from_u16(code) {
145 Ok(status) => status,
146 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
147 };
144 let status = StatusCode::from_u16(code).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
148145 (status, Json(envelope)).into_response()
149146 }
150147}
Modified lib/auth/src/openapi.rs lib/auth/src/openapi.rs +6 −1 View lib/auth/src/openapi.rs
@@ -35,7 +35,10 @@
3535use crate::services::reports::{CreateReportCmd, ReportDto};
3636use crate::services::ssh_keys::{AddSshKeyCmd, SshKeyDto};
3737use crate::services::suspensions::SuspensionDto;
38use crate::services::two_factor::{RecoveryCodesResp, TotpCodeCmd, TotpSetupResp, TotpStatusResp};
38use crate::services::two_factor::{
39 RecoveryCodesResp, RegenerateRecoveryCodesCmd, TotpCodeCmd, TotpEnableCmd, TotpSetupResp,
40 TotpStatusResp,
41};
3942use crate::share::types::{AgreementDoc, TempKey, UserPublic};
4043
4144#[derive(OpenApi)]
@@ -124,6 +127,8 @@
124127 TotpStatusResp,
125128 TotpSetupResp,
126129 TotpCodeCmd,
130 TotpEnableCmd,
131 RegenerateRecoveryCodesCmd,
127132 RecoveryCodesResp,
128133 DeviceDto,
129134 UpdateDeviceCmd,