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 3eb15d1..c3d6ab1 100644 --- a/src/api/handler/user.go +++ b/src/api/handler/user.go @@ -7,6 +7,7 @@ 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" ) @@ -48,6 +49,18 @@ func (h *UsersHandler) LoginByUsername(c *gin.Context) { return } + // Set the refresh token in a cookie + 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)) } @@ -106,6 +119,18 @@ func (h *UsersHandler) RegisterLoginByMobileNumber(c *gin.Context) { return } + // Set the refresh token in a cookie + 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)) } @@ -137,3 +162,34 @@ 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 [post] +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 + 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 52444dd..5d66947 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.POST("/refresh-token", h.RefreshToken) } diff --git a/src/config/config-development.yml b/src/config/config-development.yml index 408dfeb..91f9df4 100644 --- a/src/config/config-development.yml +++ b/src/config/config-development.yml @@ -2,6 +2,7 @@ server: internalPort: 5005 externalPort: 5005 runMode: debug + domain: localhost logger: filePath: ../logs/ encoding: json diff --git a/src/config/config-docker.yml b/src/config/config-docker.yml index 66f00b6..abb4b91 100644 --- a/src/config/config-docker.yml +++ b/src/config/config-docker.yml @@ -2,6 +2,7 @@ server: internalPort: 5000 externalPort: 0 runMode: release + domain: localhost logger: filePath: /app/logs/ encoding: json diff --git a/src/config/config-production.yml b/src/config/config-production.yml index 5350ac8..5332710 100644 --- a/src/config/config-production.yml +++ b/src/config/config-production.yml @@ -2,6 +2,7 @@ server: internalPort: 5010 externalPort: 5010 runMode: release + domain: localhost logger: filePath: logs/ encoding: json diff --git a/src/config/config.go b/src/config/config.go index fd91ae0..d1aaae4 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 + Domain 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 a20c59e..f772767 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 (u *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 (u *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 +}