Files
lti-api/internal/apikeys/service.go
T
2026-04-14 16:52:50 +07:00

234 lines
5.7 KiB
Go

package apikeys
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/secure"
"gorm.io/gorm"
)
var (
ErrInvalidAPIKey = errors.New("invalid api key")
ErrInactiveKey = errors.New("inactive api key")
)
type Principal struct {
ID uint
Name string
Environment string
Permissions []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type Authenticator interface {
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
}
type Service interface {
Authenticator
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
Revoke(ctx context.Context, environment, prefix string) error
}
type CreateInput struct {
Name string
Environment string
PermissionCodes []string
AllArea bool
AreaIDs []uint
AllLocation bool
LocationIDs []uint
}
type IssuedKey struct {
Key string
Record *entity.IntegrationAPIKey
}
type service struct {
repo Repository
now func() time.Time
}
func NewService(db *gorm.DB) Service {
return &service{
repo: NewRepository(db),
now: time.Now,
}
}
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
environment, prefix, secret, err := parseRawKey(rawKey)
if err != nil {
return nil, ErrInvalidAPIKey
}
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidAPIKey
}
return nil, err
}
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
return nil, ErrInactiveKey
}
if !secure.Verify(record.KeyHash, secret) {
return nil, ErrInvalidAPIKey
}
usedAt := s.now().UTC()
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
}
return &Principal{
ID: record.ID,
Name: record.Name,
Environment: record.Environment,
Permissions: canonicalPermissions(record.PermissionCodes),
AllArea: record.AllArea,
AreaIDs: uniqueUint(record.AreaIDs),
AllLocation: record.AllLocation,
LocationIDs: uniqueUint(record.LocationIDs),
}, nil
}
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
name := strings.TrimSpace(input.Name)
environment := strings.ToLower(strings.TrimSpace(input.Environment))
if name == "" || environment == "" {
return nil, fmt.Errorf("name and environment are required")
}
prefix, err := randomToken(10)
if err != nil {
return nil, err
}
secret, err := randomToken(24)
if err != nil {
return nil, err
}
hash, err := secure.Hash(secret, nil)
if err != nil {
return nil, err
}
record := &entity.IntegrationAPIKey{
Name: name,
Environment: environment,
Status: entity.IntegrationAPIKeyStatusActive,
KeyPrefix: prefix,
KeyHash: hash,
PermissionCodes: canonicalPermissions(input.PermissionCodes),
AllArea: input.AllArea,
AreaIDs: uniqueUint(input.AreaIDs),
AllLocation: input.AllLocation,
LocationIDs: uniqueUint(input.LocationIDs),
}
if err := s.repo.Create(ctx, record); err != nil {
return nil, err
}
return &IssuedKey{
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
Record: record,
}, nil
}
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
}
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
environment = strings.ToLower(strings.TrimSpace(environment))
prefix = strings.TrimSpace(prefix)
if environment == "" || prefix == "" {
return fmt.Errorf("environment and prefix are required")
}
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
}
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
rawKey = strings.TrimSpace(rawKey)
parts := strings.Split(rawKey, "_")
if len(parts) != 4 || parts[0] != "lti" {
return "", "", "", ErrInvalidAPIKey
}
environment = strings.ToLower(strings.TrimSpace(parts[1]))
prefix = strings.TrimSpace(parts[2])
secret = strings.TrimSpace(parts[3])
if environment == "" || prefix == "" || secret == "" {
return "", "", "", ErrInvalidAPIKey
}
return environment, prefix, secret, nil
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
return strings.ToLower(encoder.EncodeToString(buf)), nil
}
func canonicalPermissions(perms []string) []string {
if len(perms) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(perms))
result := make([]string, 0, len(perms))
for _, perm := range perms {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
continue
}
if _, ok := seen[perm]; ok {
continue
}
seen[perm] = struct{}{}
result = append(result, perm)
}
return result
}
func uniqueUint(values []uint) []uint {
if len(values) == 0 {
return []uint{}
}
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}