223 lines
6.1 KiB
Go
223 lines
6.1 KiB
Go
// Package clientkey implements independent, revocable client credentials (#237).
|
|
package clientkey
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var ErrInvalid = errors.New("名称或授权无效")
|
|
var ErrConflict = errors.New("密钥已停用或授权已被修改,请刷新后重试")
|
|
var ErrCredential = errors.New("客户端密钥无效或已停用")
|
|
|
|
type Grant struct {
|
|
Module string `json:"module"`
|
|
Write bool `json:"write"`
|
|
Actions []string `json:"actions"`
|
|
}
|
|
type Module struct {
|
|
Key string `json:"key"`
|
|
Title string `json:"title"`
|
|
Group string `json:"group"`
|
|
Writable bool `json:"writable"`
|
|
Actions []string `json:"actions"`
|
|
}
|
|
type Key struct {
|
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
|
Name string `gorm:"size:80;not null" json:"name"`
|
|
Prefix string `gorm:"size:24;not null" json:"prefix"`
|
|
Digest string `gorm:"size:64;uniqueIndex;not null" json:"-"`
|
|
GrantsJSON string `gorm:"type:text;not null" json:"-"`
|
|
Enabled bool `gorm:"not null" json:"enabled"`
|
|
Version uint64 `gorm:"not null" json:"version"`
|
|
CreatedBy uint64 `json:"createdBy"`
|
|
UpdatedBy uint64 `json:"updatedBy"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
|
}
|
|
|
|
func (Key) TableName() string { return "client_api_key" }
|
|
|
|
type Audit struct {
|
|
ID uint64 `gorm:"primaryKey"`
|
|
KeyID uint64 `gorm:"index;not null"`
|
|
ActorID uint64
|
|
Event string `gorm:"size:32;not null"`
|
|
RequestID string `gorm:"size:32;index"`
|
|
Method string `gorm:"size:10"`
|
|
Route string `gorm:"size:180"`
|
|
Status int
|
|
Changes string `gorm:"type:text"`
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
func (Audit) TableName() string { return "client_api_key_audit" }
|
|
|
|
type View struct {
|
|
Key
|
|
Grants []Grant `json:"grants"`
|
|
}
|
|
|
|
func view(k Key) View {
|
|
var g []Grant
|
|
_ = json.Unmarshal([]byte(k.GrantsJSON), &g)
|
|
return View{Key: k, Grants: g}
|
|
}
|
|
|
|
type Service struct {
|
|
DB *gorm.DB
|
|
Modules []Module
|
|
}
|
|
|
|
func (s Service) Normalize(in []Grant) ([]Grant, error) {
|
|
if len(in) == 0 || len(in) > len(s.Modules) {
|
|
return nil, ErrInvalid
|
|
}
|
|
catalog := map[string]Module{}
|
|
for _, m := range s.Modules {
|
|
catalog[m.Key] = m
|
|
}
|
|
seen := map[string]bool{}
|
|
out := make([]Grant, 0, len(in))
|
|
for _, g := range in {
|
|
m, ok := catalog[g.Module]
|
|
if !ok || seen[g.Module] || (g.Write && !m.Writable) {
|
|
return nil, ErrInvalid
|
|
}
|
|
seen[g.Module] = true
|
|
allowed := map[string]bool{}
|
|
for _, a := range m.Actions {
|
|
allowed[a] = true
|
|
}
|
|
used := map[string]bool{}
|
|
actions := []string{}
|
|
for _, a := range g.Actions {
|
|
if !allowed[a] || used[a] {
|
|
return nil, ErrInvalid
|
|
}
|
|
used[a] = true
|
|
actions = append(actions, a)
|
|
}
|
|
sort.Strings(actions)
|
|
out = append(out, Grant{g.Module, g.Write, actions})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module })
|
|
return out, nil
|
|
}
|
|
func (s Service) Create(ctx context.Context, name string, grants []Grant, actor uint64) (View, string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || len([]rune(name)) > 80 || actor == 0 {
|
|
return View{}, "", ErrInvalid
|
|
}
|
|
g, err := s.Normalize(grants)
|
|
if err != nil {
|
|
return View{}, "", err
|
|
}
|
|
raw, _ := json.Marshal(g)
|
|
secret := make([]byte, 32)
|
|
if _, err = rand.Read(secret); err != nil {
|
|
return View{}, "", err
|
|
}
|
|
token := "gak_" + hex.EncodeToString(secret)
|
|
digest := sha256.Sum256([]byte(token))
|
|
k := Key{Name: name, Prefix: token[:16], Digest: hex.EncodeToString(digest[:]), GrantsJSON: string(raw), Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor}
|
|
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(&k).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Create(&Audit{KeyID: k.ID, ActorID: actor, Event: "create", Changes: string(raw)}).Error
|
|
})
|
|
if err != nil {
|
|
return View{}, "", err
|
|
}
|
|
return view(k), token, nil
|
|
}
|
|
func (s Service) Update(ctx context.Context, id, version, actor uint64, grants []Grant, disable bool) (View, error) {
|
|
if id == 0 || version == 0 || actor == 0 {
|
|
return View{}, ErrInvalid
|
|
}
|
|
var raw []byte
|
|
if !disable {
|
|
g, err := s.Normalize(grants)
|
|
if err != nil {
|
|
return View{}, err
|
|
}
|
|
raw, _ = json.Marshal(g)
|
|
}
|
|
var k Key
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.First(&k, id).Error; err != nil {
|
|
return err
|
|
}
|
|
changes := map[string]any{"version": version + 1, "updated_by": actor, "updated_at": time.Now().UTC()}
|
|
event := "edit"
|
|
if disable {
|
|
changes["enabled"] = false
|
|
event = "disable"
|
|
} else {
|
|
changes["grants_json"] = string(raw)
|
|
}
|
|
result := tx.Model(&Key{}).Where("id = ? AND version = ? AND enabled = ?", id, version, true).Updates(changes)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrConflict
|
|
}
|
|
diff, _ := json.Marshal(map[string]string{"before": k.GrantsJSON, "after": string(raw)})
|
|
if err := tx.Create(&Audit{KeyID: id, ActorID: actor, Event: event, Changes: string(diff)}).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.First(&k, id).Error
|
|
})
|
|
return view(k), err
|
|
}
|
|
func (s Service) Authenticate(ctx context.Context, token string) (Key, error) {
|
|
if len(token) != 68 || !strings.HasPrefix(token, "gak_") {
|
|
return Key{}, ErrCredential
|
|
}
|
|
if _, err := hex.DecodeString(token[4:]); err != nil {
|
|
return Key{}, ErrCredential
|
|
}
|
|
digest := sha256.Sum256([]byte(token))
|
|
var k Key
|
|
err := s.DB.WithContext(ctx).Where("digest = ? AND enabled = ?", hex.EncodeToString(digest[:]), true).First(&k).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return Key{}, ErrCredential
|
|
}
|
|
return k, err
|
|
}
|
|
func (k Key) Allows(module, capability string) bool {
|
|
var grants []Grant
|
|
if json.Unmarshal([]byte(k.GrantsJSON), &grants) != nil {
|
|
return false
|
|
}
|
|
for _, g := range grants {
|
|
if g.Module != module {
|
|
continue
|
|
}
|
|
if capability == "read" {
|
|
return true
|
|
}
|
|
if capability == "write" {
|
|
return g.Write
|
|
}
|
|
for _, a := range g.Actions {
|
|
if a == capability {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|