mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-24 21:17:31 +00:00
258 lines
6.0 KiB
Go
258 lines
6.0 KiB
Go
package apikey
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"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{}, 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{}, "", &common.APIKeyExpirationDateError{}
|
|
}
|
|
|
|
// Generate a secure random API key
|
|
token, err := utils.GenerateRandomAlphanumericString(32)
|
|
if err != nil {
|
|
return ApiKey{}, "", 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{}, "", &common.AlreadyInUseError{Property: "API key name"}
|
|
}
|
|
return ApiKey{}, "", 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{}, "", &common.APIKeyExpirationDateError{}
|
|
}
|
|
|
|
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 err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ApiKey{}, "", &common.APIKeyNotFoundError{}
|
|
}
|
|
return ApiKey{}, "", err
|
|
}
|
|
|
|
// Only allow renewal if the key has already expired
|
|
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
|
|
return ApiKey{}, "", &common.APIKeyNotExpiredError{}
|
|
}
|
|
|
|
// Generate a secure random API key
|
|
token, err := utils.GenerateRandomAlphanumericString(32)
|
|
if err != nil {
|
|
return ApiKey{}, "", err
|
|
}
|
|
|
|
apiKey.Key = utils.CreateSha256Hash(token)
|
|
apiKey.ExpiresAt = datatype.DateTime(expiration)
|
|
|
|
err = tx.WithContext(ctx).Save(&apiKey).Error
|
|
if err != nil {
|
|
return ApiKey{}, "", err
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return ApiKey{}, "", err
|
|
}
|
|
|
|
return apiKey, token, nil
|
|
}
|
|
|
|
func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
|
|
var apiKey ApiKey
|
|
err := s.db.
|
|
WithContext(ctx).
|
|
Where("id = ? AND user_id = ?", apiKeyID, userID).
|
|
Delete(&apiKey).
|
|
Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return &common.APIKeyNotFoundError{}
|
|
}
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
|
|
if apiKey == "" {
|
|
return model.User{}, &common.NoAPIKeyProvidedError{}
|
|
}
|
|
|
|
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 err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return model.User{}, &common.InvalidAPIKeyError{}
|
|
}
|
|
|
|
return model.User{}, 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
|
|
|
|
return keys, err
|
|
}
|
|
|
|
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
|
|
func (s *Service) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
|
|
return s.db.WithContext(ctx).
|
|
Model(&ApiKey{}).
|
|
Where("id = ?", apiKeyID).
|
|
Update("expiration_email_sent", true).
|
|
Error
|
|
}
|
|
|
|
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{}, err
|
|
}
|
|
|
|
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
|
|
if err != nil {
|
|
return model.User{}, 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
|
|
|
|
return user, err
|
|
}
|
|
|
|
func (s *Service) deleteStaticApiKeyUser(ctx context.Context) error {
|
|
return s.db.
|
|
WithContext(ctx).
|
|
Delete(&model.User{}, "id = ?", common.StaticApiKeyUserID).
|
|
Error
|
|
}
|