account confirmation
This commit is contained in:
@@ -40,6 +40,8 @@ MYSQL_ROOT_PASSWORD=rustpad_root
|
|||||||
|
|
||||||
# Optional account password reset via SMTP
|
# Optional account password reset via SMTP
|
||||||
REGISTRATION_ENABLED=false
|
REGISTRATION_ENABLED=false
|
||||||
|
ACCOUNT_CONFIRMATION_REQUIRED=false
|
||||||
|
|
||||||
PUBLIC_URL=https://pad.example.com
|
PUBLIC_URL=https://pad.example.com
|
||||||
# SMTP_HOST=smtp.example.com
|
# SMTP_HOST=smtp.example.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
@@ -82,3 +82,8 @@ RUST_LOG=rustpad=debug,tower_http=info
|
|||||||
Important lifecycle, database, authentication, password-reset and WebSocket events are logged. Passwords, session tokens, reset tokens, SMTP credentials and authorization headers are never logged.
|
Important lifecycle, database, authentication, password-reset and WebSocket events are logged. Passwords, session tokens, reset tokens, SMTP credentials and authorization headers are never logged.
|
||||||
|
|
||||||
Browser diagnostics are configured separately from backend logs with `FRONTEND_LOG_LEVEL`. Supported values are `off`, `error`, `warn`, `info`, and `debug`; the default is `warn`. URL parameters cannot enable diagnostics. Use `debug` only in trusted development environments. Production should normally use `warn` or `error`.
|
Browser diagnostics are configured separately from backend logs with `FRONTEND_LOG_LEVEL`. Supported values are `off`, `error`, `warn`, `info`, and `debug`; the default is `warn`. URL parameters cannot enable diagnostics. Use `debug` only in trusted development environments. Production should normally use `warn` or `error`.
|
||||||
|
|
||||||
|
|
||||||
|
### Rejestracja i SMTP
|
||||||
|
|
||||||
|
`REGISTRATION_ENABLED=true` włącza rejestrację. Po utworzeniu konta aplikacja wysyła przez SMTP wiadomość z nickiem i adresem `PUBLIC_URL`. `ACCOUNT_CONFIRMATION_REQUIRED=true` wymaga dodatkowo kliknięcia linku potwierdzającego przed logowaniem; domyślnie opcja jest wyłączona i wymaga skonfigurowanego SMTP.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN confirmed_at VARCHAR(64) NULL;
|
||||||
|
UPDATE users SET confirmed_at = CURRENT_TIMESTAMP WHERE confirmed_at IS NULL;
|
||||||
|
CREATE TABLE account_confirmation_tokens (
|
||||||
|
token VARCHAR(128) PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
expires_at VARCHAR(64) NOT NULL,
|
||||||
|
used_at VARCHAR(64),
|
||||||
|
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT fk_account_confirmation_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_account_confirmation_user (user_id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN confirmed_at TEXT;
|
||||||
|
UPDATE users SET confirmed_at = CURRENT_TIMESTAMP::text WHERE confirmed_at IS NULL;
|
||||||
|
CREATE TABLE account_confirmation_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_account_confirmation_user ON account_confirmation_tokens(user_id);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN confirmed_at TEXT;
|
||||||
|
UPDATE users SET confirmed_at = CURRENT_TIMESTAMP WHERE confirmed_at IS NULL;
|
||||||
|
CREATE TABLE account_confirmation_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_account_confirmation_user ON account_confirmation_tokens(user_id);
|
||||||
@@ -40,6 +40,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
|
|||||||
.route("/api/auth/identity", post(auth::identity))
|
.route("/api/auth/identity", post(auth::identity))
|
||||||
.route("/api/auth/register", post(auth::register))
|
.route("/api/auth/register", post(auth::register))
|
||||||
.route("/api/auth/login", post(auth::login))
|
.route("/api/auth/login", post(auth::login))
|
||||||
|
.route("/api/auth/confirm-account", post(auth::confirm_account))
|
||||||
.route("/api/auth/me", get(auth::me))
|
.route("/api/auth/me", get(auth::me))
|
||||||
.route("/api/auth/logout", post(auth::logout))
|
.route("/api/auth/logout", post(auth::logout))
|
||||||
.route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource))
|
.route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource))
|
||||||
|
|||||||
+79
-4
@@ -15,11 +15,12 @@ const MAX_PASSWORD: usize = 128;
|
|||||||
const MAX_NICKNAME: usize = 40;
|
const MAX_NICKNAME: usize = 40;
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[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 IdentityRequest { nickname: String, #[serde(default)] session_token: Option<String> }
|
||||||
#[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: 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 LoginRequest { email: String, password: String }
|
||||||
|
#[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String }
|
||||||
#[derive(Deserialize)] pub struct ResetRequest { email: String }
|
#[derive(Deserialize)] pub struct ResetRequest { email: String }
|
||||||
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
|
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
|
||||||
#[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option<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 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 SessionResponse { token: String, nickname: String, email: String, expires_at: String }
|
||||||
#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool }
|
#[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> {
|
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
|
||||||
let nickname = validate_nickname(&req.nickname)?;
|
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.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 nickname = validate_nickname(&req.nickname)?;
|
||||||
let email = validate_email(&req.email)?;
|
let email = validate_email(&req.email)?;
|
||||||
info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested");
|
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_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.")); }
|
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 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))
|
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)?;
|
.map_err(AuthError::database)?;
|
||||||
let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?;
|
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?;
|
let session = create_session(&state, &user).await?;
|
||||||
info!(user_id = user.id, nickname = %user.nickname, "account registered and session created");
|
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> {
|
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");
|
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."))?;
|
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 !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?;
|
let session = create_session(&state, &user).await?;
|
||||||
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
||||||
Ok(Json(session))
|
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> {
|
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 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."))?;
|
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 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 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 ")}
|
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>{
|
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 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."))?;
|
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."))?;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct Config {
|
|||||||
pub asset_version: String,
|
pub asset_version: String,
|
||||||
pub smtp: Option<crate::state::SmtpConfig>,
|
pub smtp: Option<crate::state::SmtpConfig>,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
|
pub account_confirmation_required: bool,
|
||||||
pub frontend_log_level: String,
|
pub frontend_log_level: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ impl Config {
|
|||||||
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||||
smtp,
|
smtp,
|
||||||
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
|
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
|
||||||
|
account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
||||||
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
files_dir = %config.files_dir,
|
files_dir = %config.files_dir,
|
||||||
upload_max_size_bytes = config.upload_max_size_bytes,
|
upload_max_size_bytes = config.upload_max_size_bytes,
|
||||||
registration_enabled = config.registration_enabled,
|
registration_enabled = config.registration_enabled,
|
||||||
|
account_confirmation_required = config.account_confirmation_required,
|
||||||
frontend_log_level = %config.frontend_log_level,
|
frontend_log_level = %config.frontend_log_level,
|
||||||
smtp_configured = config.smtp.is_some(),
|
smtp_configured = config.smtp.is_some(),
|
||||||
asset_version = %config.asset_version,
|
asset_version = %config.asset_version,
|
||||||
@@ -55,6 +56,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
config.upload_max_size_bytes,
|
config.upload_max_size_bytes,
|
||||||
config.smtp.clone(),
|
config.smtp.clone(),
|
||||||
config.registration_enabled,
|
config.registration_enabled,
|
||||||
|
config.account_confirmation_required,
|
||||||
config.frontend_log_level.clone(),
|
config.frontend_log_level.clone(),
|
||||||
));
|
));
|
||||||
let app = app::router(
|
let app = app::router(
|
||||||
|
|||||||
+10
-4
@@ -12,19 +12,25 @@ pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_s
|
|||||||
pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('revisions', 'id'))";
|
pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('revisions', 'id'))";
|
||||||
|
|
||||||
// Authentication queries.
|
// Authentication queries.
|
||||||
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)";
|
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
|
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
|
||||||
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
|
||||||
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
|
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
|
||||||
|
pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str = "DELETE FROM account_confirmation_tokens WHERE user_id = ?";
|
||||||
|
pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str = "INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)";
|
||||||
|
pub const AUTH_FIND_CONFIRMATION_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?";
|
||||||
|
pub const AUTH_CONFIRM_USER: &str = "UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?";
|
||||||
|
pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str = "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?";
|
||||||
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?";
|
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?";
|
||||||
pub const AUTH_INSERT_RESET_TOKEN: &str = "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)";
|
pub const AUTH_INSERT_RESET_TOKEN: &str = "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)";
|
||||||
pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
|
pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
|
||||||
pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
|
pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
|
||||||
pub const AUTH_MARK_RESET_TOKEN_USED: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?";
|
pub const AUTH_MARK_RESET_TOKEN_USED: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?";
|
||||||
pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?";
|
pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?";
|
||||||
pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?";
|
pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?";
|
||||||
pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)";
|
pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)";
|
||||||
pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash FROM users WHERE nickname_key = ?";
|
pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = ?";
|
||||||
pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash FROM users WHERE email_key = ?";
|
pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?";
|
||||||
pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?";
|
pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?";
|
||||||
pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?";
|
pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?";
|
||||||
pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? ORDER BY w.updated_at DESC";
|
pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? ORDER BY w.updated_at DESC";
|
||||||
|
|||||||
+3
-2
@@ -24,13 +24,14 @@ pub struct AppState {
|
|||||||
pub upload_max_size_bytes: usize,
|
pub upload_max_size_bytes: usize,
|
||||||
pub smtp: Option<SmtpConfig>,
|
pub smtp: Option<SmtpConfig>,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
|
pub account_confirmation_required: bool,
|
||||||
pub frontend_log_level: String,
|
pub frontend_log_level: String,
|
||||||
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, registration_enabled: bool, frontend_log_level: String) -> Self {
|
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String) -> Self {
|
||||||
Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, frontend_log_level, channels: RwLock::new(HashMap::new()) }
|
Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, account_confirmation_required, frontend_log_level, channels: RwLock::new(HashMap::new()) }
|
||||||
}
|
}
|
||||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
||||||
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
|
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@
|
|||||||
<p id="identity-copy" class="dialog-copy"></p>
|
<p id="identity-copy" class="dialog-copy"></p>
|
||||||
</header>
|
</header>
|
||||||
<div class="identity-fields">
|
<div class="identity-fields">
|
||||||
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="nickname" placeholder="Your nickname"></label>
|
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true" placeholder="Your nickname"></label>
|
||||||
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
|
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
|
||||||
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required placeholder="At least 8 characters"></label>
|
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required placeholder="At least 8 characters"></label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+55
-5
@@ -49,10 +49,19 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
|||||||
switchMode.textContent = registering ? "Already registered? Log in" : "Create an account";
|
switchMode.textContent = registering ? "Already registered? Log in" : "Create an account";
|
||||||
resetButton.hidden = resetting || registering;
|
resetButton.hidden = resetting || registering;
|
||||||
backButton.hidden = !resetting;
|
backButton.hidden = !resetting;
|
||||||
form.autocomplete = resetting ? "off" : "on";
|
const loginMode = mode === "login";
|
||||||
nickname.autocomplete = "nickname";
|
form.autocomplete = loginMode ? "on" : "off";
|
||||||
email.autocomplete = "username";
|
nickname.autocomplete = "off";
|
||||||
password.autocomplete = registering ? "new-password" : "current-password";
|
nickname.dataset.bwignore = "true";
|
||||||
|
email.autocomplete = loginMode ? "username" : "off";
|
||||||
|
password.autocomplete = loginMode ? "current-password" : "off";
|
||||||
|
if (loginMode) {
|
||||||
|
email.removeAttribute("data-bwignore");
|
||||||
|
password.removeAttribute("data-bwignore");
|
||||||
|
} else {
|
||||||
|
email.dataset.bwignore = "true";
|
||||||
|
password.dataset.bwignore = "true";
|
||||||
|
}
|
||||||
message.textContent = "";
|
message.textContent = "";
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -94,6 +103,13 @@ export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }
|
|||||||
const payload = { email: email.value.trim(), password: password.value };
|
const payload = { email: email.value.trim(), password: password.value };
|
||||||
if (mode === "register") payload.nickname = nickname.value.trim();
|
if (mode === "register") payload.nickname = nickname.value.trim();
|
||||||
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
|
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
if (mode === "register" && session.confirmation_required) {
|
||||||
|
message.classList.remove("error");
|
||||||
|
message.classList.add("success");
|
||||||
|
message.textContent = session.message;
|
||||||
|
password.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
await onIdentity(session.nickname, session);
|
await onIdentity(session.nickname, session);
|
||||||
dialog.close();
|
dialog.close();
|
||||||
@@ -153,7 +169,19 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
|||||||
email.disabled = false;
|
email.disabled = false;
|
||||||
modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in";
|
modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in";
|
||||||
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
|
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
|
||||||
password.autocomplete = mode === "register" ? "new-password" : "current-password";
|
const loginMode = mode === "login";
|
||||||
|
form.autocomplete = loginMode ? "on" : "off";
|
||||||
|
nickname.autocomplete = "off";
|
||||||
|
nickname.dataset.bwignore = "true";
|
||||||
|
email.autocomplete = loginMode ? "username" : "off";
|
||||||
|
password.autocomplete = loginMode ? "current-password" : "off";
|
||||||
|
if (loginMode) {
|
||||||
|
email.removeAttribute("data-bwignore");
|
||||||
|
password.removeAttribute("data-bwignore");
|
||||||
|
} else {
|
||||||
|
email.dataset.bwignore = "true";
|
||||||
|
password.dataset.bwignore = "true";
|
||||||
|
}
|
||||||
message.textContent = "";
|
message.textContent = "";
|
||||||
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
|
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
|
||||||
};
|
};
|
||||||
@@ -208,6 +236,13 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
|||||||
const payload = { email: email.value.trim(), password: password.value };
|
const payload = { email: email.value.trim(), password: password.value };
|
||||||
if (mode === "register") payload.nickname = name;
|
if (mode === "register") payload.nickname = name;
|
||||||
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
|
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
if (mode === "register" && session.confirmation_required) {
|
||||||
|
message.classList.remove("error");
|
||||||
|
message.classList.add("success");
|
||||||
|
message.textContent = session.message;
|
||||||
|
password.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAuthSession(session);
|
setAuthSession(session);
|
||||||
await onIdentity(session.nickname, session);
|
await onIdentity(session.nickname, session);
|
||||||
return;
|
return;
|
||||||
@@ -250,6 +285,21 @@ export async function logoutCurrentSession() {
|
|||||||
clearAuthSession();
|
clearAuthSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function handleAccountConfirmationToken() {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
const token = url.searchParams.get("confirm_token");
|
||||||
|
if (!token) return;
|
||||||
|
url.searchParams.delete("confirm_token");
|
||||||
|
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
|
||||||
|
try {
|
||||||
|
const result = await api("/api/auth/confirm-account", { method: "POST", body: JSON.stringify({ token }) });
|
||||||
|
await showMessage(result.message, { title: "Account confirmed" });
|
||||||
|
} catch (error) {
|
||||||
|
await showMessage(error.message, { title: "Account confirmation failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleResetToken() {
|
export async function handleResetToken() {
|
||||||
const url = new URL(location.href);
|
const url = new URL(location.href);
|
||||||
const token = url.searchParams.get("reset_token");
|
const token = url.searchParams.get("reset_token");
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||||
installGlobalDiagnostics();
|
installGlobalDiagnostics();
|
||||||
|
|
||||||
import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
|
import { bindIdentityDialog, handleAccountConfirmationToken, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
|
||||||
import { getAuthToken } from "@rustpad/session";
|
import { getAuthToken } from "@rustpad/session";
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
|
|
||||||
@@ -80,6 +80,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handleAccountConfirmationToken();
|
||||||
handleResetToken();
|
handleResetToken();
|
||||||
|
|
||||||
const identityDialog = document.querySelector("#identity-dialog");
|
const identityDialog = document.querySelector("#identity-dialog");
|
||||||
|
|||||||
Reference in New Issue
Block a user