From f1308b3be7913da684f7d274d85549897e049d5b Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Fri, 12 Jun 2026 10:18:04 +0000 Subject: [PATCH] added logout --- cmd/backend/main.go | 3 +- docs/docs.go | 61 +++++++++++++++++++++++++++++++++++++ docs/swagger.json | 61 +++++++++++++++++++++++++++++++++++++ docs/swagger.yaml | 40 ++++++++++++++++++++++++ internal/auth/auth.go | 1 + internal/auth/handler.go | 41 ++++++++++++++++++++++++- internal/auth/middleware.go | 2 +- internal/auth/models.go | 4 +++ internal/auth/repository.go | 8 +++++ internal/auth/service.go | 23 +++++++++++++- internal/config/config.go | 5 ++- 11 files changed, 244 insertions(+), 5 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 675545b..bee7a99 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -38,7 +38,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - client, err := mongo.Connect(options.Client().ApplyURI(cfg.MongoURI)) + client, err := mongo.Connect(options.Client().ApplyURI(cfg.MongoURI).SetTimeout(10 * time.Second)) if err != nil { log.Fatalf("failed to create mongodb client: %v", err) } @@ -77,6 +77,7 @@ func main() { api.POST("/register", handler.Register) api.POST("/login", handler.Login) api.POST("/refresh", handler.Refresh) + api.POST("/logout", handler.Logout) api.GET("/me", auth.AuthMiddleware([]byte(cfg.JWTSecret)), handler.Me) } diff --git a/docs/docs.go b/docs/docs.go index 29eb1e3..3834145 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -61,6 +61,55 @@ const docTemplate = `{ } } }, + "/api/auth/logout": { + "post": { + "description": "Invalidate a refresh token (logout)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout epta", + "parameters": [ + { + "description": "Refresh token to invalidate", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/me": { "get": { "security": [ @@ -231,6 +280,18 @@ const docTemplate = `{ } } }, + "internal_auth.LogoutRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RefreshRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 4395985..909302e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -56,6 +56,55 @@ } } }, + "/api/auth/logout": { + "post": { + "description": "Invalidate a refresh token (logout)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout epta", + "parameters": [ + { + "description": "Refresh token to invalidate", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/me": { "get": { "security": [ @@ -226,6 +275,18 @@ } } }, + "internal_auth.LogoutRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RefreshRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 49ef4f7..6b394de 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -28,6 +28,14 @@ definitions: - email - password type: object + internal_auth.LogoutRequest: + properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string + required: + - refresh_token + type: object internal_auth.RefreshRequest: properties: refresh_token: @@ -107,6 +115,38 @@ paths: summary: Epta login tags: - auth + /api/auth/logout: + post: + consumes: + - application/json + description: Invalidate a refresh token (logout) + parameters: + - description: Refresh token to invalidate + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_auth.LogoutRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + summary: Logout epta + tags: + - auth /api/auth/me: get: consumes: diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c1c4721..1ddc136 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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()), }, diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 039c2fb..ce8d427 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -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 } diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 591fefe..5f0b24a 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -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 } diff --git a/internal/auth/models.go b/internal/auth/models.go index 52a5c3d..6985feb 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -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"` diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 945db7f..69186e5 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -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 +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 242a485..dd50087 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 185cb30..6507d50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"),