feat(lib/auth): add session guard and rework login/logout/me services and routes
ZhenYi committed
Modified lib/auth/src/handlers/logout.rs lib/auth/src/handlers/logout.rs +5 −1 View lib/auth/src/handlers/logout.rs
@@ -18,5 +18,9 @@
1818 State(service): State<AuthService>,
1919 session: Session,
2020) -> Result<AppResponseData<LogoutResp>, ApiError> {
21 Ok(AppResponseData::ok(service.logout(&session, None)))
21 let resp = service
22 .logout(&session)
23 .await
24 .map_err(ApiError::from)?;
25 Ok(AppResponseData::ok(resp))
2226}
No newline at end of file
Modified lib/auth/src/handlers/me.rs lib/auth/src/handlers/me.rs +4 −1 View lib/auth/src/handlers/me.rs
@@ -5,7 +5,7 @@
55use utoipa::ToSchema;
66
77use crate::errors::ApiError;
8use crate::share::session::require_uid;
8use crate::share::session::{require_uid, session_token_key};
99use crate::services::profile::{ProfileDto, UserMeDto};
1010use crate::services::AuthService;
1111
@@ -28,6 +28,9 @@
2828 session: Session,
2929) -> Result<AppResponseData<MeResp>, ApiError> {
3030 let uid = require_uid(&session)?;
31 if let Some(token_hash) = session.get::<String>(&session_token_key(uid)) {
32 let _ = service.touch_session(&token_hash).await;
33 }
3134 let (user, profile) = service.me(uid).await.map_err(ApiError::from)?;
3235 Ok(AppResponseData::ok(MeResp { user, profile }))
3336}
No newline at end of file
Modified lib/auth/src/services/login.rs lib/auth/src/services/login.rs +36 −8 View lib/auth/src/services/login.rs
@@ -10,7 +10,7 @@
1010use uuid::Uuid;
1111
1212use super::AuthService;
13use crate::share::crypto::{aes_decrypt, now_secs, sha256_b64url};
13use crate::share::crypto::{aes_decrypt, now_secs, random_token, sha256_b64url};
1414use crate::errors::AuthError;
1515use crate::share::rate::{attempts_reset, attempts_track};
1616use crate::share::totp::verify_totp_code;
@@ -46,6 +46,8 @@
4646struct TwoFactorChallenge {
4747 pub uid: Uuid,
4848 pub expires_at: i64,
49 pub ua_hash: Option<String>,
50 pub ip_hash: Option<String>,
4951}
5052
5153impl From<&user::Model> for UserPublic {
@@ -105,6 +107,8 @@
105107 let challenge = TwoFactorChallenge {
106108 uid: user.uid,
107109 expires_at: now_secs() + TWO_FA_TTL_SECS,
110 ua_hash: cmd.ua_hash,
111 ip_hash: cmd.ip_hash,
108112 };
109113 session.set(TWO_FA_KEY, challenge);
110114 return Ok(LoginResp {
@@ -155,7 +159,8 @@
155159
156160 self.consume_two_factor_code(&user, &cmd.code).await?;
157161 attempts_reset(self.redis(), &attempts_key).await?;
158 self.finish_login(session, &user, None, None).await
162 self.finish_login(session, &user, challenge.ua_hash, challenge.ip_hash)
163 .await
159164 }
160165
161166 async fn consume_two_factor_code(
@@ -227,6 +232,25 @@
227232 .await?;
228233 let email = primary.map(|row| row.email).unwrap_or_default();
229234
235 let device_id = match ua_hash.as_deref() {
236 Some(hash) => match self.record_device(user.uid, hash, ip_hash.clone()).await {
237 Ok(id) => id,
238 Err(_) => None,
239 },
240 None => None,
241 };
242
243 let token = random_token(32);
244 let token_hash = sha256_b64url(token.as_bytes());
245 self.create_session(
246 &token_hash,
247 user.uid,
248 ua_hash.as_deref(),
249 ip_hash.as_deref(),
250 device_id,
251 )
252 .await?;
253
230254 let auth_user = AuthUser {
231255 uid: user.uid,
232256 username: user.username.clone(),
@@ -235,6 +259,10 @@
235259 email,
236260 };
237261 crate::share::session::apply_login(session, auth_user, user.passwd_version);
262 session.set(
263 &crate::share::session::session_token_key(user.uid),
264 token_hash,
265 );
238266
239267 let now = OffsetDateTime::now_utc();
240268 let mut active = user.clone().into_active_model();
@@ -242,9 +270,6 @@
242270 active.updated_at = Set(now);
243271 let _ = active.update(db.write()).await;
244272
245 if let Some(hash) = ua_hash {
246 let _ = self.record_device(user.uid, &hash, ip_hash).await;
247 }
248273 Ok(LoginResp {
249274 user: UserPublic::from(user),
250275 needs_2fa: false,
@@ -257,7 +282,7 @@
257282 uid: Uuid,
258283 ua_hash: &str,
259284 ip_hash: Option<String>,
260 ) -> Result<(), AuthError> {
285 ) -> Result<Option<Uuid>, AuthError> {
261286 let db = self.db();
262287 let now = OffsetDateTime::now_utc();
263288 if let Some(device) = user_devices::Entity::find()
@@ -266,14 +291,17 @@
266291 .one(db.write())
267292 .await?
268293 {
294 let id = device.id;
269295 let mut active = device.into_active_model();
270296 active.last_seen_at = Set(now);
271297 active.last_ip_hash = Set(ip_hash);
272298 active.updated_at = Set(now);
273299 active.update(db.write()).await?;
300 Ok(Some(id))
274301 } else {
302 let id = Uuid::now_v7();
275303 let row = user_devices::ActiveModel {
276 id: Set(Uuid::now_v7()),
304 id: Set(id),
277305 user_id: Set(uid),
278306 device_name: Set(None),
279307 device_type: Set(None),
@@ -288,8 +316,8 @@
288316 updated_at: Set(now),
289317 };
290318 row.insert(db.write()).await?;
319 Ok(Some(id))
291320 }
292 Ok(())
293321 }
294322
295323 async fn find_user_by_identifier(
Modified lib/auth/src/services/logout.rs lib/auth/src/services/logout.rs +13 −11 View lib/auth/src/services/logout.rs
@@ -1,8 +1,9 @@
1use session::{Session, SessionUserMut};
1use session::{Session, SessionUser, SessionUserMut};
22use serde::{Deserialize, Serialize};
3use uuid::Uuid;
43
54use super::AuthService;
5use crate::errors::AuthError;
6use crate::share::session::session_token_key;
67
78#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
89pub struct LogoutResp {
@@ -10,15 +11,16 @@
1011}
1112
1213impl 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 }
14 pub async fn logout(&self, session: &Session) -> Result<LogoutResp, AuthError> {
15 let Some(uid) = session.uid() else {
16 session.clear_uid();
17 return Ok(LogoutResp { logged_out: true });
18 };
19 if let Some(token_hash) = session.get::<String>(&session_token_key(uid)) {
20 let _ = self.revoke_session(&token_hash).await;
2221 }
22 session.logout_user(uid);
23 session.remove(&session_token_key(uid));
24 Ok(LogoutResp { logged_out: true })
2325 }
2426}
No newline at end of file
Modified lib/auth/src/services/mod.rs lib/auth/src/services/mod.rs +1 −0 View lib/auth/src/services/mod.rs
@@ -20,6 +20,7 @@
2020pub mod profile;
2121pub mod register;
2222pub mod reports;
23pub mod sessions;
2324pub mod ssh_keys;
2425pub mod suspensions;
2526pub mod two_factor;
Added lib/auth/src/services/sessions.rs lib/auth/src/services/sessions.rs +88 −0 View lib/auth/src/services/sessions.rs
@@ -0,0 +1,88 @@
1use models::users::user_sessions;
2use models::users::user_sessions::SessionKind;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
4use time::{Duration, OffsetDateTime};
5use uuid::Uuid;
6
7use super::AuthService;
8use crate::errors::AuthError;
9
10const DEFAULT_SESSION_LIFETIME: Duration = Duration::days(30);
11
12impl AuthService {
13 pub fn session_lifetime(&self) -> Duration {
14 match self.config().session.lifespan_secs {
15 Some(secs) if secs > 0 => Duration::seconds(secs),
16 _ => DEFAULT_SESSION_LIFETIME,
17 }
18 }
19
20 pub async fn create_session(
21 &self,
22 token_hash: &str,
23 user_id: Uuid,
24 ua_hash: Option<&str>,
25 ip_hash: Option<&str>,
26 device_id: Option<Uuid>,
27 ) -> Result<(), AuthError> {
28 let now = OffsetDateTime::now_utc();
29 let id = Uuid::now_v7();
30 let row = user_sessions::ActiveModel {
31 id: Set(id),
32 user_id: Set(user_id),
33 token_hash: Set(token_hash.to_string()),
34 user_agent_hash: Set(ua_hash.map(str::to_string)),
35 client_ip_hash: Set(ip_hash.map(str::to_string)),
36 device_id: Set(device_id),
37 kind: Set(SessionKind::WebLogin),
38 family_id: Set(id),
39 parent_refresh_id: Set(None),
40 is_revoked: Set(false),
41 revoked_at: Set(None),
42 last_seen_at: Set(now),
43 created_at: Set(now),
44 expires_at: Set(now + self.session_lifetime()),
45 replaced_at: Set(None),
46 grace_expires_at: Set(None),
47 updated_at: Set(now),
48 replaced_by: Set(None),
49 signing_key_id: Set(None),
50 app_id: Set(None),
51 };
52 row.insert(self.db().write()).await?;
53 Ok(())
54 }
55
56 pub async fn touch_session(&self, token_hash: &str) -> Result<(), AuthError> {
57 let now = OffsetDateTime::now_utc();
58 let Some(row) = user_sessions::Entity::find()
59 .filter(user_sessions::Column::TokenHash.eq(token_hash))
60 .one(self.db().write())
61 .await?
62 else {
63 return Ok(());
64 };
65 let mut active = row.into_active_model();
66 active.last_seen_at = Set(now);
67 active.updated_at = Set(now);
68 active.update(self.db().write()).await?;
69 Ok(())
70 }
71
72 pub async fn revoke_session(&self, token_hash: &str) -> Result<(), AuthError> {
73 let now = OffsetDateTime::now_utc();
74 let Some(row) = user_sessions::Entity::find()
75 .filter(user_sessions::Column::TokenHash.eq(token_hash))
76 .one(self.db().write())
77 .await?
78 else {
79 return Ok(());
80 };
81 let mut active = row.into_active_model();
82 active.is_revoked = Set(true);
83 active.revoked_at = Set(Some(now));
84 active.updated_at = Set(now);
85 active.update(self.db().write()).await?;
86 Ok(())
87 }
88}
No newline at end of file
Added lib/auth/src/share/guard.rs lib/auth/src/share/guard.rs +45 −0 View lib/auth/src/share/guard.rs
@@ -0,0 +1,45 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::{IntoResponse, Response};
4use models::users::user_sessions;
5use models::users::user;
6use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
7use session::{Session, SessionUser, SessionUserMut};
8use time::OffsetDateTime;
9
10use crate::errors::{ApiError, AuthError};
11use crate::services::AuthService;
12use crate::share::session::{pwd_version_matches, session_token_key};
13
14pub async fn auth_guard(
15 State(service): State<AuthService>,
16 session: Session,
17 request: Request,
18 next: Next,
19) -> Response {
20 let Some(uid) = session.uid() else {
21 return ApiError::from(AuthError::Unauthorized).into_response();
22 };
23 let Some(token_hash) = session.get::<String>(&session_token_key(uid)) else {
24 return ApiError::from(AuthError::Unauthorized).into_response();
25 };
26 let outcome = user_sessions::Entity::find()
27 .filter(user_sessions::Column::TokenHash.eq(&token_hash))
28 .find_also_related(user::Entity)
29 .one(service.db().read())
30 .await;
31 let authorized = match outcome {
32 Ok(Some((row, Some(current)))) => {
33 !row.is_revoked
34 && row.expires_at > OffsetDateTime::now_utc()
35 && pwd_version_matches(&session, current.passwd_version)
36 }
37 Ok(_) => false,
38 Err(error) => return ApiError::from(AuthError::from(error)).into_response(),
39 };
40 if !authorized {
41 session.logout_user(uid);
42 return ApiError::from(AuthError::Unauthorized).into_response();
43 }
44 next.run(request).await
45}
No newline at end of file
Modified lib/auth/src/share/mod.rs lib/auth/src/share/mod.rs +1 −0 View lib/auth/src/share/mod.rs
@@ -1,4 +1,5 @@
11pub mod crypto;
2pub mod guard;
23pub mod rate;
34pub mod redis;
45pub mod session;
Modified lib/auth/src/share/session.rs lib/auth/src/share/session.rs +4 −0 View lib/auth/src/share/session.rs
@@ -5,6 +5,10 @@
55
66pub const PWD_VERSION_KEY: &str = "auth:pwd_version";
77
8pub fn session_token_key(uid: Uuid) -> String {
9 format!("auth:token_hash:{uid}")
10}
11
812pub fn require_uid(session: &Session) -> Result<Uuid, Box<ApiError>> {
913 match session.uid() {
1014 Some(uid) => Ok(uid),
Modified lib/auth/src/routes.rs lib/auth/src/routes.rs +17 −10 View lib/auth/src/routes.rs
@@ -1,3 +1,4 @@
1use axum::middleware;
12use axum::routing::{delete, get, patch, post, put};
23use axum::Router;
34
@@ -59,9 +60,10 @@
5960use crate::handlers::verify_email::verify_email;
6061use crate::handlers::verify_email_page::verify_email_page;
6162use crate::services::AuthService;
63use crate::share::guard::auth_guard;
6264
63pub fn router() -> Router<AuthService> {
64 Router::new()
65pub fn router(service: AuthService) -> Router<AuthService> {
66 let public = Router::new()
6567 .route("/auth/pow/challenge", get(pow_challenge))
6668 .route("/auth/register", post(register))
6769 .route("/auth/login", post(login))
@@ -71,12 +73,19 @@
7173 .route("/auth/email/verify/page", get(verify_email_page))
7274 .route("/auth/email/resend", post(resend_code))
7375 .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))
7776 .route("/auth/password/forgot", post(forgot_password))
7877 .route("/auth/password/reset", post(reset_password))
7978 .route("/auth/password/reset/page", get(reset_password_page))
79 .route("/auth/agreements", get(list_agreements))
80 .route("/auth/agreements/{key}/page", get(agreement_page))
81 .route("/users/{uid}", get(get_user))
82 .route("/users/{uid}/followers", get(list_followers))
83 .route("/users/{uid}/following", get(list_following));
84
85 let authed = Router::new()
86 .route("/auth/email/change", post(request_email_change))
87 .route("/auth/email/change/confirm", post(confirm_email_change))
88 .route("/auth/email/change/{ticket_id}", delete(cancel_email_change))
8089 .route("/auth/password/change", post(change_password))
8190 .route("/auth/2fa/status", get(totp_status))
8291 .route("/auth/2fa/setup", post(totp_setup))
@@ -104,13 +113,8 @@
104113 "/auth/preferences",
105114 get(get_preferences).patch(update_preferences),
106115 )
107 .route("/auth/agreements", get(list_agreements))
108116 .route("/auth/agreements/{key}", put(accept_agreement))
109 .route("/auth/agreements/{key}/page", get(agreement_page))
110 .route("/users/{uid}", get(get_user))
111117 .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))
114118 .route("/users/{uid}/block", put(block_user).delete(unblock_user))
115119 .route("/users/blocks", get(list_blocked_users))
116120 .route("/notifications", get(list_notifications))
@@ -131,4 +135,7 @@
131135 .route("/deletion", get(deletion_status).post(request_deletion))
132136 .route("/deletion/cancel", post(cancel_deletion))
133137 .route("/suspension", get(active_suspension))
138 .route_layer(middleware::from_fn_with_state(service, auth_guard));
139
140 public.merge(authed)
134141}
No newline at end of file