feat: integration v2 and API key PART 1/3
This commit is contained in:
1 parent
7e9bd9ba30
commit
844842fdae
40 files changed
+3782
-64
No files matched your search
@@ -0,0 +1,42 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrAPIKeyNotFound = errors.New("api key not found")
|
||||
var ErrAPIKeyInvalid = errors.New("api key invalid")
|
||||
var ErrExpiryNotInFuture = errors.New("api key expiry not in the future")
|
||||
|
||||
const RawKeyPrefix = "rcms_"
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusExpired Status = "expired"
|
||||
StatusRevoked Status = "revoked"
|
||||
)
|
||||
|
||||
type APIKey struct {
|
||||
ID uint32 `json:"id"`
|
||||
UserID uint32 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
HashedKey string `json:"hashed_key"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (k APIKey) StatusAt(now time.Time) Status {
|
||||
if k.RevokedAt != nil {
|
||||
return StatusRevoked
|
||||
}
|
||||
if k.ExpiresAt != nil && !now.Before(*k.ExpiresAt) {
|
||||
return StatusExpired
|
||||
}
|
||||
return StatusActive
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package apikey
|
||||
|
||||
type KeyGenerator interface {
|
||||
Generate() (rawKey string, prefix string, hashedKey string)
|
||||
Hash(rawKey string) string
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package apikey
|
||||
|
||||
import "time"
|
||||
|
||||
type Repository interface {
|
||||
Create(key APIKey) (APIKey, error)
|
||||
Get(id uint32) (APIKey, error)
|
||||
GetByHashedKey(hashedKey string) (APIKey, error)
|
||||
ListByUser(userId uint32) ([]APIKey, error)
|
||||
ListAll() ([]APIKey, error)
|
||||
Revoke(id uint32, at time.Time) error
|
||||
RevokeAllByUser(userId uint32, at time.Time) error
|
||||
TouchLastUsed(id uint32, at time.Time) error
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package hashing
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
const (
|
||||
apiKeyRandomBytes = 32
|
||||
apiKeyPrefixLen = 12
|
||||
)
|
||||
|
||||
type SHA256KeyGenerator struct{}
|
||||
|
||||
func NewSHA256KeyGenerator() *SHA256KeyGenerator {
|
||||
return &SHA256KeyGenerator{}
|
||||
}
|
||||
|
||||
func (g *SHA256KeyGenerator) Generate() (string, string, string) {
|
||||
random := make([]byte, apiKeyRandomBytes)
|
||||
_, _ = rand.Read(random)
|
||||
rawKey := apikey.RawKeyPrefix + base64.RawURLEncoding.EncodeToString(random)
|
||||
return rawKey, rawKey[:apiKeyPrefixLen], g.Hash(rawKey)
|
||||
}
|
||||
|
||||
func (g *SHA256KeyGenerator) Hash(rawKey string) string {
|
||||
sum := sha256.Sum256([]byte(rawKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
|
||||
@@ -0,0 +1,34 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func APIKeyToDomain(m models.APIKey) apikey.APIKey {
|
||||
return apikey.APIKey{
|
||||
ID: m.ID,
|
||||
UserID: m.UserID,
|
||||
Name: m.Name,
|
||||
Prefix: m.Prefix,
|
||||
HashedKey: m.HashedKey,
|
||||
ExpiresAt: m.ExpiresAt,
|
||||
RevokedAt: m.RevokedAt,
|
||||
LastUsedAt: m.LastUsedAt,
|
||||
CreatedAt: m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func APIKeyToModel(k apikey.APIKey) models.APIKey {
|
||||
return models.APIKey{
|
||||
ID: k.ID,
|
||||
UserID: k.UserID,
|
||||
Name: k.Name,
|
||||
Prefix: k.Prefix,
|
||||
HashedKey: k.HashedKey,
|
||||
ExpiresAt: k.ExpiresAt,
|
||||
RevokedAt: k.RevokedAt,
|
||||
LastUsedAt: k.LastUsedAt,
|
||||
CreatedAt: k.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type APIKey struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID uint32 `gorm:"index;not null"`
|
||||
Name string `gorm:"not null"`
|
||||
Prefix string `gorm:"not null"`
|
||||
HashedKey string `gorm:"uniqueIndex;not null"`
|
||||
ExpiresAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type APIKeyRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAPIKeyRepository(db *gorm.DB) *APIKeyRepository {
|
||||
return &APIKeyRepository{db}
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) Create(key domainAPIKey.APIKey) (domainAPIKey.APIKey, error) {
|
||||
model := mappers.APIKeyToModel(key)
|
||||
model.ID = 0
|
||||
if err := a.db.Create(&model).Error; err != nil {
|
||||
return domainAPIKey.APIKey{}, err
|
||||
}
|
||||
return mappers.APIKeyToDomain(model), nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) Get(id uint32) (domainAPIKey.APIKey, error) {
|
||||
var model entity.APIKey
|
||||
if err := a.db.First(&model, id).Error; err != nil {
|
||||
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
|
||||
}
|
||||
return mappers.APIKeyToDomain(model), nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) GetByHashedKey(hashedKey string) (domainAPIKey.APIKey, error) {
|
||||
var model entity.APIKey
|
||||
if err := a.db.Where("hashed_key = ?", hashedKey).First(&model).Error; err != nil {
|
||||
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
|
||||
}
|
||||
return mappers.APIKeyToDomain(model), nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) ListByUser(userId uint32) ([]domainAPIKey.APIKey, error) {
|
||||
return a.list(a.db.Where("user_id = ?", userId))
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) ListAll() ([]domainAPIKey.APIKey, error) {
|
||||
return a.list(a.db)
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) list(query *gorm.DB) ([]domainAPIKey.APIKey, error) {
|
||||
var found []entity.APIKey
|
||||
if err := query.Order("created_at DESC, id DESC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := make([]domainAPIKey.APIKey, 0, len(found))
|
||||
for _, model := range found {
|
||||
keys = append(keys, mappers.APIKeyToDomain(model))
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) Revoke(id uint32, at time.Time) error {
|
||||
result := a.db.Model(&entity.APIKey{}).
|
||||
Where("id = ? AND revoked_at IS NULL", id).
|
||||
Update("revoked_at", at)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) RevokeAllByUser(userId uint32, at time.Time) error {
|
||||
return a.db.Model(&entity.APIKey{}).
|
||||
Where("user_id = ? AND revoked_at IS NULL", userId).
|
||||
Update("revoked_at", at).Error
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) TouchLastUsed(id uint32, at time.Time) error {
|
||||
return a.db.Model(&entity.APIKey{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_used_at", at).Error
|
||||
}
|
||||
|
||||
var _ domainAPIKey.Repository = &APIKeyRepository{}
|
||||
@@ -0,0 +1,67 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const lastUsedWriteInterval = time.Minute
|
||||
|
||||
type AuthenticateAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
keyGenerator apikey.KeyGenerator
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type AuthenticatedAPIKey struct {
|
||||
Key apikey.APIKey
|
||||
User user.User
|
||||
}
|
||||
|
||||
func NewAuthenticateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, userRepository user.Repository, now func() time.Time) *AuthenticateAPIKeyUseCase {
|
||||
return &AuthenticateAPIKeyUseCase{apiKeyRepository, keyGenerator, userRepository, now}
|
||||
}
|
||||
|
||||
func (a *AuthenticateAPIKeyUseCase) AuthenticateAPIKey(rawKey string) (AuthenticatedAPIKey, error) {
|
||||
if !strings.HasPrefix(rawKey, apikey.RawKeyPrefix) {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
|
||||
key, err := a.apiKeyRepository.GetByHashedKey(a.keyGenerator.Hash(rawKey))
|
||||
if err != nil {
|
||||
if errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
return AuthenticatedAPIKey{}, err
|
||||
}
|
||||
|
||||
now := a.now()
|
||||
if key.StatusAt(now) != apikey.StatusActive {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
|
||||
owner, err := a.userRepository.Get(key.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
return AuthenticatedAPIKey{}, err
|
||||
}
|
||||
if !owner.IsVerified {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
|
||||
a.recordUse(key, now)
|
||||
return AuthenticatedAPIKey{Key: key, User: owner}, nil
|
||||
}
|
||||
|
||||
func (a *AuthenticateAPIKeyUseCase) recordUse(key apikey.APIKey, now time.Time) {
|
||||
if key.LastUsedAt != nil && now.Sub(*key.LastUsedAt) < lastUsedWriteInterval {
|
||||
return
|
||||
}
|
||||
_ = a.apiKeyRepository.TouchLastUsed(key.ID, now)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
keyGenerator apikey.KeyGenerator
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type CreateAPIKeyCommand struct {
|
||||
UserID uint32
|
||||
Name string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type CreatedAPIKey struct {
|
||||
Key apikey.APIKey
|
||||
RawKey string
|
||||
}
|
||||
|
||||
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time) *CreateAPIKeyUseCase {
|
||||
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now}
|
||||
}
|
||||
|
||||
func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (CreatedAPIKey, error) {
|
||||
now := c.now()
|
||||
if command.ExpiresAt != nil && !command.ExpiresAt.After(now) {
|
||||
return CreatedAPIKey{}, apikey.ErrExpiryNotInFuture
|
||||
}
|
||||
|
||||
rawKey, prefix, hashedKey := c.keyGenerator.Generate()
|
||||
stored, err := c.apiKeyRepository.Create(apikey.APIKey{
|
||||
UserID: command.UserID,
|
||||
Name: command.Name,
|
||||
Prefix: prefix,
|
||||
HashedKey: hashedKey,
|
||||
ExpiresAt: command.ExpiresAt,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return CreatedAPIKey{}, err
|
||||
}
|
||||
|
||||
return CreatedAPIKey{Key: stored, RawKey: rawKey}, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListAPIKeysUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type APIKeyEntry struct {
|
||||
Key apikey.APIKey
|
||||
Status apikey.Status
|
||||
}
|
||||
|
||||
func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *ListAPIKeysUseCase {
|
||||
return &ListAPIKeysUseCase{apiKeyRepository, userRepository, now}
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, error) {
|
||||
requester, err := l.userRepository.Get(requesterId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys, err := l.keysVisibleTo(requester)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
if keys[i].CreatedAt.Equal(keys[j].CreatedAt) {
|
||||
return keys[i].ID > keys[j].ID
|
||||
}
|
||||
return keys[i].CreatedAt.After(keys[j].CreatedAt)
|
||||
})
|
||||
|
||||
now := l.now()
|
||||
entries := make([]APIKeyEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now)})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
|
||||
if requester.Role == user.RoleOwner {
|
||||
return l.apiKeyRepository.ListAll()
|
||||
}
|
||||
return l.apiKeyRepository.ListByUser(requester.ID)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"time"
|
||||
@@ -9,10 +10,12 @@ import (
|
||||
type ResetPasswordUseCase struct {
|
||||
userRepository user.Repository
|
||||
hasher hash.Hasher
|
||||
apiKeys apikey.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher) *ResetPasswordUseCase {
|
||||
return &ResetPasswordUseCase{userRepository, hasher}
|
||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time) *ResetPasswordUseCase {
|
||||
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now}
|
||||
}
|
||||
|
||||
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
|
||||
@@ -25,7 +28,7 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
||||
return user.ErrInvalidPasswordResetCode
|
||||
}
|
||||
|
||||
if time.Now().After(dbUser.PasswordResetExpiration) {
|
||||
if r.now().After(dbUser.PasswordResetExpiration) {
|
||||
return user.ErrPasswordResetCodeExpired
|
||||
}
|
||||
|
||||
@@ -34,6 +37,9 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword)
|
||||
return err
|
||||
if _, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.apiKeys.RevokeAllByUser(dbUser.ID, r.now())
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RevokeAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *RevokeAPIKeyUseCase {
|
||||
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now}
|
||||
}
|
||||
|
||||
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) error {
|
||||
key, err := r.apiKeyRepository.Get(keyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requester, err := r.userRepository.Get(requesterId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if requester.Role != user.RoleOwner && key.UserID != requester.ID {
|
||||
return apikey.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
if key.RevokedAt != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.apiKeyRepository.Revoke(key.ID, r.now())
|
||||
}
|
||||
Reference in new issue
Block a user