Skip to content
Clone
13aafa3
424 lines 13.3 KB Rust
use crate::models::users::users;
use crate::protos::auth::common::v1::{
AuthMethod, MfaStatus, MfaType, Principal, SshKeyAlgorithm, TokenScope, UserPublic, UserRef,
UserStatus, user_ref,
};
use chrono::{DateTime, Utc};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid;
use crate::api::context::RequestMeta;
use crate::api::convert;
use crate::errors::{AuthError, AuthResult};
use crate::share::user as user_store;
pub const ALLOWED_APP_SCOPES: [&str; 12] = [
"user_name",
"ssh_key",
"access_key",
"assets",
"email",
"profile",
"theme",
"guild",
"event",
"avatar",
"repo_read",
"repo_write",
];
pub const MAX_BATCH_USER_IDS: usize = 500;
pub async fn find_user(
db: &crate::db::DbConn,
reference: &UserRef,
) -> AuthResult<Option<users::Model>> {
let Some(key) = reference.key.as_ref() else {
return Err(AuthError::MalformedRequest);
};
let query = match key {
user_ref::Key::UserId(user_id) => users::Entity::find_by_id(*user_id),
user_ref::Key::Username(username) => users::Entity::find()
.filter(users::Column::Lowername.eq(username.trim().to_lowercase().as_str())),
user_ref::Key::Email(email) => users::Entity::find()
.filter(users::Column::Email.eq(email.trim().to_lowercase().as_str())),
};
Ok(query.one(db.read()).await?)
}
pub async fn load_user(db: &crate::db::DbConn, user_id: i64) -> AuthResult<users::Model> {
user_store::load_user(db, user_id).await
}
pub async fn load_active_user(db: &crate::db::DbConn, user_id: i64) -> AuthResult<users::Model> {
user_store::load_active_user(db, user_id).await
}
pub fn is_active(user: &users::Model) -> bool {
user_store::is_active(user)
}
pub fn ensure_active(user: &users::Model) -> AuthResult<()> {
user_store::ensure_active(user)
}
pub fn ensure_security_version(user: &users::Model, expected: i32) -> AuthResult<()> {
user_store::ensure_security_version(user, expected)
}
pub async fn active_mfa_count(db: &crate::db::DbConn, user_id: i64) -> AuthResult<u32> {
user_store::active_mfa_count(db, user_id).await
}
pub async fn has_enabled_mfa(db: &crate::db::DbConn, user_id: i64) -> AuthResult<bool> {
user_store::has_enabled_mfa(db, user_id).await
}
pub fn principal(user: &users::Model, method: AuthMethod) -> Principal {
Principal {
user_id: user.id,
username: user.username.clone(),
displayname: user
.displayname
.clone()
.unwrap_or_else(|| user.username.clone()),
is_admin: user.is_admin,
bot: user.bot,
system: user.system,
security_version: user.security_version,
method: method as i32,
session_id: String::new(),
access_key_id: String::new(),
scopes: Vec::new(),
scopes_bounded: false,
mfa_verified: false,
authenticated_at: Some(convert::now_timestamp()),
service_id: String::new(),
}
}
pub fn scope_of_name(name: &str) -> Option<TokenScope> {
let trimmed = name.trim();
ALLOWED_APP_SCOPES
.iter()
.position(|scope| *scope == trimmed)
.and_then(scope_at)
}
fn scope_at(index: usize) -> Option<TokenScope> {
Some(match index {
0 => TokenScope::UserName,
1 => TokenScope::SshKey,
2 => TokenScope::AccessKey,
3 => TokenScope::Assets,
4 => TokenScope::Email,
5 => TokenScope::Profile,
6 => TokenScope::Theme,
7 => TokenScope::Guild,
8 => TokenScope::Event,
9 => TokenScope::Avatar,
10 => TokenScope::RepoRead,
11 => TokenScope::RepoWrite,
_ => return None,
})
}
pub fn scope_name(scope: TokenScope) -> Option<&'static str> {
let index = (scope as usize).checked_sub(1)?;
ALLOWED_APP_SCOPES.get(index).copied()
}
pub fn scopes_from_names(names: &[String]) -> Vec<TokenScope> {
names
.iter()
.filter_map(|name| scope_of_name(name))
.collect()
}
pub fn scope_codes(scopes: &[TokenScope]) -> Vec<i32> {
scopes.iter().map(|scope| *scope as i32).collect()
}
/// 未知 scope 让要求的权限集合变大:丢弃未知项会把 `required` 收缩成更松的集合,
/// 空集合更会让 [`scopes_cover`] 恒真,所以保留原值(零值)并让覆盖判定失败。
pub fn required_scope_codes(names: &[String]) -> Vec<i32> {
// 只保留真正的令牌 scope:`openid` 这类 OIDC 标准字段在 auth service 里没有对应的
// TokenScope,若映射成 0 会让 `scope::valid` 判定整组失效(0 非法),
// 结果是持有 "openid profile email event" 的令牌反而被 SCOPE_INSUFFICIENT 拒掉。
names
.iter()
.filter_map(|name| scope_of_name(name).map(|scope| scope as i32))
.collect()
}
/// 每个要求项都必须被授予,且任何一项为零值(未指定/未知)都视为不满足。
pub fn scopes_cover(granted: &[i32], required: &[i32]) -> bool {
scope_valid(granted)
&& required.iter().all(|required| {
*required > 0 && scope_valid(&[*required]) && granted.contains(required)
})
}
fn scope_valid(scopes: &[i32]) -> bool {
scopes.iter().all(|scope| *scope > 0)
}
pub fn user_public(user: &users::Model, email: String, avatar_url: Option<String>) -> UserPublic {
UserPublic {
user_id: user.id,
username: user.username.clone(),
displayname: user
.displayname
.clone()
.unwrap_or_else(|| user.username.clone()),
email,
email_verified: user.email_verified_at.is_some(),
avatar_url: avatar_url.unwrap_or_default(),
is_admin: user.is_admin,
bot: user.bot,
system: user.system,
}
}
pub fn email_visible(_meta: &RequestMeta, _user_id: i64) -> bool {
// RPC 面由共享密钥信任,邮箱由调用方自行脱敏。
true
}
pub fn user_status_of(user: &users::Model, active_mfa_factors: u32) -> UserStatus {
UserStatus {
exists: true,
allow_use: user.allow_use,
forbid_login: user.forbid_login,
forbid_login_reason: user.forbid_login_reason.clone().unwrap_or_default(),
forbid_login_until: convert::datetime_opt(user.endtime_forbid_login_at),
deleted_at: convert::datetime_opt(user.deleted_at),
deletion_requested_at: convert::datetime_opt(user.deletion_requested_at),
email_verified_at: convert::datetime_opt(user.email_verified_at),
security_version: user.security_version,
active_mfa_factors,
}
}
pub fn empty_status() -> UserStatus {
UserStatus::default()
}
#[allow(clippy::too_many_arguments)]
pub async fn audit(
db: &crate::db::DbConn,
user_id: i64,
actor_user_id: Option<i64>,
event_type: crate::models::users::user_events::UserEventType,
details: Option<serde_json::Value>,
request_id: Option<&str>,
client_ip: Option<String>,
user_agent: Option<String>,
) -> AuthResult<()> {
crate::share::audit::entry(
db,
user_id,
actor_user_id,
event_type,
details,
request_id,
client_ip,
user_agent,
)
.await
}
pub fn page_token(offset: u64) -> String {
offset.to_string()
}
pub fn parse_page_token(token: &str) -> Option<u64> {
let trimmed = token.trim();
if trimmed.is_empty() {
return Some(0);
}
trimmed.parse::<u64>().ok()
}
pub fn mfa_type_code(value: MfaType) -> i32 {
value as i32
}
pub fn mfa_status_code(value: MfaStatus) -> i32 {
value as i32
}
pub fn ssh_algorithm_code(value: crate::models::users::user_ssh_keys::PublicKeyType) -> i32 {
let algorithm = match value {
crate::models::users::user_ssh_keys::PublicKeyType::RSA => SshKeyAlgorithm::Rsa,
crate::models::users::user_ssh_keys::PublicKeyType::ED25519 => SshKeyAlgorithm::Ed25519,
crate::models::users::user_ssh_keys::PublicKeyType::ECDSA => SshKeyAlgorithm::Ecdsa,
};
algorithm as i32
}
pub fn sha256_digest(value: &str) -> String {
crate::share::crypto::sha256_hex(value.as_bytes())
}
pub fn new_id() -> Uuid {
Uuid::now_v7()
}
pub async fn load_session(
cache: &crate::cache::Cache,
session_key: &str,
) -> AuthResult<Option<crate::session::SessionState>> {
use crate::session::SessionStore;
let key = crate::session::SessionKey::try_from(session_key.trim())
.map_err(|_| AuthError::Unauthenticated)?;
let store = crate::session::RedisStore::new(cache.clone());
Ok(store.load(&key).await?.map(|(state, _)| state))
}
pub fn now() -> DateTime<Utc> {
Utc::now()
}
pub fn session_index_key(cache: &crate::cache::Cache, user_id: i64) -> String {
cache.key(&["user:session", &user_id.to_string()])
}
pub async fn index_session_key(
cache: &crate::cache::Cache,
user_id: i64,
session_key: &str,
ttl: std::time::Duration,
) -> AuthResult<()> {
let index_key = session_index_key(cache, user_id);
cache.sadd(&index_key, session_key).await?;
cache.expire(&index_key, ttl).await?;
Ok(())
}
pub async fn session_index(cache: &crate::cache::Cache, user_id: i64) -> AuthResult<Vec<String>> {
Ok(cache.smembers(&session_index_key(cache, user_id)).await?)
}
pub async fn drop_session_state(
cache: &crate::cache::Cache,
user_id: i64,
session_key: &str,
) -> AuthResult<()> {
use crate::session::{SessionKey, SessionStore};
let key =
SessionKey::try_from(session_key.to_owned()).map_err(|_| AuthError::SessionNotFound)?;
crate::session::RedisStore::new(cache.clone())
.delete(&key, Some(user_id))
.await?;
Ok(())
}
pub async fn refresh_session_version(
cache: &crate::cache::Cache,
user_id: i64,
session_id: Uuid,
version: i32,
ttl_secs: u64,
) -> AuthResult<()> {
use crate::session::{RedisStore, Session, SessionKey, SessionStore};
let ttl = actix_web::cookie::time::Duration::seconds(ttl_secs.max(1) as i64);
let expected = session_id.to_string();
for key in session_index(cache, user_id).await? {
let Some(state) = load_session(cache, &key).await? else {
continue;
};
let diff = {
let session = Session::from_state(state);
if session.session_id().as_deref() != Some(expected.as_str()) {
continue;
}
session.set_security_version(version)?;
session.diff()
};
let key = SessionKey::try_from(key).map_err(|_| AuthError::SessionNotFound)?;
RedisStore::new(cache.clone())
.update(key, diff, &ttl, Some(user_id), Some(user_id))
.await?;
}
Ok(())
}
pub async fn drop_session_by_id(
cache: &crate::cache::Cache,
user_id: i64,
session_id: Uuid,
) -> AuthResult<()> {
use crate::session::Session;
let expected = session_id.to_string();
for key in session_index(cache, user_id).await? {
let Some(state) = load_session(cache, &key).await? else {
continue;
};
let matches = Session::from_state(state).session_id().as_deref() == Some(expected.as_str());
if !matches {
continue;
}
if let Err(error) = drop_session_state(cache, user_id, &key).await {
tracing::warn!(user_id, %error, "session state drop failed");
continue;
}
if let Err(error) = cache.srem(&session_index_key(cache, user_id), &key).await {
tracing::warn!(user_id, %error, "session index cleanup failed");
}
}
Ok(())
}
pub async fn drop_user_session_states(
cache: &crate::cache::Cache,
user_id: i64,
keep: Option<Uuid>,
) -> AuthResult<u32> {
use crate::session::Session;
use futures_util::StreamExt;
let index_key = session_index_key(cache, user_id);
let dropped = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
futures_util::stream::iter(session_index(cache, user_id).await?)
.map(|key| {
let cache = cache.clone();
let dropped = dropped.clone();
let index_key = index_key.clone();
async move {
let bound = match load_session(&cache, &key).await {
Ok(Some(state)) => Session::from_state(state)
.get::<String>("auth_session_id")
.ok()
.flatten()
.and_then(|value| Uuid::parse_str(&value).ok())
.is_some_and(|id| Some(id) != keep),
Ok(None) => true,
Err(error) => {
tracing::warn!(user_id, %error, "session state load failed during drop");
false
}
};
if !bound {
return;
}
if let Err(error) = drop_session_state(&cache, user_id, &key).await {
tracing::warn!(user_id, %error, "session state drop failed");
return;
}
if let Err(error) = cache.srem(&index_key, &key).await {
tracing::warn!(user_id, %error, "session index cleanup failed");
return;
}
dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
})
.buffer_unordered(8)
.for_each(|_| async {})
.await;
Ok(dropped.load(std::sync::atomic::Ordering::Relaxed))
}