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 }