refactor(auth): refresh feature usecase implementations
zhenyi committed
Modified lib/auth/features/agreement/usecase.rs lib/auth/features/agreement/usecase.rs +16 −0 View lib/auth/features/agreement/usecase.rs
@@ -8,6 +8,7 @@
88
99use crate::config::AuthConfig;
1010use crate::error::AuthError;
11use crate::metrics::{self, record_agreement_accept};
1112
1213use super::{AcceptAgreementCmd, AcceptAgreementResp};
1314
@@ -28,6 +29,21 @@
2829 db: &DatabaseConnection,
2930 config: &AuthConfig,
3031) -> Result<AcceptAgreementResp, AuthError> {
32 let _timer = metrics::Timer::start(metrics::FEATURE_AGREEMENT_ACCEPT);
33 let agreement_key = cmd.agreement_key.clone();
34 let result = accept_agreement_inner(cmd, db, config).await;
35 if result.is_ok() {
36 record_agreement_accept(&agreement_key);
37 }
38 metrics::record_outcome(metrics::FEATURE_AGREEMENT_ACCEPT, &result);
39 result
40}
41
42async fn accept_agreement_inner(
43 cmd: AcceptAgreementCmd,
44 db: &DatabaseConnection,
45 config: &AuthConfig,
46) -> Result<AcceptAgreementResp, AuthError> {
3147 let known = match cmd.agreement_key.as_str() {
3248 "terms" => Some(&config.agreements.terms),
3349 "privacy" => Some(&config.agreements.privacy),
Modified lib/auth/features/email_code/usecase.rs lib/auth/features/email_code/usecase.rs +35 −0 View lib/auth/features/email_code/usecase.rs
@@ -17,6 +17,7 @@
1717use crate::config::AuthConfig;
1818use crate::error::AuthError;
1919use crate::features::TempKey;
20use crate::metrics::{self, EmailVerifyOutcome, record_email_code_verify};
2021
2122use super::types::{IssueCodeArtifacts, IssueCodeCmd, VerifyCodeCmd, VerifyCodeResult};
2223
@@ -96,6 +97,18 @@
9697 kv: &RedisCache,
9798 config: &AuthConfig,
9899) -> Result<IssueCodeArtifacts, AuthError> {
100 let _timer = metrics::Timer::start(metrics::FEATURE_EMAIL_CODE_ISSUE);
101 let result = issue_code_inner(cmd, db, kv, config).await;
102 metrics::record_outcome(metrics::FEATURE_EMAIL_CODE_ISSUE, &result);
103 result
104}
105
106async fn issue_code_inner(
107 cmd: IssueCodeCmd,
108 db: &DatabaseConnection,
109 kv: &RedisCache,
110 config: &AuthConfig,
111) -> Result<IssueCodeArtifacts, AuthError> {
99112 let rate_key = format!(
100113 "auth:code_rate:{}:{}",
101114 purpose_to_str(cmd.purpose),
@@ -183,6 +196,28 @@
183196 kv: &RedisCache,
184197 config: &AuthConfig,
185198) -> Result<VerifyCodeResult, AuthError> {
199 let _timer = metrics::Timer::start(metrics::FEATURE_EMAIL_CODE_VERIFY);
200 let result = verify_code_inner(cmd, db, kv, config).await;
201 let outcome = match &result {
202 Ok(_) => EmailVerifyOutcome::Success,
203 Err(AuthError::BadRequest(msg)) if msg.contains("expired") => EmailVerifyOutcome::Expired,
204 Err(AuthError::BadRequest(msg)) if msg.contains("not found") => {
205 EmailVerifyOutcome::NotFound
206 }
207 Err(AuthError::BadRequest(_)) => EmailVerifyOutcome::Mismatch,
208 _ => EmailVerifyOutcome::Mismatch,
209 };
210 record_email_code_verify(outcome);
211 metrics::record_outcome(metrics::FEATURE_EMAIL_CODE_VERIFY, &result);
212 result
213}
214
215async fn verify_code_inner(
216 cmd: VerifyCodeCmd,
217 db: &DatabaseConnection,
218 kv: &RedisCache,
219 config: &AuthConfig,
220) -> Result<VerifyCodeResult, AuthError> {
186221 let temp_id = Uuid::parse_str(&cmd.temp_key_value)?;
187222 let key = redis_key(cmd.purpose, &temp_id);
188223 let attempts_key = format!(
Modified lib/auth/features/forgot_password/usecase.rs lib/auth/features/forgot_password/usecase.rs +15 −0 View lib/auth/features/forgot_password/usecase.rs
@@ -15,6 +15,7 @@
1515use crate::features::AuthService;
1616use crate::features::TempKey;
1717use crate::features::register::PowProof;
18use crate::metrics::{self, EmailChannel, record_email_code_issued};
1819
1920use super::types::{ForgotPasswordCmd, ForgotPasswordResp};
2021
@@ -92,6 +93,20 @@
9293 svc: &AuthService,
9394 expected_cid: Option<&str>,
9495) -> Result<ForgotPasswordResp, AuthError> {
96 let _timer = metrics::Timer::start(metrics::FEATURE_FORGOT_PASSWORD);
97 let result = forgot_password_inner(cmd, svc, expected_cid).await;
98 if result.is_ok() {
99 record_email_code_issued(EmailChannel::Reset);
100 }
101 metrics::record_outcome(metrics::FEATURE_FORGOT_PASSWORD, &result);
102 result
103}
104
105async fn forgot_password_inner(
106 cmd: ForgotPasswordCmd,
107 svc: &AuthService,
108 expected_cid: Option<&str>,
109) -> Result<ForgotPasswordResp, AuthError> {
95110 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
96111 let db = svc.db.writer();
97112
Modified lib/auth/features/login/usecase.rs lib/auth/features/login/usecase.rs +64 −52 View lib/auth/features/login/usecase.rs
@@ -12,6 +12,7 @@
1212use crate::features::AuthService;
1313use crate::features::password_hash;
1414use crate::features::register::PowProof;
15use crate::metrics::{self, JwtPairKind};
1516use crate::pow::PowService;
1617
1718use super::types::{LoginCmd, LoginResp, UserPublic};
@@ -79,63 +80,74 @@
7980 svc: &AuthService,
8081 expected_cid: Option<&str>,
8182) -> Result<LoginResp, AuthError> {
82 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
83 let db = svc.db.writer();
83 let _timer = metrics::Timer::start(metrics::FEATURE_LOGIN);
84 let result: Result<LoginResp, AuthError> = async {
85 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
86 let db = svc.db.writer();
8487
85 let user = find_user_by_identifier(&db, &cmd.identifier)
86 .await?
87 .ok_or(AuthError::InvalidCredentials)?;
88 let user = find_user_by_identifier(&db, &cmd.identifier)
89 .await?
90 .ok_or(AuthError::InvalidCredentials)?;
8891
89 if user.prohibit_login {
90 return Err(AuthError::Forbidden("login prohibited".into()));
91 }
92 if user.is_restricted {
93 return Err(AuthError::Forbidden("account restricted".into()));
94 }
95 if !user.is_active {
96 return Err(AuthError::Forbidden(
97 "account inactive, please verify your email".into(),
98 ));
99 }
92 if user.prohibit_login {
93 return Err(AuthError::Forbidden("login prohibited".into()));
94 }
95 if user.is_restricted {
96 return Err(AuthError::Forbidden("account restricted".into()));
97 }
98 if !user.is_active {
99 return Err(AuthError::Forbidden(
100 "account inactive, please verify your email".into(),
101 ));
102 }
100103
101 let hash = user
102 .passwd_hash
103 .clone()
104 .ok_or_else(|| AuthError::BadRequest("this account is registered via a third-party provider; please sign in with that provider".into()))?;
104 let hash = user.passwd_hash.clone().ok_or_else(|| {
105 AuthError::BadRequest(
106 "this account is registered via a third-party provider; please sign in with that provider"
107 .into(),
108 )
109 })?;
105110
106 let valid = password_hash::usecase::verify_password(
107 password_hash::PasswordInput {
108 password: &cmd.password,
109 },
110 &password_hash::PasswordHashString(hash),
111 )?;
112 if !valid {
113 return Err(AuthError::InvalidCredentials);
114 }
111 let valid = password_hash::usecase::verify_password(
112 password_hash::PasswordInput {
113 password: &cmd.password,
114 },
115 &password_hash::PasswordHashString(hash),
116 )?;
117 if !valid {
118 return Err(AuthError::InvalidCredentials);
119 }
115120
116 // Issue the token pair via jwt rotation.
117 let cache = svc.jwt.cache.read().await;
118 let ctx = RotationContext {
119 db: &db,
120 settings: &svc.jwt.settings,
121 cache: &cache,
122 };
123 let pair = issue_login_pair(
124 &ctx,
125 user.uid,
126 user.username.clone(),
127 user.is_admin,
128 user.is_restricted,
129 user.passwd_version.unwrap_or(0),
130 cmd.app_id.clone(),
131 )
132 .await?;
121 // Issue the token pair via jwt rotation.
122 let cache = svc.jwt.cache.read().await;
123 let ctx = RotationContext {
124 db: &db,
125 settings: &svc.jwt.settings,
126 cache: &cache,
127 };
128 let pair = issue_login_pair(
129 &ctx,
130 user.uid,
131 user.username.clone(),
132 user.is_admin,
133 user.is_restricted,
134 user.passwd_version.unwrap_or(0),
135 cmd.app_id.clone(),
136 )
137 .await?;
133138
134 // Update last_login_at.
135 let mut am: UserActiveModel = user.clone().into();
136 am.last_login_at = Set(Some(OffsetDateTime::now_utc()));
137 am.updated_at = Set(OffsetDateTime::now_utc());
138 let _ = am.update(&db).await; // best-effort
139 // Update last_login_at.
140 let mut am: UserActiveModel = user.clone().into();
141 am.last_login_at = Set(Some(OffsetDateTime::now_utc()));
142 am.updated_at = Set(OffsetDateTime::now_utc());
143 let _ = am.update(&db).await; // best-effort
139144
140 Ok(build_resp(pair.into(), user))
145 Ok(build_resp(pair.into(), user))
146 }
147 .await;
148 metrics::record_outcome(metrics::FEATURE_LOGIN, &result);
149 if result.is_ok() {
150 metrics::record_jwt_pair_issued(JwtPairKind::Login);
151 }
152 result
141153}
Modified lib/auth/features/logout/usecase.rs lib/auth/features/logout/usecase.rs +58 −44 View lib/auth/features/logout/usecase.rs
@@ -6,6 +6,7 @@
66
77use crate::error::AuthError;
88use crate::features::AuthService;
9use crate::metrics::{self, JwtRevokeKind};
910
1011use super::{LogoutCmd, LogoutResp};
1112
@@ -24,53 +25,66 @@
2425 svc: &AuthService,
2526 access_token: Option<String>,
2627) -> Result<LogoutResp, AuthError> {
27 let db = svc.db.writer();
28 let _timer = metrics::Timer::start(metrics::FEATURE_LOGOUT);
29 let result: Result<LogoutResp, AuthError> = async {
30 let db = svc.db.writer();
2831
29 let kid = extract_kid(&cmd.refresh_token).ok_or(JwtError::InvalidHeader)?;
30 let cache = svc.jwt.cache.read().await;
31 let cached = cache
32 .get(&kid)
33 .ok_or(JwtError::UnknownKid(kid.to_string()))?;
34 let _claims = decode_refresh_token(
35 &cmd.refresh_token,
36 &cached.decoding,
37 cached.algorithm,
38 &svc.jwt.settings.issuer,
39 &svc.jwt.settings.audience,
40 svc.jwt.settings.clock_skew_seconds,
41 )?;
42 drop(cache);
32 let kid = extract_kid(&cmd.refresh_token).ok_or(JwtError::InvalidHeader)?;
33 let cache = svc.jwt.cache.read().await;
34 let cached = cache
35 .get(&kid)
36 .ok_or(JwtError::UnknownKid(kid.to_string()))?;
37 let _claims = decode_refresh_token(
38 &cmd.refresh_token,
39 &cached.decoding,
40 cached.algorithm,
41 &svc.jwt.settings.issuer,
42 &svc.jwt.settings.audience,
43 svc.jwt.settings.clock_skew_seconds,
44 )?;
45 drop(cache);
4346
44 let token_hash = session::hash_token(&cmd.refresh_token);
45 let hit = session::find_session_by_hash(&db, &token_hash).await?;
46 let resp = match hit {
47 Some(sess) => {
48 session::revoke_session(&db, sess.id).await?;
49 LogoutResp { revoked: true }
47 let token_hash = session::hash_token(&cmd.refresh_token);
48 let hit = session::find_session_by_hash(&db, &token_hash).await?;
49 let resp = match hit {
50 Some(sess) => {
51 session::revoke_session(&db, sess.id).await?;
52 LogoutResp { revoked: true }
53 }
54 None => LogoutResp { revoked: false },
55 };
56
57 // VULN-005: revoke the presented access token too (best-effort — an expired
58 // or malformed token is simply ignored).
59 if let Some(token) = access_token
60 && let Some(akid) = extract_kid(&token)
61 && let Some(acached) = svc.jwt.cache.read().await.get(&akid)
62 && let Ok(claims) = decode_access_token(
63 &token,
64 &acached.decoding,
65 acached.algorithm,
66 &svc.jwt.settings.issuer,
67 &svc.jwt.settings.audience,
68 svc.jwt.settings.clock_skew_seconds,
69 )
70 {
71 let ttl = svc.jwt.settings.access_ttl.whole_seconds().max(1) as u64;
72 AccessTokenBlacklist::new(svc.kv.clone())
73 .revoke_jti(claims.jti, ttl)
74 .await
75 .map_err(map_redis)?;
5076 }
51 None => LogoutResp { revoked: false },
52 };
5377
54 // VULN-005: revoke the presented access token too (best-effort — an expired
55 // or malformed token is simply ignored).
56 if let Some(token) = access_token
57 && let Some(akid) = extract_kid(&token)
58 && let Some(acached) = svc.jwt.cache.read().await.get(&akid)
59 && let Ok(claims) = decode_access_token(
60 &token,
61 &acached.decoding,
62 acached.algorithm,
63 &svc.jwt.settings.issuer,
64 &svc.jwt.settings.audience,
65 svc.jwt.settings.clock_skew_seconds,
66 )
67 {
68 let ttl = svc.jwt.settings.access_ttl.whole_seconds().max(1) as u64;
69 AccessTokenBlacklist::new(svc.kv.clone())
70 .revoke_jti(claims.jti, ttl)
71 .await
72 .map_err(map_redis)?;
78 Ok(resp)
7379 }
74
75 Ok(resp)
80 .await;
81 let revoked_session = matches!(
82 &result,
83 Ok(LogoutResp { revoked: true })
84 );
85 metrics::record_outcome(metrics::FEATURE_LOGOUT, &result);
86 if revoked_session {
87 metrics::record_jwt_session_revoked(JwtRevokeKind::Logout);
88 }
89 result
7690}
No newline at end of file
Modified lib/auth/features/oauth/oauth_apple/usecase.rs lib/auth/features/oauth/oauth_apple/usecase.rs +2 −0 View lib/auth/features/oauth/oauth_apple/usecase.rs
@@ -5,6 +5,7 @@
55use crate::config::OAuthProviderConfig;
66use crate::error::AuthError;
77use crate::features::oauth::types::{OAuthProvider, ProviderIdentity};
8use crate::metrics::{OAuthProvider as MetricsOAuth, OAuthProviderFetchTimer};
89
910#[derive(Debug, Deserialize)]
1011struct AppleTokenResp {
@@ -129,6 +130,7 @@
129130 cfg: &OAuthProviderConfig,
130131 client: &reqwest::Client,
131132) -> Result<ProviderIdentity, AuthError> {
133 let _timer = OAuthProviderFetchTimer::start(MetricsOAuth::Apple);
132134 let client_secret = apple_client_secret(cfg)?;
133135
134136 let token: AppleTokenResp = client
Modified lib/auth/features/oauth/oauth_github/usecase.rs lib/auth/features/oauth/oauth_github/usecase.rs +2 −0 View lib/auth/features/oauth/oauth_github/usecase.rs
@@ -3,6 +3,7 @@
33use crate::config::OAuthProviderConfig;
44use crate::error::AuthError;
55use crate::features::oauth::types::{OAuthProvider, ProviderIdentity};
6use crate::metrics::{OAuthProvider as MetricsOAuth, OAuthProviderFetchTimer};
67
78#[derive(Debug, Deserialize)]
89struct TokenResp {
@@ -56,6 +57,7 @@
5657 cfg: &OAuthProviderConfig,
5758 client: &reqwest::Client,
5859) -> Result<ProviderIdentity, AuthError> {
60 let _timer = OAuthProviderFetchTimer::start(MetricsOAuth::Github);
5961 let token: TokenResp = client
6062 .post(&cfg.token_url)
6163 .header("Accept", "application/json")
Modified lib/auth/features/oauth/oauth_google/usecase.rs lib/auth/features/oauth/oauth_google/usecase.rs +2 −0 View lib/auth/features/oauth/oauth_google/usecase.rs
@@ -4,6 +4,7 @@
44use crate::config::OAuthProviderConfig;
55use crate::error::AuthError;
66use crate::features::oauth::types::{OAuthProvider, ProviderIdentity};
7use crate::metrics::{OAuthProvider as MetricsOAuth, OAuthProviderFetchTimer};
78
89#[derive(Debug, Deserialize)]
910struct TokenResp {
@@ -32,6 +33,7 @@
3233 cfg: &OAuthProviderConfig,
3334 client: &reqwest::Client,
3435) -> Result<ProviderIdentity, AuthError> {
36 let _timer = OAuthProviderFetchTimer::start(MetricsOAuth::Google);
3537 let token: TokenResp = client
3638 .post(&cfg.token_url)
3739 .form(&[
Modified lib/auth/features/oauth/usecase.rs lib/auth/features/oauth/usecase.rs +173 −111 View lib/auth/features/oauth/usecase.rs
@@ -24,6 +24,10 @@
2424use crate::error::AuthError;
2525use crate::features::AuthService;
2626use crate::features::TempKey;
27use crate::metrics::{
28 self, JwtPairKind, OAuthAuthorizeOutcome, OAuthCallbackOutcome, OAuthProvider as MetricsOAuth,
29 OAuthResolutionBranch,
30};
2731
2832use super::oauth_apple::fetch_apple_identity;
2933use super::oauth_github::fetch_github_identity;
@@ -33,6 +37,14 @@
3337 OAuthProviderInfo, ProviderIdentity,
3438};
3539
40fn to_metrics_provider(p: OAuthProvider) -> MetricsOAuth {
41 match p {
42 OAuthProvider::Google => MetricsOAuth::Google,
43 OAuthProvider::Github => MetricsOAuth::Github,
44 OAuthProvider::Apple => MetricsOAuth::Apple,
45 }
46}
47
3648fn map_redis(e: RedisError) -> AuthError {
3749 AuthError::Internal(format!("redis: {e}"))
3850}
@@ -125,57 +137,71 @@
125137 cmd: AuthorizeCmd,
126138 svc: &AuthService,
127139) -> Result<AuthorizationUrlResp, AuthError> {
128 let cfg = get_provider_cfg(&svc.config, cmd.provider)?;
129 let temp_id = Uuid::new_v4();
130 let state = generate_state();
131 let ttl = svc.config.oauth_state_ttl_secs;
140 let _timer = metrics::Timer::start(metrics::FEATURE_OAUTH_AUTHORIZE);
141 let provider = cmd.provider;
142 let mp = to_metrics_provider(provider);
143 let result: Result<AuthorizationUrlResp, AuthError> = async {
144 let cfg = get_provider_cfg(&svc.config, provider)?;
145 let temp_id = Uuid::new_v4();
146 let state = generate_state();
147 let ttl = svc.config.oauth_state_ttl_secs;
132148
133 // VULN-005: derive code_challenge = BASE64URL(SHA256(verifier)) per RFC 7636.
134 let code_challenge = {
135 use sha2::Digest;
136 let digest = sha2::Sha256::digest(cmd.code_verifier.as_bytes());
137 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
138 };
149 // VULN-005: derive code_challenge = BASE64URL(SHA256(verifier)) per RFC 7636.
150 let code_challenge = {
151 use sha2::Digest;
152 let digest = sha2::Sha256::digest(cmd.code_verifier.as_bytes());
153 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
154 };
139155
140 // Persist state + verifier together so the callback can recover both.
141 let payload = serde_json::json!({
142 "state": state,
143 "code_verifier": cmd.code_verifier,
144 })
145 .to_string();
146 svc.kv
147 .set_bytes(&redis_state_key(&temp_id), payload.into_bytes(), ttl)
148 .await
149 .map_err(map_redis)?;
156 // Persist state + verifier together so the callback can recover both.
157 let payload = serde_json::json!({
158 "state": state,
159 "code_verifier": cmd.code_verifier,
160 })
161 .to_string();
162 svc.kv
163 .set_bytes(&redis_state_key(&temp_id), payload.into_bytes(), ttl)
164 .await
165 .map_err(map_redis)?;
166
167 let mut url = Url::parse(&cfg.authorize_url)
168 .map_err(|e| AuthError::Internal(format!("bad authorize_url: {e}")))?;
169 {
170 let mut q = url.query_pairs_mut();
171 q.append_pair("response_type", "code");
172 q.append_pair("client_id", &cfg.client_id);
173 q.append_pair("redirect_uri", &cfg.redirect_uri);
174 q.append_pair("scope", &cfg.scopes.join(" "));
175 q.append_pair("state", &state);
176 q.append_pair("code_challenge", &code_challenge);
177 q.append_pair("code_challenge_method", "S256");
178 }
179
180 let temp_key = TempKey {
181 name: cookie_name(&svc.config.cookie_name_prefix, provider),
182 value: temp_id.to_string(),
183 ttl_seconds: ttl,
184 secure: svc.config.cookie_secure,
185 http_only: true,
186 same_site: svc.config.cookie_same_site.clone(),
187 domain: svc.config.cookie_domain.clone(),
188 path: Some("/".to_string()),
189 };
150190
151 let mut url = Url::parse(&cfg.authorize_url)
152 .map_err(|e| AuthError::Internal(format!("bad authorize_url: {e}")))?;
153 {
154 let mut q = url.query_pairs_mut();
155 q.append_pair("response_type", "code");
156 q.append_pair("client_id", &cfg.client_id);
157 q.append_pair("redirect_uri", &cfg.redirect_uri);
158 q.append_pair("scope", &cfg.scopes.join(" "));
159 q.append_pair("state", &state);
160 q.append_pair("code_challenge", &code_challenge);
161 q.append_pair("code_challenge_method", "S256");
191 Ok(AuthorizationUrlResp {
192 url: url.to_string(),
193 temp_key,
194 })
162195 }
163
164 let temp_key = TempKey {
165 name: cookie_name(&svc.config.cookie_name_prefix, cmd.provider),
166 value: temp_id.to_string(),
167 ttl_seconds: ttl,
168 secure: svc.config.cookie_secure,
169 http_only: true,
170 same_site: svc.config.cookie_same_site.clone(),
171 domain: svc.config.cookie_domain.clone(),
172 path: Some("/".to_string()),
196 .await;
197 metrics::record_outcome(metrics::FEATURE_OAUTH_AUTHORIZE, &result);
198 let authorize_outcome = match &result {
199 Ok(_) => OAuthAuthorizeOutcome::Success,
200 Err(AuthError::BadRequest(_)) => OAuthAuthorizeOutcome::ProviderDisabled,
201 Err(_) => OAuthAuthorizeOutcome::Other,
173202 };
174
175 Ok(AuthorizationUrlResp {
176 url: url.to_string(),
177 temp_key,
178 })
203 metrics::record_oauth_authorize(mp, authorize_outcome);
204 result
179205}
180206
181207/// Fetch the provider identity for the given code. Dispatches to the
@@ -212,6 +238,7 @@
212238 .one(db)
213239 .await?;
214240 if let Some((_link, Some(user))) = link {
241 metrics::record_oauth_user_resolution(OAuthResolutionBranch::ExistingLink);
215242 return Ok((user, false));
216243 }
217244
@@ -239,7 +266,7 @@
239266 .set_refresh_token_hash(identity
240267 .refresh_token
241268 .as_ref()
242 .map(|t| sha256_hex(t.as_bytes())))
269 .map(|t| sha256_hex(t.as_bytes())))
243270 .set_token_expires_at(identity.expires_at)
244271 .set_scopes(identity.scopes.clone())
245272 .set_created_at(now)
@@ -257,7 +284,10 @@
257284 .exec(db)
258285 .await?;
259286 match inserted {
260 db::conn::sea_orm::TryInsertResult::Inserted(_) => return Ok((user, false)),
287 db::conn::sea_orm::TryInsertResult::Inserted(_) => {
288 metrics::record_oauth_user_resolution(OAuthResolutionBranch::EmailMatch);
289 return Ok((user, false));
290 }
261291 db::conn::sea_orm::TryInsertResult::Conflicted => {
262292 let winner = ProviderEntity::find()
263293 .filter(ProviderEntity::COLUMN.provider
@@ -268,6 +298,7 @@
268298 .one(db)
269299 .await?;
270300 if let Some((_l, Some(winning_user))) = winner {
301 metrics::record_oauth_user_resolution(OAuthResolutionBranch::EmailMatch);
271302 return Ok((winning_user, false));
272303 }
273304 return Err(AuthError::Internal(
@@ -280,6 +311,7 @@
280311 }
281312
282313 // 3. Auto-register a passwordless account.
314 metrics::record_oauth_user_resolution(OAuthResolutionBranch::AutoRegister);
283315 let now = OffsetDateTime::now_utc();
284316 let uid = Uuid::new_v4();
285317 let email = identity.email.clone().ok_or_else(|| {
@@ -408,75 +440,105 @@
408440 cmd: CallbackCmd,
409441 svc: &AuthService,
410442) -> Result<OAuthLoginResp, AuthError> {
411 let db = svc.db.writer();
443 let _timer = metrics::Timer::start(metrics::FEATURE_OAUTH_CALLBACK);
444 let provider = cmd.provider;
445 let mp = to_metrics_provider(provider);
446 let result: Result<OAuthLoginResp, AuthError> = async {
447 let db = svc.db.writer();
448
449 // Validate state.
450 let temp_id = Uuid::parse_str(&cmd.temp_key_value)?;
451 let key = redis_state_key(&temp_id);
452 let stored_raw = svc
453 .kv
454 .get_bytes(&key)
455 .await
456 .map_err(map_redis)?
457 .ok_or_else(|| AuthError::BadRequest("oauth state expired".into()))?;
458 svc.kv.delete(&key).await.map_err(map_redis)?;
459 let stored: serde_json::Value = serde_json::from_slice(&stored_raw)
460 .map_err(|e| AuthError::Internal(format!("oauth state parse: {e}")))?;
461 if stored["state"].as_str() != Some(cmd.state.as_str()) {
462 return Err(AuthError::BadRequest("oauth state mismatch".into()));
463 }
464 let code_verifier = stored["code_verifier"]
465 .as_str()
466 .ok_or_else(|| AuthError::BadRequest("missing code_verifier".into()))?;
412467
413 // Validate state.
414 let temp_id = Uuid::parse_str(&cmd.temp_key_value)?;
415 let key = redis_state_key(&temp_id);
416 let stored_raw = svc
417 .kv
418 .get_bytes(&key)
419 .await
420 .map_err(map_redis)?
421 .ok_or_else(|| AuthError::BadRequest("oauth state expired".into()))?;
422 svc.kv.delete(&key).await.map_err(map_redis)?;
423 let stored: serde_json::Value = serde_json::from_slice(&stored_raw)
424 .map_err(|e| AuthError::Internal(format!("oauth state parse: {e}")))?;
425 if stored["state"].as_str() != Some(cmd.state.as_str()) {
426 return Err(AuthError::BadRequest("oauth state mismatch".into()));
427 }
428 let code_verifier = stored["code_verifier"]
429 .as_str()
430 .ok_or_else(|| AuthError::BadRequest("missing code_verifier".into()))?;
468 let cfg = get_provider_cfg(&svc.config, provider)?;
469 let client = reqwest::Client::new();
470 let identity = fetch_identity(provider, &cmd.code, code_verifier, cfg, &client).await?;
431471
432 let cfg = get_provider_cfg(&svc.config, cmd.provider)?;
433 let client = reqwest::Client::new();
434 let identity = fetch_identity(cmd.provider, &cmd.code, code_verifier, cfg, &client).await?;
472 let (user, auto_registered) = resolve_or_create_user(&identity, &db).await?;
435473
436 let (user, auto_registered) = resolve_or_create_user(&identity, &db).await?;
474 if user.prohibit_login {
475 return Err(AuthError::Forbidden("login prohibited".into()));
476 }
477 if user.is_restricted {
478 return Err(AuthError::Forbidden("account restricted".into()));
479 }
480 if !user.is_active {
481 return Err(AuthError::Forbidden("account inactive".into()));
482 }
437483
438 if user.prohibit_login {
439 return Err(AuthError::Forbidden("login prohibited".into()));
440 }
441 if user.is_restricted {
442 return Err(AuthError::Forbidden("account restricted".into()));
443 }
444 if !user.is_active {
445 return Err(AuthError::Forbidden("account inactive".into()));
446 }
484 // Issue tokens.
485 let cache = svc.jwt.cache.read().await;
486 let ctx = RotationContext {
487 db: &db,
488 settings: &svc.jwt.settings,
489 cache: &cache,
490 };
491 let pair = issue_login_pair(
492 &ctx,
493 user.uid,
494 user.username.clone(),
495 user.is_admin,
496 user.is_restricted,
497 user.passwd_version.unwrap_or(0),
498 cmd.app_id.clone(),
499 )
500 .await?;
501 drop(cache);
447502
448 // Issue tokens.
449 let cache = svc.jwt.cache.read().await;
450 let ctx = RotationContext {
451 db: &db,
452 settings: &svc.jwt.settings,
453 cache: &cache,
454 };
455 let pair = issue_login_pair(
456 &ctx,
457 user.uid,
458 user.username.clone(),
459 user.is_admin,
460 user.is_restricted,
461 user.passwd_version.unwrap_or(0),
462 cmd.app_id.clone(),
463 )
464 .await?;
465 drop(cache);
503 let resp: TokenPairResponse = pair.into();
466504
467 let resp: TokenPairResponse = pair.into();
505 // Auto-record configured agreements on successful login.
506 for doc in svc.config.agreements.iter() {
507 if svc.accept_agreement(user.uid, &doc.key, &doc.version).await.is_ok() {
508 metrics::record_agreement_record(&doc.key);
509 }
510 }
468511
469 // Auto-record configured agreements on successful login.
470 for doc in svc.config.agreements.iter() {
471 let _ = svc.accept_agreement(user.uid, &doc.key, &doc.version).await;
512 Ok(OAuthLoginResp {
513 access_token: resp.access_token,
514 refresh_token: resp.refresh_token,
515 token_type: resp.token_type,
516 expires_in: resp.expires_in,
517 user_id: user.uid,
518 auto_registered,
519 })
472520 }
473
474 Ok(OAuthLoginResp {
475 access_token: resp.access_token,
476 refresh_token: resp.refresh_token,
477 token_type: resp.token_type,
478 expires_in: resp.expires_in,
479 user_id: user.uid,
480 auto_registered,
481 })
521 .await;
522 metrics::record_outcome(metrics::FEATURE_OAUTH_CALLBACK, &result);
523 let cb_outcome = match &result {
524 Ok(_) => OAuthCallbackOutcome::Success,
525 Err(e) => match e {
526 AuthError::BadRequest(msg) if msg == "oauth state expired" => {
527 OAuthCallbackOutcome::StateExpired
528 }
529 AuthError::BadRequest(msg) if msg == "oauth state mismatch" => {
530 OAuthCallbackOutcome::StateMismatch
531 }
532 AuthError::BadRequest(_) | AuthError::Unauthorized(_) | AuthError::NotFound(_) => {
533 OAuthCallbackOutcome::ProviderError
534 }
535 AuthError::Forbidden(_) => OAuthCallbackOutcome::UserGate,
536 _ => OAuthCallbackOutcome::Other,
537 },
538 };
539 metrics::record_oauth_callback(mp, cb_outcome);
540 if result.is_ok() {
541 metrics::record_jwt_pair_issued(JwtPairKind::Login);
542 }
543 result
482544}
Modified lib/auth/features/password_hash/usecase.rs lib/auth/features/password_hash/usecase.rs +8 −0 View lib/auth/features/password_hash/usecase.rs
@@ -5,12 +5,14 @@
55
66use crate::config::PasswordPolicy;
77use crate::error::AuthError;
8use crate::metrics::{PasswordOp, PasswordOpTimer, record_password_policy_rejection};
89
910use super::{PasswordHashString, PasswordInput, ValidatePasswordRequest};
1011
1112/// Hash a password using Argon2id with a fresh random salt. The result is a
1213/// PHC-formatted string containing the algorithm parameters and the salt.
1314pub fn hash_password(input: PasswordInput<'_>) -> Result<PasswordHashString, AuthError> {
15 let _timer = PasswordOpTimer::start(PasswordOp::Hash);
1416 let mut bytes = [0u8; 16];
1517 rand::rng().fill_bytes(&mut bytes);
1618 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
@@ -28,6 +30,7 @@
2830 input: PasswordInput<'_>,
2931 hash: &PasswordHashString,
3032) -> Result<bool, AuthError> {
33 let _timer = PasswordOpTimer::start(PasswordOp::Verify);
3134 let parsed = PasswordHash::new(hash.as_ref())
3235 .map_err(|e| AuthError::Internal(format!("malformed password hash: {e}")))?;
3336 let argon2 = Argon2::default();
@@ -44,27 +47,32 @@
4447) -> Result<(), AuthError> {
4548 let p = req.password;
4649 if p.chars().count() < policy.min_length {
50 record_password_policy_rejection("min_length");
4751 return Err(AuthError::BadRequest(format!(
4852 "password must be at least {} characters long",
4953 policy.min_length
5054 )));
5155 }
5256 if policy.require_upper && !p.chars().any(|c| c.is_ascii_uppercase()) {
57 record_password_policy_rejection("require_upper");
5358 return Err(AuthError::BadRequest(
5459 "password must contain an uppercase letter".into(),
5560 ));
5661 }
5762 if policy.require_lower && !p.chars().any(|c| c.is_ascii_lowercase()) {
63 record_password_policy_rejection("require_lower");
5864 return Err(AuthError::BadRequest(
5965 "password must contain a lowercase letter".into(),
6066 ));
6167 }
6268 if policy.require_digit && !p.chars().any(|c| c.is_ascii_digit()) {
69 record_password_policy_rejection("require_digit");
6370 return Err(AuthError::BadRequest(
6471 "password must contain a digit".into(),
6572 ));
6673 }
6774 if policy.require_symbol && !p.chars().any(|c| !(c.is_ascii_alphanumeric())) {
75 record_password_policy_rejection("require_symbol");
6876 return Err(AuthError::BadRequest(
6977 "password must contain a symbol".into(),
7078 ));
Modified lib/auth/features/reclaim/usecase.rs lib/auth/features/reclaim/usecase.rs +15 −0 View lib/auth/features/reclaim/usecase.rs
@@ -4,6 +4,7 @@
44
55use crate::error::AuthError;
66use crate::features::AuthService;
7use crate::metrics::{self, EmailChannel, record_email_code_issued};
78
89use super::types::{ReclaimCmd, ReclaimResp};
910
@@ -54,6 +55,20 @@
5455 svc: &AuthService,
5556 expected_cid: Option<&str>,
5657) -> Result<ReclaimResp, AuthError> {
58 let _timer = metrics::Timer::start(metrics::FEATURE_RECLAIM);
59 let result = reclaim_inner(cmd, svc, expected_cid).await;
60 if matches!(&result, Ok(r) if r.sent) {
61 record_email_code_issued(EmailChannel::Reclaim);
62 }
63 metrics::record_outcome(metrics::FEATURE_RECLAIM, &result);
64 result
65}
66
67async fn reclaim_inner(
68 cmd: ReclaimCmd,
69 svc: &AuthService,
70 expected_cid: Option<&str>,
71) -> Result<ReclaimResp, AuthError> {
5772 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
5873 let db = svc.db.writer();
5974
Modified lib/auth/features/refresh/usecase.rs lib/auth/features/refresh/usecase.rs +71 −61 View lib/auth/features/refresh/usecase.rs
@@ -6,72 +6,82 @@
66
77use crate::error::AuthError;
88use crate::features::AuthService;
9use crate::metrics::{self, JwtPairKind};
910
1011use super::{RefreshCmd, RefreshResp};
1112
1213pub async fn refresh(cmd: RefreshCmd, svc: &AuthService) -> Result<RefreshResp, AuthError> {
13 let db = svc.db.writer();
14 let cache = svc.jwt.cache.read().await;
14 let _timer = metrics::Timer::start(metrics::FEATURE_REFRESH);
15 let result: Result<RefreshResp, AuthError> = async {
16 let db = svc.db.writer();
17 let cache = svc.jwt.cache.read().await;
18
19 // VULN-007: decode the refresh token to learn user_id, then look up
20 // the live passwd_version so rotate_refresh can reject a token whose
21 // claim lags behind a password reset.
22 let user_id = {
23 let kid = extract_kid(&cmd.refresh_token)
24 .ok_or_else(|| AuthError::Unauthorized("invalid refresh token".into()))?;
25 let cached = cache
26 .get(&kid)
27 .ok_or_else(|| AuthError::Unauthorized("unknown signing key".into()))?;
28 let claims = decode_refresh_token(
29 &cmd.refresh_token,
30 &cached.decoding,
31 cached.algorithm,
32 &svc.jwt.settings.issuer,
33 &svc.jwt.settings.audience,
34 svc.jwt.settings.clock_skew_seconds,
35 )
36 .map_err(AuthError::Jwt)?;
37 claims.sub
38 };
39 let user = UserEntity::find_by_id(user_id)
40 .one(&db)
41 .await?
42 .ok_or_else(|| AuthError::Unauthorized("unknown user".into()))?;
43 // VULN-002/B4: suspended / restricted / deactivated accounts must not be
44 // able to keep their session alive through refresh. Mirror the login gate
45 // so a suspended user cannot mint fresh token pairs indefinitely.
46 if user.is_restricted {
47 return Err(AuthError::Forbidden("account restricted".into()));
48 }
49 if user.prohibit_login {
50 return Err(AuthError::Forbidden("login prohibited".into()));
51 }
52 if !user.is_active {
53 return Err(AuthError::Forbidden(
54 "account inactive, please verify your email".into(),
55 ));
56 }
57 let current_pwd_version = user.passwd_version.unwrap_or(0);
1558
16 // VULN-007: decode the refresh token to learn user_id, then look up
17 // the live passwd_version so rotate_refresh can reject a token whose
18 // claim lags behind a password reset.
19 let user_id = {
20 let kid = extract_kid(&cmd.refresh_token)
21 .ok_or_else(|| AuthError::Unauthorized("invalid refresh token".into()))?;
22 let cached = cache
23 .get(&kid)
24 .ok_or_else(|| AuthError::Unauthorized("unknown signing key".into()))?;
25 let claims = decode_refresh_token(
26 &cmd.refresh_token,
27 &cached.decoding,
28 cached.algorithm,
29 &svc.jwt.settings.issuer,
30 &svc.jwt.settings.audience,
31 svc.jwt.settings.clock_skew_seconds,
32 )
33 .map_err(AuthError::Jwt)?;
34 claims.sub
35 };
36 let user = UserEntity::find_by_id(user_id)
37 .one(&db)
38 .await?
39 .ok_or_else(|| AuthError::Unauthorized("unknown user".into()))?;
40 // VULN-002/B4: suspended / restricted / deactivated accounts must not be
41 // able to keep their session alive through refresh. Mirror the login gate
42 // so a suspended user cannot mint fresh token pairs indefinitely.
43 if user.is_restricted {
44 return Err(AuthError::Forbidden("account restricted".into()));
59 let ctx = RotationContext {
60 db: &db,
61 settings: &svc.jwt.settings,
62 cache: &cache,
63 };
64 let snapshot = UserSnapshot {
65 pwd_version: current_pwd_version,
66 username: user.username.clone(),
67 is_admin: user.is_admin,
68 is_restricted: user.is_restricted,
69 };
70 let pair: TokenPairResponse =
71 rotate_refresh(&ctx, &cmd.refresh_token, &snapshot, cmd.app_id)
72 .await?
73 .into();
74 Ok(RefreshResp {
75 access_token: pair.access_token,
76 refresh_token: pair.refresh_token,
77 token_type: pair.token_type,
78 expires_in: pair.expires_in,
79 })
4580 }
46 if user.prohibit_login {
47 return Err(AuthError::Forbidden("login prohibited".into()));
48 }
49 if !user.is_active {
50 return Err(AuthError::Forbidden(
51 "account inactive, please verify your email".into(),
52 ));
81 .await;
82 metrics::record_outcome(metrics::FEATURE_REFRESH, &result);
83 if result.is_ok() {
84 metrics::record_jwt_pair_issued(JwtPairKind::Refresh);
5385 }
54 let current_pwd_version = user.passwd_version.unwrap_or(0);
55
56 let ctx = RotationContext {
57 db: &db,
58 settings: &svc.jwt.settings,
59 cache: &cache,
60 };
61 let snapshot = UserSnapshot {
62 pwd_version: current_pwd_version,
63 username: user.username.clone(),
64 is_admin: user.is_admin,
65 is_restricted: user.is_restricted,
66 };
67 let pair: TokenPairResponse =
68 rotate_refresh(&ctx, &cmd.refresh_token, &snapshot, cmd.app_id)
69 .await?
70 .into();
71 Ok(RefreshResp {
72 access_token: pair.access_token,
73 refresh_token: pair.refresh_token,
74 token_type: pair.token_type,
75 expires_in: pair.expires_in,
76 })
86 result
7787}
Modified lib/auth/features/register/usecase.rs lib/auth/features/register/usecase.rs +115 −100 View lib/auth/features/register/usecase.rs
@@ -14,6 +14,7 @@
1414
1515use crate::error::AuthError;
1616use crate::features::password_hash;
17use crate::metrics::{self, EmailChannel};
1718use crate::pow::PowService;
1819
1920use super::types::{RegisterCmd, RegisterResp};
@@ -116,115 +117,129 @@
116117 svc: &crate::features::AuthService,
117118 expected_cid: Option<&str>,
118119) -> Result<RegisterResp, AuthError> {
119 validate_email(&cmd.email)?;
120 let username = derive_username(&cmd.email, cmd.username.as_deref());
121 validate_username(&username)?;
122 password_hash::validate_password(
123 password_hash::ValidatePasswordRequest {
124 password: &cmd.password,
125 },
126 &svc.config.password_policy,
127 )?;
128 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
120 let _timer = metrics::Timer::start(metrics::FEATURE_REGISTER);
121 let result: Result<RegisterResp, AuthError> = async {
122 validate_email(&cmd.email)?;
123 let username = derive_username(&cmd.email, cmd.username.as_deref());
124 validate_username(&username)?;
125 password_hash::validate_password(
126 password_hash::ValidatePasswordRequest {
127 password: &cmd.password,
128 },
129 &svc.config.password_policy,
130 )?;
131 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
129132
130 let db = svc.db.writer();
131 // VULN-006: return identical messages for email and username collisions
132 // so an attacker cannot enumerate which addresses / handles are taken.
133 if email_exists(&db, &cmd.email).await? {
134 return Err(AuthError::Conflict("registration failed".into()));
135 }
136 if username_exists(&db, &username).await? {
137 return Err(AuthError::Conflict("registration failed".into()));
138 }
133 let db = svc.db.writer();
134 // VULN-006: return identical messages for email and username collisions
135 // so an attacker cannot enumerate which addresses / handles are taken.
136 if email_exists(&db, &cmd.email).await? {
137 return Err(AuthError::Conflict("registration failed".into()));
138 }
139 if username_exists(&db, &username).await? {
140 return Err(AuthError::Conflict("registration failed".into()));
141 }
139142
140 let now = OffsetDateTime::now_utc();
141 let uid = Uuid::new_v4();
142 let hash = password_hash::usecase::hash_password(password_hash::PasswordInput {
143 password: &cmd.password,
144 })?;
143 let now = OffsetDateTime::now_utc();
144 let uid = Uuid::new_v4();
145 let hash =
146 password_hash::usecase::hash_password(password_hash::PasswordInput {
147 password: &cmd.password,
148 })?;
145149
146 // Transactional insert: users + user_email.
147 let txn = db.begin().await?;
148 let user_am: UserActiveModel = UserActiveModel::builder()
149 .set_uid(uid)
150 .set_username(username.clone())
151 .set_lower_name(username.to_ascii_lowercase())
152 .set_display_name(username.clone())
153 .set_avatar(String::new())
154 .set_is_active(false)
155 .set_is_admin(false)
156 .set_is_restricted(false)
157 .set_prohibit_login(false)
158 .set_visibility(ProfileVisibility::Public)
159 .set_bot(false)
160 .set_system(false)
161 .set_last_login_at(None)
162 .set_last_login_ip_hash(None)
163 .set_version(1)
164 .set_passwd_hash(Some(hash.0))
165 .set_password_last_changed_at(Some(now))
166 .set_passwd_version(Some(1i64))
167 .set_totp_encrypted_secret(None)
168 .set_totp_enabled(false)
169 .set_totp_last_used_at(None)
170 .set_totp_last_counter(None)
171 .set_timezone(None)
172 .set_locale(None)
173 .set_language(None)
174 .set_created_at(now)
175 .set_updated_at(now)
176 .into();
177 user_am.insert(&txn).await?;
150 // Transactional insert: users + user_email.
151 let txn = db.begin().await?;
152 let user_am: UserActiveModel = UserActiveModel::builder()
153 .set_uid(uid)
154 .set_username(username.clone())
155 .set_lower_name(username.to_ascii_lowercase())
156 .set_display_name(username.clone())
157 .set_avatar(String::new())
158 .set_is_active(false)
159 .set_is_admin(false)
160 .set_is_restricted(false)
161 .set_prohibit_login(false)
162 .set_visibility(ProfileVisibility::Public)
163 .set_bot(false)
164 .set_system(false)
165 .set_last_login_at(None)
166 .set_last_login_ip_hash(None)
167 .set_version(1)
168 .set_passwd_hash(Some(hash.0))
169 .set_password_last_changed_at(Some(now))
170 .set_passwd_version(Some(1i64))
171 .set_totp_encrypted_secret(None)
172 .set_totp_enabled(false)
173 .set_totp_last_used_at(None)
174 .set_totp_last_counter(None)
175 .set_timezone(None)
176 .set_locale(None)
177 .set_language(None)
178 .set_created_at(now)
179 .set_updated_at(now)
180 .into();
181 user_am.insert(&txn).await?;
178182
179 let email_am: EmailActiveModel = EmailActiveModel::builder()
180 .set_id(Uuid::new_v4())
181 .set_user_id(uid)
182 .set_email(cmd.email.clone())
183 .set_lower_email(lower_email(&cmd.email))
184 .set_is_activated(false)
185 .set_is_primary(true)
186 .set_created_at(now)
187 .set_updated_at(now)
188 .set_activated_at(None)
189 .set_deleted_at(None)
190 .into();
191 email_am.insert(&txn).await?;
192 txn.commit().await?;
183 let email_am: EmailActiveModel = EmailActiveModel::builder()
184 .set_id(Uuid::new_v4())
185 .set_user_id(uid)
186 .set_email(cmd.email.clone())
187 .set_lower_email(lower_email(&cmd.email))
188 .set_is_activated(false)
189 .set_is_primary(true)
190 .set_created_at(now)
191 .set_updated_at(now)
192 .set_activated_at(None)
193 .set_deleted_at(None)
194 .into();
195 email_am.insert(&txn).await?;
196 txn.commit().await?;
193197
194 // Issue verification code (Redis + DB row).
195 let artifacts = crate::features::email_code::usecase::issue_code(
196 crate::features::email_code::types::IssueCodeCmd {
197 email: cmd.email.clone(),
198 purpose: EmailVerificationPurpose::Signup,
199 user_id: uid,
200 },
201 &db,
202 &svc.kv,
203 &svc.config,
204 )
205 .await?;
198 // Issue verification code (Redis + DB row).
199 let artifacts = crate::features::email_code::usecase::issue_code(
200 crate::features::email_code::types::IssueCodeCmd {
201 email: cmd.email.clone(),
202 purpose: EmailVerificationPurpose::Signup,
203 user_id: uid,
204 },
205 &db,
206 &svc.kv,
207 &svc.config,
208 )
209 .await?;
206210
207 // VULN-008: keep the account alive even if the email send fails.
208 // The user can re-issue the verification code through the resend
209 // endpoint; rolling the user back would let an attacker create and
210 // immediately abandon accounts to spam the email pipeline.
211 if let Err(e) = send_verification_email(&svc.email, &cmd.email, &artifacts.code).await {
212 tracing::warn!(?e, email = %cmd.email, "verification email send failed; account kept pending, caller may resend");
213 }
211 // VULN-008: keep the account alive even if the email send fails.
212 // The user can re-issue the verification code through the resend
213 // endpoint; rolling the user back would let an attacker create and
214 // immediately abandon accounts to spam the email pipeline.
215 if let Err(e) =
216 send_verification_email(&svc.email, &cmd.email, &artifacts.code).await
217 {
218 tracing::warn!(?e, email = %cmd.email, "verification email send failed; account kept pending, caller may resend");
219 }
214220
215 // Record accepted agreements (best-effort; ignore unknown keys).
216 for key in &cmd.accept_agreements {
217 if let Some(doc) = svc.config.agreements.iter().find(|d| &d.key == key) {
218 let _ = svc.record_agreement(uid, &doc.key, &doc.version).await;
221 // Record accepted agreements (best-effort; ignore unknown keys).
222 for key in &cmd.accept_agreements {
223 if let Some(doc) = svc.config.agreements.iter().find(|d| &d.key == key)
224 && svc.record_agreement(uid, &doc.key, &doc.version).await.is_ok()
225 {
226 metrics::record_agreement_record(&doc.key);
227 }
219228 }
229
230 Ok(RegisterResp {
231 uid,
232 pending: true,
233 temp_key: artifacts.temp_key,
234 purpose: EmailVerificationPurpose::Signup,
235 })
220236 }
221
222 Ok(RegisterResp {
223 uid,
224 pending: true,
225 temp_key: artifacts.temp_key,
226 purpose: EmailVerificationPurpose::Signup,
227 })
237 .await;
238 metrics::record_outcome(metrics::FEATURE_REGISTER, &result);
239 if result.is_ok() {
240 metrics::record_email_code_issued(EmailChannel::Verify);
241 }
242 result
228243}
229244
230245// Helper trait to iterate agreements (Terms + Privacy) uniformly.
Modified lib/auth/features/resend_code/usecase.rs lib/auth/features/resend_code/usecase.rs +15 −0 View lib/auth/features/resend_code/usecase.rs
@@ -3,6 +3,7 @@
33
44use crate::error::AuthError;
55use crate::features::AuthService;
6use crate::metrics::{self, EmailChannel, record_email_code_issued};
67
78use super::types::{ResendCodeCmd, ResendCodeResp};
89
@@ -73,6 +74,20 @@
7374 svc: &AuthService,
7475 expected_cid: Option<&str>,
7576) -> Result<ResendCodeResp, AuthError> {
77 let _timer = metrics::Timer::start(metrics::FEATURE_RESEND_CODE);
78 let result = resend_code_inner(cmd, svc, expected_cid).await;
79 if matches!(&result, Ok(r) if r.sent) {
80 record_email_code_issued(EmailChannel::Verify);
81 }
82 metrics::record_outcome(metrics::FEATURE_RESEND_CODE, &result);
83 result
84}
85
86async fn resend_code_inner(
87 cmd: ResendCodeCmd,
88 svc: &AuthService,
89 expected_cid: Option<&str>,
90) -> Result<ResendCodeResp, AuthError> {
7691 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
7792 let db = svc.db.writer();
7893
Modified lib/auth/features/reset_password/usecase.rs lib/auth/features/reset_password/usecase.rs +11 −0 View lib/auth/features/reset_password/usecase.rs
@@ -17,6 +17,7 @@
1717use crate::error::AuthError;
1818use crate::features::AuthService;
1919use crate::features::password_hash;
20use crate::metrics;
2021
2122use super::{ResetPasswordCmd, ResetPasswordResp};
2223
@@ -52,6 +53,16 @@
5253 cmd: ResetPasswordCmd,
5354 svc: &AuthService,
5455) -> Result<ResetPasswordResp, AuthError> {
56 let _timer = metrics::Timer::start(metrics::FEATURE_RESET_PASSWORD);
57 let result = reset_password_inner(cmd, svc).await;
58 metrics::record_outcome(metrics::FEATURE_RESET_PASSWORD, &result);
59 result
60}
61
62async fn reset_password_inner(
63 cmd: ResetPasswordCmd,
64 svc: &AuthService,
65) -> Result<ResetPasswordResp, AuthError> {
5566 password_hash::validate_password(
5667 password_hash::ValidatePasswordRequest {
5768 password: &cmd.new_password,