added logout

This commit is contained in:
Mephimeow
2026-06-12 10:18:04 +00:00
committed by zero@thinky
parent a822d8c3b6
commit f1308b3be7
11 changed files with 244 additions and 5 deletions
+2 -1
View File
@@ -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
View File
@@ -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": [
+61
View File
@@ -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": [
+40
View File
@@ -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:
+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"),