Implements the cleanup half of ADR-0021 (which had only config infrastructure landed). Non-primary expired secrets are removed by a goroutine that runs at auth.jwt.secret_retention.cleanup_interval (default 1h). Primary secret is never removed regardless of expiration — invariant preserved. Changes: - pkg/user/jwt_manager.go : add sync.Mutex protection; add RemoveExpiredSecrets() int and StartCleanupLoop(ctx, interval) methods. Reset() now also cancels any running cleanup goroutine. - pkg/user/auth_service.go : delegate to manager via new AuthService methods StartJWTSecretCleanupLoop and RemoveExpiredJWTSecrets. - pkg/user/user.go : extend AuthService interface accordingly. - pkg/server/server.go Run() : start cleanup loop tied to rootCtx so it stops on graceful shutdown. - pkg/jwt/* : same treatment on the secondary (less-used) implementation for consistency. - adr/0021-jwt-secret-retention-policy.md : Status → Implemented + fix numbering (was incorrectly "10."). Tests: - 4 new unit tests in pkg/user/jwt_manager_test.go covering RemoveExpiredSecrets (expired removed, primary preserved, future kept) and StartCleanupLoop (fires + stops on context cancel). - go test -race ./pkg/user/... passes. - Full BDD suite (auth/config/greet/health/info/jwt) still green. - BDD scenarios at @todo / @skip remain so — they require an admin endpoint /api/v1/admin/jwt/secrets which is explicitly out of scope. Verifier verdict: APPROVE_WITH_NITS — StartCleanupLoop is 34 lines (just over the 30-line guideline); 2 time.Sleeps in TestStartCleanupLoop_FiresAndStops are justified by the goroutine-timing nature of the test.
83 lines
3.6 KiB
Go
83 lines
3.6 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
// User represents a user in the system
|
|
type User struct {
|
|
ID uint `json:"id" gorm:"primaryKey"`
|
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
|
DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"`
|
|
Username string `json:"username" gorm:"unique;not null" validate:"required,min=3,max=50"`
|
|
PasswordHash string `json:"-" gorm:"not null"`
|
|
Description *string `json:"description,omitempty"`
|
|
CurrentGoal *string `json:"current_goal,omitempty"`
|
|
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
|
AllowPasswordReset bool `json:"allow_password_reset" gorm:"default:false"`
|
|
LastLogin *time.Time `json:"last_login,omitempty"`
|
|
}
|
|
|
|
// UserRepository defines the interface for user persistence
|
|
type UserRepository interface {
|
|
CreateUser(ctx context.Context, user *User) error
|
|
GetUserByUsername(ctx context.Context, username string) (*User, error)
|
|
GetUserByID(ctx context.Context, id uint) (*User, error)
|
|
UpdateUser(ctx context.Context, user *User) error
|
|
DeleteUser(ctx context.Context, id uint) error
|
|
AllowPasswordReset(ctx context.Context, username string) error
|
|
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
|
UserExists(ctx context.Context, username string) (bool, error)
|
|
CheckDatabaseHealth(ctx context.Context) error
|
|
}
|
|
|
|
// AuthService defines interface for authentication operations
|
|
type AuthService interface {
|
|
Authenticate(ctx context.Context, username, password string) (*User, error)
|
|
GenerateJWT(ctx context.Context, user *User) (string, error)
|
|
ValidateJWT(ctx context.Context, token string) (*User, error)
|
|
AdminAuthenticate(ctx context.Context, masterPassword string) (*User, error)
|
|
AddJWTSecret(secret string, isPrimary bool, expiresIn time.Duration)
|
|
RotateJWTSecret(newSecret string)
|
|
GetJWTSecretByIndex(index int) (string, bool)
|
|
ResetJWTSecrets() // Reset JWT secrets to initial state for test cleanup
|
|
// StartJWTSecretCleanupLoop starts a goroutine that periodically calls
|
|
// RemoveExpiredJWTSecrets at the given interval, stopping when ctx is
|
|
// cancelled. Implements the cleanup half of ADR-0021. interval <= 0
|
|
// disables the loop.
|
|
StartJWTSecretCleanupLoop(ctx context.Context, interval time.Duration)
|
|
// RemoveExpiredJWTSecrets triggers an immediate cleanup pass and returns
|
|
// the count of removed non-primary expired secrets. Useful for tests
|
|
// driving cleanup synchronously.
|
|
RemoveExpiredJWTSecrets() int
|
|
}
|
|
|
|
// UserManager defines interface for user management operations
|
|
type UserManager interface {
|
|
UserExists(ctx context.Context, username string) (bool, error)
|
|
CreateUser(ctx context.Context, user *User) error
|
|
}
|
|
|
|
// PasswordService defines interface for password operations
|
|
type PasswordService interface {
|
|
HashPassword(ctx context.Context, password string) (string, error)
|
|
RequestPasswordReset(ctx context.Context, username string) error
|
|
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
|
}
|
|
|
|
// UserService composes all user-related interfaces using Go's interface composition
|
|
// This is cleaner than aggregation and better for testing
|
|
type UserService interface {
|
|
AuthService
|
|
UserManager
|
|
PasswordService
|
|
}
|
|
|
|
// PasswordResetService defines the interface for password reset workflow
|
|
type PasswordResetService interface {
|
|
RequestPasswordReset(ctx context.Context, username string) error
|
|
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
|
}
|