469 lines
16 KiB
Rust
469 lines
16 KiB
Rust
use crank_core::{
|
|
LoginOutcome, MembershipRole, User, UserId, UserSessionId, UserStatus, WorkspaceId,
|
|
};
|
|
use crank_registry::{AdminSecurityAuditRequest, ConsumeAdminBootstrapContractRequest};
|
|
use serde_json::json;
|
|
use time::OffsetDateTime;
|
|
use tracing::instrument;
|
|
|
|
use crate::{
|
|
auth::{
|
|
AuthenticatedSession, SessionCookie, create_csrf_token_value, create_session_cookie,
|
|
hash_csrf_token, hash_password, hash_session_secret, verify_password,
|
|
},
|
|
error::ApiError,
|
|
service::{
|
|
AdminService, BootstrapStatusResponse, ChangePasswordPayload, CompleteBootstrapPayload,
|
|
LoginPayload, SessionResponse, UpdateProfilePayload, hash_access_secret,
|
|
map_identity_error, new_prefixed_id, validate_profile_display_name, validate_profile_email,
|
|
},
|
|
};
|
|
|
|
impl AdminService {
|
|
pub async fn bootstrap_status(&self) -> Result<BootstrapStatusResponse, ApiError> {
|
|
Ok(BootstrapStatusResponse {
|
|
bootstrap_required: !self.registry.has_password_admin().await?,
|
|
})
|
|
}
|
|
|
|
pub async fn complete_bootstrap(
|
|
&self,
|
|
payload: CompleteBootstrapPayload,
|
|
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
if payload.token.len() < 32 || payload.token.len() > 256 {
|
|
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
|
|
.await;
|
|
return Err(Self::constant_bootstrap_error());
|
|
}
|
|
if payload.password.len() < 12 || payload.password.len() > 256 {
|
|
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
|
|
.await;
|
|
return Err(Self::constant_bootstrap_error());
|
|
}
|
|
|
|
let now = OffsetDateTime::now_utc();
|
|
let bootstrap_token_hash = hash_access_secret(&format!("bootstrap:{}", payload.token));
|
|
if !self
|
|
.registry
|
|
.admin_bootstrap_contract_is_consumable(&bootstrap_token_hash, &now)
|
|
.await?
|
|
{
|
|
self.record_admin_security_audit(None, "bootstrap_rejected", "rejected", "bootstrap")
|
|
.await;
|
|
return Err(Self::constant_bootstrap_error());
|
|
}
|
|
|
|
let password_hash = hash_password(&payload.password, &self.auth_settings.password_pepper)?;
|
|
let user_id = self
|
|
.registry
|
|
.consume_admin_bootstrap_contract(ConsumeAdminBootstrapContractRequest {
|
|
token_hash: &bootstrap_token_hash,
|
|
password_hash: &password_hash,
|
|
now: &now,
|
|
})
|
|
.await
|
|
.map_err(|_| Self::constant_bootstrap_error());
|
|
let user_id = match user_id {
|
|
Ok(user_id) => user_id,
|
|
Err(error) => {
|
|
self.record_admin_security_audit(
|
|
None,
|
|
"bootstrap_rejected",
|
|
"rejected",
|
|
"bootstrap",
|
|
)
|
|
.await;
|
|
return Err(error);
|
|
}
|
|
};
|
|
self.record_admin_security_audit(
|
|
Some(&user_id),
|
|
"bootstrap_completed",
|
|
"success",
|
|
"bootstrap",
|
|
)
|
|
.await;
|
|
let user = self
|
|
.registry
|
|
.get_auth_user_by_id(&user_id)
|
|
.await?
|
|
.ok_or_else(|| ApiError::internal("bootstrap user was not found"))?
|
|
.user;
|
|
self.create_session_for_user(user).await
|
|
}
|
|
|
|
fn constant_bootstrap_error() -> ApiError {
|
|
ApiError::unauthorized("bootstrap request is invalid or expired")
|
|
}
|
|
|
|
pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
|
|
let password_hash = hash_password(
|
|
&self.auth_settings.bootstrap_admin.password,
|
|
&self.auth_settings.password_pepper,
|
|
)?;
|
|
let user_id = self
|
|
.registry
|
|
.ensure_bootstrap_user(
|
|
&self.auth_settings.bootstrap_admin.email,
|
|
&self.auth_settings.bootstrap_admin.display_name,
|
|
&password_hash,
|
|
)
|
|
.await?;
|
|
self.registry
|
|
.ensure_membership(
|
|
&WorkspaceId::new("ws_default"),
|
|
&user_id,
|
|
MembershipRole::Owner,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn seed_demo_assets(&self) -> Result<(), ApiError> {
|
|
let admin_user = self
|
|
.registry
|
|
.get_auth_user_by_email(&self.auth_settings.bootstrap_admin.email)
|
|
.await?
|
|
.ok_or_else(|| ApiError::internal("bootstrap admin user was not found"))?;
|
|
let admin_user_id = admin_user.user.id.clone();
|
|
let default_workspace_id = WorkspaceId::new("ws_default");
|
|
|
|
self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_session(
|
|
&self,
|
|
session_id: &UserSessionId,
|
|
session_value: &str,
|
|
) -> Result<Option<AuthenticatedSession>, ApiError> {
|
|
let secret_hash = hash_session_secret(
|
|
session_id,
|
|
session_value,
|
|
&self.auth_settings.session_secret,
|
|
);
|
|
let session = self
|
|
.registry
|
|
.get_user_session(session_id, &secret_hash)
|
|
.await?
|
|
.map(|record| AuthenticatedSession {
|
|
session_id: record.session_id,
|
|
user: record.user,
|
|
memberships: record.memberships,
|
|
current_workspace_id: record.current_workspace_id,
|
|
});
|
|
|
|
Ok(session)
|
|
}
|
|
|
|
pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> {
|
|
self.registry.touch_user_session(session_id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn verify_session_csrf(
|
|
&self,
|
|
session_id: &UserSessionId,
|
|
csrf_hash: &str,
|
|
) -> Result<bool, ApiError> {
|
|
Ok(self
|
|
.registry
|
|
.verify_session_csrf(session_id, csrf_hash)
|
|
.await?)
|
|
}
|
|
|
|
pub async fn login(
|
|
&self,
|
|
payload: LoginPayload,
|
|
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
self.login_with_client_bucket(payload, "anonymous").await
|
|
}
|
|
|
|
pub async fn login_with_client_bucket(
|
|
&self,
|
|
payload: LoginPayload,
|
|
client_bucket: &str,
|
|
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
let scope_hash = hash_access_secret(&format!(
|
|
"login:{}:{}",
|
|
client_bucket,
|
|
payload.email.trim().to_ascii_lowercase()
|
|
));
|
|
if let Some(locked_until) = self
|
|
.registry
|
|
.login_backoff_locked_until(&scope_hash)
|
|
.await?
|
|
{
|
|
let retry_after_ms = (locked_until - OffsetDateTime::now_utc())
|
|
.whole_milliseconds()
|
|
.clamp(1, 300_000);
|
|
return Err(ApiError::rate_limited_with_context(
|
|
"login temporarily unavailable",
|
|
json!({
|
|
"retry_after_ms": retry_after_ms,
|
|
"error_code": "login_throttled",
|
|
"recovery": "retry_after_delay"
|
|
}),
|
|
));
|
|
}
|
|
let authenticated = match self.authenticate_login(&payload).await {
|
|
Ok(authenticated) => authenticated,
|
|
Err(error) => {
|
|
let _ = self
|
|
.registry
|
|
.record_login_failure(&scope_hash, &OffsetDateTime::now_utc())
|
|
.await;
|
|
self.record_admin_security_audit(None, "login_rejected", "rejected", "login")
|
|
.await;
|
|
return Err(error);
|
|
}
|
|
};
|
|
self.registry.reset_login_backoff(&scope_hash).await?;
|
|
self.record_admin_security_audit(
|
|
Some(&authenticated.user.id),
|
|
"login_succeeded",
|
|
"success",
|
|
"login",
|
|
)
|
|
.await;
|
|
self.create_session_for_user(authenticated.user).await
|
|
}
|
|
|
|
async fn create_session_for_user(
|
|
&self,
|
|
user: User,
|
|
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
|
let session_cookie = create_session_cookie(&self.auth_settings)?;
|
|
let csrf_token = create_csrf_token_value();
|
|
let secret_hash = hash_session_secret(
|
|
&session_cookie.session_id,
|
|
&session_cookie.value,
|
|
&self.auth_settings.session_secret,
|
|
);
|
|
let csrf_hash = hash_csrf_token(
|
|
&session_cookie.session_id,
|
|
&csrf_token,
|
|
&self.auth_settings.session_secret,
|
|
);
|
|
let memberships = self.registry.list_workspaces_for_user(&user.id).await?;
|
|
let default_workspace_id = memberships
|
|
.iter()
|
|
.find(|membership| membership.workspace.id.as_str() == "ws_default")
|
|
.map(|membership| membership.workspace.id.as_str().to_owned());
|
|
let current_workspace_id = default_workspace_id.or_else(|| {
|
|
memberships
|
|
.first()
|
|
.map(|membership| membership.workspace.id.as_str().to_owned())
|
|
});
|
|
let current_workspace_ref = current_workspace_id
|
|
.as_ref()
|
|
.map(|workspace_id| WorkspaceId::new(workspace_id.clone()));
|
|
self.registry
|
|
.create_user_session(
|
|
&session_cookie.session_id,
|
|
&user.id,
|
|
current_workspace_ref.as_ref(),
|
|
&secret_hash,
|
|
Some(&csrf_hash),
|
|
&session_cookie.expires_at,
|
|
)
|
|
.await?;
|
|
|
|
Ok((
|
|
session_cookie,
|
|
SessionResponse {
|
|
user,
|
|
memberships,
|
|
current_workspace_id,
|
|
csrf_token,
|
|
},
|
|
))
|
|
}
|
|
|
|
async fn authenticate_login(
|
|
&self,
|
|
payload: &LoginPayload,
|
|
) -> Result<crank_core::AuthenticatedIdentity, ApiError> {
|
|
if let Some(identity_provider) = &self.identity_provider {
|
|
return match identity_provider
|
|
.login_password(crank_core::LoginPayload {
|
|
email: payload.email.clone(),
|
|
password: payload.password.clone(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(LoginOutcome::Authenticated(identity)) => Ok(identity),
|
|
Err(error) => Err(map_identity_error(error)),
|
|
};
|
|
}
|
|
|
|
let user = self
|
|
.registry
|
|
.get_auth_user_by_email(&payload.email)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
let _ = hash_password(&payload.password, &self.auth_settings.password_pepper);
|
|
ApiError::unauthorized("invalid email or password")
|
|
})?;
|
|
|
|
if user.user.status != UserStatus::Active {
|
|
let _ = verify_password(
|
|
&payload.password,
|
|
&self.auth_settings.password_pepper,
|
|
&user.password_hash,
|
|
);
|
|
return Err(ApiError::unauthorized("invalid email or password"));
|
|
}
|
|
|
|
if !verify_password(
|
|
&payload.password,
|
|
&self.auth_settings.password_pepper,
|
|
&user.password_hash,
|
|
) {
|
|
return Err(ApiError::unauthorized("invalid email or password"));
|
|
}
|
|
|
|
Ok(crank_core::AuthenticatedIdentity {
|
|
user: user.user,
|
|
memberships: vec![],
|
|
current_workspace_id: None,
|
|
})
|
|
}
|
|
|
|
pub async fn logout(
|
|
&self,
|
|
session_id: &UserSessionId,
|
|
_session_value: &str,
|
|
) -> Result<(), ApiError> {
|
|
self.registry.revoke_user_session(session_id).await?;
|
|
self.record_admin_security_audit(None, "logout", "success", "session")
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[instrument(skip(self))]
|
|
pub async fn session_response(
|
|
&self,
|
|
session_id: &UserSessionId,
|
|
session_value: &str,
|
|
) -> Result<Option<SessionResponse>, ApiError> {
|
|
let Some(session) = self.get_session(session_id, session_value).await? else {
|
|
return Ok(None);
|
|
};
|
|
Ok(Some(SessionResponse {
|
|
user: session.user,
|
|
memberships: session.memberships,
|
|
current_workspace_id: session
|
|
.current_workspace_id
|
|
.map(|id| id.as_str().to_owned()),
|
|
csrf_token: String::new(),
|
|
}))
|
|
}
|
|
|
|
pub async fn update_profile(
|
|
&self,
|
|
user_id: &crank_core::UserId,
|
|
session_id: &UserSessionId,
|
|
current_workspace_id: Option<&WorkspaceId>,
|
|
payload: UpdateProfilePayload,
|
|
) -> Result<SessionResponse, ApiError> {
|
|
let display_name = validate_profile_display_name(&payload.display_name)?;
|
|
let email = validate_profile_email(&payload.email)?;
|
|
|
|
let user = self
|
|
.registry
|
|
.update_user_profile(user_id, &email, &display_name)
|
|
.await?;
|
|
let memberships = self.registry.list_workspaces_for_user(user_id).await?;
|
|
|
|
Ok(SessionResponse {
|
|
user,
|
|
memberships,
|
|
current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()),
|
|
csrf_token: self.rotate_session_csrf_token(session_id).await?,
|
|
})
|
|
}
|
|
|
|
pub async fn change_password(
|
|
&self,
|
|
user_id: &crank_core::UserId,
|
|
_current_session_id: &UserSessionId,
|
|
payload: ChangePasswordPayload,
|
|
) -> Result<(), ApiError> {
|
|
if payload.new_password.len() < 12 || payload.new_password.len() > 256 {
|
|
return Err(ApiError::validation(
|
|
"new password must be between 12 and 256 characters long",
|
|
));
|
|
}
|
|
|
|
let user = self
|
|
.registry
|
|
.get_auth_user_by_id(user_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
ApiError::not_found_with_context(
|
|
format!("user {} was not found", user_id.as_str()),
|
|
json!({ "user_id": user_id.as_str() }),
|
|
)
|
|
})?;
|
|
|
|
if !verify_password(
|
|
&payload.current_password,
|
|
&self.auth_settings.password_pepper,
|
|
&user.password_hash,
|
|
) {
|
|
return Err(ApiError::unauthorized("current password is invalid"));
|
|
}
|
|
|
|
let password_hash =
|
|
hash_password(&payload.new_password, &self.auth_settings.password_pepper)?;
|
|
self.registry
|
|
.update_user_password_and_revoke_all_sessions(user_id, &password_hash)
|
|
.await?;
|
|
self.record_admin_security_audit(Some(user_id), "password_rotated", "success", "password")
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn record_admin_security_audit(
|
|
&self,
|
|
actor_user_id: Option<&UserId>,
|
|
action: &'static str,
|
|
outcome: &'static str,
|
|
source: &'static str,
|
|
) {
|
|
let (request_id, trace_id) = crank_observability::current_request_correlation();
|
|
let audit_id = new_prefixed_id("audit");
|
|
let _ = self
|
|
.registry
|
|
.record_admin_security_audit(AdminSecurityAuditRequest {
|
|
id: &audit_id,
|
|
action,
|
|
outcome,
|
|
actor_user_id,
|
|
session_id: None,
|
|
request_id: request_id.as_deref(),
|
|
trace_id: trace_id.as_deref(),
|
|
source,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
pub async fn rotate_session_csrf_token(
|
|
&self,
|
|
session_id: &UserSessionId,
|
|
) -> Result<String, ApiError> {
|
|
let csrf_token = create_csrf_token_value();
|
|
let csrf_hash =
|
|
hash_csrf_token(session_id, &csrf_token, &self.auth_settings.session_secret);
|
|
self.registry
|
|
.update_user_session_csrf(session_id, &csrf_hash)
|
|
.await?;
|
|
Ok(csrf_token)
|
|
}
|
|
}
|