backend signup
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user