From 20eee13b84c133acf6b0674b96765d4762c08e25 Mon Sep 17 00:00:00 2001 From: Ali Elmi Date: Sat, 22 Mar 2025 01:39:22 +0330 Subject: [PATCH 1/2] feat: add refresh token endpoint and related configurations New Feature: -Implemented the RefreshToken method in the TokenUsecase to handle token refreshing. -The method retrieves the refresh token from the HTTP cookie, validates it, and generates a new access and refresh token pair. Details: -Extracts the refresh token from the cookie using c.Cookie. -Validates the refresh token and extracts claims using the GetClaims method. -Converts roles from []interface{} to []string for proper type handling. -Generates a new token pair using the GenerateToken method. Reason for Addition: -To provide functionality for refreshing expired access tokens while maintaining security through refresh tokens. -This is a critical feature for session management in the application. Benefits: -Enables secure token lifecycle management. -Improves user experience by allowing seamless token refresh without requiring re-login. --- src/api/handler/user.go | 43 ++++++++++++++++++++++-- src/api/router/users.go | 1 + src/config/config-development.yml | 1 + src/config/config-docker.yml | 1 + src/config/config-production.yml | 1 + src/config/config.go | 11 ++++--- src/constant/constanst.go | 3 ++ src/docs/docs.go | 35 ++++++++++++++++++++ src/docs/swagger.json | 35 ++++++++++++++++++++ src/docs/swagger.yaml | 23 +++++++++++++ src/pkg/service_errors/error_code.go | 18 +++++----- src/usecase/token_usecase.go | 49 ++++++++++++++++++++++++++++ 12 files changed, 205 insertions(+), 16 deletions(-) diff --git a/src/api/handler/user.go b/src/api/handler/user.go index 9d6329a..cd90a3b 100644 --- a/src/api/handler/user.go +++ b/src/api/handler/user.go @@ -7,18 +7,27 @@ import ( "github.com/naeemaei/golang-clean-web-api/api/dto" "github.com/naeemaei/golang-clean-web-api/api/helper" "github.com/naeemaei/golang-clean-web-api/config" + "github.com/naeemaei/golang-clean-web-api/constant" "github.com/naeemaei/golang-clean-web-api/dependency" "github.com/naeemaei/golang-clean-web-api/usecase" ) type UsersHandler struct { - usecase *usecase.UserUsecase - otpUsecase *usecase.OtpUsecase + usecase *usecase.UserUsecase + otpUsecase *usecase.OtpUsecase + tokenUsecase *usecase.TokenUsecase + config *config.Config } func NewUserHandler(cfg *config.Config) *UsersHandler { + tokenUsecase := usecase.NewTokenUsecase(cfg) usecase := usecase.NewUserUsecase(cfg, dependency.GetUserRepository(cfg)) - return &UsersHandler{usecase: usecase} + + return &UsersHandler{ + usecase: usecase, + tokenUsecase: tokenUsecase, + config: cfg, + } } // LoginByUsername godoc @@ -47,6 +56,9 @@ func (h *UsersHandler) LoginByUsername(c *gin.Context) { return } + // Set the refresh token in a cookie + c.SetCookie(constant.RefreshTokenCookieName, token.RefreshToken, int(h.config.JWT.RefreshTokenExpireDuration*60), "/", h.config.Server.Domin, true, true) + c.JSON(http.StatusCreated, helper.GenerateBaseResponse(token, true, helper.Success)) } @@ -105,6 +117,9 @@ func (h *UsersHandler) RegisterLoginByMobileNumber(c *gin.Context) { return } + // Set the refresh token in a cookie + c.SetCookie(constant.RefreshTokenCookieName, token.RefreshToken, int(h.config.JWT.RefreshTokenExpireDuration*60), "/", h.config.Server.Domin, true, true) + c.JSON(http.StatusCreated, helper.GenerateBaseResponse(token, true, helper.Success)) } @@ -136,3 +151,25 @@ func (h *UsersHandler) SendOtp(c *gin.Context) { // TODO: Call internal SMS service c.JSON(http.StatusCreated, helper.GenerateBaseResponse(nil, true, helper.Success)) } + +// RefreshToken godoc +// @Summary RefreshToken +// @Description RefreshToken +// @Tags Users +// @Accept json +// @Produce json +// @Success 200 {object} helper.BaseHttpResponse "Success" +// @Failure 400 {object} helper.BaseHttpResponse "Failed" +// @Failure 401 {object} helper.BaseHttpResponse "Failed" +// @Router /v1/users/refresh-token [get] +func (h *UsersHandler) RefreshToken(c *gin.Context) { + token, err := h.tokenUsecase.RefreshToken(c) + if err != nil { + c.AbortWithStatusJSON(helper.TranslateErrorToStatusCode(err), + helper.GenerateBaseResponseWithError(nil, false, helper.InternalError, err)) + return + } + // Set the refresh token in a cookie + c.SetCookie(constant.RefreshTokenCookieName, token.RefreshToken, int(h.config.JWT.RefreshTokenExpireDuration*60), "/", h.config.Server.Domin, true, true) + c.JSON(http.StatusOK, helper.GenerateBaseResponse(token, true, helper.Success)) +} diff --git a/src/api/router/users.go b/src/api/router/users.go index 52444dd..9ae2dc9 100644 --- a/src/api/router/users.go +++ b/src/api/router/users.go @@ -14,4 +14,5 @@ func User(router *gin.RouterGroup, cfg *config.Config) { router.POST("/login-by-username", h.LoginByUsername) router.POST("/register-by-username", h.RegisterByUsername) router.POST("/login-by-mobile", h.RegisterLoginByMobileNumber) + router.GET("/refresh-token", h.RefreshToken) } diff --git a/src/config/config-development.yml b/src/config/config-development.yml index 408dfeb..d07faf7 100644 --- a/src/config/config-development.yml +++ b/src/config/config-development.yml @@ -2,6 +2,7 @@ server: internalPort: 5005 externalPort: 5005 runMode: debug + domin: localhost logger: filePath: ../logs/ encoding: json diff --git a/src/config/config-docker.yml b/src/config/config-docker.yml index 66f00b6..c283118 100644 --- a/src/config/config-docker.yml +++ b/src/config/config-docker.yml @@ -2,6 +2,7 @@ server: internalPort: 5000 externalPort: 0 runMode: release + domin: localhost logger: filePath: /app/logs/ encoding: json diff --git a/src/config/config-production.yml b/src/config/config-production.yml index 5350ac8..f7be423 100644 --- a/src/config/config-production.yml +++ b/src/config/config-production.yml @@ -2,6 +2,7 @@ server: internalPort: 5010 externalPort: 5010 runMode: release + domin: localhost logger: filePath: logs/ encoding: json diff --git a/src/config/config.go b/src/config/config.go index fd91ae0..d9d7c60 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -21,9 +21,10 @@ type Config struct { } type ServerConfig struct { - InternalPort string - ExternalPort string - RunMode string + InternalPort string + ExternalPort string + RunMode string + Domin string } type LoggerConfig struct { @@ -93,10 +94,10 @@ func GetConfig() *Config { cfg, err := ParseConfig(v) envPort := os.Getenv("PORT") - if envPort != ""{ + if envPort != "" { cfg.Server.ExternalPort = envPort log.Printf("Set external port from environment -> %s", cfg.Server.ExternalPort) - }else{ + } else { cfg.Server.ExternalPort = cfg.Server.InternalPort log.Printf("Set external port from environment -> %s", cfg.Server.ExternalPort) } diff --git a/src/constant/constanst.go b/src/constant/constanst.go index d41e2d3..06fe426 100644 --- a/src/constant/constanst.go +++ b/src/constant/constanst.go @@ -17,4 +17,7 @@ const ( MobileNumberKey string = "MobileNumber" RolesKey string = "Roles" ExpireTimeKey string = "Exp" + + // JWT + RefreshTokenCookieName string = "refresh_token" ) diff --git a/src/docs/docs.go b/src/docs/docs.go index c9eb776..29562aa 100644 --- a/src/docs/docs.go +++ b/src/docs/docs.go @@ -4661,6 +4661,41 @@ const docTemplate = `{ } } }, + "/v1/users/refresh-token": { + "get": { + "description": "RefreshToken", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "RefreshToken", + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse" + } + }, + "400": { + "description": "Failed", + "schema": { + "$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse" + } + }, + "401": { + "description": "Failed", + "schema": { + "$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse" + } + } + } + } + }, "/v1/users/register-by-username": { "post": { "description": "RegisterByUsername", diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 2e4d6db..44b60c6 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -4650,6 +4650,41 @@ } } }, + "/v1/users/refresh-token": { + "get": { + "description": "RefreshToken", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "RefreshToken", + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse" + } + }, + "400": { + "description": "Failed", + "schema": { + "$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse" + } + }, + "401": { + "description": "Failed", + "schema": { + "$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse" + } + } + } + } + }, "/v1/users/register-by-username": { "post": { "description": "RegisterByUsername", diff --git a/src/docs/swagger.yaml b/src/docs/swagger.yaml index 369d5a1..c9971db 100644 --- a/src/docs/swagger.yaml +++ b/src/docs/swagger.yaml @@ -3821,6 +3821,29 @@ paths: summary: LoginByUsername tags: - Users + /v1/users/refresh-token: + get: + consumes: + - application/json + description: RefreshToken + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse' + "400": + description: Failed + schema: + $ref: '#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse' + "401": + description: Failed + schema: + $ref: '#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse' + summary: RefreshToken + tags: + - Users /v1/users/register-by-username: post: consumes: diff --git a/src/pkg/service_errors/error_code.go b/src/pkg/service_errors/error_code.go index 1b12e94..d706e38 100644 --- a/src/pkg/service_errors/error_code.go +++ b/src/pkg/service_errors/error_code.go @@ -2,11 +2,12 @@ package service_errors const ( // Token - UnExpectedError = "Expected error" - ClaimsNotFound = "Claims not found" - TokenRequired = "token required" - TokenExpired = "token expired" - TokenInvalid = "token invalid" + UnExpectedError = "Expected error" + ClaimsNotFound = "Claims not found" + TokenRequired = "token required" + TokenExpired = "token expired" + TokenInvalid = "token invalid" + InvalidRefreshToken = "invalid refresh token" // OTP OptExists = "Otp exists" @@ -14,10 +15,11 @@ const ( OtpNotValid = "Otp invalid" // User - EmailExists = "Email exists" - UsernameExists = "Username exists" - PermissionDenied = "Permission denied" + EmailExists = "Email exists" + UsernameExists = "Username exists" + PermissionDenied = "Permission denied" UsernameOrPasswordInvalid = "username or password invalid" + InvalidRolesFormat = "invalid roles format" // DB RecordNotFound = "record not found" diff --git a/src/usecase/token_usecase.go b/src/usecase/token_usecase.go index 48981c7..7209288 100644 --- a/src/usecase/token_usecase.go +++ b/src/usecase/token_usecase.go @@ -3,6 +3,7 @@ package usecase import ( "time" + "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt" "github.com/naeemaei/golang-clean-web-api/config" "github.com/naeemaei/golang-clean-web-api/constant" @@ -62,6 +63,12 @@ func (s *TokenUsecase) GenerateToken(token tokenDto) (*dto.TokenDetail, error) { rtc := jwt.MapClaims{} rtc[constant.UserIdKey] = token.UserId + rtc[constant.FirstNameKey] = token.FirstName + rtc[constant.LastNameKey] = token.LastName + rtc[constant.UsernameKey] = token.Username + rtc[constant.EmailKey] = token.Email + rtc[constant.MobileNumberKey] = token.MobileNumber + rtc[constant.RolesKey] = token.Roles rtc[constant.ExpireTimeKey] = td.RefreshTokenExpireTime rt := jwt.NewWithClaims(jwt.SigningMethodHS256, rtc) @@ -105,3 +112,45 @@ func (s *TokenUsecase) GetClaims(token string) (claimMap map[string]interface{}, } return nil, &service_errors.ServiceError{EndUserMessage: service_errors.ClaimsNotFound} } + +func (s *TokenUsecase) RefreshToken(c *gin.Context) (*dto.TokenDetail, error) { + refreshToken, err := c.Cookie(constant.RefreshTokenCookieName) + if err != nil { + return nil, &service_errors.ServiceError{EndUserMessage: service_errors.InvalidRefreshToken} + } + + claims, err := s.GetClaims(refreshToken) + if err != nil { + return nil, err + } + + // Convert roles to []string + rolesInterface, ok := claims[constant.RolesKey].([]interface{}) + if !ok { + return nil, &service_errors.ServiceError{EndUserMessage: service_errors.InvalidRolesFormat} + } + + roles := make([]string, len(rolesInterface)) + for i, role := range rolesInterface { + roles[i], ok = role.(string) + if !ok { + return nil, &service_errors.ServiceError{EndUserMessage: service_errors.InvalidRolesFormat} + } + } + + tokenDto := tokenDto{ + UserId: int(claims[constant.UserIdKey].(float64)), + FirstName: claims[constant.FirstNameKey].(string), + LastName: claims[constant.LastNameKey].(string), + Username: claims[constant.UsernameKey].(string), + MobileNumber: claims[constant.MobileNumberKey].(string), + Email: claims[constant.EmailKey].(string), + Roles: roles, + } + newTokenDetail, err := s.GenerateToken(tokenDto) + if err != nil { + return nil, err + } + + return newTokenDetail, nil +} From bee8aca21eae977d0386393866a048007ab8cfe1 Mon Sep 17 00:00:00 2001 From: Ali Elmi Date: Sat, 10 May 2025 00:30:40 +0330 Subject: [PATCH 2/2] Fix refresh token method, set CSRF SameSite, and minor domain config correction - change refresh token request method from GET to POST for better security and API alignment. - Added 'SameSite' attribute to CSRF cookie in production for improved security. - Corrected a minor typo in domain config (added missing 'a' in "domain") --- src/api/handler/base_generic_crud.go | 3 +-- src/api/handler/user.go | 35 ++++++++++++++++++++++++---- src/api/router/users.go | 2 +- src/config/config-development.yml | 2 +- src/config/config-docker.yml | 2 +- src/config/config-production.yml | 2 +- src/config/config.go | 2 +- 7 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/api/handler/base_generic_crud.go b/src/api/handler/base_generic_crud.go index 1ccf822..6029fb9 100644 --- a/src/api/handler/base_generic_crud.go +++ b/src/api/handler/base_generic_crud.go @@ -29,8 +29,7 @@ var logger = logging.NewLogger(config.GetConfig()) func Create[TRequest any, TUInput any, TUOutput any, TResponse any](c *gin.Context, requestMapper func(req TRequest) (res TUInput), responseMapper func(req TUOutput) (res TResponse), - usecaseCreate func(ctx context.Context, - req TUInput) (TUOutput, error)) { + usecaseCreate func(ctx context.Context, req TUInput) (TUOutput, error)) { // bind http request request := new(TRequest) diff --git a/src/api/handler/user.go b/src/api/handler/user.go index cd90a3b..cc0f8d3 100644 --- a/src/api/handler/user.go +++ b/src/api/handler/user.go @@ -57,7 +57,16 @@ func (h *UsersHandler) LoginByUsername(c *gin.Context) { } // Set the refresh token in a cookie - c.SetCookie(constant.RefreshTokenCookieName, token.RefreshToken, int(h.config.JWT.RefreshTokenExpireDuration*60), "/", h.config.Server.Domin, true, true) + http.SetCookie(c.Writer, &http.Cookie{ + Name: constant.RefreshTokenCookieName, + Value: token.RefreshToken, + MaxAge: int(h.config.JWT.RefreshTokenExpireDuration * 60), + Path: "/", + Domain: h.config.Server.Domain, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) c.JSON(http.StatusCreated, helper.GenerateBaseResponse(token, true, helper.Success)) } @@ -118,7 +127,16 @@ func (h *UsersHandler) RegisterLoginByMobileNumber(c *gin.Context) { } // Set the refresh token in a cookie - c.SetCookie(constant.RefreshTokenCookieName, token.RefreshToken, int(h.config.JWT.RefreshTokenExpireDuration*60), "/", h.config.Server.Domin, true, true) + http.SetCookie(c.Writer, &http.Cookie{ + Name: constant.RefreshTokenCookieName, + Value: token.RefreshToken, + MaxAge: int(h.config.JWT.RefreshTokenExpireDuration * 60), + Path: "/", + Domain: h.config.Server.Domain, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) c.JSON(http.StatusCreated, helper.GenerateBaseResponse(token, true, helper.Success)) } @@ -161,7 +179,7 @@ func (h *UsersHandler) SendOtp(c *gin.Context) { // @Success 200 {object} helper.BaseHttpResponse "Success" // @Failure 400 {object} helper.BaseHttpResponse "Failed" // @Failure 401 {object} helper.BaseHttpResponse "Failed" -// @Router /v1/users/refresh-token [get] +// @Router /v1/users/refresh-token [post] func (h *UsersHandler) RefreshToken(c *gin.Context) { token, err := h.tokenUsecase.RefreshToken(c) if err != nil { @@ -170,6 +188,15 @@ func (h *UsersHandler) RefreshToken(c *gin.Context) { return } // Set the refresh token in a cookie - c.SetCookie(constant.RefreshTokenCookieName, token.RefreshToken, int(h.config.JWT.RefreshTokenExpireDuration*60), "/", h.config.Server.Domin, true, true) + http.SetCookie(c.Writer, &http.Cookie{ + Name: constant.RefreshTokenCookieName, + Value: token.RefreshToken, + MaxAge: int(h.config.JWT.RefreshTokenExpireDuration * 60), + Path: "/", + Domain: h.config.Server.Domain, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) c.JSON(http.StatusOK, helper.GenerateBaseResponse(token, true, helper.Success)) } diff --git a/src/api/router/users.go b/src/api/router/users.go index 9ae2dc9..5d66947 100644 --- a/src/api/router/users.go +++ b/src/api/router/users.go @@ -14,5 +14,5 @@ func User(router *gin.RouterGroup, cfg *config.Config) { router.POST("/login-by-username", h.LoginByUsername) router.POST("/register-by-username", h.RegisterByUsername) router.POST("/login-by-mobile", h.RegisterLoginByMobileNumber) - router.GET("/refresh-token", h.RefreshToken) + router.POST("/refresh-token", h.RefreshToken) } diff --git a/src/config/config-development.yml b/src/config/config-development.yml index d07faf7..91f9df4 100644 --- a/src/config/config-development.yml +++ b/src/config/config-development.yml @@ -2,7 +2,7 @@ server: internalPort: 5005 externalPort: 5005 runMode: debug - domin: localhost + domain: localhost logger: filePath: ../logs/ encoding: json diff --git a/src/config/config-docker.yml b/src/config/config-docker.yml index c283118..abb4b91 100644 --- a/src/config/config-docker.yml +++ b/src/config/config-docker.yml @@ -2,7 +2,7 @@ server: internalPort: 5000 externalPort: 0 runMode: release - domin: localhost + domain: localhost logger: filePath: /app/logs/ encoding: json diff --git a/src/config/config-production.yml b/src/config/config-production.yml index f7be423..5332710 100644 --- a/src/config/config-production.yml +++ b/src/config/config-production.yml @@ -2,7 +2,7 @@ server: internalPort: 5010 externalPort: 5010 runMode: release - domin: localhost + domain: localhost logger: filePath: logs/ encoding: json diff --git a/src/config/config.go b/src/config/config.go index d9d7c60..d1aaae4 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -24,7 +24,7 @@ type ServerConfig struct { InternalPort string ExternalPort string RunMode string - Domin string + Domain string } type LoggerConfig struct {