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 = std::result::Result; #[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("username or password is incorrect")] InvalidCredentials, #[error("login is forbidden{}", reason.as_deref().map(|reason| format!(": {reason}")).unwrap_or_default())] LoginForbidden { reason: Option }, #[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 for AuthError { fn from(error: sea_orm::DbErr) -> Self { Self::Database(crate::db::DbError::from(error)) } } impl AuthError { pub fn internal(message: impl Into) -> 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::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::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::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::::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, }