mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-13 16:53:03 +03:00
feat: send email to user when api key expires within 7 days (#451)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -49,7 +49,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC
|
|||||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||||
userGroupService := service.NewUserGroupService(db, appConfigService)
|
userGroupService := service.NewUserGroupService(db, appConfigService)
|
||||||
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
||||||
apiKeyService := service.NewApiKeyService(db)
|
apiKeyService := service.NewApiKeyService(db, emailService)
|
||||||
|
|
||||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC
|
|||||||
job.RegisterLdapJobs(ctx, ldapService, appConfigService)
|
job.RegisterLdapJobs(ctx, ldapService, appConfigService)
|
||||||
job.RegisterDbCleanupJobs(ctx, db)
|
job.RegisterDbCleanupJobs(ctx, db)
|
||||||
job.RegisterFileCleanupJobs(ctx, db)
|
job.RegisterFileCleanupJobs(ctx, db)
|
||||||
|
job.RegisterApiKeyExpiryJob(ctx, apiKeyService, appConfigService)
|
||||||
|
|
||||||
// Initialize middleware for specific routes
|
// Initialize middleware for specific routes
|
||||||
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, userService, jwtService)
|
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, userService, jwtService)
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ type ApiKeyCreateDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ApiKeyDto struct {
|
type ApiKeyDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
ExpirationEmailSent bool `json:"expirationEmailSent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiKeyResponseDto struct {
|
type ApiKeyResponseDto struct {
|
||||||
|
|||||||
49
backend/internal/job/api_key_expiry_job.go
Normal file
49
backend/internal/job/api_key_expiry_job.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|||||||
type ApiKey struct {
|
type ApiKey struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Name string `sortable:"true"`
|
Name string `sortable:"true"`
|
||||||
Key string
|
Key string
|
||||||
Description *string
|
Description *string
|
||||||
ExpiresAt datatype.DateTime `sortable:"true"`
|
ExpiresAt datatype.DateTime `sortable:"true"`
|
||||||
LastUsedAt *datatype.DateTime `sortable:"true"`
|
LastUsedAt *datatype.DateTime `sortable:"true"`
|
||||||
|
ExpirationEmailSent bool
|
||||||
|
|
||||||
UserID string
|
UserID string
|
||||||
User User
|
User User
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
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/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
@@ -16,11 +17,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ApiKeyService struct {
|
type ApiKeyService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
emailService *EmailService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApiKeyService(db *gorm.DB) *ApiKeyService {
|
func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
|
||||||
return &ApiKeyService{db: db}
|
return &ApiKeyService{db: db, emailService: emailService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
|||||||
}
|
}
|
||||||
|
|
||||||
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
|
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
|
||||||
Name: user.Username,
|
Name: user.FullName(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
}, NewLoginTemplate, &NewLoginTemplateData{
|
}, NewLoginTemplate, &NewLoginTemplateData{
|
||||||
IPAddress: ipAddress,
|
IPAddress: ipAddress,
|
||||||
|
|||||||
@@ -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 {
|
type NewLoginTemplateData struct {
|
||||||
IPAddress string
|
IPAddress string
|
||||||
Country string
|
Country string
|
||||||
@@ -56,5 +63,11 @@ type OneTimeAccessTemplateData = struct {
|
|||||||
LoginLinkWithCode string
|
LoginLinkWithCode string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiKeyExpiringSoonTemplateData struct {
|
||||||
|
Name string
|
||||||
|
ApiKeyName string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// this is list of all template paths used for preloading templates
|
// 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}
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
|
|||||||
}
|
}
|
||||||
|
|
||||||
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
|
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
|
||||||
Name: user.Username,
|
Name: user.FullName(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||||
Code: oneTimeAccessToken,
|
Code: oneTimeAccessToken,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{ define "base" }}
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
|
||||||
|
<h1>{{ .AppName }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="warning">Warning</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>API Key Expiring Soon</h2>
|
||||||
|
<p>
|
||||||
|
Hello {{ .Data.Name }},<br/><br/>
|
||||||
|
This is a reminder that your API key <strong>{{ .Data.ApiKeyName }}</strong> will expire on <strong>{{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}</strong>.<br/><br/>
|
||||||
|
Please generate a new API key if you need continued access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -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 -}}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE api_keys
|
||||||
|
DROP COLUMN IF EXISTS expiration_email_sent;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE api_keys
|
||||||
|
ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE api_keys
|
||||||
|
DROP COLUMN expiration_email_sent;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE api_keys
|
||||||
|
ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT 0;
|
||||||
Reference in New Issue
Block a user