mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
234 lines
5.7 KiB
Go
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
|
|
}
|