ef0b83a
pub use export::KenAuthError;
use actix_web::http::StatusCode;
use actix_web::http::header::{HeaderValue, RETRY_AFTER};
use actix_web::{HttpResponse, ResponseError};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub type AuthResult<T> = std::result::Result<T, AuthError>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AuthError {
#[error("challenge expired")]
Expired,
#[error("proof payload does not match the request")]
InvalidPayload,
#[error("proof does not meet the required difficulty")]
InvalidProof,
#[error("request payload is malformed")]
MalformedRequest,
#[error("username is invalid")]
InvalidUsername,
#[error("display name is invalid")]
InvalidDisplayname,
#[error("email is invalid")]
InvalidEmail,
#[error("email is unchanged")]
EmailUnchanged,
#[error("birth date is invalid")]
InvalidBirthDate,
#[error("you must be at least {min_age} years old")]
UnderMinimumAge { min_age: u32 },
#[error("password does not satisfy the password policy")]
InvalidPassword,
#[error("password hashing failed: {0}")]
PasswordHash(String),
#[error("username is already taken")]
UsernameTaken,
#[error("email is already taken")]
EmailTaken,
#[error("email verification code is invalid")]
InvalidEmailCode,
#[error("too many invalid email verification attempts")]
EmailCodeAttemptsExceeded,
#[error("password reset token is invalid")]
InvalidResetToken,
#[error("authentication required")]
Unauthenticated,
#[error("forbidden")]
Forbidden,
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Conflict(String),
#[error("username or password is incorrect")]
InvalidCredentials,
#[error("login is forbidden{}", reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default())]
LoginForbidden { reason: Option<String> },
#[error("account is unavailable: {reason}")]
AccountUnavailable { reason: String },
#[error("two-factor verification is required")]
MfaRequired,
#[error("totp is not configured")]
TotpNotConfigured,
#[error("totp is already enabled")]
TotpAlreadyEnabled,
#[error("totp code is invalid")]
TotpCodeInvalid,
#[error("totp code was already used")]
TotpStepReused,
#[error("recovery code is invalid")]
RecoveryCodeInvalid,
#[error("recovery codes are exhausted")]
RecoveryCodesExhausted,
#[error("access token is invalid")]
InvalidAccessToken,
#[error("access token does not exist")]
AccessTokenNotFound,
#[error("access key name is invalid")]
InvalidAccessKeyName,
#[error("access token scope is invalid")]
InvalidScope,
#[error("expires_at is invalid")]
InvalidExpiry,
#[error("too many access keys")]
TooManyAccessKeys,
#[error("public key is invalid")]
SshKeyInvalid,
#[error("permission `{permission}` is not recognized")]
InvalidPermission { permission: String },
#[error("organization does not exist")]
OrganizationNotFound,
#[error("organization member does not exist")]
OrganizationMemberNotFound,
#[error("organization role does not exist")]
OrganizationRoleNotFound,
#[error("organization invite does not exist")]
OrganizationInviteNotFound,
#[error("organization slug is already taken")]
OrganizationSlugTaken,
#[error("user is already a member of this organization")]
OrganizationMemberExists,
#[error("organization role key is already used")]
OrganizationRoleKeyTaken,
#[error("organization role is still assigned to members")]
OrganizationRoleInUse,
#[error("organization role is built in and cannot be modified")]
OrganizationRoleSystem,
#[error("organization invite is exhausted or revoked")]
OrganizationInviteExhausted,
#[error("organization invite has expired")]
OrganizationInviteExpired,
#[error("organization must have an owner; transfer ownership first")]
OrganizationOwnerRequired,
#[error("organization still has active members")]
OrganizationHasMembers,
#[error("organization is not active")]
OrganizationInactive,
#[error("organization membership is banned")]
OrganizationMemberBanned,
#[error("resource version mismatch, current version is {current}")]
VersionMismatch { current: i32 },
#[error("public key is already registered")]
SshKeyTaken,
#[error("ssh key name is invalid")]
SshKeyNameInvalid,
#[error("ssh key does not exist")]
SshKeyNotFound,
#[error("agreement `{key}` does not exist")]
AgreementNotFound { key: String },
#[error("agreement `{key}` must be accepted")]
AgreementNotAccepted { key: String },
#[error("account deletion is already requested")]
DeletionAlreadyRequested,
#[error("account deletion is not requested")]
DeletionNotRequested,
#[error("too many requests, retry after {retry_after_secs}s")]
RateLimited { retry_after_secs: u64 },
#[error("user does not exist")]
UserNotFound,
#[error("app does not exist")]
AppNotFound,
#[error("session does not exist")]
SessionNotFound,
#[error("token does not exist")]
TokenNotFound,
#[error("token has expired")]
TokenExpired,
#[error("token has been revoked")]
TokenRevoked,
#[error("credentials are stale, security version is {current}")]
SecurityVersionStale { current: i32 },
#[error("scope `{required}` is missing")]
ScopeInsufficient { required: String },
#[error("dependency is unavailable: {0}")]
Unavailable(String),
#[error("internal error: {0}")]
Internal(String),
#[error("database error: {0}")]
Database(#[from] crate::db::DbError),
#[error("cache error: {0}")]
Cache(#[from] crate::cache::CacheError),
#[error("session error: {0}")]
Session(#[from] crate::session::SessionError),
#[error("email error: {0}")]
Email(#[from] crate::email::EmailError),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
impl From<sea_orm::DbErr> for AuthError {
fn from(error: sea_orm::DbErr) -> Self {
Self::Database(crate::db::DbError::from(error))
}
}
impl AuthError {
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
pub fn status_code(&self) -> StatusCode {
match self {
Self::Unauthenticated
| Self::InvalidCredentials
| Self::InvalidAccessToken
| Self::AccessTokenNotFound
| Self::TokenExpired
| Self::TokenRevoked
| Self::SecurityVersionStale { .. }
| Self::MfaRequired => StatusCode::UNAUTHORIZED,
Self::Forbidden
| Self::BadRequest(_)
| Self::LoginForbidden { .. }
| Self::UnderMinimumAge { .. }
| Self::AccountUnavailable { .. }
| Self::ScopeInsufficient { .. }
| Self::OrganizationInactive
| Self::OrganizationMemberBanned
| Self::AgreementNotAccepted { .. } => StatusCode::FORBIDDEN,
Self::UserNotFound
| Self::SessionNotFound
| Self::TokenNotFound
| Self::AppNotFound
| Self::SshKeyNotFound
| Self::OrganizationNotFound
| Self::OrganizationMemberNotFound
| Self::OrganizationRoleNotFound
| Self::OrganizationInviteNotFound
| Self::AgreementNotFound { .. } => StatusCode::NOT_FOUND,
Self::Unavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
Self::UsernameTaken
| Self::Conflict(_)
| Self::EmailTaken
| Self::SshKeyTaken
| Self::OrganizationSlugTaken
| Self::OrganizationMemberExists
| Self::OrganizationRoleKeyTaken
| Self::OrganizationHasMembers
| Self::TotpAlreadyEnabled
| Self::TotpNotConfigured
| Self::DeletionAlreadyRequested => StatusCode::CONFLICT,
Self::OrganizationRoleInUse
| Self::OrganizationRoleSystem
| Self::OrganizationInviteExhausted
| Self::OrganizationInviteExpired
| Self::OrganizationOwnerRequired => StatusCode::PRECONDITION_FAILED,
Self::VersionMismatch { .. } => StatusCode::CONFLICT,
Self::RateLimited { .. }
| Self::EmailCodeAttemptsExceeded
| Self::TooManyAccessKeys
| Self::RecoveryCodesExhausted => StatusCode::TOO_MANY_REQUESTS,
Self::PasswordHash(_)
| Self::Internal(_)
| Self::Database(_)
| Self::Cache(_)
| Self::Session(_)
| Self::Email(_)
| Self::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_REQUEST,
}
}
pub fn code(&self) -> &'static str {
match self {
Self::Expired => "expired",
Self::InvalidPayload => "invalid_payload",
Self::InvalidProof => "invalid_proof",
Self::MalformedRequest => "malformed_request",
Self::InvalidUsername => "invalid_username",
Self::InvalidDisplayname => "invalid_displayname",
Self::InvalidEmail => "invalid_email",
Self::EmailUnchanged => "email_unchanged",
Self::InvalidBirthDate => "invalid_birth_date",
Self::UnderMinimumAge { .. } => "under_minimum_age",
Self::InvalidPassword => "invalid_password",
Self::PasswordHash(_) => "password_hash",
Self::UsernameTaken => "username_taken",
Self::EmailTaken => "email_taken",
Self::InvalidEmailCode => "invalid_email_code",
Self::EmailCodeAttemptsExceeded => "email_code_attempts_exceeded",
Self::InvalidResetToken => "invalid_reset_token",
Self::Unauthenticated => "unauthenticated",
Self::Forbidden => "forbidden",
Self::BadRequest(_) => "bad_request",
Self::Conflict(_) => "conflict",
Self::InvalidCredentials => "invalid_credentials",
Self::LoginForbidden { .. } => "login_forbidden",
Self::AccountUnavailable { .. } => "account_unavailable",
Self::MfaRequired => "mfa_required",
Self::TotpNotConfigured => "totp_not_configured",
Self::TotpAlreadyEnabled => "totp_already_enabled",
Self::TotpCodeInvalid => "totp_code_invalid",
Self::TotpStepReused => "totp_step_reused",
Self::RecoveryCodeInvalid => "recovery_code_invalid",
Self::RecoveryCodesExhausted => "recovery_codes_exhausted",
Self::InvalidAccessToken => "invalid_access_token",
Self::AccessTokenNotFound => "access_token_not_found",
Self::InvalidAccessKeyName => "invalid_access_key_name",
Self::InvalidScope => "invalid_scope",
Self::InvalidExpiry => "invalid_expiry",
Self::TooManyAccessKeys => "too_many_access_keys",
Self::SshKeyInvalid => "ssh_key_invalid",
Self::InvalidPermission { .. } => "invalid_permission",
Self::OrganizationNotFound => "organization_not_found",
Self::OrganizationMemberNotFound => "organization_member_not_found",
Self::OrganizationRoleNotFound => "organization_role_not_found",
Self::OrganizationInviteNotFound => "organization_invite_not_found",
Self::OrganizationSlugTaken => "organization_slug_taken",
Self::OrganizationMemberExists => "organization_member_exists",
Self::OrganizationRoleKeyTaken => "organization_role_key_taken",
Self::OrganizationRoleInUse => "organization_role_in_use",
Self::OrganizationRoleSystem => "organization_role_system",
Self::OrganizationInviteExhausted => "organization_invite_exhausted",
Self::OrganizationInviteExpired => "organization_invite_expired",
Self::OrganizationOwnerRequired => "organization_owner_required",
Self::OrganizationHasMembers => "organization_has_members",
Self::OrganizationInactive => "organization_inactive",
Self::OrganizationMemberBanned => "organization_member_banned",
Self::VersionMismatch { .. } => "version_mismatch",
Self::SshKeyTaken => "ssh_key_taken",
Self::SshKeyNameInvalid => "ssh_key_name_invalid",
Self::SshKeyNotFound => "ssh_key_not_found",
Self::AgreementNotFound { .. } => "agreement_not_found",
Self::AgreementNotAccepted { .. } => "agreement_not_accepted",
Self::DeletionAlreadyRequested => "deletion_already_requested",
Self::DeletionNotRequested => "deletion_not_requested",
Self::RateLimited { .. } => "rate_limited",
Self::UserNotFound => "user_not_found",
Self::AppNotFound => "app_not_found",
Self::SessionNotFound => "session_not_found",
Self::TokenNotFound => "token_not_found",
Self::TokenExpired => "token_expired",
Self::TokenRevoked => "token_revoked",
Self::SecurityVersionStale { .. } => "security_version_stale",
Self::ScopeInsufficient { .. } => "scope_insufficient",
Self::Unavailable(_) => "unavailable",
Self::Internal(_) => "internal",
Self::Database(_) => "database",
Self::Cache(_) => "cache",
Self::Session(_) => "session",
Self::Email(_) => "email",
Self::Serialization(_) => "serialization",
}
}
pub fn is_internal(&self) -> bool {
self.status_code().is_server_error()
}
pub fn message(&self) -> String {
if self.is_internal() {
"internal server error".to_string()
} else {
self.to_string()
}
}
pub fn body(&self) -> ErrorBody {
ErrorBody {
code: self.code().to_string(),
message: self.message(),
}
}
}
impl ResponseError for AuthError {
fn status_code(&self) -> StatusCode {
AuthError::status_code(self)
}
fn error_response(&self) -> HttpResponse {
let body = self.body();
let mut response = crate::envelope::Envelope::<crate::envelope::NoData>::err(
self.status_code().as_u16() as i32,
body.message,
)
.with_error_code(body.code)
.into_http_response();
if let Self::RateLimited { retry_after_secs } = self
&& let Ok(value) = HeaderValue::from_str(&retry_after_secs.to_string())
{
response.headers_mut().insert(RETRY_AFTER, value);
}
response
}
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct ErrorBody {
pub code: String,
pub message: String,
}
use std::fmt;
#[derive(Debug)]
pub enum AuthBusinessError {
BadRequest(String),
InvalidCredentials,
Unauthorized,
Forbidden,
NotFound,
Conflict(String),
RateLimited,
Internal(String),
TwoFactorExpired,
TwoFactorCodeInvalid,
RecoveryCodeInvalid,
VerificationCodeExpired,
VerificationCodeInvalid,
ResetTokenInvalid,
}
impl fmt::Display for AuthBusinessError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadRequest(message) => write!(f, "{message}"),
Self::InvalidCredentials => write!(f, "invalid credentials"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::Forbidden => write!(f, "forbidden"),
Self::NotFound => write!(f, "not found"),
Self::Conflict(message) => write!(f, "{message}"),
Self::RateLimited => write!(f, "rate limit exceeded"),
Self::Internal(message) => write!(f, "{message}"),
Self::TwoFactorExpired => write!(f, "two-factor challenge expired"),
Self::TwoFactorCodeInvalid => write!(f, "invalid two-factor code"),
Self::RecoveryCodeInvalid => write!(f, "invalid or used recovery code"),
Self::VerificationCodeExpired => write!(f, "verification code expired"),
Self::VerificationCodeInvalid => write!(f, "invalid verification code"),
Self::ResetTokenInvalid => write!(f, "invalid or expired reset token"),
}
}
}
impl std::error::Error for AuthBusinessError {}
impl From<deletion::DeletionError> for AuthBusinessError {
fn from(error: deletion::DeletionError) -> Self {
use deletion::DeletionError;
match error {
DeletionError::Database(error) => Self::from(error),
DeletionError::Conflict(message) => Self::Conflict(message),
DeletionError::NotFound(message) => {
tracing::debug!(%message, "deletion target not found");
Self::NotFound
}
DeletionError::KenAuth(message)
| DeletionError::KenShell(message)
| DeletionError::Config(message) => Self::Internal(message),
}
}
}
export::db_error_conversions!(AuthBusinessError);
export::redis_error_conversion!(AuthBusinessError);
export::export_error_conversion!(AuthBusinessError);
export::json_payload_conversion!(AuthBusinessError);
export::api_error! {
log: "auth",
pub enum ApiError {
BadRequest(400, "request failed validation"),
InvalidCredentials(400, "credentials rejected"),
Unauthorized(401, "authentication required"),
Forbidden(403, "operation not allowed"),
NotFound(404, "resource not found"),
Conflict(409, "resource conflict"),
RateLimited(429, "rate limit exceeded"),
Internal(500, "internal server error"),
}
}
impl From<AuthBusinessError> for ApiError {
fn from(error: AuthBusinessError) -> Self {
match error {
AuthBusinessError::BadRequest(msg) => Self::bad_request(msg),
AuthBusinessError::InvalidCredentials => {
Self::build(400, Self::payload(400, "invalid credentials"))
}
AuthBusinessError::Unauthorized => Self::unauthorized(),
AuthBusinessError::Forbidden => Self::forbidden(),
AuthBusinessError::NotFound => Self::not_found("auth resource not found"),
AuthBusinessError::Conflict(msg) => Self::conflict(msg),
AuthBusinessError::RateLimited => Self::rate_limited_exceeded(),
AuthBusinessError::TwoFactorExpired => Self::build(
400,
Self::payload(400, "two-factor challenge expired")
.with_error_code("two_factor_expired"),
),
AuthBusinessError::TwoFactorCodeInvalid => Self::build(
400,
Self::payload(400, "invalid two-factor code")
.with_error_code("two_factor_code_invalid"),
),
AuthBusinessError::RecoveryCodeInvalid => Self::build(
400,
Self::payload(400, "invalid or used recovery code")
.with_error_code("recovery_code_invalid"),
),
AuthBusinessError::VerificationCodeExpired => Self::build(
400,
Self::payload(400, "verification code expired")
.with_error_code("verification_code_expired"),
),
AuthBusinessError::VerificationCodeInvalid => Self::build(
400,
Self::payload(400, "invalid verification code")
.with_error_code("verification_code_invalid"),
),
AuthBusinessError::ResetTokenInvalid => Self::build(
400,
Self::payload(400, "invalid or expired reset token")
.with_error_code("reset_token_invalid"),
),
AuthBusinessError::Internal(message) => Self::internal(message),
}
}
}
impl From<space::errors::SpaceError> for AuthError {
fn from(error: space::errors::SpaceError) -> Self {
use space::errors::SpaceError as E;
match error {
E::BadRequest(message) => Self::BadRequest(message),
E::Conflict(message) => Self::Conflict(message),
E::Unauthorized => Self::Unauthenticated,
E::Forbidden
| E::JoinNotAllowed(_)
| E::JoinRequirementNotMet(_)
| E::JoinRequestCooldown(_)
| E::SpaceInactive
| E::SpaceMemberBanned => Self::Forbidden,
E::MalformedRequest => Self::MalformedRequest,
E::InvalidEmail => Self::InvalidEmail,
E::InvalidPermission { permission } => Self::InvalidPermission { permission },
E::NotFound | E::SpaceNotFound => Self::OrganizationNotFound,
E::SpaceMemberNotFound => Self::OrganizationMemberNotFound,
E::SpaceRoleNotFound => Self::OrganizationRoleNotFound,
E::SpaceInviteNotFound => Self::OrganizationInviteNotFound,
E::SpaceSlugTaken => Self::OrganizationSlugTaken,
E::SpaceMemberExists => Self::OrganizationMemberExists,
E::SpaceRoleKeyTaken => Self::OrganizationRoleKeyTaken,
E::SpaceRoleInUse => Self::OrganizationRoleInUse,
E::SpaceRoleSystem => Self::OrganizationRoleSystem,
E::SpaceInviteExhausted => Self::OrganizationInviteExhausted,
E::SpaceInviteExpired => Self::OrganizationInviteExpired,
E::SpaceOwnerRequired => Self::OrganizationOwnerRequired,
E::SpaceHasMembers => Self::OrganizationHasMembers,
E::VersionMismatch { current } => Self::VersionMismatch { current },
E::UserNotFound => Self::UserNotFound,
E::ScopeInsufficient { required } => Self::ScopeInsufficient { required },
E::RateLimited(_) => Self::RateLimited {
retry_after_secs: 0,
},
E::Internal(message) => Self::Internal(message),
}
}
}