From b3a405016e064330dadac0c0e114c8b985718970 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Tue, 15 Jul 2025 20:54:12 +0300 Subject: [PATCH] refactor: introduce DTOs for claims, session, and request handling feat: add token validation service method refactor: update middleware to use structured DTOs feat: implement session info propagation through context refactor: replace ad-hoc structs with DTOs in middleware chore: organize auth-related data structures --- backend/internal/dto/claims.go | 32 +++++++++ backend/internal/dto/request.go | 23 ++++++ backend/internal/dto/sessionInfo.go | 27 +++++++ backend/internal/dto/userInfo.go | 24 +++++++ backend/internal/middleware/auth.go | 29 ++++---- backend/internal/middleware/request.go | 54 +++++++------- backend/internal/services/auth.go | 97 ++++++++++++++++++++++++++ backend/internal/utils/request.go | 15 ++-- 8 files changed, 249 insertions(+), 52 deletions(-) create mode 100644 backend/internal/dto/claims.go create mode 100644 backend/internal/dto/request.go create mode 100644 backend/internal/dto/sessionInfo.go create mode 100644 backend/internal/dto/userInfo.go diff --git a/backend/internal/dto/claims.go b/backend/internal/dto/claims.go new file mode 100644 index 0000000..87f9bb5 --- /dev/null +++ b/backend/internal/dto/claims.go @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package dto + +import ( + "easywish/internal/utils/enums" + + "github.com/golang-jwt/jwt/v5" +) + +type UserClaims struct { + Username string `json:"username"` + Role enums.Role `json:"role"` + Type enums.JwtTokenType `json:"type"` + Session string `json:"session"` + jwt.RegisteredClaims +} diff --git a/backend/internal/dto/request.go b/backend/internal/dto/request.go new file mode 100644 index 0000000..17672db --- /dev/null +++ b/backend/internal/dto/request.go @@ -0,0 +1,23 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package dto + +type Request[T any] struct { + User ClientInfo + Body T +} diff --git a/backend/internal/dto/sessionInfo.go b/backend/internal/dto/sessionInfo.go new file mode 100644 index 0000000..8de7a86 --- /dev/null +++ b/backend/internal/dto/sessionInfo.go @@ -0,0 +1,27 @@ + +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package dto + +import "easywish/internal/utils/enums" + +type SessionInfo struct { + Username string + Session string + Role enums.Role +} diff --git a/backend/internal/dto/userInfo.go b/backend/internal/dto/userInfo.go new file mode 100644 index 0000000..42ba7b0 --- /dev/null +++ b/backend/internal/dto/userInfo.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package dto + +type ClientInfo struct { + SessionInfo + IP string + UserAgent string +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 284381b..358d76f 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -21,6 +21,7 @@ import ( "context" "easywish/config" "easywish/internal/database" + "easywish/internal/dto" "easywish/internal/utils/enums" "errors" "fmt" @@ -34,14 +35,6 @@ import ( "go.uber.org/zap" ) -type Claims struct { - Username string `json:"username"` - Role enums.Role `json:"role"` - Type enums.JwtTokenType `json:"type"` - Session string `json:"session"` - jwt.RegisteredClaims -} - // XXX: cluttered; move cache & database check to auth service func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { @@ -49,8 +42,12 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi authHeader := c.GetHeader("Authorization") if authHeader == "" { - c.Set("username", nil) - c.Set("role", enums.GuestRole) + + c.Set("session_info", dto.SessionInfo{ + Username: "", + Session: "", + Role: enums.GuestRole}, + ) c.Next() return } @@ -59,7 +56,7 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi token, err := jwt.ParseWithClaims( tokenString, - &Claims{}, + &dto.UserClaims{}, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -77,7 +74,7 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi return } - if claims, ok := token.Claims.(*Claims); ok && token.Valid { + if claims, ok := token.Claims.(*dto.UserClaims); ok && token.Valid { if claims.Type != enums.JwtAccessTokenType { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not an access token"}) @@ -142,8 +139,12 @@ func AuthMiddleware(log *zap.Logger, dbctx database.DbContext, redisClient *redi return } - c.Set("username", claims.Username) - c.Set("role", claims.Role) + c.Set("session_info", dto.SessionInfo{ + Username: claims.Username, + Session: claims.Session, + Role: claims.Role, + }) + c.Next() } else { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid claims"}) diff --git a/backend/internal/middleware/request.go b/backend/internal/middleware/request.go index 36ef57b..a729644 100644 --- a/backend/internal/middleware/request.go +++ b/backend/internal/middleware/request.go @@ -18,6 +18,7 @@ package middleware import ( + "easywish/internal/dto" "easywish/internal/utils/enums" "easywish/internal/validation" "fmt" @@ -27,58 +28,51 @@ import ( "github.com/go-playground/validator/v10" ) -type UserInfo struct { - Username string - Role enums.Role -} - -type Request[T any] struct { - User UserInfo - Body T -} - const requestKey = "request" -func UserInfoFromContext(c *gin.Context) (*UserInfo, bool) { +func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) { - var username any - var role any var ok bool - username, ok = c.Get("username") ; if !ok { - return &UserInfo{Username: "", Role: enums.GuestRole}, true - } + ip := c.ClientIP() + userAgent := c.Request.UserAgent() - role, ok = c.Get("role"); if !ok { + sessionInfoFromCtx, ok := c.Get("session_info"); if !ok { return nil, false } - if username == nil { - return &UserInfo{Username: "", Role: enums.GuestRole}, true + sessionInfo := sessionInfoFromCtx.(dto.SessionInfo) + + if sessionInfo.Username == "" { + return &dto.ClientInfo{ + SessionInfo: sessionInfo, + IP: ip, + UserAgent: userAgent, + }, true } - if role == nil { - return nil, false - } - - return &UserInfo{Username: username.(string), Role: role.(enums.Role)}, true + return &dto.ClientInfo{ + SessionInfo: sessionInfo, + IP: ip, + UserAgent: userAgent, + }, true } -func RequestFromContext[T any](c *gin.Context) Request[T] { - return c.Value(requestKey).(Request[T]) +func RequestFromContext[T any](c *gin.Context) dto.Request[T] { + return c.Value(requestKey).(dto.Request[T]) } func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc { return gin.HandlerFunc(func(c *gin.Context) { - userInfo, ok := UserInfoFromContext(c) + clientInfo, ok := ClientInfoFromContext(c) if !ok { c.Status(http.StatusUnauthorized) return } - if userInfo.Role < role { + if clientInfo.Role < role { c.Status(http.StatusForbidden) return } @@ -99,8 +93,8 @@ func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc { return } - request := Request[T]{ - User: *userInfo, + request := dto.Request[T]{ + User: *clientInfo, Body: body, } diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go index 5e5c21e..f14d6fe 100644 --- a/backend/internal/services/auth.go +++ b/backend/internal/services/auth.go @@ -21,6 +21,7 @@ import ( "context" "easywish/config" "easywish/internal/database" + "easywish/internal/dto" errs "easywish/internal/errors" "easywish/internal/models" "easywish/internal/utils" @@ -30,6 +31,7 @@ import ( "time" "github.com/go-redis/redis/v8" + "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" @@ -43,6 +45,7 @@ type AuthService interface { Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error) PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error) + ValidateToken(token string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error) } type authServiceImpl struct { @@ -505,6 +508,100 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo } func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) { + + var err error + + token, err := jwt.ParseWithClaims( + request.RefreshToken, + &dto.UserClaims{}, + func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(config.GetConfig().JwtSecret), nil + }, + ) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + // AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token expired"}) + } else { + // c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + } + return nil, errs.ErrUnauthorized + } + + if claims, ok := token.Claims.(*dto.UserClaims); ok && token.Valid { + + if claims.Type != enums.JwtAccessTokenType { + // c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Not an access token"}) + return nil, errs.ErrUnauthorized + } + + ctx := context.TODO() + isTerminated, redisErr := a.redis.Get(ctx, fmt.Sprintf("session::%s::is_terminated", claims.Session)).Bool() + if redisErr != nil && redisErr != redis.Nil { + a.log.Error( + "Failed to lookup cache to check whether session is not terminated", + zap.Error(redisErr)) + // c.AbortWithStatus(http.StatusInternalServerError) + return nil, errs.ErrServerError + } + + // Cache if nil + if redisErr == redis.Nil { + db := database.NewDbHelper(a.dbctx) + + session, err := db.Queries.GetSessionByGuid(db.CTX, claims.Session) + if err != nil { + + if errors.Is(err, pgx.ErrNoRows) { + a.log.Warn( + "Session does not exist or was deleted", + zap.String("session", claims.Session)) + // c.AbortWithStatus(http.StatusUnauthorized) + return nil, errs.ErrUnauthorized + } + + a.log.Error( + "Failed to lookup session in database", + zap.String("session", claims.Session), + zap.Error(err)) + // c.AbortWithStatus(http.StatusInternalServerError) + return nil, errs.ErrServerError + } + + if err := a.redis.Set( + ctx, + fmt.Sprintf("session::%s::is_terminated", claims.Session), + session.Terminated, + time.Duration(8 * time.Hour), // XXX: magic number + ).Err(); err != nil { + a.log.Error( + "Failed to cache session's is_terminated state", + zap.String("session", claims.Session), + zap.Error(err)) + // c.AbortWithStatus(http.StatusInternalServerError) + return nil, errs.ErrServerError + } + + isTerminated = *session.Terminated + } + + if isTerminated { + a.log.Warn( + "Attempt to access resource from a terminated session", + zap.String("session", claims.Session)) + // c.AbortWithStatus(http.StatusUnauthorized) + return nil, errs.ErrUnauthorized + } + } + // TODO: generate some tokens + + return nil, errs.ErrNotImplemented +} + +func (a *authServiceImpl) ValidateToken(token string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error) { return nil, errs.ErrNotImplemented } diff --git a/backend/internal/utils/request.go b/backend/internal/utils/request.go index 876c823..81e3195 100644 --- a/backend/internal/utils/request.go +++ b/backend/internal/utils/request.go @@ -1,32 +1,31 @@ // Copyright (c) 2025 Nikolai Papin -// +// // This file is part of Easywish -// +// // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. -// +// // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // the GNU General Public License for more details. -// +// // You should have received a copy of the GNU General Public License // along with this program. If not, see . package utils import ( - "easywish/internal/middleware" - + "easywish/internal/dto" "github.com/gin-gonic/gin" ) -func GetRequest[T any](c *gin.Context) (*middleware.Request[T], bool) { +func GetRequest[T any](c *gin.Context) (*dto.Request[T], bool) { req, ok := c.Get("request") - request := req.(middleware.Request[T]) + request := req.(dto.Request[T]) if !ok { return nil, false }