account confirmation
This commit is contained in:
+79
-4
@@ -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."))?;
|
||||
|
||||
Reference in New Issue
Block a user