Skip to content

Commit 4b6c4a9

Browse files
alielmi98naeemaei
andauthored
feat: add refresh token endpoint and related configurations (#9)
* 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. * 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") * feat: add token usecase and config to UsersHandler --------- Co-authored-by: Hamed Naeemaei <[email protected]>
1 parent 45401e6 commit 4b6c4a9

File tree

13 files changed

+228
-18
lines changed

13 files changed

+228
-18
lines changed

src/api/handler/base_generic_crud.go

+1-2
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ var logger = logging.NewLogger(config.GetConfig())
2929
func Create[TRequest any, TUInput any, TUOutput any, TResponse any](c *gin.Context,
3030
requestMapper func(req TRequest) (res TUInput),
3131
responseMapper func(req TUOutput) (res TResponse),
32-
usecaseCreate func(ctx context.Context,
33-
req TUInput) (TUOutput, error)) {
32+
usecaseCreate func(ctx context.Context, req TUInput) (TUOutput, error)) {
3433

3534
// bind http request
3635
request := new(TRequest)

src/api/handler/user.go

+62-3
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,23 @@ import (
77
"github.com/naeemaei/golang-clean-web-api/api/dto"
88
"github.com/naeemaei/golang-clean-web-api/api/helper"
99
"github.com/naeemaei/golang-clean-web-api/config"
10+
"github.com/naeemaei/golang-clean-web-api/constant"
1011
"github.com/naeemaei/golang-clean-web-api/dependency"
1112
"github.com/naeemaei/golang-clean-web-api/usecase"
1213
)
1314

1415
type UsersHandler struct {
15-
userUsecase *usecase.UserUsecase
16-
otpUsecase *usecase.OtpUsecase
16+
userUsecase *usecase.UserUsecase
17+
otpUsecase *usecase.OtpUsecase
18+
tokenUsecase *usecase.TokenUsecase
19+
config *config.Config
1720
}
1821

1922
func NewUserHandler(cfg *config.Config) *UsersHandler {
2023
userUsecase := usecase.NewUserUsecase(cfg, dependency.GetUserRepository(cfg))
2124
otpUsecase := usecase.NewOtpUsecase(cfg)
22-
return &UsersHandler{userUsecase: userUsecase, otpUsecase: otpUsecase}
25+
tokenUsecase := usecase.NewTokenUsecase(cfg)
26+
return &UsersHandler{userUsecase: userUsecase, otpUsecase: otpUsecase, tokenUsecase: tokenUsecase, config: cfg}
2327
}
2428

2529
// LoginByUsername godoc
@@ -48,6 +52,18 @@ func (h *UsersHandler) LoginByUsername(c *gin.Context) {
4852
return
4953
}
5054

55+
// Set the refresh token in a cookie
56+
http.SetCookie(c.Writer, &http.Cookie{
57+
Name: constant.RefreshTokenCookieName,
58+
Value: token.RefreshToken,
59+
MaxAge: int(h.config.JWT.RefreshTokenExpireDuration * 60),
60+
Path: "/",
61+
Domain: h.config.Server.Domain,
62+
Secure: true,
63+
HttpOnly: true,
64+
SameSite: http.SameSiteStrictMode,
65+
})
66+
5167
c.JSON(http.StatusCreated, helper.GenerateBaseResponse(token, true, helper.Success))
5268
}
5369

@@ -106,6 +122,18 @@ func (h *UsersHandler) RegisterLoginByMobileNumber(c *gin.Context) {
106122
return
107123
}
108124

125+
// Set the refresh token in a cookie
126+
http.SetCookie(c.Writer, &http.Cookie{
127+
Name: constant.RefreshTokenCookieName,
128+
Value: token.RefreshToken,
129+
MaxAge: int(h.config.JWT.RefreshTokenExpireDuration * 60),
130+
Path: "/",
131+
Domain: h.config.Server.Domain,
132+
Secure: true,
133+
HttpOnly: true,
134+
SameSite: http.SameSiteStrictMode,
135+
})
136+
109137
c.JSON(http.StatusCreated, helper.GenerateBaseResponse(token, true, helper.Success))
110138
}
111139

@@ -137,3 +165,34 @@ func (h *UsersHandler) SendOtp(c *gin.Context) {
137165
// TODO: Call internal SMS service
138166
c.JSON(http.StatusCreated, helper.GenerateBaseResponse(nil, true, helper.Success))
139167
}
168+
169+
// RefreshToken godoc
170+
// @Summary RefreshToken
171+
// @Description RefreshToken
172+
// @Tags Users
173+
// @Accept json
174+
// @Produce json
175+
// @Success 200 {object} helper.BaseHttpResponse "Success"
176+
// @Failure 400 {object} helper.BaseHttpResponse "Failed"
177+
// @Failure 401 {object} helper.BaseHttpResponse "Failed"
178+
// @Router /v1/users/refresh-token [post]
179+
func (h *UsersHandler) RefreshToken(c *gin.Context) {
180+
token, err := h.tokenUsecase.RefreshToken(c)
181+
if err != nil {
182+
c.AbortWithStatusJSON(helper.TranslateErrorToStatusCode(err),
183+
helper.GenerateBaseResponseWithError(nil, false, helper.InternalError, err))
184+
return
185+
}
186+
// Set the refresh token in a cookie
187+
http.SetCookie(c.Writer, &http.Cookie{
188+
Name: constant.RefreshTokenCookieName,
189+
Value: token.RefreshToken,
190+
MaxAge: int(h.config.JWT.RefreshTokenExpireDuration * 60),
191+
Path: "/",
192+
Domain: h.config.Server.Domain,
193+
Secure: true,
194+
HttpOnly: true,
195+
SameSite: http.SameSiteStrictMode,
196+
})
197+
c.JSON(http.StatusOK, helper.GenerateBaseResponse(token, true, helper.Success))
198+
}

src/api/router/users.go

+1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ func User(router *gin.RouterGroup, cfg *config.Config) {
1414
router.POST("/login-by-username", h.LoginByUsername)
1515
router.POST("/register-by-username", h.RegisterByUsername)
1616
router.POST("/login-by-mobile", h.RegisterLoginByMobileNumber)
17+
router.POST("/refresh-token", h.RefreshToken)
1718
}

src/config/config-development.yml

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ server:
22
internalPort: 5005
33
externalPort: 5005
44
runMode: debug
5+
domain: localhost
56
logger:
67
filePath: ../logs/
78
encoding: json

src/config/config-docker.yml

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ server:
22
internalPort: 5000
33
externalPort: 0
44
runMode: release
5+
domain: localhost
56
logger:
67
filePath: /app/logs/
78
encoding: json

src/config/config-production.yml

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ server:
22
internalPort: 5010
33
externalPort: 5010
44
runMode: release
5+
domain: localhost
56
logger:
67
filePath: logs/
78
encoding: json

src/config/config.go

+6-5
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ type Config struct {
2121
}
2222

2323
type ServerConfig struct {
24-
InternalPort string
25-
ExternalPort string
26-
RunMode string
24+
InternalPort string
25+
ExternalPort string
26+
RunMode string
27+
Domain string
2728
}
2829

2930
type LoggerConfig struct {
@@ -93,10 +94,10 @@ func GetConfig() *Config {
9394

9495
cfg, err := ParseConfig(v)
9596
envPort := os.Getenv("PORT")
96-
if envPort != ""{
97+
if envPort != "" {
9798
cfg.Server.ExternalPort = envPort
9899
log.Printf("Set external port from environment -> %s", cfg.Server.ExternalPort)
99-
}else{
100+
} else {
100101
cfg.Server.ExternalPort = cfg.Server.InternalPort
101102
log.Printf("Set external port from environment -> %s", cfg.Server.ExternalPort)
102103
}

src/constant/constanst.go

+3
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ const (
1717
MobileNumberKey string = "MobileNumber"
1818
RolesKey string = "Roles"
1919
ExpireTimeKey string = "Exp"
20+
21+
// JWT
22+
RefreshTokenCookieName string = "refresh_token"
2023
)

src/docs/docs.go

+35
Original file line numberDiff line numberDiff line change
@@ -4661,6 +4661,41 @@ const docTemplate = `{
46614661
}
46624662
}
46634663
},
4664+
"/v1/users/refresh-token": {
4665+
"get": {
4666+
"description": "RefreshToken",
4667+
"consumes": [
4668+
"application/json"
4669+
],
4670+
"produces": [
4671+
"application/json"
4672+
],
4673+
"tags": [
4674+
"Users"
4675+
],
4676+
"summary": "RefreshToken",
4677+
"responses": {
4678+
"200": {
4679+
"description": "Success",
4680+
"schema": {
4681+
"$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse"
4682+
}
4683+
},
4684+
"400": {
4685+
"description": "Failed",
4686+
"schema": {
4687+
"$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse"
4688+
}
4689+
},
4690+
"401": {
4691+
"description": "Failed",
4692+
"schema": {
4693+
"$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse"
4694+
}
4695+
}
4696+
}
4697+
}
4698+
},
46644699
"/v1/users/register-by-username": {
46654700
"post": {
46664701
"description": "RegisterByUsername",

src/docs/swagger.json

+35
Original file line numberDiff line numberDiff line change
@@ -4650,6 +4650,41 @@
46504650
}
46514651
}
46524652
},
4653+
"/v1/users/refresh-token": {
4654+
"get": {
4655+
"description": "RefreshToken",
4656+
"consumes": [
4657+
"application/json"
4658+
],
4659+
"produces": [
4660+
"application/json"
4661+
],
4662+
"tags": [
4663+
"Users"
4664+
],
4665+
"summary": "RefreshToken",
4666+
"responses": {
4667+
"200": {
4668+
"description": "Success",
4669+
"schema": {
4670+
"$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse"
4671+
}
4672+
},
4673+
"400": {
4674+
"description": "Failed",
4675+
"schema": {
4676+
"$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse"
4677+
}
4678+
},
4679+
"401": {
4680+
"description": "Failed",
4681+
"schema": {
4682+
"$ref": "#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse"
4683+
}
4684+
}
4685+
}
4686+
}
4687+
},
46534688
"/v1/users/register-by-username": {
46544689
"post": {
46554690
"description": "RegisterByUsername",

src/docs/swagger.yaml

+23
Original file line numberDiff line numberDiff line change
@@ -3821,6 +3821,29 @@ paths:
38213821
summary: LoginByUsername
38223822
tags:
38233823
- Users
3824+
/v1/users/refresh-token:
3825+
get:
3826+
consumes:
3827+
- application/json
3828+
description: RefreshToken
3829+
produces:
3830+
- application/json
3831+
responses:
3832+
"200":
3833+
description: Success
3834+
schema:
3835+
$ref: '#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse'
3836+
"400":
3837+
description: Failed
3838+
schema:
3839+
$ref: '#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse'
3840+
"401":
3841+
description: Failed
3842+
schema:
3843+
$ref: '#/definitions/github_com_naeemaei_golang-clean-web-api_api_helper.BaseHttpResponse'
3844+
summary: RefreshToken
3845+
tags:
3846+
- Users
38243847
/v1/users/register-by-username:
38253848
post:
38263849
consumes:

src/pkg/service_errors/error_code.go

+10-8
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,24 @@ package service_errors
22

33
const (
44
// Token
5-
UnExpectedError = "Expected error"
6-
ClaimsNotFound = "Claims not found"
7-
TokenRequired = "token required"
8-
TokenExpired = "token expired"
9-
TokenInvalid = "token invalid"
5+
UnExpectedError = "Expected error"
6+
ClaimsNotFound = "Claims not found"
7+
TokenRequired = "token required"
8+
TokenExpired = "token expired"
9+
TokenInvalid = "token invalid"
10+
InvalidRefreshToken = "invalid refresh token"
1011

1112
// OTP
1213
OptExists = "Otp exists"
1314
OtpUsed = "Otp used"
1415
OtpNotValid = "Otp invalid"
1516

1617
// User
17-
EmailExists = "Email exists"
18-
UsernameExists = "Username exists"
19-
PermissionDenied = "Permission denied"
18+
EmailExists = "Email exists"
19+
UsernameExists = "Username exists"
20+
PermissionDenied = "Permission denied"
2021
UsernameOrPasswordInvalid = "username or password invalid"
22+
InvalidRolesFormat = "invalid roles format"
2123

2224
// DB
2325
RecordNotFound = "record not found"

src/usecase/token_usecase.go

+49
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package usecase
33
import (
44
"time"
55

6+
"github.com/gin-gonic/gin"
67
"github.com/golang-jwt/jwt"
78
"github.com/naeemaei/golang-clean-web-api/config"
89
"github.com/naeemaei/golang-clean-web-api/constant"
@@ -62,6 +63,12 @@ func (u *TokenUsecase) GenerateToken(token tokenDto) (*dto.TokenDetail, error) {
6263
rtc := jwt.MapClaims{}
6364

6465
rtc[constant.UserIdKey] = token.UserId
66+
rtc[constant.FirstNameKey] = token.FirstName
67+
rtc[constant.LastNameKey] = token.LastName
68+
rtc[constant.UsernameKey] = token.Username
69+
rtc[constant.EmailKey] = token.Email
70+
rtc[constant.MobileNumberKey] = token.MobileNumber
71+
rtc[constant.RolesKey] = token.Roles
6572
rtc[constant.ExpireTimeKey] = td.RefreshTokenExpireTime
6673

6774
rt := jwt.NewWithClaims(jwt.SigningMethodHS256, rtc)
@@ -105,3 +112,45 @@ func (u *TokenUsecase) GetClaims(token string) (claimMap map[string]interface{},
105112
}
106113
return nil, &service_errors.ServiceError{EndUserMessage: service_errors.ClaimsNotFound}
107114
}
115+
116+
func (s *TokenUsecase) RefreshToken(c *gin.Context) (*dto.TokenDetail, error) {
117+
refreshToken, err := c.Cookie(constant.RefreshTokenCookieName)
118+
if err != nil {
119+
return nil, &service_errors.ServiceError{EndUserMessage: service_errors.InvalidRefreshToken}
120+
}
121+
122+
claims, err := s.GetClaims(refreshToken)
123+
if err != nil {
124+
return nil, err
125+
}
126+
127+
// Convert roles to []string
128+
rolesInterface, ok := claims[constant.RolesKey].([]interface{})
129+
if !ok {
130+
return nil, &service_errors.ServiceError{EndUserMessage: service_errors.InvalidRolesFormat}
131+
}
132+
133+
roles := make([]string, len(rolesInterface))
134+
for i, role := range rolesInterface {
135+
roles[i], ok = role.(string)
136+
if !ok {
137+
return nil, &service_errors.ServiceError{EndUserMessage: service_errors.InvalidRolesFormat}
138+
}
139+
}
140+
141+
tokenDto := tokenDto{
142+
UserId: int(claims[constant.UserIdKey].(float64)),
143+
FirstName: claims[constant.FirstNameKey].(string),
144+
LastName: claims[constant.LastNameKey].(string),
145+
Username: claims[constant.UsernameKey].(string),
146+
MobileNumber: claims[constant.MobileNumberKey].(string),
147+
Email: claims[constant.EmailKey].(string),
148+
Roles: roles,
149+
}
150+
newTokenDetail, err := s.GenerateToken(tokenDto)
151+
if err != nil {
152+
return nil, err
153+
}
154+
155+
return newTokenDetail, nil
156+
}

0 commit comments

Comments
 (0)