added logout
This commit is contained in:
+2
-1
@@ -38,7 +38,7 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("failed to create mongodb client: %v", err)
|
log.Fatalf("failed to create mongodb client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -77,6 +77,7 @@ func main() {
|
|||||||
api.POST("/register", handler.Register)
|
api.POST("/register", handler.Register)
|
||||||
api.POST("/login", handler.Login)
|
api.POST("/login", handler.Login)
|
||||||
api.POST("/refresh", handler.Refresh)
|
api.POST("/refresh", handler.Refresh)
|
||||||
|
api.POST("/logout", handler.Logout)
|
||||||
api.GET("/me", auth.AuthMiddleware([]byte(cfg.JWTSecret)), handler.Me)
|
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": {
|
"/api/auth/me": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"internal_auth.RefreshRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"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": {
|
"/api/auth/me": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -226,6 +275,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_auth.LogoutRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"refresh_token"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"refresh_token": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_auth.RefreshRequest": {
|
"internal_auth.RefreshRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ definitions:
|
|||||||
- email
|
- email
|
||||||
- password
|
- password
|
||||||
type: object
|
type: object
|
||||||
|
internal_auth.LogoutRequest:
|
||||||
|
properties:
|
||||||
|
refresh_token:
|
||||||
|
example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- refresh_token
|
||||||
|
type: object
|
||||||
internal_auth.RefreshRequest:
|
internal_auth.RefreshRequest:
|
||||||
properties:
|
properties:
|
||||||
refresh_token:
|
refresh_token:
|
||||||
@@ -107,6 +115,38 @@ paths:
|
|||||||
summary: Epta login
|
summary: Epta login
|
||||||
tags:
|
tags:
|
||||||
- auth
|
- 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:
|
/api/auth/me:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func GenerateToken(userID, email string, secret []byte, expiration time.Duration
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
Email: email,
|
Email: email,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: userID,
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -38,6 +39,7 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("register error: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,12 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
resp, err := h.service.Login(c.Request.Context(), req)
|
resp, err := h.service.Login(c.Request.Context(), req)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +101,7 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("refresh error: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -101,6 +109,36 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, resp)
|
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
|
// @Summary Epta get current user
|
||||||
// @Description Get authenticated user's profile
|
// @Description Get authenticated user's profile
|
||||||
// @Tags auth
|
// @Tags auth
|
||||||
@@ -129,6 +167,7 @@ func (h *Handler) Me(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("me error: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func AuthMiddleware(jwtSecret []byte) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
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"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid authorization header format"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ type RefreshRequest struct {
|
|||||||
RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
|
RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogoutRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
|
||||||
|
}
|
||||||
|
|
||||||
type RefreshTokenDoc struct {
|
type RefreshTokenDoc struct {
|
||||||
ID bson.ObjectID `json:"id" bson:"_id"`
|
ID bson.ObjectID `json:"id" bson:"_id"`
|
||||||
UserID bson.ObjectID `json:"user_id" bson:"user_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})
|
_, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"_id": id})
|
||||||
return err
|
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"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -21,6 +23,7 @@ var (
|
|||||||
ErrInvalidUserID = errors.New("invalid user ID")
|
ErrInvalidUserID = errors.New("invalid user ID")
|
||||||
ErrInvalidRefresh = errors.New("invalid refresh token")
|
ErrInvalidRefresh = errors.New("invalid refresh token")
|
||||||
ErrRefreshExpired = errors.New("refresh token expired")
|
ErrRefreshExpired = errors.New("refresh token expired")
|
||||||
|
ErrLogoutInvalid = errors.New("refresh token not found or already used")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
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) {
|
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)
|
existing, err := s.repo.FindByEmail(ctx, req.Email)
|
||||||
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
|
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
return nil, fmt.Errorf("failed to check existing user: %w", err)
|
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) {
|
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)
|
user, err := s.repo.FindByEmail(ctx, req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
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) {
|
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
|
return nil, ErrRefreshExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +159,20 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse
|
|||||||
return s.issueTokenPair(ctx, user)
|
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) {
|
func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) {
|
||||||
id, err := bson.ObjectIDFromHex(userID)
|
id, err := bson.ObjectIDFromHex(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,7 +19,9 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
godotenv.Load()
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Printf("warning: .env file not loaded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||||
|
|||||||
Reference in New Issue
Block a user