From 5e23d9cc9f15ecb83269a8605456bf6280011d1b Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 13:49:47 +0200 Subject: [PATCH] feat: added logout handler --- internal/api/handler/auth.go | 64 ++++++++++++++++++++++++++++++++++- internal/api/request/auth.go | 4 +++ internal/api/router/router.go | 5 ++- internal/domain/auth.go | 12 +++++++ internal/repository/auth.go | 19 +++++++++++ 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go index f756e56..b85a1cd 100644 --- a/internal/api/handler/auth.go +++ b/internal/api/handler/auth.go @@ -60,7 +60,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { // @Success 200 {object} domain.TokenPair // @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 500 {object} response.ErrorResponse "failed to refresh token" -// @Router /api/v1/refresh [post] +// @Router /api/v1/login/refresh [post] func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -81,3 +81,65 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { response.JSON(ctx, w, http.StatusOK, tokens) } + +// Logout handles logging out a user +// +// @Summary Logout route +// @Description Logout current user +// @Tags Authorization +// @Accept json +// @Produce json +// @Success 200 {object} nil +// @Error 400 {object} response.ErrorResponse "failed to decode request body" +// @Error 500 {object} response.ErrorResponse "failed to logout" +// @Router /api/v1/logout [post] +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + payload, err := request.DecodeJSON[request.LogoutPayload](r) + if err != nil { + slog.ErrorContext(ctx, "failed to decode logout payload", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body") + return + } + + err = h.AuthService.Logout(ctx, payload.RefreshToken) + if err != nil { + slog.ErrorContext(ctx, "failed to logout", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to logout") + return + } + + response.JSON(ctx, w, http.StatusOK, nil) +} + +// LogoutAllDevices handles logging out a user on all devices +// +// @Summary Logout all route +// @Description Logout user from all devices (revokes all refresh tokens) +// @Tags Authorization +// @Accept json +// @Produce json +// @Success 200 {object} nil +// @Error 401 {object} response.ErrorResponse "failed to get auth context" +// @Error 500 {object} response.ErrorResponse "failed to logout" +// @Router /api/v1/logout/all [post] +func (h *AuthHandler) LogoutAllDevices(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + authContext, err := domain.GetAuthContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err)) + response.Error(ctx, w, http.StatusUnauthorized, "failed to get auth context") + return + } + + err = h.AuthService.LogoutAllDevices(ctx, authContext.UserID) + if err != nil { + slog.ErrorContext(ctx, "failed to logout", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to logout") + return + } + + response.JSON(ctx, w, http.StatusOK, nil) +} diff --git a/internal/api/request/auth.go b/internal/api/request/auth.go index 5d5f1c3..7674f5c 100644 --- a/internal/api/request/auth.go +++ b/internal/api/request/auth.go @@ -8,3 +8,7 @@ type LoginPayload struct { type RefreshPayload struct { RefreshToken string `json:"refresh_token"` } + +type LogoutPayload struct { + RefreshToken string `json:"refresh_token"` +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index ba0b395..20f8eb5 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -33,8 +33,11 @@ func NewMux(cfg Config) http.Handler { apiMux := http.NewServeMux() apiMux.HandleFunc("/", handler.DefaultHandler) apiMux.HandleFunc("GET /health", handler.HealthHandler) + apiMux.HandleFunc("POST /login", authHandler.Login) - apiMux.HandleFunc("POST /auth/refresh", authHandler.Refresh) + apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh) + apiMux.HandleFunc("POST /logout", authHandler.Logout) + apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices) apiMux.Handle("POST /users", protected(userHandler.CreateUser)) diff --git a/internal/domain/auth.go b/internal/domain/auth.go index 467df9c..68754a0 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -19,6 +19,8 @@ type AuthRepository interface { GetUserByEmail(ctx context.Context, email string) (*AuthUser, error) StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) + RevokeRefreshToken(ctx context.Context, tokenHash string) error + RevokeRefreshTokens(ctx context.Context, userID string) error } type AuthService struct { @@ -102,6 +104,16 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (TokenPa return s.issueTokens(ctx, authUser) } +func (s *AuthService) Logout(ctx context.Context, refreshToken string) error { + tokenHash := hashToken(refreshToken) + + return s.repo.RevokeRefreshToken(ctx, tokenHash) +} + +func (s *AuthService) LogoutAllDevices(ctx context.Context, userID string) error { + return s.repo.RevokeRefreshTokens(ctx, userID) +} + type JWTClaims struct { UserID string `json:"user_id"` Email string `json:"email"` diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 1c05973..37d50da 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -57,6 +57,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa return nil } + func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) { var userID string err := r.db.QueryRowContext(ctx, @@ -72,3 +73,21 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s return userID, nil } + +func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash) + if err != nil { + return fmt.Errorf("failed to revoke refresh token: %w", err) + } + + return nil +} + +func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID) + if err != nil { + return fmt.Errorf("failed to revoke refresh tokens: %w", err) + } + + return nil +}