Initial working authentication with SigV4

This commit is contained in:
2026-02-27 01:35:20 +01:00
parent 79819ad2d0
commit 678c10a3ad
18 changed files with 1385 additions and 10 deletions

186
auth/service.go Normal file
View File

@@ -0,0 +1,186 @@
package auth
import (
"encoding/json"
"errors"
"fmt"
"fs/models"
"net/http"
"strings"
"time"
)
type Store interface {
GetAuthIdentity(accessKeyID string) (*models.AuthIdentity, error)
PutAuthIdentity(identity *models.AuthIdentity) error
GetAuthPolicy(accessKeyID string) (*models.AuthPolicy, error)
PutAuthPolicy(policy *models.AuthPolicy) error
}
type Service struct {
cfg Config
store Store
masterKey []byte
now func() time.Time
}
func NewService(cfg Config, store Store) (*Service, error) {
if store == nil {
return nil, errors.New("auth store is required")
}
svc := &Service{
cfg: cfg,
store: store,
now: func() time.Time { return time.Now().UTC() },
}
if !cfg.Enabled {
return svc, nil
}
if strings.TrimSpace(cfg.MasterKey) == "" {
return nil, ErrMasterKeyRequired
}
masterKey, err := decodeMasterKey(cfg.MasterKey)
if err != nil {
return nil, err
}
svc.masterKey = masterKey
return svc, nil
}
func (s *Service) Config() Config {
return s.cfg
}
func (s *Service) EnsureBootstrap() error {
if !s.cfg.Enabled {
return nil
}
accessKey := strings.TrimSpace(s.cfg.BootstrapAccessKey)
secret := strings.TrimSpace(s.cfg.BootstrapSecretKey)
if accessKey == "" || secret == "" {
return nil
}
if len(accessKey) < 3 {
return errors.New("bootstrap access key must be at least 3 characters")
}
if len(secret) < 8 {
return errors.New("bootstrap secret key must be at least 8 characters")
}
now := s.now().Unix()
ciphertext, nonce, err := encryptSecret(s.masterKey, accessKey, secret)
if err != nil {
return err
}
identity := &models.AuthIdentity{
AccessKeyID: accessKey,
SecretEnc: ciphertext,
SecretNonce: nonce,
EncAlg: "AES-256-GCM",
KeyVersion: "v1",
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
if existing, err := s.store.GetAuthIdentity(accessKey); err == nil && existing != nil {
identity.CreatedAt = existing.CreatedAt
}
if err := s.store.PutAuthIdentity(identity); err != nil {
return err
}
policy := defaultBootstrapPolicy(accessKey)
if strings.TrimSpace(s.cfg.BootstrapPolicy) != "" {
parsed, err := parsePolicyJSON(s.cfg.BootstrapPolicy)
if err != nil {
return err
}
policy = parsed
policy.Principal = accessKey
}
return s.store.PutAuthPolicy(policy)
}
func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) {
if !s.cfg.Enabled {
return RequestContext{Authenticated: false, AuthType: "disabled"}, nil
}
input, err := parseSigV4(r)
if err != nil {
return RequestContext{}, err
}
if err := validateSigV4Input(s.now(), s.cfg, input); err != nil {
return RequestContext{}, err
}
identity, err := s.store.GetAuthIdentity(input.AccessKeyID)
if err != nil {
return RequestContext{}, ErrInvalidAccessKeyID
}
if !strings.EqualFold(identity.Status, "active") {
return RequestContext{}, ErrCredentialDisabled
}
secret, err := decryptSecret(s.masterKey, identity.AccessKeyID, identity.SecretEnc, identity.SecretNonce)
if err != nil {
return RequestContext{}, ErrSignatureDoesNotMatch
}
ok, err := signatureMatches(secret, r, input)
if err != nil {
return RequestContext{}, err
}
if !ok {
return RequestContext{}, ErrSignatureDoesNotMatch
}
policy, err := s.store.GetAuthPolicy(identity.AccessKeyID)
if err != nil {
return RequestContext{}, ErrAccessDenied
}
target := resolveTarget(r)
if target.Action == "" {
return RequestContext{}, ErrAccessDenied
}
if !isAllowed(policy, target) {
return RequestContext{}, ErrAccessDenied
}
authType := "sigv4-header"
if input.Presigned {
authType = "sigv4-presign"
}
return RequestContext{
Authenticated: true,
AccessKeyID: identity.AccessKeyID,
AuthType: authType,
}, nil
}
func parsePolicyJSON(raw string) (*models.AuthPolicy, error) {
policy := models.AuthPolicy{}
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
return nil, fmt.Errorf("invalid bootstrap policy: %w", err)
}
if len(policy.Statements) == 0 {
return nil, errors.New("bootstrap policy must contain at least one statement")
}
return &policy, nil
}
func defaultBootstrapPolicy(principal string) *models.AuthPolicy {
return &models.AuthPolicy{
Principal: principal,
Statements: []models.AuthPolicyStatement{
{
Effect: "allow",
Actions: []string{"s3:*"},
Bucket: "*",
Prefix: "*",
},
},
}
}