@@ -0,0 +1,157 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// ErrCurrentApiKey 拒绝删除当前配置正在使用的签名 key。
|
||||
var ErrCurrentApiKey = errors.New("当前配置正在使用的 API Key 不可删除,请先替换")
|
||||
|
||||
// apiKeyVerifyAttempts / apiKeyVerifyDelay 控制新 key 生效验证的重试节奏;
|
||||
// OCI 上传公钥后生效有秒级延迟。测试将 delay 调小。
|
||||
var (
|
||||
apiKeyVerifyAttempts = 6
|
||||
apiKeyVerifyDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// CreatedApiKey 是新建/轮换 API key 的一次性返回;私钥仅此一次,不落库。
|
||||
type CreatedApiKey struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PrivateKey string `json:"privateKey"`
|
||||
ConfigIni string `json:"configIni"`
|
||||
}
|
||||
|
||||
// UserApiKeyInfo 是列表项:key 元数据加该 key 的 CLI config 模板(预览用)。
|
||||
type UserApiKeyInfo struct {
|
||||
oci.TenantUserApiKey
|
||||
ConfigIni string `json:"configIni"`
|
||||
}
|
||||
|
||||
// TenantUserApiKeys 列出用户 API 签名 key(oci 层已标注当前使用)。
|
||||
func (s *OciConfigService) TenantUserApiKeys(ctx context.Context, id uint, userID string) ([]UserApiKeyInfo, error) {
|
||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := s.client.ListTenantUserApiKeys(ctx, cred, homeRegion, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]UserApiKeyInfo, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, UserApiKeyInfo{TenantUserApiKey: k, ConfigIni: ociConfigIni(cred, userID, k.Fingerprint)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddTenantUserApiKey 生成 RSA-2048 密钥对并上传公钥;私钥仅本次返回。
|
||||
func (s *OciConfigService) AddTenantUserApiKey(ctx context.Context, id uint, userID string) (CreatedApiKey, error) {
|
||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||
if err != nil {
|
||||
return CreatedApiKey{}, err
|
||||
}
|
||||
privPEM, pubPEM, err := generateRsaKeyPair()
|
||||
if err != nil {
|
||||
return CreatedApiKey{}, err
|
||||
}
|
||||
fp, err := s.client.UploadTenantUserApiKey(ctx, cred, homeRegion, userID, pubPEM)
|
||||
if err != nil {
|
||||
return CreatedApiKey{}, err
|
||||
}
|
||||
return CreatedApiKey{Fingerprint: fp, PrivateKey: privPEM, ConfigIni: ociConfigIni(cred, userID, fp)}, nil
|
||||
}
|
||||
|
||||
// DeleteTenantUserApiKey 删除用户单把 API key;当前签名 key 拒删以免面板失联。
|
||||
func (s *OciConfigService) DeleteTenantUserApiKey(ctx context.Context, id uint, userID, fingerprint string) error {
|
||||
cred, homeRegion, err := s.credentialsAndHomeRegion(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if userID == cred.UserOCID && fingerprint == cred.Fingerprint {
|
||||
return ErrCurrentApiKey
|
||||
}
|
||||
return s.client.DeleteTenantUserApiKey(ctx, cred, homeRegion, userID, fingerprint)
|
||||
}
|
||||
|
||||
// ActivateApiKey 把刚创建的 key 设为本配置的签名凭据:验证可用后落库,不删除旧 key。
|
||||
// 私钥由前端回传(仅创建时下发过);归属无需显式校验——非本签名用户的 key 验证必失败。
|
||||
func (s *OciConfigService) ActivateApiKey(ctx context.Context, id uint, fingerprint, privateKey string) error {
|
||||
cfg, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cred, err := s.credentialsOf(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newCred := cred
|
||||
newCred.Fingerprint, newCred.PrivateKey, newCred.Passphrase = fingerprint, privateKey, ""
|
||||
if err := newCred.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.waitApiKeyUsable(ctx, newCred); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.persistSigningKey(cfg, newCred)
|
||||
}
|
||||
|
||||
// waitApiKeyUsable 用新凭据测活,等待上传的公钥在 OCI 侧生效。
|
||||
func (s *OciConfigService) waitApiKeyUsable(ctx context.Context, cred oci.Credentials) error {
|
||||
var err error
|
||||
for i := 0; i < apiKeyVerifyAttempts; i++ {
|
||||
if _, err = s.client.ValidateKey(ctx, cred); err == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(apiKeyVerifyDelay):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("new api key not usable: %w", err)
|
||||
}
|
||||
|
||||
// persistSigningKey 加密新私钥,更新配置指纹并清空口令密文(面板生成的 key 无口令)。
|
||||
func (s *OciConfigService) persistSigningKey(cfg *model.OciConfig, newCred oci.Credentials) error {
|
||||
enc, err := s.cipher.EncryptString(newCred.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt private key: %w", err)
|
||||
}
|
||||
updates := map[string]any{"fingerprint": newCred.Fingerprint, "private_key_enc": enc, "passphrase_enc": ""}
|
||||
if err := s.db.Model(cfg).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("persist rotated key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRsaKeyPair 生成 RSA-2048 密钥对,返回 PKCS#1 私钥 PEM 与 PKIX 公钥 PEM。
|
||||
func generateRsaKeyPair() (privatePEM, publicPEM string, err error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generate rsa key: %w", err)
|
||||
}
|
||||
privBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("marshal public key: %w", err)
|
||||
}
|
||||
pubBlock := &pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}
|
||||
return string(pem.EncodeToMemory(privBlock)), string(pem.EncodeToMemory(pubBlock)), nil
|
||||
}
|
||||
|
||||
// ociConfigIni 拼装 OCI CLI config 文本;key_file 需用户保存私钥后自行填写。
|
||||
func ociConfigIni(cred oci.Credentials, userID, fingerprint string) string {
|
||||
return fmt.Sprintf(
|
||||
"[DEFAULT]\nuser=%s\nfingerprint=%s\ntenancy=%s\nregion=%s\n# 保存私钥到本机后替换为实际路径\nkey_file=~/.oci/oci_api_key.pem\n",
|
||||
userID, fingerprint, cred.TenancyOCID, cred.Region)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
"oci-portal/internal/oci"
|
||||
)
|
||||
|
||||
// apiKeyClient 记录 API key 相关调用的 fake。
|
||||
type apiKeyClient struct {
|
||||
oci.Client
|
||||
|
||||
keys []oci.TenantUserApiKey
|
||||
uploadFp string
|
||||
uploadErr error
|
||||
uploadedPub string
|
||||
deleted []string
|
||||
validateErr error
|
||||
validated []string // ValidateKey 收到的指纹序列
|
||||
}
|
||||
|
||||
func (f *apiKeyClient) ListTenantUserApiKeys(ctx context.Context, cred oci.Credentials, homeRegion, userID string) ([]oci.TenantUserApiKey, error) {
|
||||
return f.keys, nil
|
||||
}
|
||||
|
||||
func (f *apiKeyClient) UploadTenantUserApiKey(ctx context.Context, cred oci.Credentials, homeRegion, userID, publicKeyPEM string) (string, error) {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
}
|
||||
f.uploadedPub = publicKeyPEM
|
||||
return f.uploadFp, nil
|
||||
}
|
||||
|
||||
func (f *apiKeyClient) DeleteTenantUserApiKey(ctx context.Context, cred oci.Credentials, homeRegion, userID, fingerprint string) error {
|
||||
f.deleted = append(f.deleted, fingerprint)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *apiKeyClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) {
|
||||
f.validated = append(f.validated, cred.Fingerprint)
|
||||
if f.validateErr != nil {
|
||||
return oci.TenancyInfo{}, f.validateErr
|
||||
}
|
||||
return oci.TenancyInfo{Name: "t"}, nil
|
||||
}
|
||||
|
||||
const testKeyPEM = "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----"
|
||||
|
||||
// seedApiKeyConfig 落一条可解密的配置,当前签名 key 指纹为 aa:bb。
|
||||
func seedApiKeyConfig(t *testing.T, s *OciConfigService) *model.OciConfig {
|
||||
t.Helper()
|
||||
enc, err := s.cipher.EncryptString(testKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
cfg := &model.OciConfig{
|
||||
Alias: "t1", UserOCID: "ocid1.user.oc1..me", TenancyOCID: "ocid1.tenancy.oc1..t",
|
||||
Fingerprint: "aa:bb", Region: "ap-tokyo-1", PrivateKeyEnc: enc,
|
||||
}
|
||||
if err := s.db.Create(cfg).Error; err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestDeleteTenantUserApiKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userID string
|
||||
fingerprint string
|
||||
wantErr error
|
||||
wantDeleted bool
|
||||
}{
|
||||
{"当前签名 key 拒删", "ocid1.user.oc1..me", "aa:bb", ErrCurrentApiKey, false},
|
||||
{"当前用户其他指纹可删", "ocid1.user.oc1..me", "cc:dd", nil, true},
|
||||
{"其他用户同指纹可删", "ocid1.user.oc1..other", "aa:bb", nil, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fc := &apiKeyClient{}
|
||||
s := newTestService(t, fc)
|
||||
cfg := seedApiKeyConfig(t, s)
|
||||
err := s.DeleteTenantUserApiKey(context.Background(), cfg.ID, tt.userID, tt.fingerprint)
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
if got := len(fc.deleted) > 0; got != tt.wantDeleted {
|
||||
t.Fatalf("deleted = %v, want deleted=%v", fc.deleted, tt.wantDeleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantUserApiKeys(t *testing.T) {
|
||||
fc := &apiKeyClient{keys: []oci.TenantUserApiKey{{Fingerprint: "aa:bb", IsCurrent: true}}}
|
||||
s := newTestService(t, fc)
|
||||
cfg := seedApiKeyConfig(t, s)
|
||||
|
||||
items, err := s.TenantUserApiKeys(context.Background(), cfg.ID, "ocid1.user.oc1..me")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(items) != 1 || !items[0].IsCurrent {
|
||||
t.Fatalf("items = %+v", items)
|
||||
}
|
||||
if !strings.Contains(items[0].ConfigIni, "fingerprint=aa:bb") {
|
||||
t.Fatalf("configIni missing fingerprint:\n%s", items[0].ConfigIni)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTenantUserApiKey(t *testing.T) {
|
||||
fc := &apiKeyClient{uploadFp: "11:22"}
|
||||
s := newTestService(t, fc)
|
||||
cfg := seedApiKeyConfig(t, s)
|
||||
|
||||
created, err := s.AddTenantUserApiKey(context.Background(), cfg.ID, "ocid1.user.oc1..other")
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if created.Fingerprint != "11:22" {
|
||||
t.Fatalf("fingerprint = %q", created.Fingerprint)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(created.PrivateKey))
|
||||
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||
t.Fatalf("private key not PKCS#1 PEM")
|
||||
}
|
||||
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil {
|
||||
t.Fatalf("parse private key: %v", err)
|
||||
}
|
||||
if !strings.Contains(fc.uploadedPub, "PUBLIC KEY") {
|
||||
t.Fatalf("uploaded public key = %q", fc.uploadedPub)
|
||||
}
|
||||
for _, want := range []string{"user=ocid1.user.oc1..other", "fingerprint=11:22", "tenancy=ocid1.tenancy.oc1..t", "region=ap-tokyo-1"} {
|
||||
if !strings.Contains(created.ConfigIni, want) {
|
||||
t.Fatalf("configIni missing %q:\n%s", want, created.ConfigIni)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateApiKey(t *testing.T) {
|
||||
apiKeyVerifyDelay = time.Millisecond
|
||||
const newKey = "-----BEGIN RSA PRIVATE KEY-----\nnew\n-----END RSA PRIVATE KEY-----"
|
||||
tests := []struct {
|
||||
name string
|
||||
privateKey string
|
||||
validateErr error
|
||||
wantErr bool
|
||||
wantFp string // 期望落库指纹
|
||||
}{
|
||||
{"成功:验证通过后落库,不删任何 key", newKey, nil, false, "11:22"},
|
||||
{"验证失败:配置不动", newKey, errors.New("401"), true, "aa:bb"},
|
||||
{"私钥非 PEM:直接拒绝", "not-a-pem", nil, true, "aa:bb"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fc := &apiKeyClient{validateErr: tt.validateErr}
|
||||
s := newTestService(t, fc)
|
||||
cfg := seedApiKeyConfig(t, s)
|
||||
|
||||
err := s.ActivateApiKey(context.Background(), cfg.ID, "11:22", tt.privateKey)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
var got model.OciConfig
|
||||
if err := s.db.First(&got, cfg.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if got.Fingerprint != tt.wantFp {
|
||||
t.Fatalf("fingerprint = %q, want %q", got.Fingerprint, tt.wantFp)
|
||||
}
|
||||
if len(fc.deleted) != 0 {
|
||||
t.Fatalf("deleted = %v, want none", fc.deleted)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
// 成功路径:落库私钥可解密且与回传一致,验证调用用的是新指纹
|
||||
plain, err := s.cipher.DecryptString(got.PrivateKeyEnc)
|
||||
if err != nil || plain != newKey {
|
||||
t.Fatalf("persisted key mismatch (err=%v)", err)
|
||||
}
|
||||
if len(fc.validated) == 0 || fc.validated[0] != "11:22" {
|
||||
t.Fatalf("validated = %v", fc.validated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user