mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
Add `POST /admin/masters/{id}/keys` allowing admins to issue child keys
on behalf of a master. Introduce an `issued_by` field in the Key model
to audit whether a key was issued by the master or an admin.
Refactor master service to use typed errors for consistent HTTP status
mapping and ensure validation logic (active status, group check) is
shared.
175 lines
4.8 KiB
Go
175 lines
4.8 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/ez-api/ez-api/internal/model"
|
|
"github.com/ez-api/foundation/tokenhash"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var (
|
|
ErrMasterNotFound = errors.New("master not found")
|
|
ErrMasterNotActive = errors.New("master is not active")
|
|
ErrChildKeyLimitReached = errors.New("child key limit reached")
|
|
ErrChildKeyGroupForbidden = errors.New("cannot issue key for a different group")
|
|
)
|
|
|
|
type MasterService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewMasterService(db *gorm.DB) *MasterService {
|
|
return &MasterService{db: db}
|
|
}
|
|
|
|
func (s *MasterService) CreateMaster(name, group string, maxChildKeys, globalQPS int) (*model.Master, string, error) {
|
|
rawMasterKey, err := generateRandomKey(32)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to generate master key: %w", err)
|
|
}
|
|
|
|
hashedMasterKey, err := bcrypt.GenerateFromPassword([]byte(rawMasterKey), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to hash master key: %w", err)
|
|
}
|
|
|
|
masterKeyDigest := tokenhash.HashToken(rawMasterKey)
|
|
|
|
master := &model.Master{
|
|
Name: name,
|
|
MasterKey: string(hashedMasterKey),
|
|
MasterKeyDigest: masterKeyDigest,
|
|
Group: group,
|
|
MaxChildKeys: maxChildKeys,
|
|
GlobalQPS: globalQPS,
|
|
Status: "active",
|
|
Epoch: 1,
|
|
}
|
|
|
|
if err := s.db.Create(master).Error; err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
return master, rawMasterKey, nil
|
|
}
|
|
|
|
func (s *MasterService) ValidateMasterKey(masterKey string) (*model.Master, error) {
|
|
digest := tokenhash.HashToken(masterKey)
|
|
|
|
var master model.Master
|
|
if err := s.db.Where("master_key_digest = ?", digest).First(&master).Error; err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
|
|
// Backward compatibility: look for legacy rows without digest.
|
|
var masters []model.Master
|
|
if err := s.db.Where("master_key_digest = '' OR master_key_digest IS NULL").Find(&masters).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range masters {
|
|
if bcrypt.CompareHashAndPassword([]byte(m.MasterKey), []byte(masterKey)) == nil {
|
|
master = m
|
|
// Opportunistically backfill digest for next time.
|
|
if strings.TrimSpace(m.MasterKeyDigest) == "" {
|
|
_ = s.db.Model(&m).Update("master_key_digest", digest).Error
|
|
}
|
|
goto verified
|
|
}
|
|
}
|
|
return nil, errors.New("invalid master key")
|
|
}
|
|
|
|
if bcrypt.CompareHashAndPassword([]byte(master.MasterKey), []byte(masterKey)) != nil {
|
|
return nil, errors.New("invalid master key")
|
|
}
|
|
|
|
verified:
|
|
if master.Status != "active" {
|
|
return nil, fmt.Errorf("master is not active")
|
|
}
|
|
|
|
return &master, nil
|
|
}
|
|
|
|
func (s *MasterService) IssueChildKey(masterID uint, group string, scopes string) (*model.Key, string, error) {
|
|
return s.issueChildKey(masterID, group, scopes, "master")
|
|
}
|
|
|
|
func (s *MasterService) IssueChildKeyAsAdmin(masterID uint, group string, scopes string) (*model.Key, string, error) {
|
|
return s.issueChildKey(masterID, group, scopes, "admin")
|
|
}
|
|
|
|
func (s *MasterService) issueChildKey(masterID uint, group string, scopes string, issuedBy string) (*model.Key, string, error) {
|
|
var master model.Master
|
|
if err := s.db.First(&master, masterID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, "", fmt.Errorf("%w: %d", ErrMasterNotFound, masterID)
|
|
}
|
|
return nil, "", fmt.Errorf("load master: %w", err)
|
|
}
|
|
if master.Status != "active" {
|
|
return nil, "", fmt.Errorf("%w", ErrMasterNotActive)
|
|
}
|
|
|
|
group = strings.TrimSpace(group)
|
|
if group == "" {
|
|
group = master.Group
|
|
}
|
|
if group != master.Group {
|
|
return nil, "", fmt.Errorf("%w", ErrChildKeyGroupForbidden)
|
|
}
|
|
|
|
var count int64
|
|
s.db.Model(&model.Key{}).Where("master_id = ?", masterID).Count(&count)
|
|
if count >= int64(master.MaxChildKeys) {
|
|
return nil, "", fmt.Errorf("%w for master %d", ErrChildKeyLimitReached, masterID)
|
|
}
|
|
|
|
rawChildKey, err := generateRandomKey(32)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to generate child key: %w", err)
|
|
}
|
|
|
|
tokenHash := tokenhash.HashToken(rawChildKey)
|
|
|
|
hashedChildKey, err := bcrypt.GenerateFromPassword([]byte(rawChildKey), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to hash child key: %w", err)
|
|
}
|
|
|
|
key := &model.Key{
|
|
MasterID: masterID,
|
|
KeySecret: string(hashedChildKey),
|
|
TokenHash: tokenHash,
|
|
Group: group,
|
|
Scopes: scopes,
|
|
IssuedAtEpoch: master.Epoch,
|
|
Status: "active",
|
|
IssuedBy: strings.TrimSpace(issuedBy),
|
|
}
|
|
if key.IssuedBy == "" {
|
|
key.IssuedBy = "master"
|
|
}
|
|
|
|
if err := s.db.Create(key).Error; err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
return key, rawChildKey, nil
|
|
}
|
|
|
|
func generateRandomKey(length int) (string, error) {
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(bytes), nil
|
|
}
|