fix(security): resolve FLAW-08 with token_version invalidation and jwt blacklist
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
This commit is contained in:
1 parent
f7e70b649f
commit
50b017f387
10 files changed
+372
-63
No files matched your search
@@ -72,6 +72,7 @@ func (s *Store) migrate() error {
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
token_version INTEGER NOT NULL DEFAULT 1,
|
||||
alarm_channels TEXT NOT NULL DEFAULT 'discord',
|
||||
alarm_email TEXT NOT NULL DEFAULT '',
|
||||
discord_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
@@ -307,6 +308,16 @@ func (s *Store) migrate() error {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revoked_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id);
|
||||
`
|
||||
if _, err := s.db.Exec(schema); err != nil {
|
||||
return err
|
||||
@@ -318,6 +329,7 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
|
||||
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
|
||||
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
|
||||
"ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1;",
|
||||
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
|
||||
"ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';",
|
||||
@@ -536,7 +548,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
|
||||
|
||||
// --- USERS ---
|
||||
|
||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at`
|
||||
const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at`
|
||||
|
||||
func parseAlarmChannels(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
@@ -558,13 +570,16 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
var channelsRaw string
|
||||
err := row.Scan(
|
||||
&u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
|
||||
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
|
||||
&u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail,
|
||||
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.IsActive = isActiveInt != 0
|
||||
if u.TokenVersion <= 0 {
|
||||
u.TokenVersion = 1
|
||||
}
|
||||
if u.Role == "" {
|
||||
u.Role = "member"
|
||||
}
|
||||
@@ -604,6 +619,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
Role: role,
|
||||
IsActive: true,
|
||||
TokenVersion: 1,
|
||||
AlarmChannels: []string{},
|
||||
AlarmEmail: cleanEmail,
|
||||
DiscordWebhookURL: "",
|
||||
@@ -613,8 +629,8 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam
|
||||
CreatedAt: domain.NowISO(),
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, '', ?)`,
|
||||
`INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`,
|
||||
u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -811,14 +827,17 @@ func (s *Store) UpdateUserFull(
|
||||
if avatarURL != nil {
|
||||
u.AvatarURL = strings.TrimSpace(*avatarURL)
|
||||
}
|
||||
if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) {
|
||||
u.TokenVersion++
|
||||
}
|
||||
activeInt := 0
|
||||
if u.IsActive {
|
||||
activeInt = 1
|
||||
}
|
||||
channelsStr := strings.Join(u.AlarmChannels, ",")
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
||||
`UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`,
|
||||
u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -827,15 +846,45 @@ func (s *Store) UpdateUserFull(
|
||||
}
|
||||
|
||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||||
_, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
|
||||
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeToken adds an individual JWT to the blacklist until its expiration date
|
||||
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
|
||||
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
// Idempotently prune old expired tokens
|
||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
|
||||
func (s *Store) IsTokenRevoked(tokenStr string) bool {
|
||||
hash := auth.HashAPIKey(tokenStr)
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
|
||||
hash, time.Now().UTC().Format(time.RFC3339),
|
||||
).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
|
||||
func (s *Store) RevokeAllUserTokens(userID string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- THOUGHTS & TRIAGE ---
|
||||
|
||||
func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) {
|
||||
|
||||
Reference in new issue
Block a user