Skip to content
Clone

feat(auth): add access key and SSH key services with registration and lookup functionality

ZhenYi committed
parent 7ab1606 commit 6ca5b70 Browse files

11 files changed

+581 −0

File tree 11
Added lib/infra/proto/proto/internal/v1/space_directory.proto View lib/infra/proto/proto/internal/v1/space_directory.proto
@@ -0,0 +1,41 @@
1syntax = "proto3";
2
3package internal.v1;
4
5// 平台面(serve 进程托管):空间(组织)目录的只读解析。
6// 供 git 等外部托管面把 URL 里的 slug 解析成空间 id 与归属判据。
7// 入口由 `x-rpc-key` 把守,不注册到公共网关。
8service SpaceDirectoryService {
9 // 按 slug 或 id 解析单个空间;两者都为空时返回 INVALID_ARGUMENT。
10 rpc LookupSpace(LookupSpaceRequest) returns (LookupSpaceResponse);
11}
12
13message LookupSpaceRequest {
14 // 二选一;同时给出时以 space_id 为准。
15 string slug = 1;
16 string space_id = 2;
17 string request_id = 3;
18}
19
20message LookupSpaceResponse {
21 SpaceInfo space = 1;
22}
23
24// 归属判据所需的最小字段集;不包含成员、角色与邀请视图。
25message SpaceInfo {
26 string id = 1;
27 string slug = 2;
28 string name = 3;
29 // 唯一 OWNER;调用方用它做所有者短路判断。
30 int64 owner_user_id = 4;
31 SpaceStatus status = 5;
32 // 成员/角色/权限变更的版本键,与 spaces.version 一致。
33 int32 version = 6;
34}
35
36enum SpaceStatus {
37 SPACE_STATUS_UNSPECIFIED = 0;
38 SPACE_STATUS_ACTIVE = 1;
39 SPACE_STATUS_SUSPENDED = 2;
40 SPACE_STATUS_ARCHIVED = 3;
41}
Added lib/service/auth/src/rpc/access_key.rs View lib/service/auth/src/rpc/access_key.rs
@@ -0,0 +1,202 @@
1//! auth.v1 的 access key 校验。
2//!
3//! 明文令牌只出现在请求里,本层立即摘要后丢弃;库内只存 sha256 hex。
4//! 调用方:git(HTTP 传输的凭据校验)。
5
6use chrono::{DateTime, Utc};
7use internal::Gate;
8use internal::server::InternalRouter;
9use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
10use tonic::{Request, Response, Status};
11
12use crate::models::users::{user_access_tokens, users};
13use crate::protos::auth::common::v1::{AccessKeyInfo, AuthMethod, Code, Principal};
14use crate::protos::auth::v1::access_key_service_server::{
15 AccessKeyService as AccessKeyTrait, AccessKeyServiceServer,
16};
17use crate::protos::auth::v1::{
18 CreateAccessKeyRequest, CreateAccessKeyResponse, ListAccessKeysRequest, ListAccessKeysResponse,
19 RevokeAccessKeyRequest, RevokeAccessKeyResponse, RotateAccessKeyRequest, RotateAccessKeyResponse,
20 VerifyAccessKeyRequest, VerifyAccessKeyResponse,
21};
22use crate::services::AuthService;
23use crate::share::crypto::sha256_hex;
24
25pub fn register(router: InternalRouter, service: &AuthService, gate: Gate) -> InternalRouter {
26 router.add_service(AccessKeyServiceServer::with_interceptor(
27 AccessKeyRpc {
28 service: service.clone(),
29 },
30 gate,
31 ))
32}
33
34#[derive(Clone)]
35struct AccessKeyRpc {
36 service: AuthService,
37}
38
39#[tonic::async_trait]
40impl AccessKeyTrait for AccessKeyRpc {
41 async fn verify_access_key(
42 &self,
43 request: Request<VerifyAccessKeyRequest>,
44 ) -> Result<Response<VerifyAccessKeyResponse>, Status> {
45 let _meta = internal::interceptor::meta(&request)?;
46 let request = request.into_inner();
47 let plaintext = request.access_key.trim();
48 if plaintext.is_empty() {
49 return Err(Status::invalid_argument("access key is required"));
50 }
51
52 let db = self.service.db().read();
53 let token = user_access_tokens::Entity::find()
54 .filter(user_access_tokens::Column::TokenHash.eq(sha256_hex(plaintext.as_bytes())))
55 .one(db)
56 .await
57 .map_err(|error| {
58 tracing::error!(%error, "access key lookup failed");
59 Status::internal("access key lookup failed")
60 })?;
61
62 let Some(token) = token else {
63 return Ok(Response::new(rejected(Code::Unauthenticated)));
64 };
65 if token.revoked_at.is_some() {
66 return Ok(Response::new(rejected(Code::TokenRevoked)));
67 }
68 if token.expires_at.is_some_and(|expiry| expiry <= Utc::now()) {
69 return Ok(Response::new(rejected(Code::TokenExpired)));
70 }
71
72 let user = users::Entity::find_by_id(token.user_id)
73 .one(db)
74 .await
75 .map_err(|error| {
76 tracing::error!(%error, "access key owner lookup failed");
77 Status::internal("access key owner lookup failed")
78 })?;
79 let Some(user) = user else {
80 return Ok(Response::new(rejected(Code::UserDisabled)));
81 };
82 if user.deleted_at.is_some() || !user.allow_use || user.forbid_login {
83 return Ok(Response::new(rejected(Code::UserDisabled)));
84 }
85 // token.version 记录签发时的 users.security_version。
86 if token.version < user.security_version {
87 return Ok(Response::new(rejected(Code::SecurityVersionStale)));
88 }
89
90 let scopes = scopes_of(&token.scopes);
91 if !scopes_cover(&scopes, &request.required_scopes) {
92 return Ok(Response::new(rejected(Code::ScopeInsufficient)));
93 }
94
95 let key = AccessKeyInfo {
96 access_key_id: token.id.to_string(),
97 name: token.name.clone(),
98 description: token.description.clone().unwrap_or_default(),
99 fingerprint: token.fingerprint.clone(),
100 scopes: scopes.clone(),
101 hash_key_id: token.hash_key_id.clone(),
102 created_at: Some(timestamp(token.created_at)),
103 expires_at: token.expires_at.map(timestamp),
104 last_used_at: token.last_used_at.map(timestamp),
105 revoked_at: token.revoked_at.map(timestamp),
106 };
107
108 Ok(Response::new(VerifyAccessKeyResponse {
109 valid: true,
110 reason: Code::Unspecified as i32,
111 principal: Some(Principal {
112 user_id: user.id,
113 username: user.username.clone(),
114 displayname: user.displayname.clone().unwrap_or_default(),
115 is_admin: user.is_admin,
116 security_version: user.security_version,
117 method: AuthMethod::AccessKey as i32,
118 access_key_id: token.id.to_string(),
119 scopes: scopes.clone(),
120 // 空 scope 等价于不限范围,此时不是有界令牌。
121 scopes_bounded: !scopes.is_empty(),
122 mfa_verified: false,
123 authenticated_at: Some(timestamp(Utc::now())),
124 bot: user.bot,
125 system: user.system,
126 ..Default::default()
127 }),
128 key: Some(key),
129 current_security_version: user.security_version,
130 }))
131 }
132
133 async fn create_access_key(
134 &self,
135 _request: Request<CreateAccessKeyRequest>,
136 ) -> Result<Response<CreateAccessKeyResponse>, Status> {
137 Err(Status::unimplemented(
138 "access key issuance is not exposed by this service",
139 ))
140 }
141
142 async fn rotate_access_key(
143 &self,
144 _request: Request<RotateAccessKeyRequest>,
145 ) -> Result<Response<RotateAccessKeyResponse>, Status> {
146 Err(Status::unimplemented(
147 "access key rotation is not exposed by this service",
148 ))
149 }
150
151 async fn list_access_keys(
152 &self,
153 _request: Request<ListAccessKeysRequest>,
154 ) -> Result<Response<ListAccessKeysResponse>, Status> {
155 Err(Status::unimplemented(
156 "access key listing is not exposed by this service",
157 ))
158 }
159
160 async fn revoke_access_key(
161 &self,
162 _request: Request<RevokeAccessKeyRequest>,
163 ) -> Result<Response<RevokeAccessKeyResponse>, Status> {
164 Err(Status::unimplemented(
165 "access key revocation is not exposed by this service",
166 ))
167 }
168}
169
170/// 失败响应只给原因码,不回显令牌或用户是否存在。
171fn rejected(reason: Code) -> VerifyAccessKeyResponse {
172 VerifyAccessKeyResponse {
173 valid: false,
174 reason: reason as i32,
175 ..Default::default()
176 }
177}
178
179fn scopes_of(value: &serde_json::Value) -> Vec<i32> {
180 value
181 .as_array()
182 .map(|items| {
183 items
184 .iter()
185 .filter_map(|item| item.as_i64())
186 .map(|value| value as i32)
187 .collect()
188 })
189 .unwrap_or_default()
190}
191
192/// 调用方未声明范围时放行;令牌本身不限范围时也放行。
193fn scopes_cover(granted: &[i32], required: &[i32]) -> bool {
194 required.is_empty() || granted.is_empty() || required.iter().all(|scope| granted.contains(scope))
195}
196
197fn timestamp(value: DateTime<Utc>) -> prost_types::Timestamp {
198 prost_types::Timestamp {
199 seconds: value.timestamp(),
200 nanos: value.timestamp_subsec_nanos() as i32,
201 }
202}
Modified lib/service/auth/src/rpc/mod.rs View lib/service/auth/src/rpc/mod.rs
@@ -14,7 +14,12 @@
1414use crate::errors::AuthError;
1515use crate::services::AuthService;
1616
17mod access_key;
18mod ssh_key;
19
1720pub fn register(router: InternalRouter, service: &AuthService, gate: Gate) -> InternalRouter {
21 let router = access_key::register(router, service, gate.clone());
22 let router = ssh_key::register(router, service, gate.clone());
1823 router.add_service(IdentityServiceServer::with_interceptor(
1924 IdentityRpc {
2025 service: service.clone(),
Added lib/service/auth/src/rpc/ssh_key.rs View lib/service/auth/src/rpc/ssh_key.rs
@@ -0,0 +1,168 @@
1//! auth.v1 的 SSH 公钥解析。
2//!
3//! 调用方:ssh(连接认证,并对长连接的每条 exec 复验一次)。
4
5use chrono::{DateTime, Utc};
6use internal::Gate;
7use internal::server::InternalRouter;
8use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
9use tonic::{Request, Response, Status};
10
11use crate::models::users::{user_ssh_keys, users};
12use crate::models::users::user_ssh_keys::PublicKeyType;
13use crate::protos::auth::common::v1::{AuthMethod, Principal, SshKeyAlgorithm, SshKeyInfo};
14use crate::protos::auth::v1::ssh_key_service_server::{
15 SshKeyService as SshKeyTrait, SshKeyServiceServer,
16};
17use crate::protos::auth::v1::{
18 AddSshKeyRequest, AddSshKeyResponse, ListUserSshKeysRequest, ListUserSshKeysResponse,
19 LookupSshKeyRequest, LookupSshKeyResponse, RevokeSshKeyRequest, RevokeSshKeyResponse,
20};
21use crate::services::AuthService;
22
23pub fn register(router: InternalRouter, service: &AuthService, gate: Gate) -> InternalRouter {
24 router.add_service(SshKeyServiceServer::with_interceptor(
25 SshKeyRpc {
26 service: service.clone(),
27 },
28 gate,
29 ))
30}
31
32#[derive(Clone)]
33struct SshKeyRpc {
34 service: AuthService,
35}
36
37#[tonic::async_trait]
38impl SshKeyTrait for SshKeyRpc {
39 async fn lookup_ssh_key(
40 &self,
41 request: Request<LookupSshKeyRequest>,
42 ) -> Result<Response<LookupSshKeyResponse>, Status> {
43 let _meta = internal::interceptor::meta(&request)?;
44 let request = request.into_inner();
45 let fingerprint = request.fingerprint.trim();
46 let public_key = request.public_key.trim();
47 if fingerprint.is_empty() && public_key.is_empty() {
48 return Err(Status::invalid_argument(
49 "fingerprint or public_key is required",
50 ));
51 }
52
53 let db = self.service.db().read();
54 let mut query = user_ssh_keys::Entity::find();
55 query = if fingerprint.is_empty() {
56 query.filter(user_ssh_keys::Column::PublicKey.eq(public_key))
57 } else {
58 query.filter(user_ssh_keys::Column::Fingerprint.eq(fingerprint))
59 };
60 let key = query.one(db).await.map_err(|error| {
61 tracing::error!(%error, "ssh key lookup failed");
62 Status::internal("ssh key lookup failed")
63 })?;
64
65 let Some(key) = key else {
66 return Ok(Response::new(missed()));
67 };
68 if request.require_active
69 && (key.revoked_at.is_some() || key.expires_at.is_some_and(|expiry| expiry <= Utc::now()))
70 {
71 return Ok(Response::new(missed()));
72 }
73
74 let user = users::Entity::find_by_id(key.user_id)
75 .one(db)
76 .await
77 .map_err(|error| {
78 tracing::error!(%error, "ssh key owner lookup failed");
79 Status::internal("ssh key owner lookup failed")
80 })?;
81 let Some(user) = user else {
82 return Ok(Response::new(missed()));
83 };
84 if user.deleted_at.is_some() || !user.allow_use || user.forbid_login {
85 return Ok(Response::new(missed()));
86 }
87
88 Ok(Response::new(LookupSshKeyResponse {
89 found: true,
90 key: Some(SshKeyInfo {
91 key_id: key.id.to_string(),
92 user_id: key.user_id,
93 name: key.name.clone(),
94 description: key.description.clone().unwrap_or_default(),
95 algorithm: algorithm(key.algorithm) as i32,
96 public_key: key.public_key.clone(),
97 fingerprint: key.fingerprint.clone(),
98 created_at: Some(timestamp(key.created_at)),
99 expires_at: key.expires_at.map(timestamp),
100 last_used_at: key.last_used_at.map(timestamp),
101 revoked_at: key.revoked_at.map(timestamp),
102 }),
103 principal: Some(Principal {
104 user_id: user.id,
105 username: user.username.clone(),
106 displayname: user.displayname.clone().unwrap_or_default(),
107 is_admin: user.is_admin,
108 security_version: user.security_version,
109 method: AuthMethod::SshKey as i32,
110 authenticated_at: Some(timestamp(Utc::now())),
111 bot: user.bot,
112 system: user.system,
113 ..Default::default()
114 }),
115 current_security_version: user.security_version,
116 }))
117 }
118
119 async fn add_ssh_key(
120 &self,
121 _request: Request<AddSshKeyRequest>,
122 ) -> Result<Response<AddSshKeyResponse>, Status> {
123 Err(Status::unimplemented(
124 "ssh key registration is not exposed by this service",
125 ))
126 }
127
128 async fn list_user_ssh_keys(
129 &self,
130 _request: Request<ListUserSshKeysRequest>,
131 ) -> Result<Response<ListUserSshKeysResponse>, Status> {
132 Err(Status::unimplemented(
133 "ssh key listing is not exposed by this service",
134 ))
135 }
136
137 async fn revoke_ssh_key(
138 &self,
139 _request: Request<RevokeSshKeyRequest>,
140 ) -> Result<Response<RevokeSshKeyResponse>, Status> {
141 Err(Status::unimplemented(
142 "ssh key revocation is not exposed by this service",
143 ))
144 }
145}
146
147/// 未命中一律返回空响应,不区分「不存在」与「不可用」,避免成为探测接口。
148fn missed() -> LookupSshKeyResponse {
149 LookupSshKeyResponse {
150 found: false,
151 ..Default::default()
152 }
153}
154
155fn algorithm(value: PublicKeyType) -> SshKeyAlgorithm {
156 match value {
157 PublicKeyType::RSA => SshKeyAlgorithm::Rsa,
158 PublicKeyType::ED25519 => SshKeyAlgorithm::Ed25519,
159 PublicKeyType::ECDSA => SshKeyAlgorithm::Ecdsa,
160 }
161}
162
163fn timestamp(value: DateTime<Utc>) -> prost_types::Timestamp {
164 prost_types::Timestamp {
165 seconds: value.timestamp(),
166 nanos: value.timestamp_subsec_nanos() as i32,
167 }
168}
Modified lib/service/space/src/rpc/mod.rs View lib/service/space/src/rpc/mod.rs
@@ -20,10 +20,13 @@
2020use crate::errors::SpaceError;
2121use crate::services::service::SpaceService;
2222
23mod space_directory;
24
2325pub fn register(router: InternalRouter, service: &SpaceService, gate: Gate) -> InternalRouter {
2426 let rpc = SpaceRpc {
2527 service: service.clone(),
2628 };
29 let router = space_directory::register(router, service, gate.clone());
2730 let router = router.add_service(IssueAuthorizationServiceServer::with_interceptor(
2831 rpc.clone(),
2932 gate.clone(),
Added lib/service/space/src/rpc/space_directory.rs View lib/service/space/src/rpc/space_directory.rs
@@ -0,0 +1,92 @@
1//! 内部 RPC 的 space 目录:把 URL 里的 slug(或 id)解析成归属判据所需的最小视图。
2
3use internal::Gate;
4use internal::server::InternalRouter;
5use models::space::{SpaceStatus, spaces};
6use proto::internal::v1::space_directory_service_server::{
7 SpaceDirectoryService as SpaceDirectoryTrait, SpaceDirectoryServiceServer,
8};
9use proto::internal::v1::{LookupSpaceRequest, LookupSpaceResponse, SpaceInfo, SpaceStatus as PbStatus};
10use sea_orm::EntityTrait;
11use tonic::{Request, Response, Status};
12use uuid::Uuid;
13
14use crate::authority;
15use crate::errors::SpaceError;
16use crate::services::service::SpaceService;
17
18pub fn register(router: InternalRouter, service: &SpaceService, gate: Gate) -> InternalRouter {
19 router.add_service(SpaceDirectoryServiceServer::with_interceptor(
20 SpaceDirectoryRpc {
21 service: service.clone(),
22 },
23 gate,
24 ))
25}
26
27#[derive(Clone)]
28struct SpaceDirectoryRpc {
29 service: SpaceService,
30}
31
32#[tonic::async_trait]
33impl SpaceDirectoryTrait for SpaceDirectoryRpc {
34 async fn lookup_space(
35 &self,
36 request: Request<LookupSpaceRequest>,
37 ) -> Result<Response<LookupSpaceResponse>, Status> {
38 let _meta = internal::interceptor::meta(&request)?;
39 let request = request.into_inner();
40 let space = if !request.space_id.trim().is_empty() {
41 let id = Uuid::parse_str(request.space_id.trim())
42 .map_err(|_| Status::invalid_argument("space_id must be a UUID"))?;
43 spaces::Entity::find_by_id(id)
44 .one(self.service.access.database.read())
45 .await
46 .map_err(bad_gateway)?
47 .filter(|space| space.deleted_at.is_none())
48 .ok_or_else(|| Status::not_found("space not found"))?
49 } else if !request.slug.trim().is_empty() {
50 authority::space_by_slug(&self.service, &request.slug)
51 .await
52 .map_err(status_of)?
53 } else {
54 return Err(Status::invalid_argument("slug or space_id is required"));
55 };
56
57 Ok(Response::new(LookupSpaceResponse {
58 space: Some(SpaceInfo {
59 id: space.id.to_string(),
60 slug: space.slug,
61 name: space.name,
62 owner_user_id: space.owner_user_id,
63 status: status(space.status) as i32,
64 version: space.version,
65 }),
66 }))
67 }
68}
69
70fn status(value: SpaceStatus) -> PbStatus {
71 match value {
72 SpaceStatus::Active => PbStatus::Active,
73 SpaceStatus::Suspended => PbStatus::Suspended,
74 SpaceStatus::Archived => PbStatus::Archived,
75 }
76}
77
78fn status_of(error: SpaceError) -> Status {
79 match error {
80 SpaceError::NotFound | SpaceError::SpaceNotFound => Status::not_found("space not found"),
81 SpaceError::BadRequest(message) => Status::invalid_argument(message),
82 other => {
83 tracing::warn!(%other, "internal space directory lookup failed");
84 Status::internal("space directory lookup failed")
85 }
86 }
87}
88
89fn bad_gateway(error: sea_orm::DbErr) -> Status {
90 tracing::error!(%error, "space directory query failed");
91 Status::internal("space directory query failed")
92}
Modified lib/share/models/src/users/mod.rs View lib/share/models/src/users/mod.rs
@@ -1,6 +1,7 @@
11pub const DATABASE: &str = "user";
22
33pub mod user;
4pub mod user_access_tokens;
45pub mod user_agreement_acceptances;
56pub mod user_blocks;
67pub mod user_events;
Added lib/share/models/src/users/user_access_tokens.rs View lib/share/models/src/users/user_access_tokens.rs
@@ -0,0 +1,57 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::Set;
3use sea_orm::entity::prelude::*;
4use uuid::Uuid;
5
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
8#[sea_orm(table_name = "user_access_tokens")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub id: Uuid,
12 #[sea_orm(indexed)]
13 pub user_id: i64,
14
15 #[sea_orm(column_type = "String(StringLen::N(64))")]
16 pub name: String,
17 #[sea_orm(column_type = "String(StringLen::N(255))")]
18 pub description: Option<String>,
19
20 // 明文令牌的 sha256 hex;明文只在签发时出现一次,库内不留可逆形式。
21 #[sea_orm(unique, column_type = "String(StringLen::N(64))")]
22 pub token_hash: String,
23 // 摘要算法版本,当前固定 "sha256-v1"。
24 #[sea_orm(column_type = "String(StringLen::N(16))", default_value = "sha256-v1")]
25 pub hash_key_id: String,
26 // 明文前若干字节摘要,供用户在令牌列表里辨认。
27 #[sea_orm(column_type = "String(StringLen::N(32))")]
28 pub fingerprint: String,
29
30 // auth.common.v1.TokenScope 的枚举值数组。
31 #[sea_orm(column_type = "JsonBinary", default_value = "[]")]
32 pub scopes: JsonValue,
33
34 #[sea_orm(default_expr = "Expr::current_timestamp()")]
35 pub created_at: DateTimeUtc,
36 pub expires_at: Option<DateTimeUtc>,
37 pub last_used_at: Option<DateTimeUtc>,
38 pub revoked_at: Option<DateTimeUtc>,
39 #[sea_orm(default_value = 1)]
40 pub version: i32,
41
42 #[sea_orm(belongs_to, from = "user_id", to = "id", on_delete = "Cascade")]
43 pub user: BelongsTo<super::users::Entity>,
44}
45
46#[async_trait::async_trait]
47impl ActiveModelBehavior for ActiveModel {
48 async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
49 where
50 C: ConnectionTrait,
51 {
52 if insert && self.id.is_not_set() {
53 self.id = Set(Uuid::now_v7());
54 }
55 Ok(self)
56 }
57}
Renamed view/auth/public/media/gitdataai-red-panda.png → view/public/media/gitdataai-red-panda.png View view/public/media/gitdataai-red-panda.png

Binary file not shown.

Added view/public/robots.txt View view/public/robots.txt
@@ -0,0 +1,6 @@
1User-agent: *
2Allow: /
3Disallow: /auth
4Disallow: /app
5
6Sitemap: https://gitdata.ai/sitemap.xml
Added view/public/sitemap.xml View view/public/sitemap.xml
@@ -0,0 +1,6 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3 <url>
4 <loc>https://gitdata.ai/</loc>
5 </url>
6</urlset>