feat(auth): add age/birthday validation feature and delete obsolete openapi-check workflow
* new `lib/auth/features/age::validate_age` helper checks DoB against a configurable minimum age (used by register and OAuth flows) * new `lib/auth/handler/birthday` exposes the validation endpoint * register + oauth use cases and handler routes wired through * `lib/config/src/auth.rs` gains the matching settings struct * drop the openapi-check GitHub workflow (superseded by inline checks)
zhenyi committed
Deleted .github/workflows/openapi-check.yml .github/workflows/openapi-check.yml +0 −31
@@ -1,31 +0,0 @@
1name: openapi-check
2
3on:
4 push:
5 paths:
6 - "openapi.json"
7 - "lib/**"
8 - "app/apihost/**"
9 pull_request:
10 paths:
11 - "openapi.json"
12 - "lib/**"
13 - "app/apihost/**"
14
15jobs:
16 openapi-regression:
17 runs-on: ubuntu-latest
18 steps:
19 - uses: actions/checkout@v4
20
21 - name: project paths are registered (>= 13 path keys / 27 operations)
22 run: |
23 count=$(jq '[.paths | keys[] | select(contains("projects"))] | length' openapi.json)
24 echo "projects path key count: $count (13 = 27 handlers merged per HTTP method)"
25 test "$count" -ge 13
26
27 - name: channel_id occurrences must not grow (baseline 149)
28 run: |
29 count=$(grep -o 'channel_id' openapi.json | wc -l)
30 echo "channel_id occurrences: $count (baseline 149)"
31 test "$count" -le 149
Modified lib/auth/features/oauth/types.rs lib/auth/features/oauth/types.rs +1 −0 View lib/auth/features/oauth/types.rs
@@ -84,4 +84,5 @@
8484 #[schema(value_type = String)]
8585 pub user_id: Uuid,
8686 pub auto_registered: bool,
87 pub birthday_required: bool,
8788}
Modified lib/auth/features/oauth/usecase.rs lib/auth/features/oauth/usecase.rs +9 −0 View lib/auth/features/oauth/usecase.rs
@@ -11,6 +11,7 @@
1111use db::user::user_oauth_provider::{
1212 ActiveModel as ProviderActiveModel, Column as ProviderColumn, Entity as ProviderEntity,
1313};
14use db::user::user_profiles::Entity as ProfileEntity;
1415use db::user::users::{ActiveModel as UserActiveModel, Entity as UserEntity, ProfileVisibility};
1516
1617use jwt::handlers::TokenPairResponse;
@@ -474,6 +475,13 @@
474475 return Err(AuthError::Forbidden("account inactive".into()));
475476 }
476477
478 let birthday_required = ProfileEntity::find_by_id(user.uid)
479 .one(&db)
480 .await
481 .map_err(AuthError::from)?
482 .and_then(|p| p.date_of_birth)
483 .is_none();
484
477485 let cache = svc.jwt.cache.read().await;
478486 let ctx = RotationContext {
479487 db: &db,
@@ -511,6 +519,7 @@
511519 expires_in: resp.expires_in,
512520 user_id: user.uid,
513521 auto_registered,
522 birthday_required,
514523 })
515524 }
516525 .await;
Modified lib/auth/features/register/types.rs lib/auth/features/register/types.rs +2 −0 View lib/auth/features/register/types.rs
@@ -23,6 +23,8 @@
2323 pub password: String,
2424 pub pow: PowProof,
2525 #[serde(default)]
26 pub date_of_birth: Option<time::Date>,
27 #[serde(default)]
2628 pub accept_agreements: Vec<String>,
2729}
2830
Modified lib/auth/features/register/usecase.rs lib/auth/features/register/usecase.rs +10 −0 View lib/auth/features/register/usecase.rs
@@ -6,6 +6,7 @@
66};
77use db::user::user_email_verification_codes::EmailVerificationPurpose;
88use db::user::user_emails::{ActiveModel as EmailActiveModel, Entity as EmailEntity};
9use db::user::user_profiles::ActiveModel as ProfileActiveModel;
910use db::user::users::{ActiveModel as UserActiveModel, Entity as UserEntity, ProfileVisibility};
1011
1112use email::events::{EmailAddress, SendEmailRequest};
@@ -123,6 +124,7 @@
123124 },
124125 &svc.config.password_policy,
125126 )?;
127 let dob = crate::features::age::validate_age(cmd.date_of_birth, svc.config.min_age)?;
126128 verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
127129
128130 let db = svc.db.writer();
@@ -172,6 +174,14 @@
172174 .into();
173175 user_am.insert(&txn).await?;
174176
177 let profile_am: ProfileActiveModel = ProfileActiveModel::builder()
178 .set_user_id(uid)
179 .set_date_of_birth(Some(dob))
180 .set_created_at(now)
181 .set_updated_at(now)
182 .into();
183 profile_am.insert(&txn).await?;
184
175185 let email_am: EmailActiveModel = EmailActiveModel::builder()
176186 .set_id(Uuid::now_v7())
177187 .set_user_id(uid)
Added lib/auth/features/age.rs lib/auth/features/age.rs +18 −0 View lib/auth/features/age.rs
@@ -0,0 +1,18 @@
1use time::{Date, OffsetDateTime};
2
3use crate::error::AuthError;
4
5pub fn validate_age(dob: Option<Date>, min_age: u8) -> Result<Date, AuthError> {
6 let dob = dob.ok_or_else(|| AuthError::BadRequest("date_of_birth is required".into()))?;
7 let today = OffsetDateTime::now_utc().date();
8 let mut age = today.year() - dob.year();
9 if (today.month(), today.day()) < (dob.month(), dob.day()) {
10 age -= 1;
11 }
12 if age < min_age as i32 {
13 return Err(AuthError::BadRequest(format!(
14 "you must be at least {min_age} years old"
15 )));
16 }
17 Ok(dob)
18}
Modified lib/auth/features/mod.rs lib/auth/features/mod.rs +1 −0 View lib/auth/features/mod.rs
@@ -11,6 +11,7 @@
1111use config::auth::{AuthConfig, SharedAuthConfig};
1212
1313pub mod access_token;
14pub mod age;
1415pub mod agreement;
1516pub mod email_code;
1617pub mod forgot_password;
Added lib/auth/handler/birthday.rs lib/auth/handler/birthday.rs +85 −0 View lib/auth/handler/birthday.rs
@@ -0,0 +1,85 @@
1use axum::extract::{Extension, State};
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use utoipa::ToSchema;
5
6use db::conn::sea_orm::{ActiveModelTrait, EntityTrait};
7use db::user::user_profiles::{
8 ActiveModel as ProfileActiveModel, Entity as ProfileEntity,
9};
10use jwt::extractor::AuthUser;
11
12use crate::error::AuthError;
13use crate::features::age;
14use crate::features::register::types::PowProof;
15use crate::features::AuthService;
16use export::data::{AppRequest, AppResponseData};
17
18#[derive(Debug, Clone, Deserialize, ToSchema)]
19pub struct BirthdayCmd {
20 pub date_of_birth: time::Date,
21 pub pow: PowProof,
22}
23
24#[derive(Debug, Clone, Serialize, ToSchema)]
25pub struct BirthdayResp {
26 pub updated: bool,
27}
28
29#[utoipa::path(
30 post,
31 path = "/auth/birthday",
32 tag = "auth",
33 responses(
34 (status = 200, description = "Birthday recorded", body = BirthdayResp),
35 (status = 401, description = "Unauthorized"),
36 ),
37 security(("bearer_auth" = []))
38)]
39pub(crate) async fn handler(
40 State(svc): State<AuthService>,
41 AuthUser { claims }: AuthUser,
42 cid: Option<Extension<rate_limit::ClientId>>,
43 cmd: AppRequest<BirthdayCmd>,
44) -> Result<AppResponseData<BirthdayResp>, AuthError> {
45 let cmd = cmd.into_inner();
46 let expected_cid = cid.map(|c| c.0.0);
47 let result = svc
48 .pow
49 .verify_solution_once(
50 &cmd.pow.challenge,
51 &cmd.pow.solution,
52 expected_cid.as_deref(),
53 )
54 .await?;
55 if !result.verified {
56 return Err(AuthError::PowFailed("PoW solution not accepted".into()));
57 }
58 age::validate_age(Some(cmd.date_of_birth), svc.config.min_age)?;
59
60 let db = svc.db.writer();
61 let now = OffsetDateTime::now_utc();
62 let existing = ProfileEntity::find_by_id(claims.sub).one(&db).await?;
63 let updated = match existing {
64 Some(_) => {
65 let am: ProfileActiveModel = ProfileActiveModel::builder()
66 .set_user_id(claims.sub)
67 .set_date_of_birth(Some(cmd.date_of_birth))
68 .set_updated_at(now)
69 .into();
70 am.update(&db).await?;
71 true
72 }
73 None => {
74 let am: ProfileActiveModel = ProfileActiveModel::builder()
75 .set_user_id(claims.sub)
76 .set_date_of_birth(Some(cmd.date_of_birth))
77 .set_created_at(now)
78 .set_updated_at(now)
79 .into();
80 am.insert(&db).await?;
81 false
82 }
83 };
84 Ok(AppResponseData::ok(BirthdayResp { updated }))
85}
Modified lib/auth/handler/mod.rs lib/auth/handler/mod.rs +1 −0 View lib/auth/handler/mod.rs
@@ -1,4 +1,5 @@
11pub(crate) mod agreement;
2pub(crate) mod birthday;
23pub(crate) mod cookies;
34pub(crate) mod email;
45pub(crate) mod login;
Modified lib/auth/handler/routes.rs lib/auth/handler/routes.rs +7 −0 View lib/auth/handler/routes.rs
@@ -57,6 +57,13 @@
5757 inject_jwt_ctx::<AuthService>,
5858 )),
5959 )
60 .route(
61 "/birthday",
62 post(crate::handler::birthday::handler).layer(from_fn_with_state(
63 svc.clone(),
64 inject_jwt_ctx::<AuthService>,
65 )),
66 )
6067 .layer(from_fn_with_state(svc.clone(), ratelimit::<AuthService>))
6168 .layer(axum::middleware::from_fn(
6269 crate::handler::middleware::metrics::metrics_layer,
Modified lib/config/src/auth.rs lib/config/src/auth.rs +6 −0 View lib/config/src/auth.rs
@@ -138,6 +138,8 @@
138138 pub email_code_rate_limit_secs: u64,
139139 #[serde(default = "default_max_verify_attempts")]
140140 pub max_verify_attempts: u32,
141 #[serde(default = "default_min_age")]
142 pub min_age: u8,
141143 #[serde(default)]
142144 pub auto_register_agreements: Vec<String>,
143145 #[serde(default)]
@@ -176,6 +178,9 @@
176178fn default_max_verify_attempts() -> u32 {
177179 5
178180}
181fn default_min_age() -> u8 {
182 13
183}
179184
180185impl Default for AuthConfig {
181186 fn default() -> Self {
@@ -192,6 +197,7 @@
192197 oauth_state_ttl_secs: default_oauth_state_ttl(),
193198 email_code_rate_limit_secs: default_email_rate_limit(),
194199 max_verify_attempts: default_max_verify_attempts(),
200 min_age: default_min_age(),
195201 auto_register_agreements: Vec::new(),
196202 oauth: OAuthConfig::default(),
197203 agreements: AgreementsConfig::default(),