security upgrade
This commit is contained in:
+161
-65
@@ -17,7 +17,8 @@ use argon2::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path as AxumPath, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use lettre::{
|
||||
@@ -53,8 +54,6 @@ pub struct User {
|
||||
#[derive(Deserialize)]
|
||||
pub struct IdentityRequest {
|
||||
nickname: String,
|
||||
#[serde(default)]
|
||||
session_token: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
@@ -259,6 +258,7 @@ pub struct ResourceList {
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionResponse {
|
||||
#[serde(skip_serializing)]
|
||||
token: String,
|
||||
nickname: String,
|
||||
email: String,
|
||||
@@ -276,6 +276,7 @@ pub struct IdentityResponse {
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
#[serde(skip_serializing)]
|
||||
token: Option<String>,
|
||||
nickname: String,
|
||||
email: String,
|
||||
@@ -286,10 +287,12 @@ pub struct RegisterResponse {
|
||||
|
||||
pub async fn identity(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<IdentityRequest>,
|
||||
) -> Result<Json<IdentityResponse>, AuthError> {
|
||||
let nickname = validate_nickname(&req.nickname)?;
|
||||
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
||||
let request_token = crate::security::session_token(&headers);
|
||||
debug!(nickname = %nickname, has_session = request_token.is_some(), "identity check requested");
|
||||
match find_user_by_nickname(&state, &nickname).await? {
|
||||
None => {
|
||||
debug!(nickname = %nickname, "nickname is available for guest use");
|
||||
@@ -299,7 +302,7 @@ pub async fn identity(
|
||||
}))
|
||||
}
|
||||
Some(user) => {
|
||||
let token = req.session_token.as_deref().ok_or_else(|| {
|
||||
let token = request_token.ok_or_else(|| {
|
||||
AuthError::unauthorized("This nickname is registered. Log in to use it.")
|
||||
})?;
|
||||
let current = user_from_token(&state, token).await?.ok_or_else(|| {
|
||||
@@ -322,7 +325,7 @@ pub async fn identity(
|
||||
pub async fn register(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
|
||||
) -> Result<Response, AuthError> {
|
||||
if state.ldap.is_some() {
|
||||
return Err(AuthError::forbidden(
|
||||
"Local registration is disabled while LDAP authentication is enabled.",
|
||||
@@ -434,12 +437,13 @@ pub async fn register(
|
||||
"Account created. Check your e-mail and confirm the account before logging in."
|
||||
.into(),
|
||||
}),
|
||||
));
|
||||
).into_response());
|
||||
}
|
||||
|
||||
let session = create_session(&state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "account registered and session created");
|
||||
Ok((
|
||||
let cookie = crate::security::session_cookie(&session.token, state.user_session_ttl_days);
|
||||
let mut response = (
|
||||
StatusCode::CREATED,
|
||||
Json(RegisterResponse {
|
||||
token: Some(session.token),
|
||||
@@ -449,22 +453,44 @@ pub async fn register(
|
||||
confirmation_required: false,
|
||||
message: "Account created.".into(),
|
||||
}),
|
||||
))
|
||||
).into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<SessionResponse>, AuthError> {
|
||||
if state.ldap.is_some() {
|
||||
ldap::login(&state, &req.email, &req.password)
|
||||
.await
|
||||
.map(Json)
|
||||
) -> Result<Response, AuthError> {
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
let login_key: String = normalize(&req.email).chars().take(320).collect();
|
||||
let client_limit_key = format!("login-client:{client_key}");
|
||||
let limit_key = format!("login:{client_key}:{login_key}");
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(client_limit_key.clone(), 30, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many login attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
state
|
||||
.check_rate_limit(limit_key.clone(), 5, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many login attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
let session = if state.ldap.is_some() {
|
||||
ldap::login(&state, &req.email, &req.password).await?
|
||||
} else {
|
||||
local::login(&state, &req.email, &req.password)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
local::login(&state, &req.email, &req.password).await?
|
||||
};
|
||||
state.clear_rate_limit(&limit_key).await;
|
||||
Ok(session_json_response(
|
||||
StatusCode::OK,
|
||||
session,
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resend_confirmation(
|
||||
@@ -673,11 +699,16 @@ fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Opti
|
||||
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."))?;
|
||||
) -> Result<Response, AuthError> {
|
||||
let token = crate::security::session_token(&headers)
|
||||
.ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
|
||||
let Some(user) = user_from_token(&state, token).await? else {
|
||||
let mut response = AuthError::unauthorized("Your session has expired.").into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
|
||||
return Ok(response);
|
||||
};
|
||||
debug!(user_id = user.id, "session validation successful");
|
||||
let expires_at: String = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
@@ -697,17 +728,21 @@ pub async fn me(
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
}))
|
||||
Ok(session_json_response(
|
||||
StatusCode::OK,
|
||||
SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
},
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
@@ -1225,7 +1260,7 @@ pub async fn optional_user(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Option<User>, AuthError> {
|
||||
match bearer(headers) {
|
||||
match crate::security::session_token(headers) {
|
||||
Some(token) => user_from_token(state, token).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
@@ -1672,32 +1707,43 @@ pub async fn is_resource_owner(
|
||||
Ok(ensure_owner(state, user.id, kind, slug).await.is_ok())
|
||||
}
|
||||
|
||||
pub async fn resource_permission(
|
||||
pub async fn account_resource_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<Option<String>, AuthError> {
|
||||
let Some(token) = token.filter(|v| !v.is_empty()) else {
|
||||
let Some(token) = token.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(user) = user_from_token(state, token).await? {
|
||||
let owns = ensure_owner(state, user.id, kind, slug).await.is_ok();
|
||||
if owns {
|
||||
return Ok(Some("rw".into()));
|
||||
}
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_BY_USER,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(user.id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
return Ok(permission);
|
||||
let Some(user) = user_from_token(state, token).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if ensure_owner(state, user.id, kind, slug).await.is_ok() {
|
||||
return Ok(Some("rw".into()));
|
||||
}
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_BY_USER,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(user.id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(permission)
|
||||
}
|
||||
|
||||
pub async fn share_link_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<Option<String>, AuthError> {
|
||||
let Some(token) = token.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
@@ -1716,8 +1762,8 @@ pub async fn resource_permission(
|
||||
pub async fn logout(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
if let Some(token) = bearer(&headers) {
|
||||
) -> Result<Response, AuthError> {
|
||||
if let Some(token) = crate::security::session_token(&headers) {
|
||||
let result = sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DELETE_SESSION_BY_TOKEN,
|
||||
@@ -1730,14 +1776,37 @@ pub async fn logout(
|
||||
} else {
|
||||
debug!("logout requested without an active session");
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
let mut response = Json(serde_json::json!({"ok": true})).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn request_reset(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ResetRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let email = validate_email(&req.email)?;
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
let window = std::time::Duration::from_secs(60 * 60);
|
||||
state
|
||||
.check_rate_limit(format!("password-reset-client:{client_key}"), 10, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many password reset requests. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
state
|
||||
.check_rate_limit(
|
||||
format!("password-reset:{client_key}:{}", normalize(&email)),
|
||||
3,
|
||||
window,
|
||||
)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many password reset requests. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
info!(email_domain = %email_domain(&email), "password reset requested");
|
||||
let smtp = state.smtp.as_ref().ok_or_else(|| {
|
||||
AuthError::service_unavailable("Password reset is not configured on this server.")
|
||||
@@ -1777,13 +1846,34 @@ pub async fn request_reset(
|
||||
|
||||
pub async fn confirm_reset(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ResetConfirmRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
validate_password(&req.password)?;
|
||||
let token_fingerprint = hash_token(req.token.trim());
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
let client_limit_key = format!("password-reset-confirm-client:{client_key}");
|
||||
let limit_key = format!(
|
||||
"password-reset-confirm:{client_key}:{}",
|
||||
&token_fingerprint[..16.min(token_fingerprint.len())]
|
||||
);
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(client_limit_key.clone(), 20, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many reset attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
state
|
||||
.check_rate_limit(limit_key.clone(), 10, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many reset attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
info!("password reset confirmation requested");
|
||||
let now_time = Utc::now();
|
||||
let now = now_time.to_rfc3339();
|
||||
let token_hash = hash_token(req.token.trim());
|
||||
let token_hash = token_fingerprint;
|
||||
let token_row = sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_FIND_RESET_TOKEN,
|
||||
@@ -1855,6 +1945,7 @@ pub async fn confirm_reset(
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
tx.commit().await.map_err(AuthError::database)?;
|
||||
state.clear_rate_limit(&limit_key).await;
|
||||
info!(
|
||||
user_id,
|
||||
"password reset completed and existing sessions revoked"
|
||||
@@ -2103,14 +2194,6 @@ fn hash_token(value: &str) -> String {
|
||||
format!("{:x}", Sha256::digest(value.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,
|
||||
@@ -2407,6 +2490,12 @@ impl AuthError {
|
||||
message: m.into(),
|
||||
}
|
||||
}
|
||||
fn rate_limited(m: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::TOO_MANY_REQUESTS,
|
||||
message: m.into(),
|
||||
}
|
||||
}
|
||||
fn service_unavailable(m: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -2419,6 +2508,13 @@ impl AuthError {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_json_response(status: StatusCode, session: SessionResponse, ttl_days: i64) -> Response {
|
||||
let cookie = crate::security::session_cookie(&session.token, ttl_days);
|
||||
let mut response = (status, Json(session)).into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
response
|
||||
}
|
||||
|
||||
fn email_domain(email: &str) -> &str {
|
||||
email
|
||||
.rsplit_once('@')
|
||||
|
||||
Reference in New Issue
Block a user