User Account System
Core concepts
- User account record –
UserAccountis the canonical row inauth.user_account. It tracks the account UUID, display name, email, ban flag, and timestamps. The same identity can be accessed through multiple login surfaces:
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct UserAccount {
pub id: Uuid,
pub name: Option<String>,
pub email: String,
pub created_at: time::PrimitiveDateTime,
pub updated_at: time::PrimitiveDateTime,
pub is_banned: bool,
}
- User attributes – The
user_attributeanduser_attribute_mappingtables replace the olduser_group/user_extra_groupssystem. Attributes are flexible tags assigned to users that drive visibility rules for announcements and productions:
pub struct UserAttribute {
pub id: i32,
pub name: String,
pub comment: String,
}
pub struct UserAttributeMapping {
pub id: i64,
pub user_id: Uuid,
pub attribute_id: i32,
}
- Profile vs. login surface –
UserAccountkeeps mutable presentation data (name, email) separate from credentials; the email stored here is not automatically a login method.
Dedicated tables hold actual login credentials: password-backed EmailAccount rows link an address and password hash to the user:
#[derive(Clone, PartialEq, Eq, sqlx::FromRow, Zeroize, ZeroizeOnDrop)]
pub struct EmailAccount {
pub id: i64,
pub password_hash: CompactString,
pub user_id: Uuid,
}
Each OAuth connection lives in OAuthAccount with provider metadata and timestamps:
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct OAuthAccount {
pub id: i64,
pub user_id: Uuid,
pub provider_name: OAuthProviderName,
pub provider_user_id: String,
pub registered_at: PrimitiveDateTime,
pub token_updated_at: PrimitiveDateTime,
}
- Events and audits – account binding/unbinding and security changes emit AMQP events (see
AccountBindEvent,AccountUnbindEvent,PasswordResetEvent, etc.) so downstream systems can react or log activity. When you add new surfaces remember to publish the appropriate events.
Account data layout
When a user registers through email, RegisterEmailAccount creates the auth.user_account row and stores the hashed password in auth.email_account:
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct RegisterEmailAccount {
pub email: String,
pub password_hash: CompactString,
}
pub enum RegisterEmailAccountResult {
Success {
user_id: Uuid,
email_account_id: i64,
},
EmailAlreadyExists,
}
OAuth registrations follow the same pattern via RegisterOAuthAccount, inserting the account with provider metadata before creating the first OAuth credential row:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisterOAuthAccount {
pub provider_name: OAuthProviderName,
pub provider_user_id: String,
pub email: Option<String>,
pub name: Option<String>,
}
The account service layers aggregate these shards on demand. UserManageService::process(ShowUserDetail) joins account info, email login (if any), OAuth logins, and whether TOTP exists so dashboards can render a full state snapshot.
Whenever you extend the schema, double-check that:
CountUserLoginMethodscontinues to report the real number of usable login paths (it currently sums email + OAuth rows)- Removal flows (user-facing and admin) still guard against deleting the last login method.
- Admin-facing DTOs (
UserDetailResponse,UserSummary) expose whatever additional surface you add for operations tooling.
Login flows and user self-service
Email/password login
EmailProviderService::process(EmailLogin) performs credential lookup, constant-time password verification (dummy hash fallback), and MFA evaluation before minting access/refresh JWTs through SessionService::CreateSession and emitting a UserLoginEvent for analytics:
#[derive(Clone, PartialEq, Eq)]
pub struct EmailLogin {
pub email: String,
pub password: String,
pub mfa: Option<MfaMethod>,
pub ip: Option<IpAddr>,
pub user_agent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmailLoginResult {
Success(AccessToken, RefreshToken),
WrongCredential,
RequireMfa,
MfaFailed,
NotFound,
}
The session creation process stores refresh tokens in Redis and generates JWT access tokens:
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSession {
pub user_id: Uuid,
pub login_method: LoginMethod,
pub ip: Option<std::net::IpAddr>,
pub user_agent: Option<String>,
}
Expect a RequireMfa or MfaFailed result when MFA is toggled on and the client omits or fails verification.
OAuth login
OAuthProviderService::process(OAuthLogin) validates the state challenge stored in Redis, exchanges the provider code for tokens, fetches user info, and either looks up or registers an OAuthAccount. New registrations populate account defaults and raise UserRegisterEvent. Successful logins create a session tagged with the provider for downstream attribution:
#[derive(Debug, Clone)]
pub struct OAuthLogin {
pub provider_name: OAuthProviderName,
pub code: String,
pub state: Uuid,
pub ip: Option<std::net::IpAddr>,
pub user_agent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OAuthLoginResult {
LoggedIn(AccessToken, RefreshToken),
InvalidState,
ProviderMismatch,
ProviderError(String),
UserRegistered(AccessToken, RefreshToken),
}
Managing login methods
Users can bind extra surfaces only after entering sudo mode. Email-based sudo tokens are issued via MfaService and verified by both EmailProviderService and OAuthProviderService before binding, changing passwords, or unlinking methods.
For removal, EmailProviderService::RemoveEmailAccount and OAuthProviderService::RemoveOAuthAccount ensure at least one login method remains, delete the credential row, and fire an AccountUnbindEvent so audit logs stay complete.
Those flows back the gRPC UserAccountService endpoints that power user settings; when adding a new method wire it through the same guardrails.
Security hardening
- MFA & sudo mode –
MfaServicesupports TOTP and email OTP verification, toggles MFA on the account, and issues short-lived sudo tokens cached in Redis. Any destructive credential change validates a sudo token first:
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MfaMethod {
Totp { code: u32 },
Email { email: String, code: String },
}
-
Password lifecycle – Password resets validate email links, hash the new password with the configured algorithm, terminate every active session, and broadcast a
PasswordResetEvent. See the Password Reset Flow guide for detailed information about the reset process and APIs. Explicit password changes follow the same hashing path and sudo check. -
Session management – The session service stores refresh tokens in Redis keyed by
SessionId, issues JWTs from config, writes login events, and can terminate an entire user’s session set on demand (used after password reset or by security tooling). -
Constant-time credential checks – Email login performs dummy verifications when the address is missing to minimize timing leaks, and password hashes are never logged thanks to explicit redaction in debug output (as shown in the
EmailAccountdebug implementation above).
Administrative operations
UserManageService exposes RBAC-protected processors for customer support and moderation:
- Counting users in configurable time windows, optionally excluding banned accounts
- Listing users with filters on email, attributes, ban status, and registration time, plus aggregate OAuth provider names for quick scanning
- Showing detailed account state (account info, login methods, MFA) for a specific user (as shown in the
ShowUserDetailimplementation above) - Removing login methods while keeping at least one usable path, editing account basics, banning/unbanning, and forcibly removing TOTP when users are locked out
All administrative operations follow the RBAC pattern established in the Manage module, where each operation implements AdminOperation with role restrictions and audit logging:
impl AdminOperation for RemoveLoginMethodRequest {
const ALLOWED_ROLES: &'static [AdminRole] = &[AdminRole::SuperAdmin, AdminRole::Moderator];
const OPERATION_NAME: &'static str = "remove_login_method";
const OPERATION_TARGET: &'static str = "user_account";
fn to_audit_log(&self) -> Result<String, serde_json::Error> {
// ...
}
}
Whenever you add a new credential type or security lever, update these processors plus the protobufs exposed by UserAccountService so both admins and end users can inspect and manage the new surface.