From 26f01f205be01fb8abd8c2e564c90c0fc4480ea5 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Sun, 20 Apr 2025 09:40:20 -0500 Subject: [PATCH] feat: send email to user when api key expires within 7 days (#451) Co-authored-by: Elias Schneider --- .../internal/bootstrap/router_bootstrap.go | 3 +- backend/internal/dto/api_key_dto.go | 13 ++--- backend/internal/job/api_key_expiry_job.go | 49 +++++++++++++++++ backend/internal/model/api_key.go | 11 ++-- backend/internal/service/api_key_service.go | 52 +++++++++++++++++-- backend/internal/service/audit_log_service.go | 2 +- .../service/email_service_templates.go | 15 +++++- backend/internal/service/user_service.go | 2 +- .../api-key-expiring-soon_html.tmpl | 17 ++++++ .../api-key-expiring-soon_text.tmpl | 10 ++++ ...expiration_email_sent_to_api_keys.down.sql | 2 + ...d_expiration_email_sent_to_api_keys.up.sql | 2 + ...expiration_email_sent_to_api_keys.down.sql | 2 + ...d_expiration_email_sent_to_api_keys.up.sql | 2 + 14 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 backend/internal/job/api_key_expiry_job.go create mode 100644 backend/resources/email-templates/api-key-expiring-soon_html.tmpl create mode 100644 backend/resources/email-templates/api-key-expiring-soon_text.tmpl create mode 100644 backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.down.sql create mode 100644 backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.up.sql create mode 100644 backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.down.sql create mode 100644 backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.up.sql diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 6edbfbab..724e1f7e 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -49,7 +49,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) userGroupService := service.NewUserGroupService(db, appConfigService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) - apiKeyService := service.NewApiKeyService(db) + apiKeyService := service.NewApiKeyService(db, emailService) rateLimitMiddleware := middleware.NewRateLimitMiddleware() @@ -61,6 +61,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC job.RegisterLdapJobs(ctx, ldapService, appConfigService) job.RegisterDbCleanupJobs(ctx, db) job.RegisterFileCleanupJobs(ctx, db) + job.RegisterApiKeyExpiryJob(ctx, apiKeyService, appConfigService) // Initialize middleware for specific routes authMiddleware := middleware.NewAuthMiddleware(apiKeyService, userService, jwtService) diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go index 989176e9..e0945c58 100644 --- a/backend/internal/dto/api_key_dto.go +++ b/backend/internal/dto/api_key_dto.go @@ -11,12 +11,13 @@ type ApiKeyCreateDto struct { } type ApiKeyDto struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - ExpiresAt datatype.DateTime `json:"expiresAt"` - LastUsedAt *datatype.DateTime `json:"lastUsedAt"` - CreatedAt datatype.DateTime `json:"createdAt"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ExpiresAt datatype.DateTime `json:"expiresAt"` + LastUsedAt *datatype.DateTime `json:"lastUsedAt"` + CreatedAt datatype.DateTime `json:"createdAt"` + ExpirationEmailSent bool `json:"expirationEmailSent"` } type ApiKeyResponseDto struct { diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go new file mode 100644 index 00000000..5a44f369 --- /dev/null +++ b/backend/internal/job/api_key_expiry_job.go @@ -0,0 +1,49 @@ +package job + +import ( + "context" + "log" + + "github.com/go-co-op/gocron/v2" + "github.com/pocket-id/pocket-id/backend/internal/service" +) + +type ApiKeyEmailJobs struct { + apiKeyService *service.ApiKeyService + appConfigService *service.AppConfigService +} + +func RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) { + jobs := &ApiKeyEmailJobs{ + apiKeyService: apiKeyService, + appConfigService: appConfigService, + } + + scheduler, err := gocron.NewScheduler() + if err != nil { + log.Fatalf("Failed to create a new scheduler: %v", err) + } + + registerJob(ctx, scheduler, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys) + + scheduler.Start() +} + +func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { + + apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7) + if err != nil { + log.Printf("Failed to list expiring API keys: %v", err) + return err + } + + for _, key := range apiKeys { + if key.User.Email == "" { + continue + } + if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil { + log.Printf("Failed to send email for key %s: %v", key.ID, err) + } + } + return nil +} diff --git a/backend/internal/model/api_key.go b/backend/internal/model/api_key.go index 2fcf3703..c2ff9308 100644 --- a/backend/internal/model/api_key.go +++ b/backend/internal/model/api_key.go @@ -5,11 +5,12 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" type ApiKey struct { Base - Name string `sortable:"true"` - Key string - Description *string - ExpiresAt datatype.DateTime `sortable:"true"` - LastUsedAt *datatype.DateTime `sortable:"true"` + Name string `sortable:"true"` + Key string + Description *string + ExpiresAt datatype.DateTime `sortable:"true"` + LastUsedAt *datatype.DateTime `sortable:"true"` + ExpirationEmailSent bool UserID string User User diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 50b04e5c..4ae4c810 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -6,6 +6,7 @@ import ( "time" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils/email" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" @@ -16,11 +17,12 @@ import ( ) type ApiKeyService struct { - db *gorm.DB + db *gorm.DB + emailService *EmailService } -func NewApiKeyService(db *gorm.DB) *ApiKeyService { - return &ApiKeyService{db: db} +func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService { + return &ApiKeyService{db: db, emailService: emailService} } func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) { @@ -117,3 +119,47 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode return key.User, nil } + +func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]model.ApiKey, error) { + var keys []model.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 +} + +func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error { + user := apiKey.User + + if user.ID == "" { + if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil { + return err + } + } + + err := SendEmail(ctx, s.emailService, email.Address{ + Name: user.FullName(), + Email: user.Email, + }, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{ + ApiKeyName: apiKey.Name, + ExpiresAt: apiKey.ExpiresAt.ToTime(), + Name: user.FirstName, + }) + if err != nil { + return err + } + + // Mark the API key as having had an expiration email sent + return s.db.WithContext(ctx). + Model(&model.ApiKey{}). + Where("id = ?", apiKey.ID). + Update("expiration_email_sent", true). + Error +} diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index bc123889..6b59fc26 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -90,7 +90,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres } innerErr = SendEmail(innerCtx, s.emailService, email.Address{ - Name: user.Username, + Name: user.FullName(), Email: user.Email, }, NewLoginTemplate, &NewLoginTemplateData{ IPAddress: ipAddress, diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go index 8b7366c5..c140ab63 100644 --- a/backend/internal/service/email_service_templates.go +++ b/backend/internal/service/email_service_templates.go @@ -42,6 +42,13 @@ var TestTemplate = email.Template[struct{}]{ }, } +var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{ + Path: "api-key-expiring-soon", + Title: func(data *email.TemplateData[ApiKeyExpiringSoonTemplateData]) string { + return fmt.Sprintf("API Key \"%s\" Expiring Soon", data.Data.ApiKeyName) + }, +} + type NewLoginTemplateData struct { IPAddress string Country string @@ -56,5 +63,11 @@ type OneTimeAccessTemplateData = struct { LoginLinkWithCode string } +type ApiKeyExpiringSoonTemplateData struct { + Name string + ApiKeyName string + ExpiresAt time.Time +} + // this is list of all template paths used for preloading templates -var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path} +var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 63211de4..afd65231 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -399,7 +399,7 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres } errInternal := SendEmail(innerCtx, s.emailService, email.Address{ - Name: user.Username, + Name: user.FullName(), Email: user.Email, }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ Code: oneTimeAccessToken, diff --git a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl new file mode 100644 index 00000000..f0632cbb --- /dev/null +++ b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl @@ -0,0 +1,17 @@ +{{ define "base" }} +
+ +
Warning
+
+
+

API Key Expiring Soon

+

+ Hello {{ .Data.Name }},

+ This is a reminder that your API key {{ .Data.ApiKeyName }} will expire on {{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}.

+ Please generate a new API key if you need continued access. +

+
+{{ end }} \ No newline at end of file diff --git a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl new file mode 100644 index 00000000..7cc78715 --- /dev/null +++ b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl @@ -0,0 +1,10 @@ +{{ define "base" -}} +API Key Expiring Soon +==================== + +Hello {{ .Data.Name }}, + +This is a reminder that your API key "{{ .Data.ApiKeyName }}" will expire on {{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}. + +Please generate a new API key if you need continued access. +{{ end -}} \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.down.sql b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.down.sql new file mode 100644 index 00000000..f9e9ab44 --- /dev/null +++ b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE api_keys +DROP COLUMN IF EXISTS expiration_email_sent; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.up.sql b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.up.sql new file mode 100644 index 00000000..5530eddc --- /dev/null +++ b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE api_keys +ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.down.sql b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.down.sql new file mode 100644 index 00000000..16532c3a --- /dev/null +++ b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE api_keys +DROP COLUMN expiration_email_sent; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.up.sql b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.up.sql new file mode 100644 index 00000000..8f63aba6 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE api_keys +ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT 0; \ No newline at end of file