added logout

This commit is contained in:
Mephimeow
2026-06-12 10:18:04 +00:00
parent 130d5d5e3d
commit a26cd891e4
11 changed files with 244 additions and 5 deletions
+1
View File
@@ -18,6 +18,7 @@ func GenerateToken(userID, email string, secret []byte, expiration time.Duration
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
+40 -1
View File
@@ -2,6 +2,7 @@ package auth
import (
"errors"
"log"
"net/http"
"github.com/gin-gonic/gin"
@@ -38,6 +39,7 @@ func (h *Handler) Register(c *gin.Context) {
c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()})
return
}
log.Printf("register error: %v", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
return
}
@@ -64,7 +66,12 @@ func (h *Handler) Login(c *gin.Context) {
resp, err := h.service.Login(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
if errors.Is(err, ErrInvalidCreds) {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
log.Printf("login error: %v", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
return
}
@@ -94,6 +101,7 @@ func (h *Handler) Refresh(c *gin.Context) {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
log.Printf("refresh error: %v", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
return
}
@@ -101,6 +109,36 @@ func (h *Handler) Refresh(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// @Summary Logout epta
// @Description Invalidate a refresh token (logout)
// @Tags auth
// @Accept json
// @Produce json
// @Param request body LogoutRequest true "Refresh token to invalidate"
// @Success 200 {object} map[string]string
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Router /api/auth/logout [post]
func (h *Handler) Logout(c *gin.Context) {
var req LogoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
if err := h.service.Logout(c.Request.Context(), req.RefreshToken); err != nil {
if errors.Is(err, ErrLogoutInvalid) {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
return
}
log.Printf("logout error: %v", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"})
}
// @Summary Epta get current user
// @Description Get authenticated user's profile
// @Tags auth
@@ -129,6 +167,7 @@ func (h *Handler) Me(c *gin.Context) {
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
return
}
log.Printf("me error: %v", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
return
}
+1 -1
View File
@@ -16,7 +16,7 @@ func AuthMiddleware(jwtSecret []byte) gin.HandlerFunc {
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid authorization header format"})
return
}
+4
View File
@@ -35,6 +35,10 @@ type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
}
type LogoutRequest struct {
RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
}
type RefreshTokenDoc struct {
ID bson.ObjectID `json:"id" bson:"_id"`
UserID bson.ObjectID `json:"user_id" bson:"user_id"`
+8
View File
@@ -90,3 +90,11 @@ func (r *Repository) DeleteRefreshToken(ctx context.Context, id bson.ObjectID) e
_, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"_id": id})
return err
}
func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) {
res, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"token_hash": hash})
if err != nil {
return false, err
}
return res.DeletedCount > 0, nil
}
+22 -1
View File
@@ -7,6 +7,8 @@ import (
"encoding/base64"
"errors"
"fmt"
"log"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -21,6 +23,7 @@ var (
ErrInvalidUserID = errors.New("invalid user ID")
ErrInvalidRefresh = errors.New("invalid refresh token")
ErrRefreshExpired = errors.New("refresh token expired")
ErrLogoutInvalid = errors.New("refresh token not found or already used")
)
type Service struct {
@@ -81,6 +84,7 @@ func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse
}
func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPublic, error) {
req.Email = strings.ToLower(req.Email)
existing, err := s.repo.FindByEmail(ctx, req.Email)
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
return nil, fmt.Errorf("failed to check existing user: %w", err)
@@ -109,6 +113,7 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPubli
}
func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
req.Email = strings.ToLower(req.Email)
user, err := s.repo.FindByEmail(ctx, req.Email)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
@@ -136,7 +141,9 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse
}
if time.Now().UTC().After(doc.ExpiresAt) {
s.repo.DeleteRefreshToken(ctx, doc.ID)
if err := s.repo.DeleteRefreshToken(ctx, doc.ID); err != nil {
log.Printf("failed to cleanup expired refresh token: %v", err)
}
return nil, ErrRefreshExpired
}
@@ -152,6 +159,20 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse
return s.issueTokenPair(ctx, user)
}
func (s *Service) Logout(ctx context.Context, rawRefresh string) error {
hash := sha256Hex(rawRefresh)
found, err := s.repo.DeleteRefreshTokenByHash(ctx, hash)
if err != nil {
return fmt.Errorf("failed to delete refresh token: %w", err)
}
if !found {
return ErrLogoutInvalid
}
return nil
}
func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) {
id, err := bson.ObjectIDFromHex(userID)
if err != nil {
+4 -1
View File
@@ -2,6 +2,7 @@ package config
import (
"fmt"
"log"
"os"
"time"
@@ -18,7 +19,9 @@ type Config struct {
}
func Load() (*Config, error) {
godotenv.Load()
if err := godotenv.Load(); err != nil {
log.Printf("warning: .env file not loaded: %v", err)
}
cfg := &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),