Files
pocket-id/backend/internal/apikey/service.go
T

272 lines
6.9 KiB
Go

package apikey
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// Service holds the business logic for managing user API keys
type Service struct {
db *gorm.DB
staticApiKey string
}
func newService(ctx context.Context, db *gorm.DB, staticApiKey string) (*Service, error) {
s := &Service{
db: db,
staticApiKey: staticApiKey,
}
if staticApiKey == "" {
err := s.deleteStaticApiKeyUser(ctx)
if err != nil {
return nil, err
}
}
return s, nil
}
func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]ApiKey, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&ApiKey{})
var apiKeys []ApiKey
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
if err != nil {
return nil, utils.PaginationResponse{}, fmt.Errorf("error listing API keys: %w", err)
}
return apiKeys, pagination, nil
}
func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) {
// Check if expiration is in the future
if !input.ExpiresAt.ToTime().After(time.Now()) {
return ApiKey{}, "", apperror.InvalidAPIKeyExpiration()
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return ApiKey{}, "", fmt.Errorf("error generating API key token: %w", err)
}
apiKey := ApiKey{
Name: input.Name,
Key: utils.CreateSha256Hash(token), // Hash the token for storage
Description: input.Description,
ExpiresAt: input.ExpiresAt,
UserID: userID,
}
err = s.db.
WithContext(ctx).
Create(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ApiKey{}, "", apperror.AlreadyInUse("API key name")
}
return ApiKey{}, "", fmt.Errorf("error creating API key: %w", err)
}
// Return the raw token only once - it cannot be retrieved later
return apiKey, token, nil
}
func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) {
// Check if expiration is in the future
if !expiration.After(time.Now()) {
return ApiKey{}, "", apperror.InvalidAPIKeyExpiration()
}
tx := s.db.Begin()
defer tx.Rollback()
var apiKey ApiKey
err := tx.
WithContext(ctx).
Model(&ApiKey{}).
Where("id = ? AND user_id = ?", apiKeyID, userID).
First(&apiKey).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return ApiKey{}, "", apperror.APIKeyNotFound()
} else if err != nil {
return ApiKey{}, "", fmt.Errorf("error loading API key: %w", err)
}
// Only allow renewal if the key has already expired
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
return ApiKey{}, "", apperror.APIKeyNotExpired()
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return ApiKey{}, "", fmt.Errorf("error generating API key token: %w", err)
}
apiKey.Key = utils.CreateSha256Hash(token)
apiKey.ExpiresAt = datatype.DateTime(expiration)
err = tx.WithContext(ctx).Save(&apiKey).Error
if err != nil {
return ApiKey{}, "", fmt.Errorf("error saving API key: %w", err)
}
err = tx.Commit().Error
if err != nil {
return ApiKey{}, "", fmt.Errorf("error committing transaction: %w", err)
}
return apiKey, token, nil
}
func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey ApiKey
result := s.db.
WithContext(ctx).
Where("id = ? AND user_id = ?", apiKeyID, userID).
Delete(&apiKey)
if result.Error != nil {
return fmt.Errorf("error deleting API key: %w", result.Error)
}
if result.RowsAffected == 0 {
return apperror.APIKeyNotFound()
}
return nil
}
func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
if apiKey == "" {
return model.User{}, apperror.NoAPIKeyProvided()
}
if s.staticApiKey != "" && apiKey == s.staticApiKey {
return s.initStaticApiKeyUser(ctx)
}
now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey)
var key ApiKey
err := s.db.
WithContext(ctx).
Model(&ApiKey{}).
Clauses(clause.Returning{}).
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
Updates(&ApiKey{
LastUsedAt: new(datatype.DateTime(now)),
}).
Preload("User").
First(&key).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, apperror.InvalidAPIKey()
} else if err != nil {
return model.User{}, fmt.Errorf("error loading API key: %w", err)
}
return key.User, nil
}
func (s *Service) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) {
var keys []ApiKey
now := time.Now()
cutoff := now.AddDate(0, 0, daysAhead)
err := s.db.
WithContext(ctx).
Preload("User").
Where("expires_at > ? AND expires_at <= ? AND expiration_email_sent = ?", datatype.DateTime(now), datatype.DateTime(cutoff), false).
Find(&keys).
Error
if err != nil {
return nil, fmt.Errorf("error listing API keys: %w", err)
}
return keys, nil
}
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
func (s *Service) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
err := s.db.WithContext(ctx).
Model(&ApiKey{}).
Where("id = ?", apiKeyID).
Update("expiration_email_sent", true).
Error
if err != nil {
return fmt.Errorf("error marking API key expiration email sent: %w", err)
}
return nil
}
func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
err = s.db.
WithContext(ctx).
First(&user, "id = ?", common.StaticApiKeyUserID).
Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, fmt.Errorf("error loading static API key user: %w", err)
}
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
if err != nil {
return model.User{}, fmt.Errorf("error generating static API key username suffix: %w", err)
}
user = model.User{
Base: model.Base{
ID: common.StaticApiKeyUserID,
},
FirstName: "Static API User",
Username: "static-api-user-" + usernameSuffix,
DisplayName: "Static API User",
IsAdmin: true,
}
err = s.db.
WithContext(ctx).
Create(&user).
Error
if err != nil {
return model.User{}, fmt.Errorf("error creating static API key user: %w", err)
}
return user, nil
}
func (s *Service) deleteStaticApiKeyUser(ctx context.Context) error {
err := s.db.
WithContext(ctx).
Delete(&model.User{}, "id = ?", common.StaticApiKeyUserID).
Error
if err != nil {
return fmt.Errorf("error deleting static API key user: %w", err)
}
return nil
}