backend signup

This commit is contained in:
2025-10-30 01:15:26 +01:00
parent 4d14d5a3d4
commit 84f4cd05f4
8 changed files with 458 additions and 142 deletions
+44
View File
@@ -17,6 +17,8 @@ pub struct JwtService {
download_expiry: Duration,
selector_audience: String,
selector_expiry: Duration,
signup_audience: String,
signup_expiry: Duration,
}
impl JwtService {
@@ -31,6 +33,8 @@ impl JwtService {
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
selector_expiry: Duration::minutes(15),
signup_audience: format!("{}:signup", config.jwt_audience),
signup_expiry: Duration::minutes(15),
})
}
@@ -108,6 +112,35 @@ impl JwtService {
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
pub fn generate_signup_token(
&self,
user_id: Uuid,
challenge_id: Uuid,
username: String,
) -> Result<String> {
let now = Utc::now();
let exp = now + self.signup_expiry;
let claims = SignupClaims {
sub: user_id,
challenge_id,
username,
iss: self.issuer.clone(),
aud: self.signup_audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_signup_token(&self, token: &str) -> Result<SignupClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.signup_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -140,3 +173,14 @@ pub struct TenantSelectionClaims {
pub iat: usize,
pub exp: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignupClaims {
pub sub: Uuid,
pub challenge_id: Uuid,
pub username: String,
pub iss: String,
pub aud: String,
pub iat: usize,
pub exp: usize,
}
+155 -5
View File
@@ -25,6 +25,32 @@ pub struct PasskeyService {
challenge_ttl: ChronoDuration,
}
pub struct PreparedPasskey {
pub id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
}
impl PreparedPasskey {
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> NewUserPasskey {
NewUserPasskey {
id: self.id,
user_id,
credential_id: self.credential_id,
public_key: self.public_key,
credential: self.credential,
sign_count: self.sign_count,
transports: self.transports,
aaguid: self.aaguid,
nickname,
}
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RegistrationChallengeResponse {
@@ -147,6 +173,48 @@ impl PasskeyService {
})
}
pub fn start_signup_registration(
&self,
conn: &mut PgConnection,
user_id: Uuid,
username: &str,
) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn);
let (challenge, state) = self
.webauthn
.start_passkey_registration(user_id, username, username, None)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: None,
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
}
pub fn finish_registration(
&self,
conn: &mut PgConnection,
@@ -325,11 +393,7 @@ impl PasskeyService {
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
}
pub fn active_passkey_count(
&self,
conn: &mut PgConnection,
user_id: Uuid,
) -> AppResult<i64> {
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
let count: i64 = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user_id))
.filter(passkey_dsl::revoked_at.is_null())
@@ -338,6 +402,92 @@ impl PasskeyService {
Ok(count)
}
pub fn consume_signup_challenge(
&self,
conn: &mut PgConnection,
challenge_id: Uuid,
credential: &RegisterPublicKeyCredential,
) -> AppResult<PreparedPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_REGISTRATION {
return Err(AppError::bad_request("challenge is not for registration"));
}
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
.context("failed to decode registration state")
.map_err(AppError::internal)?;
let passkey = self
.webauthn
.finish_passkey_registration(credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation")
})?;
let credential_struct: Credential = passkey.clone().into();
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(conn)
.optional()?;
if duplicate.is_some() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::conflict("credential already registered"));
}
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let aaguid = match credential_struct.attestation.metadata {
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
Some(aaguid)
}
_ => None,
};
let credential_json = serde_json::to_value(&passkey)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
Ok(PreparedPasskey {
id: Uuid::new_v4(),
credential_id: credential_id_vec,
public_key: public_key_bytes,
credential: credential_json,
sign_count: credential_struct.counter as i64,
transports,
aaguid,
})
}
pub fn revoke_passkey(
&self,
conn: &mut PgConnection,
+38 -8
View File
@@ -8,7 +8,8 @@ use uuid::Uuid;
paths(
doc::health_check,
doc::login,
doc::signup,
doc::signup_start,
doc::signup_finish,
doc::refresh,
doc::logout,
doc::me,
@@ -60,7 +61,9 @@ use uuid::Uuid;
components(
schemas(
schemas::LoginRequest,
schemas::SignupRequest,
schemas::SignupStartRequest,
schemas::SignupStartResponse,
schemas::SignupFinishRequest,
schemas::AccessTokenResponse,
schemas::TenantSnippet,
schemas::TenantSelectionResponse,
@@ -167,16 +170,29 @@ mod doc {
#[utoipa::path(
post,
path = "/api/auth/signup",
request_body = SignupRequest,
path = "/api/auth/signup/start",
request_body = SignupStartRequest,
responses(
(status = 200, description = "Signup succeeded", body = LoginResponseVariants),
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
(status = 400, description = "Invalid signup request"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub(super) fn signup() {}
pub(super) fn signup_start() {}
#[utoipa::path(
post,
path = "/api/auth/signup/finish",
request_body = SignupFinishRequest,
responses(
(status = 200, description = "Signup completed", body = LoginResponseVariants),
(status = 400, description = "Invalid signup completion"),
(status = 409, description = "Username already exists")
),
tag = "Auth"
)]
pub(super) fn signup_finish() {}
#[utoipa::path(
post,
@@ -678,6 +694,7 @@ pub mod schemas {
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
};
use webauthn_rs::prelude::RegisterPublicKeyCredential;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct LoginRequest {
@@ -688,9 +705,22 @@ pub mod schemas {
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupRequest {
pub struct SignupStartRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SignupFinishRequest {
pub signup_token: String,
pub credential: RegisterPublicKeyCredential,
#[schema(nullable)]
pub nickname: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
+114 -49
View File
@@ -27,12 +27,13 @@ use crate::{
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
schema::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
users::dsl,
user_passkeys::dsl as passkey_dsl, users::dsl,
},
state::AppState,
};
use crate::schema::refresh_tokens::dsl as refresh_dsl;
use webauthn_rs::prelude::RegisterPublicKeyCredential;
const REFRESH_COOKIE_NAME: &str = "refresh_token";
@@ -44,13 +45,7 @@ pub struct LoginRequest {
pub preferred_tenant_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct SignupRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
#[derive(Deserialize, Serialize)]
pub struct LoginResponse {
pub access_token: String,
pub token_type: String,
@@ -58,7 +53,7 @@ pub struct LoginResponse {
pub tenant: TenantSnippet,
}
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct TenantSnippet {
pub id: Uuid,
pub name: String,
@@ -80,48 +75,22 @@ pub struct TenantSelectionRequest {
pub tenant_id: Uuid,
}
pub async fn signup(
State(state): State<AppState>,
Json(payload): Json<SignupRequest>,
) -> AppResult<Response> {
let username = payload.username.trim();
let password = payload.password.trim();
#[derive(Deserialize)]
pub struct SignupStartRequest {
pub username: String,
}
if username.is_empty() || password.is_empty() {
return Err(AppError::bad_request(
"username and password must not be empty",
));
}
#[derive(Serialize)]
pub struct SignupStartResponse {
pub signup_token: String,
pub challenge: RegistrationChallengeResponse,
}
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let user_id = Uuid::new_v4();
let password_hash = password::hash_password(password)?;
insert_user(&mut conn, user_id, username, &password_hash)?;
let tenant_name = username.to_string();
let tenant = state.tenants.create_tenant(
&tenant_name,
None,
None,
TenantStatus::Creating,
&[user_id],
Some(user_id),
)?;
let user: User = dsl::users.find(user_id).first(&mut conn)?;
issue_session(&state, &mut conn, &user, tenant.id)
#[derive(Deserialize)]
pub struct SignupFinishRequest {
pub signup_token: String,
pub credential: RegisterPublicKeyCredential,
pub nickname: Option<String>,
}
pub async fn login(
@@ -150,6 +119,102 @@ pub async fn login(
complete_login(&state, &mut conn, &user, payload.preferred_tenant_id)
}
pub async fn signup_start(
State(state): State<AppState>,
Json(payload): Json<SignupStartRequest>,
) -> AppResult<Json<SignupStartResponse>> {
let username = payload.username.trim();
if username.is_empty() {
return Err(AppError::bad_request("username must not be empty"));
}
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let user_id = Uuid::new_v4();
let challenge = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
.start_signup_registration(&mut conn, user_id, username)?;
let signup_token = state
.jwt
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
.map_err(AppError::from)?;
Ok(Json(SignupStartResponse {
signup_token,
challenge,
}))
}
pub async fn signup_finish(
State(state): State<AppState>,
Json(payload): Json<SignupFinishRequest>,
) -> AppResult<Response> {
let claims = state
.jwt
.verify_signup_token(&payload.signup_token)
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&claims.username))
.first::<User>(&mut conn)
.optional()?
.is_some();
if exists {
return Err(AppError::conflict("username already exists"));
}
let service = state
.passkeys
.as_ref()
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
let prepared_passkey =
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?;
let state_clone = state.clone();
let response = conn.transaction::<Response, AppError, _>(|conn| {
let password_seed = format!("passkey-only-{}", claims.sub);
let password_hash = password::hash_password(&password_seed)?;
insert_user(conn, claims.sub, &claims.username, &password_hash)?;
let tenant = state_clone.tenants.create_tenant_with_conn(
conn,
&claims.username,
None,
None,
TenantStatus::Creating,
&[claims.sub],
Some(claims.sub),
)?;
let passkey_insert =
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
diesel::insert_into(passkey_dsl::user_passkeys)
.values(&passkey_insert)
.execute(conn)
.map_err(AppError::from)?;
let user: User = dsl::users.find(claims.sub).first(conn)?;
issue_session(&state_clone, conn, &user, tenant.id)
})?;
Ok(response)
}
pub async fn refresh(
State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>,
+2 -1
View File
@@ -54,7 +54,8 @@ pub fn create_router(state: AppState) -> Router<()> {
};
let auth_routes = Router::new()
.route("/signup", post(auth::signup))
.route("/signup/start", post(auth::signup_start))
.route("/signup/finish", post(auth::signup_finish))
.route("/login", post(auth::login))
.route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout))
+39 -20
View File
@@ -55,15 +55,46 @@ impl TenantService {
initial_members: &[Uuid],
created_by: Option<Uuid>,
) -> AppResult<Tenant> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::bad_request("tenant name must not be empty"));
}
let mut conn = self.pool.get().map_err(|err| {
tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error")
})?;
self.create_tenant_with_conn(
&mut conn,
name,
storage_root,
quickwit_index,
status,
initial_members,
created_by,
)
}
fn load<F>(&self, loader: F) -> AppResult<Tenant>
where
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
{
let mut conn = self.pool.get().map_err(|err| {
tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error")
})?;
let tenant = loader(&mut conn)?;
Ok(tenant)
}
pub fn create_tenant_with_conn(
&self,
conn: &mut PgConnection,
name: &str,
storage_root: Option<&str>,
quickwit_index: Option<&str>,
status: TenantStatus,
initial_members: &[Uuid],
created_by: Option<Uuid>,
) -> AppResult<Tenant> {
let name = name.trim();
if name.is_empty() {
return Err(AppError::bad_request("tenant name must not be empty"));
}
let id = Uuid::new_v4();
let storage_root = normalize_storage_root(storage_root, id);
@@ -79,32 +110,20 @@ impl TenantService {
dsl::status.eq(status),
dsl::created_by.eq(created_by),
))
.execute(&mut conn)?;
.execute(conn)?;
if status == TenantStatus::Creating {
let payload = json!({
"members": initial_members,
});
enqueue_job(&mut conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| {
enqueue_job(conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| {
tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job");
AppError::internal("failed to enqueue tenant provisioning job")
})?;
}
TenantRepository::get_by_id(&mut conn, id)
}
fn load<F>(&self, loader: F) -> AppResult<Tenant>
where
F: FnOnce(&mut PgConnection) -> AppResult<Tenant>,
{
let mut conn = self.pool.get().map_err(|err| {
tracing::error!(error = ?err, "database pool error");
AppError::internal("database pool error")
})?;
let tenant = loader(&mut conn)?;
Ok(tenant)
TenantRepository::get_by_id(conn, id)
}
}