backend signup
This commit is contained in:
@@ -17,6 +17,8 @@ pub struct JwtService {
|
|||||||
download_expiry: Duration,
|
download_expiry: Duration,
|
||||||
selector_audience: String,
|
selector_audience: String,
|
||||||
selector_expiry: Duration,
|
selector_expiry: Duration,
|
||||||
|
signup_audience: String,
|
||||||
|
signup_expiry: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JwtService {
|
impl JwtService {
|
||||||
@@ -31,6 +33,8 @@ impl JwtService {
|
|||||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
||||||
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
selector_audience: format!("{}:tenant-selector", config.jwt_audience),
|
||||||
selector_expiry: Duration::minutes(15),
|
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)?;
|
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
|
||||||
Ok(data.claims)
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -140,3 +173,14 @@ pub struct TenantSelectionClaims {
|
|||||||
pub iat: usize,
|
pub iat: usize,
|
||||||
pub exp: 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,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RegistrationChallengeResponse {
|
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(
|
pub fn finish_registration(
|
||||||
&self,
|
&self,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
@@ -325,11 +393,7 @@ impl PasskeyService {
|
|||||||
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
|
Ok(passkeys.into_iter().map(PasskeySummary::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn active_passkey_count(
|
pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult<i64> {
|
||||||
&self,
|
|
||||||
conn: &mut PgConnection,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> AppResult<i64> {
|
|
||||||
let count: i64 = passkey_dsl::user_passkeys
|
let count: i64 = passkey_dsl::user_passkeys
|
||||||
.filter(passkey_dsl::user_id.eq(user_id))
|
.filter(passkey_dsl::user_id.eq(user_id))
|
||||||
.filter(passkey_dsl::revoked_at.is_null())
|
.filter(passkey_dsl::revoked_at.is_null())
|
||||||
@@ -338,6 +402,92 @@ impl PasskeyService {
|
|||||||
Ok(count)
|
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(
|
pub fn revoke_passkey(
|
||||||
&self,
|
&self,
|
||||||
conn: &mut PgConnection,
|
conn: &mut PgConnection,
|
||||||
|
|||||||
+38
-8
@@ -8,7 +8,8 @@ use uuid::Uuid;
|
|||||||
paths(
|
paths(
|
||||||
doc::health_check,
|
doc::health_check,
|
||||||
doc::login,
|
doc::login,
|
||||||
doc::signup,
|
doc::signup_start,
|
||||||
|
doc::signup_finish,
|
||||||
doc::refresh,
|
doc::refresh,
|
||||||
doc::logout,
|
doc::logout,
|
||||||
doc::me,
|
doc::me,
|
||||||
@@ -60,7 +61,9 @@ use uuid::Uuid;
|
|||||||
components(
|
components(
|
||||||
schemas(
|
schemas(
|
||||||
schemas::LoginRequest,
|
schemas::LoginRequest,
|
||||||
schemas::SignupRequest,
|
schemas::SignupStartRequest,
|
||||||
|
schemas::SignupStartResponse,
|
||||||
|
schemas::SignupFinishRequest,
|
||||||
schemas::AccessTokenResponse,
|
schemas::AccessTokenResponse,
|
||||||
schemas::TenantSnippet,
|
schemas::TenantSnippet,
|
||||||
schemas::TenantSelectionResponse,
|
schemas::TenantSelectionResponse,
|
||||||
@@ -167,16 +170,29 @@ mod doc {
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/signup",
|
path = "/api/auth/signup/start",
|
||||||
request_body = SignupRequest,
|
request_body = SignupStartRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Signup succeeded", body = LoginResponseVariants),
|
(status = 200, description = "Signup challenge created", body = SignupStartResponse),
|
||||||
(status = 400, description = "Invalid signup request"),
|
(status = 400, description = "Invalid signup request"),
|
||||||
(status = 409, description = "Username already exists")
|
(status = 409, description = "Username already exists")
|
||||||
),
|
),
|
||||||
tag = "Auth"
|
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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
@@ -678,6 +694,7 @@ pub mod schemas {
|
|||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
};
|
};
|
||||||
|
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
@@ -688,9 +705,22 @@ pub mod schemas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, ToSchema)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
pub struct SignupRequest {
|
pub struct SignupStartRequest {
|
||||||
pub username: String,
|
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)]
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
+114
-49
@@ -27,12 +27,13 @@ use crate::{
|
|||||||
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
|
||||||
schema::{
|
schema::{
|
||||||
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl,
|
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,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
use crate::schema::refresh_tokens::dsl as refresh_dsl;
|
||||||
|
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||||
|
|
||||||
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
const REFRESH_COOKIE_NAME: &str = "refresh_token";
|
||||||
|
|
||||||
@@ -44,13 +45,7 @@ pub struct LoginRequest {
|
|||||||
pub preferred_tenant_id: Option<Uuid>,
|
pub preferred_tenant_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct SignupRequest {
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
@@ -58,7 +53,7 @@ pub struct LoginResponse {
|
|||||||
pub tenant: TenantSnippet,
|
pub tenant: TenantSnippet,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct TenantSnippet {
|
pub struct TenantSnippet {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -80,48 +75,22 @@ pub struct TenantSelectionRequest {
|
|||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn signup(
|
#[derive(Deserialize)]
|
||||||
State(state): State<AppState>,
|
pub struct SignupStartRequest {
|
||||||
Json(payload): Json<SignupRequest>,
|
pub username: String,
|
||||||
) -> AppResult<Response> {
|
}
|
||||||
let username = payload.username.trim();
|
|
||||||
let password = payload.password.trim();
|
|
||||||
|
|
||||||
if username.is_empty() || password.is_empty() {
|
#[derive(Serialize)]
|
||||||
return Err(AppError::bad_request(
|
pub struct SignupStartResponse {
|
||||||
"username and password must not be empty",
|
pub signup_token: String,
|
||||||
));
|
pub challenge: RegistrationChallengeResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = state.db_unscoped()?;
|
#[derive(Deserialize)]
|
||||||
|
pub struct SignupFinishRequest {
|
||||||
let exists: bool = dsl::users
|
pub signup_token: String,
|
||||||
.filter(dsl::username.eq(username))
|
pub credential: RegisterPublicKeyCredential,
|
||||||
.first::<User>(&mut conn)
|
pub nickname: Option<String>,
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
@@ -150,6 +119,102 @@ pub async fn login(
|
|||||||
complete_login(&state, &mut conn, &user, payload.preferred_tenant_id)
|
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(
|
pub async fn refresh(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: Option<TypedHeader<Cookie>>,
|
jar: Option<TypedHeader<Cookie>>,
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let auth_routes = Router::new()
|
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("/login", post(auth::login))
|
||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
|
|||||||
+39
-20
@@ -55,15 +55,46 @@ impl TenantService {
|
|||||||
initial_members: &[Uuid],
|
initial_members: &[Uuid],
|
||||||
created_by: Option<Uuid>,
|
created_by: Option<Uuid>,
|
||||||
) -> AppResult<Tenant> {
|
) -> 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| {
|
let mut conn = self.pool.get().map_err(|err| {
|
||||||
tracing::error!(error = ?err, "database pool error");
|
tracing::error!(error = ?err, "database pool error");
|
||||||
AppError::internal("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 id = Uuid::new_v4();
|
||||||
let storage_root = normalize_storage_root(storage_root, id);
|
let storage_root = normalize_storage_root(storage_root, id);
|
||||||
@@ -79,32 +110,20 @@ impl TenantService {
|
|||||||
dsl::status.eq(status),
|
dsl::status.eq(status),
|
||||||
dsl::created_by.eq(created_by),
|
dsl::created_by.eq(created_by),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)?;
|
.execute(conn)?;
|
||||||
|
|
||||||
if status == TenantStatus::Creating {
|
if status == TenantStatus::Creating {
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
"members": initial_members,
|
"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");
|
tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job");
|
||||||
AppError::internal("failed to enqueue tenant provisioning job")
|
AppError::internal("failed to enqueue tenant provisioning job")
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
TenantRepository::get_by_id(&mut conn, id)
|
TenantRepository::get_by_id(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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-56
@@ -7,10 +7,9 @@ use backend::auth::passkeys::{
|
|||||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||||
RegistrationChallengeResponse,
|
RegistrationChallengeResponse,
|
||||||
};
|
};
|
||||||
use backend::jobs::JOB_PROVISION_TENANT;
|
use backend::models::{NewUserMembership, TenantStatus, UserPasskey};
|
||||||
use backend::models::{Job, NewUserMembership, TenantStatus, UserPasskey};
|
|
||||||
use backend::openapi::schemas::PasskeySummary;
|
use backend::openapi::schemas::PasskeySummary;
|
||||||
use backend::schema::{jobs, tenants, user_memberships, users};
|
use backend::schema::{tenants, user_memberships, users};
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -43,6 +42,12 @@ struct LoginResponse {
|
|||||||
tenant: LoginTenant,
|
tenant: LoginTenant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SignupStartResponse {
|
||||||
|
signup_token: String,
|
||||||
|
challenge: RegistrationChallengeResponse,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSelectionResponse {
|
struct TenantSelectionResponse {
|
||||||
access_token: String,
|
access_token: String,
|
||||||
@@ -93,72 +98,40 @@ async fn login_rejects_unknown_user() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn signup_creates_user_tenant_and_membership() -> Result<()> {
|
async fn signup_start_and_finish_require_valid_passkey() -> Result<()> {
|
||||||
let _lock = acquire_db_lock().await;
|
let _lock = acquire_db_lock().await;
|
||||||
let app = TestApp::new().await?;
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
let payload = json!({
|
let payload = json!({ "username": "signup-user" });
|
||||||
"username": "signup-user",
|
|
||||||
"password": "super-secret",
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = app.post_json("/api/auth/signup", &payload, None).await?;
|
let response = app
|
||||||
|
.post_json("/api/auth/signup/start", &payload, None)
|
||||||
|
.await?;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let body = body_to_vec(response.into_body()).await?;
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
let login: LoginResponse = serde_json::from_slice(&body)?;
|
let start: SignupStartResponse = serde_json::from_slice(&body)?;
|
||||||
|
assert!(!start.signup_token.is_empty());
|
||||||
|
assert_ne!(start.challenge.challenge_id, Uuid::nil());
|
||||||
|
|
||||||
// session works immediately
|
app.with_conn(|conn| {
|
||||||
let me = app.get("/api/auth/me", Some(&login.access_token)).await?;
|
let exists: bool = diesel::select(diesel::dsl::exists(
|
||||||
assert_eq!(me.status(), StatusCode::OK);
|
users::table.filter(users::username.eq("signup-user")),
|
||||||
|
|
||||||
app.with_conn(move |conn| {
|
|
||||||
let user: backend::models::User = users::table
|
|
||||||
.filter(users::username.eq("signup-user"))
|
|
||||||
.first(conn)?;
|
|
||||||
|
|
||||||
let tenant: backend::models::Tenant = tenants::table
|
|
||||||
.filter(tenants::name.eq("signup-user"))
|
|
||||||
.first(conn)?;
|
|
||||||
|
|
||||||
assert_eq!(tenant.status, TenantStatus::Creating);
|
|
||||||
assert_eq!(tenant.created_by, Some(user.id));
|
|
||||||
|
|
||||||
let membership_exists: bool = diesel::select(diesel::dsl::exists(
|
|
||||||
user_memberships::table
|
|
||||||
.filter(user_memberships::user_id.eq(user.id))
|
|
||||||
.filter(user_memberships::tenant_id.eq(tenant.id)),
|
|
||||||
))
|
))
|
||||||
.get_result(conn)?;
|
.get_result(conn)?;
|
||||||
assert!(
|
assert!(!exists);
|
||||||
!membership_exists,
|
|
||||||
"membership should be enqueued, not created synchronously"
|
|
||||||
);
|
|
||||||
|
|
||||||
let job: Job = jobs::table
|
|
||||||
.filter(jobs::tenant_id.eq(tenant.id))
|
|
||||||
.filter(jobs::job_type.eq(JOB_PROVISION_TENANT))
|
|
||||||
.first(conn)
|
|
||||||
.context("provision job missing")?;
|
|
||||||
|
|
||||||
let members = job
|
|
||||||
.payload
|
|
||||||
.get("members")
|
|
||||||
.and_then(|value| value.as_array())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
assert!(members.iter().any(|value| {
|
|
||||||
value
|
|
||||||
.as_str()
|
|
||||||
.and_then(|id| Uuid::parse_str(id).ok())
|
|
||||||
.map(|parsed| parsed == user.id)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}));
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let finish_payload = json!({
|
||||||
|
"signup_token": start.signup_token,
|
||||||
|
"credential": fake_register_credential(),
|
||||||
|
});
|
||||||
|
let finish_response = app
|
||||||
|
.post_json("/api/auth/signup/finish", &finish_payload, None)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -329,6 +302,7 @@ async fn delete_passkey_soft_revokes() -> Result<()> {
|
|||||||
let password = "secret";
|
let password = "secret";
|
||||||
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
|
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
|
||||||
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
|
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
|
||||||
|
app.insert_passkey(user_id, Some("Backup")).await?;
|
||||||
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
|
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
|
||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
@@ -355,6 +329,37 @@ async fn delete_passkey_soft_revokes() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_passkey_prevents_last() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let password = "secret";
|
||||||
|
let user_id = app.insert_user("passkey-guard", password, "admin").await?;
|
||||||
|
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
|
||||||
|
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
|
||||||
|
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/passkeys/{}", first_id),
|
||||||
|
Some(&session.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let block_response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/passkeys/{}", last_id),
|
||||||
|
Some(&session.access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(block_response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn fake_register_credential() -> RegisterPublicKeyCredential {
|
fn fake_register_credential() -> RegisterPublicKeyCredential {
|
||||||
RegisterPublicKeyCredential {
|
RegisterPublicKeyCredential {
|
||||||
id: "fake-passkey".to_string(),
|
id: "fake-passkey".to_string(),
|
||||||
|
|||||||
@@ -240,12 +240,14 @@ impl TestApp {
|
|||||||
let passkey_id = Uuid::new_v4();
|
let passkey_id = Uuid::new_v4();
|
||||||
let nickname = nickname.map(|value| value.to_string());
|
let nickname = nickname.map(|value| value.to_string());
|
||||||
self.with_conn(move |conn| {
|
self.with_conn(move |conn| {
|
||||||
|
let credential_id = passkey_id.as_bytes().to_vec();
|
||||||
|
let public_key = passkey_id.as_bytes().iter().copied().collect::<Vec<u8>>();
|
||||||
let passkey = NewUserPasskey {
|
let passkey = NewUserPasskey {
|
||||||
id: passkey_id,
|
id: passkey_id,
|
||||||
user_id,
|
user_id,
|
||||||
credential_id: vec![1, 2, 3],
|
credential_id,
|
||||||
public_key: vec![4, 5, 6],
|
public_key,
|
||||||
credential: json!({ "dummy": true }),
|
credential: json!({ "dummy": passkey_id.to_string() }),
|
||||||
sign_count: 0,
|
sign_count: 0,
|
||||||
transports: vec![Some("usb".to_string())],
|
transports: vec![Some("usb".to_string())],
|
||||||
aaguid: None,
|
aaguid: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user