feat: added logout handler

This commit is contained in:
2026-08-21 13:49:47 +02:00
parent a5860c5079
commit 5e23d9cc9f
5 changed files with 102 additions and 2 deletions
+63 -1
View File
@@ -60,7 +60,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// @Success 200 {object} domain.TokenPair // @Success 200 {object} domain.TokenPair
// @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to refresh token" // @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) { func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -81,3 +81,65 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
response.JSON(ctx, w, http.StatusOK, tokens) 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)
}
+4
View File
@@ -8,3 +8,7 @@ type LoginPayload struct {
type RefreshPayload struct { type RefreshPayload struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
} }
type LogoutPayload struct {
RefreshToken string `json:"refresh_token"`
}
+4 -1
View File
@@ -33,8 +33,11 @@ func NewMux(cfg Config) http.Handler {
apiMux := http.NewServeMux() apiMux := http.NewServeMux()
apiMux.HandleFunc("/", handler.DefaultHandler) apiMux.HandleFunc("/", handler.DefaultHandler)
apiMux.HandleFunc("GET /health", handler.HealthHandler) apiMux.HandleFunc("GET /health", handler.HealthHandler)
apiMux.HandleFunc("POST /login", authHandler.Login) 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)) apiMux.Handle("POST /users", protected(userHandler.CreateUser))
+12
View File
@@ -19,6 +19,8 @@ type AuthRepository interface {
GetUserByEmail(ctx context.Context, email string) (*AuthUser, error) GetUserByEmail(ctx context.Context, email string) (*AuthUser, error)
StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error
ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, 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 { type AuthService struct {
@@ -102,6 +104,16 @@ func (s *AuthService) Refresh(ctx context.Context, refreshToken string) (TokenPa
return s.issueTokens(ctx, authUser) 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 { type JWTClaims struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
+19
View File
@@ -57,6 +57,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa
return nil return nil
} }
func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) { func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string var userID string
err := r.db.QueryRowContext(ctx, err := r.db.QueryRowContext(ctx,
@@ -72,3 +73,21 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s
return userID, nil 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
}