Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ ACCESS_TOKEN_MINUTES=15 # Access token lifetime (default
REFRESH_TOKEN_DAYS=1 # Refresh token lifetime (default: 1 day)
REFRESH_TOKEN_REMEMBER_ME_DAYS=30 # Refresh token lifetime with "remember me" (default: 30 days)
REFRESH_TOKEN_CLEANUP_INTERVAL_HOURS=24 # Cleanup interval for expired tokens (default: 24 hours)
REFRESH_ROTATION_GRACE_SECONDS=30 # Grace window for replaying a just-rotated refresh token (default: 30s)

# Rate Limiting Configuration
REFRESH_RATE_LIMIT_REQUESTS=10 # Requests per minute per IP (default: 10)
Expand Down
6 changes: 6 additions & 0 deletions api-doc/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3878,6 +3878,12 @@ const docTemplate = `{
},
"expires_in": {
"type": "integer"
},
"refresh_expires_in": {
"type": "integer"
},
"refresh_token": {
"type": "string"
}
}
},
Expand Down
6 changes: 6 additions & 0 deletions api-doc/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -3875,6 +3875,12 @@
},
"expires_in": {
"type": "integer"
},
"refresh_expires_in": {
"type": "integer"
},
"refresh_token": {
"type": "string"
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions api-doc/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,10 @@ definitions:
type: string
expires_in:
type: integer
refresh_expires_in:
type: integer
refresh_token:
type: string
type: object
security.RefreshTokenInput:
properties:
Expand Down
6 changes: 6 additions & 0 deletions pkg/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var (
RefreshTokenDays int
RefreshTokenRememberMeDays int
RefreshTokenCleanupIntervalHours int
RefreshRotationGraceSeconds int
RefreshRateLimitRequests int
RefreshRateLimitWindowMinutes int
ResendConfirmRateLimitRequests int
Expand All @@ -62,6 +63,7 @@ type Config struct {
RefreshTokenDays int
RefreshTokenRememberMeDays int
RefreshTokenCleanupIntervalHours int
RefreshRotationGraceSeconds int
RefreshRateLimitRequests int
RefreshRateLimitWindowMinutes int
ResendConfirmRateLimitRequests int
Expand Down Expand Up @@ -91,6 +93,7 @@ func EnvInit(envFilePath string) error {
RefreshTokenDays = newConfig.RefreshTokenDays
RefreshTokenRememberMeDays = newConfig.RefreshTokenRememberMeDays
RefreshTokenCleanupIntervalHours = newConfig.RefreshTokenCleanupIntervalHours
RefreshRotationGraceSeconds = newConfig.RefreshRotationGraceSeconds
RefreshRateLimitRequests = newConfig.RefreshRateLimitRequests
RefreshRateLimitWindowMinutes = newConfig.RefreshRateLimitWindowMinutes
ResendConfirmRateLimitRequests = newConfig.ResendConfirmRateLimitRequests
Expand Down Expand Up @@ -136,6 +139,7 @@ func newConfig() Config {
RefreshTokenDays: 1,
RefreshTokenRememberMeDays: 30,
RefreshTokenCleanupIntervalHours: 24,
RefreshRotationGraceSeconds: 30,
RefreshRateLimitRequests: 10,
RefreshRateLimitWindowMinutes: 1,
ResendConfirmRateLimitRequests: 1,
Expand Down Expand Up @@ -171,6 +175,8 @@ func setEnvVars(cfg *Config) {
ifEnvEmpty(os.Getenv("REFRESH_TOKEN_REMEMBER_ME_DAYS"), strconv.Itoa(cfg.RefreshTokenRememberMeDays)))
cfg.RefreshTokenCleanupIntervalHours, _ = strconv.Atoi(
ifEnvEmpty(os.Getenv("REFRESH_TOKEN_CLEANUP_INTERVAL_HOURS"), strconv.Itoa(cfg.RefreshTokenCleanupIntervalHours)))
cfg.RefreshRotationGraceSeconds, _ = strconv.Atoi(
ifEnvEmpty(os.Getenv("REFRESH_ROTATION_GRACE_SECONDS"), strconv.Itoa(cfg.RefreshRotationGraceSeconds)))
cfg.RefreshRateLimitRequests, _ = strconv.Atoi(
ifEnvEmpty(os.Getenv("REFRESH_RATE_LIMIT_REQUESTS"), strconv.Itoa(cfg.RefreshRateLimitRequests)))
cfg.RefreshRateLimitWindowMinutes, _ = strconv.Atoi(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE refresh_token DROP COLUMN rotated_at;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- rotated_at marks tokens revoked by rotation (superseded by a successor),
-- as opposed to tokens revoked by logout / password change / admin action.
-- It drives the reuse-detection grace window on /auth/refresh.
ALTER TABLE refresh_token ADD COLUMN rotated_at TIMESTAMPTZ;
14 changes: 14 additions & 0 deletions pkg/security/audit_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (
EventRefreshFailed AuditEventType = "refresh_failed"
EventLogout AuditEventType = "logout_success"
EventLogoutAll AuditEventType = "logout_all_success"
EventRefreshReuse AuditEventType = "refresh_token_reuse_detected"
EventRateLimitExceeded AuditEventType = "rate_limit_exceeded"
)

Expand Down Expand Up @@ -114,6 +115,19 @@ func AuditLogoutAll(c *gin.Context, userID uint, revokedSessions int64) {
})
}

// AuditRefreshReuseDetected logs the replay of a superseded refresh token
// beyond the rotation grace window — a possible token-theft signal surfaced
// for out-of-band alerting (no automatic revocation is performed)
func AuditRefreshReuseDetected(c *gin.Context, userID uint) {
logAuditEvent(AuditEvent{
EventType: EventRefreshReuse,
UserID: &userID,
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Message: "Superseded refresh token replayed beyond grace window",
})
}

// AuditRateLimitExceeded logs a rate limit exceeded event
func AuditRateLimitExceeded(c *gin.Context, endpoint string) {
logAuditEvent(AuditEvent{
Expand Down
109 changes: 97 additions & 12 deletions pkg/security/handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package security

import (
"errors"
"net/http"
"time"

Expand Down Expand Up @@ -42,31 +43,115 @@ func RefreshTokenHandler(c *gin.Context) {
}

// 3. Validate refresh token
if refreshToken.Revoked {
AuditRefreshFailed(c, "refresh token has been revoked")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Refresh token has been revoked"})
return
}

if time.Now().After(refreshToken.ExpiresAt) {
AuditRefreshFailed(c, "refresh token has expired")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Refresh token has expired"})
return
}

// 4. Generate new access token
// 4. A revoked token may still be within the rotation grace window
if refreshToken.Revoked {
handleRevokedRefreshToken(c, refreshToken)
return
}

// 5. Generate the access token BEFORE rotating: if signing fails we must
// not have already burned (revoked) the presented refresh token.
accessToken, err := GenerateToken(refreshToken.AccountID)
if err != nil {
AuditRefreshFailed(c, "failed to generate access token")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate access token"})
c.JSON(http.StatusInternalServerError, gin.H{"error": helper.ErrMsgInternalServer})
return
}

// 5. Update last_used_at (ignore errors - non-blocking)
_ = UpdateLastUsed(c.Request.Context(), refreshToken.ID)
// 6. Strict rotation: revoke the presented token and issue its successor.
successor, err := RotateRefreshToken(c.Request.Context(), refreshToken)
switch {
case err == nil:
AuditRefreshSuccess(c, refreshToken.AccountID)
c.JSON(http.StatusOK, RefreshResponse{
AccessToken: accessToken,
ExpiresIn: int64(config.AccessTokenMinutes * 60),
RefreshToken: successor.Token,
RefreshExpiresIn: max(0, int64(time.Until(successor.ExpiresAt).Seconds())),
})
case errors.Is(err, ErrTokenAlreadyRotated):
// The row changed between the read and the rotation UPDATE (a
// concurrent rotation, a logout/password/admin revocation, or a
// just-crossed expiry). Re-read and apply the current policy rather
// than blindly granting access.
reevaluateRefreshAfterRace(c, input.Token)
default:
AuditRefreshFailed(c, "failed to rotate refresh token")
c.JSON(http.StatusInternalServerError, gin.H{"error": helper.ErrMsgInternalServer})
}
}

// 6. Audit successful refresh and respond
AuditRefreshSuccess(c, refreshToken.AccountID)
// reevaluateRefreshAfterRace re-reads a token that could not be rotated
// (the row was revoked/rotated/expired concurrently) and re-applies the full
// validation, so a benign rotation race still yields access while a
// concurrent revocation or expiry correctly returns 401.
func reevaluateRefreshAfterRace(c *gin.Context, tokenString string) {
latest, err := GetRefreshToken(c.Request.Context(), tokenString)
if err != nil {
AuditRefreshFailed(c, "invalid refresh token")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
return
}
if time.Now().After(latest.ExpiresAt) {
AuditRefreshFailed(c, "refresh token has expired")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Refresh token has expired"})
return
}
handleRevokedRefreshToken(c, latest)
}

// handleRevokedRefreshToken answers a refresh attempt with a revoked token.
// Rotation deliberately does NOT auto-revoke sibling sessions on replay: a
// superseded token replayed by a benign client (multi-tab race, a lost
// response on a flaky network, a backgrounded tab) is common, and an
// account-wide logout would be a worse outcome than a re-login.
// - rotated within the grace window AND the account still has a live
// session: benign race → issue an access token (no re-rotation)
// - otherwise: plain 401. A replay past the grace window is logged as a
// reuse signal for out-of-band alerting, but triggers no revocation.
func handleRevokedRefreshToken(c *gin.Context, refreshToken *RefreshToken) {
graceWindow := time.Duration(config.RefreshRotationGraceSeconds) * time.Second
if refreshToken.RotatedAt != nil && time.Since(*refreshToken.RotatedAt) <= graceWindow {
// Guard the grace path against a concurrent logout / password change /
// admin action: if no live session remains, the account was revoked
// and the grace must not resurrect access.
live, err := HasLiveRefreshToken(c.Request.Context(), refreshToken.AccountID)
if err != nil {
helper.LogAndSanitize(err, "refresh grace: live-token check failed")
c.JSON(http.StatusInternalServerError, gin.H{"error": helper.ErrMsgInternalServer})
return
}
if live {
respondWithAccessToken(c, refreshToken.AccountID)
return
}
}

if refreshToken.RotatedAt != nil {
// Superseded token replayed beyond grace: log for alerting, no action
AuditRefreshReuseDetected(c, refreshToken.AccountID)
} else {
AuditRefreshFailed(c, "refresh token has been revoked")
}
c.JSON(http.StatusUnauthorized, gin.H{"error": helper.ErrMsgUnauthorized})
}

// respondWithAccessToken issues a new access token for an already-valid
// session (rotation-race loser or benign grace replay) without rotating
func respondWithAccessToken(c *gin.Context, accountID uint) {
accessToken, err := GenerateToken(accountID)
if err != nil {
AuditRefreshFailed(c, "failed to generate access token")
c.JSON(http.StatusInternalServerError, gin.H{"error": helper.ErrMsgInternalServer})
return
}
AuditRefreshSuccess(c, accountID)
c.JSON(http.StatusOK, RefreshResponse{
AccessToken: accessToken,
ExpiresIn: int64(config.AccessTokenMinutes * 60),
Expand Down
98 changes: 97 additions & 1 deletion pkg/security/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/Angak0k/pimpmypack/pkg/config"
"github.com/Angak0k/pimpmypack/pkg/database"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -59,6 +60,102 @@ func TestRefreshTokenHandler_Success(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, response.AccessToken)
assert.Positive(t, response.ExpiresIn)

// Strict rotation: a successor refresh token is returned
assert.NotEmpty(t, response.RefreshToken)
assert.NotEqual(t, refreshToken.Token, response.RefreshToken)
assert.Positive(t, response.RefreshExpiresIn)

// The presented token is now revoked-by-rotation
oldRow, err := GetRefreshToken(ctx, refreshToken.Token)
require.NoError(t, err)
assert.True(t, oldRow.Revoked)
require.NotNil(t, oldRow.RotatedAt)

// The successor is usable
w = postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: response.RefreshToken}, "")
assert.Equal(t, http.StatusOK, w.Code)
}

func TestRefreshTokenHandler_GraceOnRecentRotation(t *testing.T) {
ctx := context.Background()
accountID := createTestAccount(t)
router := setupTestRouter()

oldToken, err := CreateRefreshToken(ctx, accountID, false)
require.NoError(t, err)

// Rotate: oldToken is superseded
w := postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: oldToken.Token}, "")
require.Equal(t, http.StatusOK, w.Code)
var rotated RefreshResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &rotated))

// Immediate replay of the superseded token (multi-tab race): a fresh
// access token is issued WITHOUT another rotation and without nuking
w = postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: oldToken.Token}, "")
assert.Equal(t, http.StatusOK, w.Code)
var grace RefreshResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &grace))
assert.NotEmpty(t, grace.AccessToken)
assert.Empty(t, grace.RefreshToken)

// The successor is still live
w = postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: rotated.RefreshToken}, "")
assert.Equal(t, http.StatusOK, w.Code)
}

func TestRefreshTokenHandler_SupersededReplayBeyondGraceIs401(t *testing.T) {
ctx := context.Background()
accountID := createTestAccount(t)
router := setupTestRouter()

oldToken, err := CreateRefreshToken(ctx, accountID, false)
require.NoError(t, err)

// The token gets rotated by the legitimate client
w := postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: oldToken.Token}, "")
require.Equal(t, http.StatusOK, w.Code)
var rotated RefreshResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &rotated))

graceWindow := time.Duration(config.RefreshRotationGraceSeconds) * time.Second

// Backdate the rotation beyond the grace window
_, err = database.DB().ExecContext(ctx,
`UPDATE refresh_token SET rotated_at = $1 WHERE token = $2`,
time.Now().Add(-2*graceWindow), hashRefreshToken(oldToken.Token),
)
require.NoError(t, err)

// Replaying the superseded token is rejected...
w = postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: oldToken.Token}, "")
assert.Equal(t, http.StatusUnauthorized, w.Code)

// ...but the legitimate successor session is NOT revoked (no auto-nuke:
// a lost response / backgrounded tab must not log the user out everywhere)
w = postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: rotated.RefreshToken}, "")
assert.Equal(t, http.StatusOK, w.Code)
}

func TestRefreshTokenHandler_GraceDeniedAfterLogoutAll(t *testing.T) {
ctx := context.Background()
accountID := createTestAccount(t)
router := setupTestRouter()

oldToken, err := CreateRefreshToken(ctx, accountID, false)
require.NoError(t, err)

// Rotate, then revoke every session (logout-all / password change)
w := postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: oldToken.Token}, "")
require.Equal(t, http.StatusOK, w.Code)
_, err = RevokeAllUserTokens(ctx, accountID)
require.NoError(t, err)

// The just-rotated token is within its grace window, but no live session
// remains → the grace must not resurrect access
w = postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: oldToken.Token}, "")
assert.Equal(t, http.StatusUnauthorized, w.Code)
}

func TestRefreshTokenHandler_InvalidToken(t *testing.T) {
Expand Down Expand Up @@ -116,7 +213,6 @@ func TestLogoutHandler_Success(t *testing.T) {
// The revoked token must no longer refresh
w = postJSON(t, router, "/auth/refresh", RefreshTokenInput{Token: refreshToken.Token}, "")
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "revoked")
}

func TestLogoutHandler_InvalidTokenStillSucceeds(t *testing.T) {
Expand Down
Loading