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
+2 -2
View File
@@ -4,8 +4,8 @@ PORT=8080
HOST=0.0.0.0 HOST=0.0.0.0
JWT_SECRET=change-me-in-production JWT_SECRET=change-me-in-production
JWT_EXPIRY=24h JWT_EXPIRY=1h
REFRESH_TOKEN_EXPIRY=168h REFRESH_TOKEN_EXPIRY=7d
CORS_ORIGINS=http://localhost:3000 CORS_ORIGINS=http://localhost:3000
+40 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
@@ -51,15 +52,24 @@ func main() {
log.Fatalf("Invalid JWT_EXPIRY: %v", err) log.Fatalf("Invalid JWT_EXPIRY: %v", err)
} }
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
if err != nil {
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
}
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours) jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
passwordHasher := crypto.NewBcryptHasher() passwordHasher := crypto.NewBcryptHasher()
userRepo := sqlite.NewUserRepository(db.Conn()) userRepo := sqlite.NewUserRepository(db.Conn())
habitRepo := sqlite.NewHabitRepository(db.Conn()) habitRepo := sqlite.NewHabitRepository(db.Conn())
entryRepo := sqlite.NewHabitEntryRepository(db.Conn()) entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
createHandler := commands.NewCreateHabitHandler(habitRepo) createHandler := commands.NewCreateHabitHandler(habitRepo)
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
@@ -71,7 +81,7 @@ func main() {
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, jwtService) authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler) statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler)
healthHandlers := httpInfra.NewHealthHandlers(db.Conn()) healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
@@ -94,3 +104,32 @@ func parseJWTExpiry(expiry string) (int, error) {
} }
return 0, fmt.Errorf("invalid format, expected format like '24h'") return 0, fmt.Errorf("invalid format, expected format like '24h'")
} }
func parseDuration(duration string) (time.Duration, error) {
duration = strings.TrimSpace(duration)
if strings.HasSuffix(duration, "m") {
minutes := strings.TrimSuffix(duration, "m")
mins, err := strconv.Atoi(minutes)
if err != nil {
return 0, fmt.Errorf("invalid minutes value: %w", err)
}
return time.Duration(mins) * time.Minute, nil
}
if strings.HasSuffix(duration, "h") {
hours := strings.TrimSuffix(duration, "h")
hrs, err := strconv.Atoi(hours)
if err != nil {
return 0, fmt.Errorf("invalid hours value: %w", err)
}
return time.Duration(hrs) * time.Hour, nil
}
if strings.HasSuffix(duration, "d") {
days := strings.TrimSuffix(duration, "d")
dys, err := strconv.Atoi(days)
if err != nil {
return 0, fmt.Errorf("invalid days value: %w", err)
}
return time.Duration(dys) * 24 * time.Hour, nil
}
return 0, fmt.Errorf("invalid format, expected format like '15m', '24h', or '7d'")
}
+1225
View File
File diff suppressed because it is too large Load Diff
+1200
View File
File diff suppressed because it is too large Load Diff
+795
View File
@@ -0,0 +1,795 @@
basePath: /api/v1
definitions:
http.AuthResponse:
properties:
refresh_token:
type: string
token:
type: string
user_id:
type: string
type: object
http.CreateHabitRequest:
properties:
carry_over:
type: boolean
description:
type: string
frequency:
$ref: '#/definitions/value_objects.Frequency'
is_negative:
type: boolean
name:
type: string
specific_dates:
items:
type: integer
type: array
specific_days:
items:
type: integer
type: array
target_value:
type: number
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
http.ErrorResponse:
properties:
error:
type: string
type: object
http.HabitEntriesResponse:
properties:
entries:
items:
$ref: '#/definitions/http.HabitEntryResponse'
type: array
limit:
type: integer
page:
type: integer
total:
type: integer
type: object
http.HabitEntryResponse:
properties:
completed_at:
type: string
habit_id:
type: string
id:
type: string
scheduled_date:
type: string
value:
type: number
type: object
http.HealthResponse:
properties:
database:
type: string
status:
type: string
uptime:
type: string
type: object
http.LoginRequest:
properties:
email:
type: string
password:
type: string
type: object
http.LogoutRequest:
properties:
refresh_token:
type: string
type: object
http.MarkHabitRequest:
properties:
scheduled_date:
type: string
value:
type: number
type: object
http.RefreshRequest:
properties:
refresh_token:
type: string
type: object
http.RegisterRequest:
properties:
email:
type: string
password:
type: string
timezone:
type: string
type: object
http.TodaysHabitResponse:
properties:
id:
type: string
is_carried_over:
type: boolean
is_negative:
type: boolean
name:
type: string
scheduled_date:
type: string
target_value:
type: number
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
http.UpdateHabitRequest:
properties:
carry_over:
type: boolean
description:
type: string
name:
type: string
specific_dates:
items:
type: integer
type: array
specific_days:
items:
type: integer
type: array
target_value:
type: number
type: object
http.UserHabitResponse:
properties:
carry_over:
type: boolean
frequency:
$ref: '#/definitions/value_objects.Frequency'
id:
type: string
is_negative:
type: boolean
name:
type: string
specific_days:
items:
type: integer
type: array
target_value:
type: number
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
queries.HabitStatsDTO:
properties:
completion_rate:
type: number
completions_this_month:
type: integer
completions_this_week:
type: integer
current_streak:
type: integer
habit_id:
type: string
habit_name:
type: string
longest_streak:
type: integer
total_completions:
type: integer
type: object
value_objects.Frequency:
enum:
- DAILY
- WEEKLY
- MONTHLY
type: string
x-enum-varnames:
- FrequencyDaily
- FrequencyWeekly
- FrequencyMonthly
value_objects.HabitType:
enum:
- BOOLEAN
- COUNTER
- VALUE
type: string
x-enum-varnames:
- HabitTypeBoolean
- HabitTypeCounter
- HabitTypeValue
info:
contact:
name: API Support
url: https://github.com/davidfolch/apocapoc-api
description: Self-hosted habit tracking service
license:
name: MIT
url: https://opensource.org/licenses/MIT
termsOfService: http://swagger.io/terms/
title: Apocapoc API
version: "1.0"
paths:
/auth/login:
post:
consumes:
- application/json
description: Authenticate user with email and password. Returns both access
token and refresh token. The access token is used for API requests, the refresh
token is used to obtain new access tokens when they expire.
parameters:
- description: Login credentials
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.LoginRequest'
produces:
- application/json
responses:
"200":
description: Returns access token, refresh token, and user ID
schema:
$ref: '#/definitions/http.AuthResponse'
"400":
description: Invalid request body
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Invalid email or password
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Login user
tags:
- auth
/auth/logout:
post:
consumes:
- application/json
description: Revoke the refresh token to invalidate the user session. After
logout, the refresh token cannot be used to obtain new access tokens. The
user will need to login again. Always call this endpoint before clearing tokens
from client storage to ensure proper session termination.
parameters:
- description: Refresh token to revoke
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.LogoutRequest'
produces:
- application/json
responses:
"200":
description: Successfully logged out - the refresh token is now invalid
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid request body or missing refresh token
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Refresh token not found (already revoked or never existed)
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Logout user
tags:
- auth
/auth/refresh:
post:
consumes:
- application/json
description: 'Exchange a valid refresh token for a new access token and refresh
token pair. IMPORTANT: The old refresh token is automatically revoked and
you receive a NEW refresh token - always update both tokens in storage. Use
this endpoint when the access token expires to maintain the user session without
requiring re-login.'
parameters:
- description: Current refresh token
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.RefreshRequest'
produces:
- application/json
responses:
"200":
description: Returns NEW access token and NEW refresh token - the old refresh
token is now invalid
schema:
$ref: '#/definitions/http.AuthResponse'
"400":
description: Invalid request body
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Invalid or expired refresh token
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Refresh access token
tags:
- auth
/auth/register:
post:
consumes:
- application/json
description: Create a new user account and receive both access token and refresh
token. Store both tokens securely - the refresh token is used to obtain new
access tokens when they expire.
parameters:
- description: 'Registration data (password requires: min 8 chars, uppercase,
lowercase, digit, special char)'
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.RegisterRequest'
produces:
- application/json
responses:
"201":
description: Returns access token, refresh token, and user ID
schema:
$ref: '#/definitions/http.AuthResponse'
"400":
description: 'Invalid input: email format, password requirements, or timezone'
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
description: Email already registered
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Register a new user
tags:
- auth
/habits:
get:
description: Get all active habits for the authenticated user
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/http.UserHabitResponse'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Get all user habits
tags:
- habits
post:
consumes:
- application/json
description: Create a new habit for the authenticated user
parameters:
- description: Habit data
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.CreateHabitRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Create a new habit
tags:
- habits
/habits/{id}:
delete:
description: Archive (soft delete) a habit
parameters:
- description: Habit ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Archive habit
tags:
- habits
get:
description: Get a specific habit by ID
parameters:
- description: Habit ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/http.UserHabitResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Get habit by ID
tags:
- habits
put:
consumes:
- application/json
description: Update an existing habit
parameters:
- description: Habit ID
in: path
name: id
required: true
type: string
- description: Update data
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.UpdateHabitRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Update habit
tags:
- habits
/habits/{id}/entries:
get:
description: Get entries (completion history) for a habit with optional date
filtering and pagination
parameters:
- description: Habit ID
in: path
name: id
required: true
type: string
- description: Start date (YYYY-MM-DD)
in: query
name: from
type: string
- description: End date (YYYY-MM-DD)
in: query
name: to
type: string
- description: Page number
in: query
name: page
type: integer
- description: Page size (max 100)
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/http.HabitEntriesResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Get habit entries
tags:
- habits
/habits/{id}/entries/{date}:
delete:
description: Delete a habit entry (unmark completion)
parameters:
- description: Habit ID
in: path
name: id
required: true
type: string
- description: Date (YYYY-MM-DD)
in: path
name: date
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Unmark habit
tags:
- habits
/habits/{id}/mark:
post:
consumes:
- application/json
description: Mark a habit as completed for a specific date
parameters:
- description: Habit ID
in: path
name: id
required: true
type: string
- description: Mark data
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.MarkHabitRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
description: Conflict
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Mark habit as complete
tags:
- habits
/habits/today:
get:
description: Get all habits scheduled for today for the authenticated user
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/http.TodaysHabitResponse'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Get today's habits
tags:
- habits
/health:
get:
description: Get API health status including database connectivity and uptime
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/http.HealthResponse'
"503":
description: Service Unavailable
schema:
$ref: '#/definitions/http.HealthResponse'
summary: Health check
tags:
- system
/stats/habits/{id}:
get:
description: Get statistics for a specific habit including streaks and completion
rates
parameters:
- description: Habit ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/queries.HabitStatsDTO'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Get habit statistics
tags:
- stats
securityDefinitions:
BearerAuth:
description: Type "Bearer" followed by a space and JWT token.
in: header
name: Authorization
type: apiKey
swagger: "2.0"
@@ -0,0 +1,30 @@
package commands
import (
"context"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type RevokeAllTokensCommand struct {
UserID string
}
type RevokeAllTokensHandler struct {
refreshTokenRepo repositories.RefreshTokenRepository
}
func NewRevokeAllTokensHandler(refreshTokenRepo repositories.RefreshTokenRepository) *RevokeAllTokensHandler {
return &RevokeAllTokensHandler{
refreshTokenRepo: refreshTokenRepo,
}
}
func (h *RevokeAllTokensHandler) Handle(ctx context.Context, cmd RevokeAllTokensCommand) error {
if cmd.UserID == "" {
return errors.ErrInvalidInput
}
return h.refreshTokenRepo.RevokeAllByUserID(ctx, cmd.UserID)
}
@@ -0,0 +1,30 @@
package commands
import (
"context"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type RevokeTokenCommand struct {
RefreshToken string
}
type RevokeTokenHandler struct {
refreshTokenRepo repositories.RefreshTokenRepository
}
func NewRevokeTokenHandler(refreshTokenRepo repositories.RefreshTokenRepository) *RevokeTokenHandler {
return &RevokeTokenHandler{
refreshTokenRepo: refreshTokenRepo,
}
}
func (h *RevokeTokenHandler) Handle(ctx context.Context, cmd RevokeTokenCommand) error {
if cmd.RefreshToken == "" {
return errors.ErrInvalidInput
}
return h.refreshTokenRepo.RevokeByToken(ctx, cmd.RefreshToken)
}
@@ -0,0 +1,82 @@
package queries
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type RefreshTokenQuery struct {
RefreshToken string
}
type RefreshTokenResult struct {
UserID string
Email string
Timezone string
}
type RefreshTokenHandler struct {
refreshTokenRepo repositories.RefreshTokenRepository
userRepo repositories.UserRepository
}
func NewRefreshTokenHandler(
refreshTokenRepo repositories.RefreshTokenRepository,
userRepo repositories.UserRepository,
) *RefreshTokenHandler {
return &RefreshTokenHandler{
refreshTokenRepo: refreshTokenRepo,
userRepo: userRepo,
}
}
func (h *RefreshTokenHandler) Handle(ctx context.Context, query RefreshTokenQuery) (*RefreshTokenResult, error) {
if query.RefreshToken == "" {
return nil, errors.ErrInvalidInput
}
refreshToken, err := h.refreshTokenRepo.FindByToken(ctx, query.RefreshToken)
if err != nil {
return nil, errors.ErrNotFound
}
if !refreshToken.IsValid() {
return nil, errors.ErrNotFound
}
user, err := h.userRepo.FindByID(ctx, refreshToken.UserID)
if err != nil {
return nil, errors.ErrNotFound
}
return &RefreshTokenResult{
UserID: user.ID,
Email: user.Email,
Timezone: user.Timezone,
}, nil
}
func GenerateRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate refresh token: %w", err)
}
return base64.URLEncoding.EncodeToString(b), nil
}
func CreateRefreshToken(userID string, expiryDuration time.Duration) (*entities.RefreshToken, error) {
token, err := GenerateRefreshToken()
if err != nil {
return nil, err
}
expiresAt := time.Now().Add(expiryDuration)
return entities.NewRefreshToken(userID, token, expiresAt), nil
}
+34
View File
@@ -0,0 +1,34 @@
package entities
import "time"
type RefreshToken struct {
ID string
UserID string
Token string
ExpiresAt time.Time
CreatedAt time.Time
RevokedAt *time.Time
}
func NewRefreshToken(userID, token string, expiresAt time.Time) *RefreshToken {
return &RefreshToken{
UserID: userID,
Token: token,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
RevokedAt: nil,
}
}
func (rt *RefreshToken) IsValid() bool {
if rt.RevokedAt != nil {
return false
}
return time.Now().Before(rt.ExpiresAt)
}
func (rt *RefreshToken) Revoke() {
now := time.Now()
rt.RevokedAt = &now
}
@@ -0,0 +1,16 @@
package repositories
import (
"context"
"apocapoc-api/internal/domain/entities"
)
type RefreshTokenRepository interface {
Create(ctx context.Context, token *entities.RefreshToken) error
FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error)
FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error)
RevokeByToken(ctx context.Context, token string) error
RevokeAllByUserID(ctx context.Context, userID string) error
DeleteExpired(ctx context.Context) error
}
+163 -11
View File
@@ -3,9 +3,11 @@ package http
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"time"
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/infrastructure/auth" "apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/shared/errors" "apocapoc-api/internal/shared/errors"
) )
@@ -13,18 +15,33 @@ import (
type AuthHandlers struct { type AuthHandlers struct {
registerHandler *commands.RegisterUserHandler registerHandler *commands.RegisterUserHandler
loginHandler *queries.LoginUserHandler loginHandler *queries.LoginUserHandler
refreshTokenHandler *queries.RefreshTokenHandler
revokeTokenHandler *commands.RevokeTokenHandler
revokeAllTokensHandler *commands.RevokeAllTokensHandler
jwtService *auth.JWTService jwtService *auth.JWTService
refreshTokenRepo repositories.RefreshTokenRepository
refreshTokenExpiry time.Duration
} }
func NewAuthHandlers( func NewAuthHandlers(
registerHandler *commands.RegisterUserHandler, registerHandler *commands.RegisterUserHandler,
loginHandler *queries.LoginUserHandler, loginHandler *queries.LoginUserHandler,
refreshTokenHandler *queries.RefreshTokenHandler,
revokeTokenHandler *commands.RevokeTokenHandler,
revokeAllTokensHandler *commands.RevokeAllTokensHandler,
jwtService *auth.JWTService, jwtService *auth.JWTService,
refreshTokenRepo repositories.RefreshTokenRepository,
refreshTokenExpiry time.Duration,
) *AuthHandlers { ) *AuthHandlers {
return &AuthHandlers{ return &AuthHandlers{
registerHandler: registerHandler, registerHandler: registerHandler,
loginHandler: loginHandler, loginHandler: loginHandler,
refreshTokenHandler: refreshTokenHandler,
revokeTokenHandler: revokeTokenHandler,
revokeAllTokensHandler: revokeAllTokensHandler,
jwtService: jwtService, jwtService: jwtService,
refreshTokenRepo: refreshTokenRepo,
refreshTokenExpiry: refreshTokenExpiry,
} }
} }
@@ -41,20 +58,29 @@ type LoginRequest struct {
type AuthResponse struct { type AuthResponse struct {
Token string `json:"token"` Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
} }
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type LogoutRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Register godoc // Register godoc
// @Summary Register a new user // @Summary Register a new user
// @Description Create a new user account // @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.
// @Tags auth // @Tags auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body RegisterRequest true "Registration data" // @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
// @Success 201 {object} AuthResponse // @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID"
// @Failure 400 {object} ErrorResponse // @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone"
// @Failure 409 {object} ErrorResponse // @Failure 409 {object} ErrorResponse "Email already registered"
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/register [post] // @Router /auth/register [post]
func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest var req RegisterRequest
@@ -89,23 +115,35 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
return
}
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
return
}
respondJSON(w, http.StatusCreated, AuthResponse{ respondJSON(w, http.StatusCreated, AuthResponse{
Token: token, Token: token,
RefreshToken: refreshToken.Token,
UserID: userID, UserID: userID,
}) })
} }
// Login godoc // Login godoc
// @Summary Login user // @Summary Login user
// @Description Authenticate user and get JWT token // @Description Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.
// @Tags auth // @Tags auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body LoginRequest true "Login credentials" // @Param request body LoginRequest true "Login credentials"
// @Success 200 {object} AuthResponse // @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID"
// @Failure 400 {object} ErrorResponse // @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse // @Failure 401 {object} ErrorResponse "Invalid email or password"
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/login [post] // @Router /auth/login [post]
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest var req LoginRequest
@@ -135,8 +173,122 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
refreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
return
}
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
return
}
respondJSON(w, http.StatusOK, AuthResponse{ respondJSON(w, http.StatusOK, AuthResponse{
Token: token, Token: token,
RefreshToken: refreshToken.Token,
UserID: result.UserID, UserID: result.UserID,
}) })
} }
// Refresh godoc
// @Summary Refresh access token
// @Description Exchange a valid refresh token for a new access token and refresh token pair. IMPORTANT: The old refresh token is automatically revoked and you receive a NEW refresh token - always update both tokens in storage. Use this endpoint when the access token expires to maintain the user session without requiring re-login.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "Current refresh token"
// @Success 200 {object} AuthResponse "Returns NEW access token and NEW refresh token - the old refresh token is now invalid"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Invalid or expired refresh token"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/refresh [post]
func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
var req RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
query := queries.RefreshTokenQuery{
RefreshToken: req.RefreshToken,
}
result, err := h.refreshTokenHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrNotFound || err == errors.ErrInvalidInput {
respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token")
return
}
respondError(w, http.StatusInternalServerError, "Failed to refresh token")
return
}
token, err := h.jwtService.GenerateToken(result.UserID, result.Email)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate token")
return
}
newRefreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
return
}
if err := h.refreshTokenRepo.Create(r.Context(), newRefreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
return
}
if err := h.refreshTokenRepo.RevokeByToken(r.Context(), req.RefreshToken); err != nil {
}
respondJSON(w, http.StatusOK, AuthResponse{
Token: token,
RefreshToken: newRefreshToken.Token,
UserID: result.UserID,
})
}
// Logout godoc
// @Summary Logout user
// @Description Revoke the refresh token to invalidate the user session. After logout, the refresh token cannot be used to obtain new access tokens. The user will need to login again. Always call this endpoint before clearing tokens from client storage to ensure proper session termination.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body LogoutRequest true "Refresh token to revoke"
// @Success 200 {object} map[string]string "Successfully logged out - the refresh token is now invalid"
// @Failure 400 {object} ErrorResponse "Invalid request body or missing refresh token"
// @Failure 404 {object} ErrorResponse "Refresh token not found (already revoked or never existed)"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/logout [post]
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
var req LogoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
cmd := commands.RevokeTokenCommand{
RefreshToken: req.RefreshToken,
}
err := h.revokeTokenHandler.Handle(r.Context(), cmd)
if err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Refresh token not found")
return
}
if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, "Invalid refresh token")
return
}
respondError(w, http.StatusInternalServerError, "Failed to revoke token")
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Successfully logged out",
})
}
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"apocapoc-api/internal/application/commands" "apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries" "apocapoc-api/internal/application/queries"
@@ -38,23 +39,32 @@ func setupTestServer(t *testing.T) *TestServer {
userRepo := sqlite.NewUserRepository(db) userRepo := sqlite.NewUserRepository(db)
habitRepo := sqlite.NewHabitRepository(db) habitRepo := sqlite.NewHabitRepository(db)
entryRepo := sqlite.NewHabitEntryRepository(db) entryRepo := sqlite.NewHabitEntryRepository(db)
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher) registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher) loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
createHandler := commands.NewCreateHabitHandler(habitRepo) createHandler := commands.NewCreateHabitHandler(habitRepo)
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo) getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo) getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
updateHandler := commands.NewUpdateHabitHandler(habitRepo) updateHandler := commands.NewUpdateHabitHandler(habitRepo)
archiveHandler := commands.NewArchiveHabitHandler(habitRepo) archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
authHandlers := NewAuthHandlers(registerHandler, loginHandler, jwtService) refreshTokenExpiry := 7 * 24 * time.Hour
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
router := NewRouter("*", habitHandlers, authHandlers, jwtService) authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
statsHandlers := NewStatsHandlers(getHabitStatsHandler)
healthHandlers := NewHealthHandlers(db)
router := NewRouter("*", habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
handler := http.Handler(router) handler := http.Handler(router)
return &TestServer{ return &TestServer{
+2
View File
@@ -40,6 +40,8 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
r.Use(httprate.LimitByIP(10, 1*time.Minute)) r.Use(httprate.LimitByIP(10, 1*time.Minute))
r.Post("/register", authHandlers.Register) r.Post("/register", authHandlers.Register)
r.Post("/login", authHandlers.Login) r.Post("/login", authHandlers.Login)
r.Post("/refresh", authHandlers.Refresh)
r.Post("/logout", authHandlers.Logout)
}) })
r.Route("/api/v1/habits", func(r chi.Router) { r.Route("/api/v1/habits", func(r chi.Router) {
@@ -105,12 +105,13 @@ func TestHabitEntryRepositoryUpdate(t *testing.T) {
t.Fatalf("Create failed: %v", err) t.Fatalf("Create failed: %v", err)
} }
now := time.Now() retrieved, err := repo.FindByID(ctx, entry.ID)
entry.DeletedAt = &now
err = repo.Update(ctx, entry)
if err != nil { 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.HabitTypeBoolean,
value_objects.FrequencyDaily, value_objects.FrequencyDaily,
false, false,
false,
) )
habit.Description = "Exercise every morning" habit.Description = "Exercise every morning"
@@ -49,6 +50,7 @@ func TestHabitRepositoryCreateWithSpecificDays(t *testing.T) {
value_objects.HabitTypeBoolean, value_objects.HabitTypeBoolean,
value_objects.FrequencyWeekly, value_objects.FrequencyWeekly,
false, false,
false,
) )
habit.SpecificDays = []int{1, 3, 5} habit.SpecificDays = []int{1, 3, 5}
@@ -80,6 +82,7 @@ func TestHabitRepositoryFindByID(t *testing.T) {
value_objects.HabitTypeCounter, value_objects.HabitTypeCounter,
value_objects.FrequencyDaily, value_objects.FrequencyDaily,
true, true,
false,
) )
targetValue := 30.0 targetValue := 30.0
habit.TargetValue = &targetValue habit.TargetValue = &targetValue
@@ -165,6 +168,7 @@ func TestHabitRepositoryUpdate(t *testing.T) {
value_objects.HabitTypeBoolean, value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily, value_objects.FrequencyDaily,
false, false,
false,
) )
err := repo.Create(ctx, habit) err := repo.Create(ctx, habit)
@@ -210,6 +214,7 @@ func TestHabitRepositoryUpdateNotFound(t *testing.T) {
value_objects.HabitTypeBoolean, value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily, value_objects.FrequencyDaily,
false, false,
false,
) )
habit.ID = "non-existent" habit.ID = "non-existent"
@@ -232,6 +237,7 @@ func TestHabitRepositoryArchive(t *testing.T) {
value_objects.HabitTypeBoolean, value_objects.HabitTypeBoolean,
value_objects.FrequencyDaily, value_objects.FrequencyDaily,
false, false,
false,
) )
err := repo.Create(ctx, habit) err := repo.Create(ctx, habit)
@@ -9,6 +9,7 @@ func RunMigrations(db *sql.DB) error {
createUsersTable, createUsersTable,
createHabitsTable, createHabitsTable,
createHabitEntriesTable, createHabitEntriesTable,
createRefreshTokensTable,
createIndexes, 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 = ` const createIndexes = `
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id); 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_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_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_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
}