added logout
This commit is contained in:
+2
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user