feat: integration v2 and API key PART 1/3

This commit is contained in:
fsylvain committed 2026-10-04 21:56:14 +02:00
1 parent 7e9bd9ba30
commit 844842fdae
40 files changed
+3782 -64

No files matched your search

+42
View File
@@ -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())
}