account confirmation

This commit is contained in:
Mateusz Gruszczyński
2026-07-22 19:20:30 +02:00
parent d5ec8a28e7
commit 7c38edeef7
14 changed files with 193 additions and 17 deletions
+79 -4
View File
@@ -15,11 +15,12 @@ const MAX_PASSWORD: usize = 128;
const MAX_NICKNAME: usize = 40;
#[derive(Debug, Clone, FromRow)]
pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String }
pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option<String> }
#[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option<String> }
#[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String }
#[derive(Deserialize)] pub struct LoginRequest { email: String, password: String }
#[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String }
#[derive(Deserialize)] pub struct ResetRequest { email: String }
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
#[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option<String> }
@@ -27,6 +28,7 @@ pub struct User { pub id: i64, pub nickname: String, pub email: String, pub pass
#[derive(Serialize)] pub struct ResourceList { workspaces: Vec<ResourceItem>, pads: Vec<ResourceItem> }
#[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String }
#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool }
#[derive(Serialize)] pub struct RegisterResponse { token: Option<String>, nickname: String, email: String, expires_at: Option<String>, confirmation_required: bool, message: String }
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
let nickname = validate_nickname(&req.nickname)?;
@@ -43,8 +45,9 @@ pub async fn identity(State(state): State<SharedState>, Json(req): Json<Identity
}
}
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<SessionResponse>), AuthError> {
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
if !state.registration_enabled { warn!("registration attempt rejected because registration is disabled"); return Err(AuthError::forbidden("Registration is disabled.")); }
if state.account_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable("Account confirmation requires SMTP configuration.")); }
let nickname = validate_nickname(&req.nickname)?;
let email = validate_email(&req.email)?;
info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested");
@@ -54,13 +57,49 @@ pub async fn register(State(state): State<SharedState>, Json(req): Json<Register
if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); }
if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); }
let hash = hash_password(&req.password)?;
let confirmed_at = (!state.account_confirmation_required).then(|| Utc::now().to_rfc3339());
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER))
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).execute(state.db.pool()).await
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).bind(confirmed_at).execute(state.db.pool()).await
.map_err(AuthError::database)?;
let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?;
let mut confirmation_token = None;
if state.smtp.is_some() {
let token = random_token();
if state.account_confirmation_required {
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN))
.bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?;
confirmation_token = Some(token.as_str());
}
if let Err(error) = send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await {
if state.account_confirmation_required {
if let Err(delete_error) = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user.id).execute(state.db.pool()).await {
tracing::error!(error=%delete_error, user_id=user.id, "failed to roll back account after confirmation e-mail error");
}
return Err(error);
}
warn!(user_id = user.id, "account created, but registration e-mail could not be sent");
}
} else {
warn!(user_id = user.id, "account created without registration e-mail because SMTP is not configured");
}
if state.account_confirmation_required {
info!(user_id = user.id, nickname = %user.nickname, "account registered; confirmation required");
return Ok((StatusCode::CREATED, Json(RegisterResponse {
token: None, nickname: user.nickname, email: user.email, expires_at: None,
confirmation_required: true,
message: "Account created. Check your e-mail and confirm the account before logging in.".into(),
})));
}
let session = create_session(&state, &user).await?;
info!(user_id = user.id, nickname = %user.nickname, "account registered and session created");
Ok((StatusCode::CREATED, Json(session)))
Ok((StatusCode::CREATED, Json(RegisterResponse {
token: Some(session.token), nickname: session.nickname, email: session.email, expires_at: Some(session.expires_at),
confirmation_required: false, message: "Account created.".into(),
})))
}
pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginRequest>) -> Result<Json<SessionResponse>, AuthError> {
@@ -68,11 +107,29 @@ pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginReques
debug!(email_domain = %email_domain(&email), "login requested");
let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
if !verify_password(&user.password_hash, &req.password) { warn!(user_id = user.id, "login rejected: invalid password"); return Err(AuthError::unauthorized("Invalid e-mail address or password.")); }
if state.account_confirmation_required && user.confirmed_at.is_none() { return Err(AuthError::forbidden("Confirm the account using the link sent by e-mail before logging in.")); }
let session = create_session(&state, &user).await?;
info!(user_id = user.id, nickname = %user.nickname, "login successful");
Ok(Json(session))
}
pub async fn confirm_account(State(state): State<SharedState>, Json(req): Json<ConfirmAccountRequest>) -> Result<Json<serde_json::Value>, AuthError> {
let now_time = Utc::now();
let now = now_time.to_rfc3339();
let token_hash = hash_token(req.token.trim());
let row: Option<(i64, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_CONFIRMATION_TOKEN))
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
let (user_id, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?;
let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?.with_timezone(&Utc);
if used_at.is_some() || expires_at <= now_time { return Err(AuthError::bad_request("The confirmation link is invalid or has expired.")); }
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)).bind(&now).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_CONFIRMATION_TOKEN_USED)).bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?;
tx.commit().await.map_err(AuthError::database)?;
info!(user_id, "account confirmed");
Ok(Json(serde_json::json!({"ok": true, "message": "Account confirmed. You can now log in."})))
}
pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<SessionResponse>, AuthError> {
let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?;
@@ -222,6 +279,24 @@ fn verify_password(hash:&str,v:&str)->bool{PasswordHash::new(hash).ok().and_then
fn random_token()->String{let mut bytes=[0u8;32];let mut rng=OsRng;rng.fill_bytes(&mut bytes);bytes.iter().map(|b|format!("{b:02x}")).collect()}
fn hash_token(v:&str)->String{format!("{:x}",Sha256::digest(v.as_bytes()))}
fn bearer(headers:&HeaderMap)->Option<&str>{headers.get("authorization")?.to_str().ok()?.strip_prefix("Bearer ")}
async fn send_registration_email(smtp: &SmtpConfig, user: &User, token: Option<&str>) -> Result<(), AuthError> {
let site = smtp.public_url.trim_end_matches('/');
let (subject, body) = match token {
Some(token) => ("Confirm your RustPad account", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours:\n{}/?confirm_token={}\n", user.nickname, user.nickname, site, site, token)),
None => ("Your RustPad account has been created", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n", user.nickname, user.nickname, site)),
};
let message = Message::builder().from(smtp.from.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid recipient address."))?).subject(subject).body(body).map_err(|_| AuthError::internal("Failed to build registration e-mail."))?;
send_message(smtp, message, "registration e-mail").await
}
async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Result<(), AuthError> {
let mut builder = if smtp.port == 465 { AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host) } else { AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host) }
.map_err(|error| { tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })?.port(smtp.port);
if !smtp.username.is_empty() { builder = builder.credentials(Credentials::new(smtp.username.clone(), smtp.password.clone())); }
builder.build().send(message).await.map_err(|error| { tracing::error!(error=%error, host=%smtp.host, port=smtp.port, message_type=label, "SMTP delivery failed"); AuthError::service_unavailable("The e-mail could not be sent. Check the SMTP configuration.") })?;
Ok(())
}
async fn send_reset(smtp:&SmtpConfig,user:&User,token:&str)->Result<(),AuthError>{
let url=format!("{}/?reset_token={}",smtp.public_url.trim_end_matches('/'),token);
let message=Message::builder().from(smtp.from.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid recipient address."))?).subject("RustPad password reset").body(format!("Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.",user.nickname,url)).map_err(|_|AuthError::internal("Failed to build reset e-mail."))?;