bbe0757ab6
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
169 lines
3.4 KiB
Go
169 lines
3.4 KiB
Go
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
|
|
}
|