Last time, we started building a handler for signing up a user with an email address and password. We learned how to validate and bind incoming JSON on an HTTP request body to a struct, and then how to call the UserService
for signing up a user.
In this tutorial, we'll complete this handler by having it reach out to a TokenService
which creates an access token and a refresh token. These tokens will be created, or derived, from the User
we signed up in the last tutorial. These tokens are what we'll use to authorize users to the eventual memorization app we plan to build. In a real-word scenario, these tokens could be used to access various applications or micro-services in a company or organization.
At the end of this section, your folder structure should be as follows. If at any point you are confused about the file structure or code, go to Github repository and checkout the branch for the previous lesson!
If you prefer video, check out the video version below!
I want to clarify that we'll definitely go into our auth flow later. For now we're going to focus on the handler logic. I've added some resources at the end of the article that I used in deciding how to store tokens in this app. This is merely a part of the auth flow decision process, but I hope you'll find the info useful.
Add Tokens Model
We need to create a model for the structure of the aforementioned tokens that we'll send to users as JSON. Recall that we create these models in our model layer, and that these models can be used across, and passed between, application layers. Right now we have a single User
model.
Let's add a model for our access and refresh tokens in ~/model/token_pair.go
. In this app, the ID token will also serve as an access token (for those of you that are thinking ahead). The tokens we ultimately serialize and send to the user as JSON with the keys in the struct tags are represented as strings.
// TokenPair used for returning pairs of id and refresh tokens
type TokenPair struct {
IDToken string `json:"idToken"`
RefreshToken string `json:"refreshToken"`
}
Create TokenService Interface
With the TokenPair
model created, we now define our expectations for the TokenService. Let's do this by adding this TokenService
to ~/model/interfaces.go
, and add our first method for creating tokens from a User
.
// TokenService defines methods the handler layer expects to interact
// with in regards to producing JWTs as string
type TokenService interface {
NewPairFromUser(ctx context.Context, u *User, prevTokenID string) (*TokenPair, error)
}
The method accepts a Context
from the handler layer, a User
, and a prevTokenID
. When we sign up a user, we'll be passing an empty string to prevTokenID
. We'll eventually create a refresh token handler which will call this method with a non-empty prevTokenID
.
Add TokenService to Handler Struct
We also need to make sure we have access to this TokenService
in our handler. Inside of ~/handler/handler.go
, let's add it to our Handler
and Config
structs. We also need to make sure to "instantiate" our handler inside of NewHandler
with the TokenService
passed from the config.
// Handler struct holds required services for handler to function
type Handler struct {
UserService model.UserService
TokenService model.TokenService
}
// Config will hold services that will eventually be injected into this
// handler layer on handler initialization
type Config struct {
R *gin.Engine
UserService model.UserService
TokenService model.TokenService
}
// NewHandler initializes the handler with required injected services along with http routes
// Does not return as it deals directly with a reference to the gin Engine
func NewHandler(c *Config) {
// Create a handler (which will later have injected services)
h := &Handler{
UserService: c.UserService,
TokenService: c.TokenService,
}
...
Create Tokens in Signup
Let's call this method back inside of ~/handler/signup.go
. I've also included some of the code from the last tutorial where we called UserService.Signup
.
...
u := &model.User{
Email: req.Email,
Password: req.Password,
}
err := h.UserService.Signup(c, u)
if err != nil {
log.Printf("Failed to sign up user: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// create token pair as strings
tokens, err := h.TokenService.NewPairFromUser(c, u, "")
if err != nil {
log.Printf("Failed to create tokens for user: %v\n", err.Error())
// may eventually implement rollback logic here
// meaning, if we fail to create tokens after creating a user,
// we make sure to clear/delete the created user in the database
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusCreated, gin.H{
"tokens": tokens,
})
We pass the reference to the successfully created User
to the NewPairFromUser
method. If there is an error, we're return this error as JSON. If the token creation is successful, we'll send the pair as JSON according to the struct tags in model.TokenPair
.
Mock TokenService.NewPairFromUser Method
Before we can test the token-creation portion of Signup
, we need to create a mock implementation of NewPairFromUser
. As always, take a look at previous lectures and the testify documentation to learn more!
Let's create a new file, ~/model/mocks/token_service.go
, remembering to include appropriate imports for your module.
// MockTokenService is a mock type for model.TokenService
type MockTokenService struct {
mock.Mock
}
// NewPairFromUser mocks concrete NewPairFromUser
func (m *MockTokenService) NewPairFromUser(ctx context.Context, u *model.User, prevTokenID string) (*model.TokenPair, error) {
ret := m.Called(ctx, u, prevTokenID)
// first value passed to "Return"
var r0 *model.TokenPair
if ret.Get(0) != nil {
// we can just return this if we know we won't be passing function to "Return"
r0 = ret.Get(0).(*model.TokenPair)
}
var r1 error
if ret.Get(1) != nil {
r1 = ret.Get(1).(error)
}
return r0, r1
}
Complete Tests of Signup Handler
With the mock created, we're now ready to add at least two more test cases.
- Successful token creation upon calling
NewPairFromUser
- An error returned upon calling
NewPairFromUser
For both of these cases, we'll need to create a mock for both the UserService and TokenService. Calling UserService.Signup
should not return an error for both of these tests, but calling TokenService.NewPairFromUser
should return a different result for the two cases.
Successful Token Creation
Let's note the important aspects of this test.
- We create a user,
u
, which will be created when callingmockUserService.Signup
. We also create amockTokenResponse
to return when callingmockTokenService.NewPairFromUser
. - We create the mock services mentioned in 1, and define responses for when their methods are called. As this is the success case, we return
mockTokenResponse, nil
when callingNewPairFromUser
. - We setup our handler, remembering to inject
mockTokenService
into theNewHandler
factory. - We setup our request body with valid email and password as in previous tests.
- We send the POST request with
reqBody
to/signup
, and define our expectations. We expect the methods on both services to be called, and we expect to receive an HTTP status code of 201, indicating a resource has been created. We also check the response body of the returned JSON.
t.Run("Successful Token Creation", func(t *testing.T) {
u := &model.User{
Email: "bob@bob.com",
Password: "avalidpassword",
}
mockTokenResp := &model.TokenPair{
IDToken: "idToken",
RefreshToken: "refreshToken",
}
mockUserService := new(mocks.MockUserService)
mockTokenService := new(mocks.MockTokenService)
mockUserService.
On("Signup", mock.AnythingOfType("*gin.Context"), u).
Return(nil)
mockTokenService.
On("NewPairFromUser", mock.AnythingOfType("*gin.Context"), u, "").
Return(mockTokenResp, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
TokenService: mockTokenService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": u.Email,
"password": u.Password,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"tokens": mockTokenResp,
})
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertExpectations(t)
mockTokenService.AssertExpectations(t)
})
Error Token Creation
Much of this test is the same as the last, with the following changes.
- We replace
mockTokenResponse
withmockErrorResponse
, and change the response ofmockTokenService.NewPairFromUser
tonil, mockErrorResponse
. - We change the expectations for the handler's JSON response. We expect the body to have an
error
key containingmockErrorResponse
, and we expect the status code to be that contained in themockErrorResponse
, which for this test we created using one of our customappErrors
for an internal, http status code 500, error.
t.Run("Failed Token Creation", func(t *testing.T) {
u := &model.User{
Email: "bob@bob.com",
Password: "avalidpassword",
}
mockErrorResponse := apperrors.NewInternal()
mockUserService := new(mocks.MockUserService)
mockTokenService := new(mocks.MockTokenService)
mockUserService.
On("Signup", mock.AnythingOfType("*gin.Context"), u).
Return(nil)
mockTokenService.
On("NewPairFromUser", mock.AnythingOfType("*gin.Context"), u, "").
Return(nil, mockErrorResponse)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
TokenService: mockTokenService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": u.Email,
"password": u.Password,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockErrorResponse,
})
assert.NoError(t, err)
assert.Equal(t, mockErrorResponse.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertExpectations(t)
mockTokenService.AssertExpectations(t)
})
Conclusion
Thanks for joining me again! I hope you've learned something today. If you didn't, you wouldn't be the first person of superior intelligence and knowledge I've encountered, and I'm fine with that.😁 But also thanks for reading this far!
Next time, we'll move onto the service layer, and eventually to the repository layer with the hope that we'll soon(ish) be able to signup users and store them in an actual database by making requests with an HTTP client.
¡Hasta pronto¡
Bonus: Auth Information
For those of you with some experience creating auth flows, I'm including some links here for you that address the complexity of storing "tokens" (in whatever form and via whatever storage mechanism). I do this so that you'll understand that I did not choose blindly the auth mechanism for this project. You may disagree with my approach, but I still encourage you to read the resources below to refine your views (whether I sway you, or push you to more vehemently disagree, I just hope you also lose as much sleep as I did mulling this over).
Please Stop Using Local Storage - Note, I highly recommend reading the comments by Jon Gross-Dubois, if not all of the comments.
Auth0 - Note that their JS/SPA APIs basically have the option of storing tokens in memory or in local storage, though they give a scary warning about using local storage. And this is a company of people that sit around (though standing desks are cool) thinking about this way more than most of us.
SPA Best Practices - Cookie session ID's are basically just a "token."
Of course, you could also go read 50 page theses written by some of the finest cryptography academicians in unreadable plaintext, which basically tell you you're screwed, all the while providing no workable solution for the "normies" like you and me. (On a hopeful note, I did see some promising implementations coming down the road. 🤞🏼)
Does your head hurt yet? Anyhow, if you lose sleep over this, I hope you'll find comfort in knowing that I, your fellow developer, have also lost sleep over this.
Top comments (1)
Why do we need separate config and handler structs with the same fields?