use crate::session::{RequestId, Session}; use actix_web::web; use uuid::Uuid; use crate::envelope::{Envelope, NoData}; use crate::server::AuthSvc; use crate::server::access_token::types::{ AccessTokenCreateRequest, AccessTokenCreateResponse, AccessTokenSummary, }; use crate::server::errors::AuthError; use crate::server::share::types::ListQuery; #[utoipa::path( get, path = "/api/v1/oauth/access-tokens", tag = "auth", operation_id = "auth_access_token_list", summary = "列示访问令牌", description = "按创建时间倒序返回当前账号的令牌摘要,只给指纹与状态,不回吐令牌原文。分页参数由 `page` / `page_size` 控制。查询串不是合法分页参数或路径/方法不匹配时返回 400 / 404 由 actix-web 直接给出。", params( ("page" = Option, Query, description = "页码,从 1 开始;默认 1"), ("page_size" = Option, Query, description = "每页条数,默认 20,最多 100") ), responses( ( status = 200, description = "令牌摘要列表", body = Envelope>, example = json!({"code":200,"message":"success","data":[{"id":"018f2b1c-4d5e-7f80-9a1b-2c3d4e5f6071","name":"cli","description":"本机命令行","fingerprint":"3f9c1a7b2d4e6058","scope":["ssh_key","assets"],"created_at":"2026-01-02T03:04:05Z","expires_at":null,"last_used_at":null,"revoked_at":null}]}) ), ( status = 401, description = "会话未绑定账号(`unauthenticated`)", body = Envelope, example = json!({"code": "unauthenticated", "message": "authentication required"}) ), ( status = 500, description = "服务端故障(`internal`、`database`、`session`、`serialization`)", body = Envelope, example = json!({"code": "internal", "message": "internal server error"}) ) ) )] pub async fn access_token_list( svc: web::Data, session: Session, query: web::Query, request_id: RequestId, ) -> Result>, AuthError> { let tokens = svc .list_access_tokens(session, query.into_inner(), request_id.0) .await?; Ok(Envelope::ok(tokens)) } /// `security_version` 与账号当前值绑定,账号级撤销会让这批令牌一起失效。 #[utoipa::path( post, path = "/api/v1/oauth/access-tokens", tag = "auth", operation_id = "auth_access_token_create", summary = "签发访问令牌", description = "名称 1~64 字符、备注最长 255 字符,`scope` 必须全部命中白名单(最多 32 项),`expires_in_secs` 取值 60 ~ 31536000(不填表示长期有效)。同一账号未吊销的令牌上限 50 个。签发成功记 `AccessKeyAdded` 审计事件。", request_body = AccessTokenCreateRequest, responses( ( status = 200, description = "令牌已签发,`token` 只在此响应里出现一次", body = Envelope, example = json!({"code":200,"message":"success","data":{"id":"018f2b1c-4d5e-7f80-9a1b-2c3d4e5f6071","name":"cli","token":"Zk9tQ2h5cFJ2TjB3U3gxbUhhcTRkRmc2WXpVMEJqTDlLbVNu","scope":["ssh_key","assets"],"created_at":"2026-01-02T03:04:05Z","expires_at":null}}) ), ( status = 400, description = "名称、备注、范围或有效期不合法(`invalid_access_key_name`、`malformed_request`、`invalid_scope`、`invalid_expiry`)", body = Envelope, example = json!({"code": "invalid_scope", "message": "access token scope is invalid"}) ), ( status = 401, description = "会话未绑定账号,或账号已删除 / 不允许使用(`unauthenticated`)", body = Envelope, example = json!({"code": "unauthenticated", "message": "authentication required"}) ), ( status = 429, description = "未吊销的令牌已达上限(`too_many_access_keys`)", body = Envelope, example = json!({"code": "too_many_access_keys", "message": "too many access keys"}) ), ( status = 500, description = "服务端故障(`internal`、`database`、`session`、`serialization`)", body = Envelope, example = json!({"code": "internal", "message": "internal server error"}) ) ) )] pub async fn access_token_create( svc: web::Data, session: Session, body: web::Json, request_id: RequestId, ) -> Result, AuthError> { let user_agent = session.user_agent()?; let ip_addr = session.client_ip()?; let response = svc .create_access_token( session, body.into_inner(), user_agent, ip_addr, request_id.0, ) .await?; Ok(Envelope::ok(response)) } #[utoipa::path( delete, path = "/api/v1/oauth/access-tokens/{token_id}", tag = "auth", operation_id = "auth_access_token_revoke", summary = "吊销访问令牌", description = "把令牌标记为已吊销,记录保留不硬删;重复调用幂等。令牌不存在或不属于当前账号返回 `access_token_not_found`(按 `errors.rs` 的归类为 401)。吊销成功记 `AccessKeyRemoved` 审计事件。", params( ("token_id" = Uuid, Path, description = "令牌编号") ), responses( ( status = 200, description = "已吊销;重复调用同样返回成功", body = Envelope, example = json!({"code":200,"message":"success","data":null}) ), ( status = 401, description = "会话未绑定账号(`unauthenticated`),或令牌不属于当前账号 / 不存在(`access_token_not_found`)", body = Envelope, example = json!({"code": "access_token_not_found", "message": "access token does not exist"}) ), ( status = 500, description = "服务端故障(`internal`、`database`、`session`、`serialization`)", body = Envelope, example = json!({"code": "internal", "message": "internal server error"}) ) ) )] pub async fn access_token_revoke( svc: web::Data, session: Session, token_id: web::Path, request_id: RequestId, ) -> Result, AuthError> { let user_agent = session.user_agent()?; let ip_addr = session.client_ip()?; svc.revoke_access_token( session, token_id.into_inner(), user_agent, ip_addr, request_id.0, ) .await?; Ok(Envelope::empty()) } /// 轮换当前账号名下的一枚访问令牌:旧密文立即失效,返回新的明文(只出现一次)。 #[utoipa::path( post, path = "/api/v1/oauth/access-tokens/{token_id}/rotate", tag = "auth", operation_id = "auth_access_token_rotate", summary = "轮换访问令牌", description = "为已签发的令牌换一枚新的明文,保留编号、名称与 scope,摘要与指纹重算。令牌不存在或不属于当前账号返回 `access_token_not_found`。新明文只在此响应里出现一次。", params( ("token_id" = Uuid, Path, description = "令牌编号") ), responses( ( status = 200, description = "已轮换,`token` 只在此响应里出现一次", body = Envelope ), ( status = 401, description = "会话未绑定账号,或令牌不属于当前账号 / 不存在", body = Envelope ), ( status = 500, description = "服务端故障", body = Envelope ) ) )] pub async fn access_token_rotate( svc: web::Data, session: Session, token_id: web::Path, request_id: RequestId, ) -> Result, AuthError> { let user_id = svc .require_session(&session, Some(request_id.0.as_str())) .await?; let user_agent = session.user_agent()?; let ip_addr = session.client_ip()?; let response = svc .rotate_access_token_for_user( user_id, token_id.into_inner(), true, ip_addr, user_agent, request_id.0, ) .await?; Ok(Envelope::ok(response)) }