Closes the missing piece of ADR-0021's admin surface. Was referenced by the @todo BDD scenarios in features/jwt/jwt_secret_retention.feature since PR #41 but never wired up. Security-first design: - Endpoint returns metadata ONLY: is_primary, created_at_unix, expires_at_unix?, age_seconds, is_expired, secret_sha256 (8-byte prefix as fingerprint). The secret VALUE is intentionally never returned — exposing it via API would defeat the retention/rotation infrastructure. The fingerprint is enough for ops correlation in logs without leak surface. - Routed under /api/v1/admin/jwt/secrets. The existing admin auth middleware (POST endpoints below) gates GET in the same way — same router subtree. Plumbing: - New JWTSecretInfo struct in pkg/user/user.go (metadata-only). - AuthService.ListJWTSecretsInfo() interface method. - userServiceImpl.ListJWTSecretsInfo() implementation: calls GetAllValidSecrets, computes age + fingerprint, returns view. - handleListJWTSecrets in pkg/user/api/admin_handler.go. - Documentation/API.md updated with full schema + security note. Tests: - TestListJWTSecretsInfo_ReturnsMetadataOnlyNotSecretValues in pkg/user/jwt_manager_test.go covers GetAllValidSecrets exclusion of expired secrets (the underlying primitive). go test -race passes. - Full BDD suite (auth/config/greet/health/info/jwt) green. @todo BDD scenarios in features/jwt/jwt_secret_retention.feature can now be activated in a follow-up PR — left as @todo for review.
101 lines
4.5 KiB
Go
101 lines
4.5 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
|
|
// ListJWTSecretsInfo returns metadata about every currently-tracked JWT
|
|
// secret WITHOUT exposing the secret values. Used by the admin
|
|
// introspection endpoint and BDD tests verifying cleanup behavior.
|
|
// Order is preserved from internal storage (insertion order).
|
|
ListJWTSecretsInfo() []JWTSecretInfo
|
|
}
|
|
|
|
// JWTSecretInfo is a non-sensitive metadata view of a JWT secret.
|
|
// The secret VALUE is intentionally NOT included — exposing it via an
|
|
// API endpoint, even an admin one, would defeat the point of the
|
|
// retention/rotation infrastructure.
|
|
type JWTSecretInfo struct {
|
|
IsPrimary bool `json:"is_primary"`
|
|
CreatedAtUnix int64 `json:"created_at_unix"`
|
|
ExpiresAtUnix *int64 `json:"expires_at_unix,omitempty"`
|
|
AgeSeconds int64 `json:"age_seconds"`
|
|
IsExpired bool `json:"is_expired"`
|
|
SecretSHA256 string `json:"secret_sha256"` // first 16 hex chars of sha256 — fingerprint, not the secret
|
|
}
|
|
|
|
// 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
|
|
}
|