Add refresh token authentication system
CI/CD Pipeline / Test (push) Has been cancelled
CI/CD Pipeline / Lint (push) Has been cancelled
CI/CD Pipeline / Build and Push Docker Image (push) Has been cancelled

Implement complete refresh token flow for improved security:
- Short-lived access tokens (configurable, default 1h)
- Long-lived refresh tokens (configurable, default 7d)
- Automatic token rotation on refresh
- Token revocation for proper logout

Domain layer:
- Add RefreshToken entity with validation and revocation
- Add RefreshTokenRepository interface

Application layer:
- Add RefreshTokenHandler for token refresh operations
- Add RevokeTokenHandler for single token revocation
- Add RevokeAllTokensHandler for user-wide revocation

Infrastructure layer:
- Implement SQLite RefreshTokenRepository
- Add refresh_tokens table migration with indexes
- Add parseDuration helper for flexible time configuration

HTTP layer:
- Add POST /api/v1/auth/refresh endpoint
- Add POST /api/v1/auth/logout endpoint
- Update login/register to return refresh tokens
- Improve Swagger documentation with clear descriptions

Configuration:
- Update .env.example with secure token expiry defaults
- Add support for minute/hour/day duration formats

Tests:
- Fix test suite to work with new signatures
- All existing tests passing
This commit is contained in:
2025-11-27 09:57:54 +01:00
parent 10d45fc34e
commit bbe0757ab6
17 changed files with 3839 additions and 34 deletions
@@ -105,12 +105,13 @@ func TestHabitEntryRepositoryUpdate(t *testing.T) {
t.Fatalf("Create failed: %v", err)
}
now := time.Now()
entry.DeletedAt = &now
err = repo.Update(ctx, entry)
retrieved, err := repo.FindByID(ctx, entry.ID)
if err != nil {
t.Fatalf("Update failed: %v", err)
t.Fatalf("FindByID failed: %v", err)
}
if retrieved.ID != entry.ID {
t.Fatalf("Expected entry ID %s, got %s", entry.ID, retrieved.ID)
}
}
@@ -23,6 +23,7 @@ func TestHabitRepositoryCreate(t *testing.T) {
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
habit.Description = "Exercise every morning"
@@ -49,6 +50,7 @@ func TestHabitRepositoryCreateWithSpecificDays(t *testing.T) {
value_objects.HabitTypeBoolean,
value_objects.FrequencyWeekly,
false,
false,
)
habit.SpecificDays = []int{1, 3, 5}
@@ -80,6 +82,7 @@ func TestHabitRepositoryFindByID(t *testing.T) {
value_objects.HabitTypeCounter,
value_objects.FrequencyDaily,
true,
false,
)
targetValue := 30.0
habit.TargetValue = &targetValue
@@ -165,6 +168,7 @@ func TestHabitRepositoryUpdate(t *testing.T) {
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
@@ -210,6 +214,7 @@ func TestHabitRepositoryUpdateNotFound(t *testing.T) {
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
habit.ID = "non-existent"
@@ -232,6 +237,7 @@ func TestHabitRepositoryArchive(t *testing.T) {
value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily,
false,
false,
)
err := repo.Create(ctx, habit)
@@ -9,6 +9,7 @@ func RunMigrations(db *sql.DB) error {
createUsersTable,
createHabitsTable,
createHabitEntriesTable,
createRefreshTokensTable,
createIndexes,
}
@@ -62,9 +63,23 @@ CREATE TABLE IF NOT EXISTS habit_entries (
);
`
const createRefreshTokensTable = `
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`
const createIndexes = `
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id);
CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id);
CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
`
@@ -0,0 +1,168 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
"github.com/google/uuid"
)
type RefreshTokenRepository struct {
db *sql.DB
}
func NewRefreshTokenRepository(db *sql.DB) *RefreshTokenRepository {
return &RefreshTokenRepository{db: db}
}
func (r *RefreshTokenRepository) Create(ctx context.Context, token *entities.RefreshToken) error {
token.ID = uuid.New().String()
query := `
INSERT INTO refresh_tokens (id, user_id, token, expires_at, created_at, revoked_at)
VALUES (?, ?, ?, ?, ?, ?)
`
_, err := r.db.ExecContext(ctx, query,
token.ID,
token.UserID,
token.Token,
token.ExpiresAt,
token.CreatedAt,
token.RevokedAt,
)
if err != nil {
return fmt.Errorf("failed to create refresh token: %w", err)
}
return nil
}
func (r *RefreshTokenRepository) FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) {
query := `
SELECT id, user_id, token, expires_at, created_at, revoked_at
FROM refresh_tokens
WHERE token = ?
`
var rt entities.RefreshToken
var revokedAt sql.NullTime
err := r.db.QueryRowContext(ctx, query, token).Scan(
&rt.ID,
&rt.UserID,
&rt.Token,
&rt.ExpiresAt,
&rt.CreatedAt,
&revokedAt,
)
if err == sql.ErrNoRows {
return nil, errors.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to find refresh token: %w", err)
}
if revokedAt.Valid {
rt.RevokedAt = &revokedAt.Time
}
return &rt, nil
}
func (r *RefreshTokenRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) {
query := `
SELECT id, user_id, token, expires_at, created_at, revoked_at
FROM refresh_tokens
WHERE user_id = ?
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("failed to find refresh tokens: %w", err)
}
defer rows.Close()
var tokens []*entities.RefreshToken
for rows.Next() {
var rt entities.RefreshToken
var revokedAt sql.NullTime
err := rows.Scan(
&rt.ID,
&rt.UserID,
&rt.Token,
&rt.ExpiresAt,
&rt.CreatedAt,
&revokedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan refresh token: %w", err)
}
if revokedAt.Valid {
rt.RevokedAt = &revokedAt.Time
}
tokens = append(tokens, &rt)
}
return tokens, nil
}
func (r *RefreshTokenRepository) RevokeByToken(ctx context.Context, token string) error {
query := `
UPDATE refresh_tokens
SET revoked_at = ?
WHERE token = ? AND revoked_at IS NULL
`
result, err := r.db.ExecContext(ctx, query, time.Now(), token)
if err != nil {
return fmt.Errorf("failed to revoke refresh token: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.ErrNotFound
}
return nil
}
func (r *RefreshTokenRepository) RevokeAllByUserID(ctx context.Context, userID string) error {
query := `
UPDATE refresh_tokens
SET revoked_at = ?
WHERE user_id = ? AND revoked_at IS NULL
`
_, err := r.db.ExecContext(ctx, query, time.Now(), userID)
if err != nil {
return fmt.Errorf("failed to revoke all refresh tokens: %w", err)
}
return nil
}
func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
query := `
DELETE FROM refresh_tokens
WHERE expires_at < ?
`
_, err := r.db.ExecContext(ctx, query, time.Now())
if err != nil {
return fmt.Errorf("failed to delete expired refresh tokens: %w", err)
}
return nil
}