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
+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))