Last time, we created and tested a handler for signing in a user. This handler accepts and validates an email and password received in a JSON request body. Let's take a look at our progress diagram to see what we'll work on today!
We'll add functionality to sign-in a user in the service layer. To do this, we'll need to be able to find a user in our database by email, and then verify that the password entered by the user matches the encrypted stored password from the database. To make this possible, we'll add a FindByEmail
method to our UserRepository
interface, mock, and Postgres implementations. This method will retrieve the user attempting to sign-in, which includes their hashed password. We'll also review and use the function we previously created to compare the user's supplied password with the password stored in the database.
At the end of the tutorial, we'll spin up the application, create a new user with a known password, and then attempt to sign in as that user.
As always, checkout the repo on Github with all of the code for this tutorial including complete tests a branch for each lesson!
If you prefer video, check out the video version below!
I will continue to create unit tests for handler- and service-layer methods. However, I will not include these in written or video tutorials unless I feel they provide some new information. I feel a lengthy portion of the tutorials has been dedicated to testing. I did this deliberately because sometimes setting up tests is more difficult than actual coding logic (seriously), and only a few tutorials write more than rudimentary tests.
Add FindByEmail to UserRepository Interface
In ~/model/interfaces.go
, let's add an expectation that a UserRepository
can find a user by email address.
// UserRepository defines methods the service layer expects
// any repository it interacts with to implement
type UserRepository interface {
Create(ctx context.Context, u *User) error
FindByEmail(ctx context.Context, email string) (*User, error)
FindByID(ctx context.Context, uid uuid.UUID) (*User, error)
}
You should now have errors because or pGUserRepository
and mockUserRepository
do not implement this newly added method. Let's add these implementations!
Add FindByEmail to mockUserRepository
Though I mentioned that I won't go over unit tests unless necessary, I'll still add the mock implementations so you don't have any errors.
Add the following to ~/model/mocks/user_repository.go
.
// FindByEmail is mock of UserRepository.FindByEmail
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
ret := m.Called(ctx, email)
var r0 *model.User
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
var r1 error
if ret.Get(1) != nil {
r1 = ret.Get(1).(error)
}
return r0, r1
}
Add FindByEmail to pGUserRepository
Let's now add the implementation to our the Postgres User Repository implementation, found in ~/repository/pg_user_repository.go
.
// FindByEmail retrieves user row by email address
func (r *pGUserRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
user := &model.User{}
query := "SELECT * FROM users WHERE email=$1"
if err := r.DB.GetContext(ctx, user, query, email); err != nil {
log.Printf("Unable to get user with email address: %v. Err: %v\n", email, err)
return user, apperrors.NewNotFound("email", email)
}
return user, nil
}
In this code block, we simply create a select query to get a user with the given email, and return a NotFound
error if the user cannot be found (and for all possible errors, which is maybe a bit lazy). If the user is found, it will be populated on the *model.User
and returned.
User Service Signin Implementation
We can now reach out to FindByEmail
within the UserService
. Let's add this in ~/service/user_service.go
!
// Signin reaches our to a UserRepository check if the user exists
// and then compares the supplied password with the provided password.
// If a valid email/password combo is provided, u will hold all
// available user fields
func (s *userService) Signin(ctx context.Context, u *model.User) error {
uFetched, err := s.UserRepository.FindByEmail(ctx, u.Email)
// Will return NotAuthorized to client to omit details of why
if err != nil {
return apperrors.NewAuthorization("Invalid email and password combination")
}
// verify password - we previously created this method
match, err := comparePasswords(uFetched.Password, u.Password)
if err != nil {
return apperrors.NewInternal()
}
if !match {
return apperrors.NewAuthorization("Invalid email and password combination")
}
u = uFetched
return nil
}
After storing the user in uFetch
, we make a call to comparePasswords
which compares the supplied password (from the HTTP request) and the retrieved password (from the database). We previously created this function in ~/service/passwords.go
. This function extracts the password salt, pwsalt[1]
, and then hashes the supplied password with this salt. If this matches the hashed password from the database, pwsalt[0]
, we know the user entered a valid password! We return whether or not the password is a match as a boolean. We can also return an error if decoding the stored password fails.
func comparePasswords(storedPassword string, suppliedPassword string) (bool, error) {
pwsalt := strings.Split(storedPassword, ".")
// check supplied password salted with hash
salt, err := hex.DecodeString(pwsalt[1])
if err != nil {
return false, fmt.Errorf("Unable to verify user password")
}
shash, err := scrypt.Key([]byte(suppliedPassword), salt, 32768, 8, 1, 32)
return hex.EncodeToString(shash) == pwsalt[0], nil
}
Run Application
From the root of the project, you should now be able to run docker-compose up
! Let's create a new user by posting a request to our signup
endpoint (I've deleted all previous users from Postgres prior to this tutorial). I'll use curl here to keep things simple, and Postman in the video if you prefer to check that out!
➜ curl --location --request POST 'http://malcorp.test/api/account/signup' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "guy01@guy.com",
"password": "avalidpassword123"
}'
We get a status 201, created, response with the following body.
HTTP/1.1 201 Created
Content-Length: 871
Content-Type: application/json; charset=utf-8
Date: Wed, 23 Dec 2020 01:39:09 GMT
{"tokens":{"idToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVpZCI6ImVlODZjYzJjLTg2ODEtNDU5YS1hOTc3LTNjYWY5NzUzZTE0YiIsImVtYWlsIjoiZ3V5MDFAZ3V5LmNvbSIsIm5hbWUiOiIiLCJpbWFnZVVybCI6IiIsIndlYnNpdGUiOiIifSwiZXhwIjoxNjA4Njg4NDQ5LCJpYXQiOjE2MDg2ODc1NDl9.haU3a-15xfoUYrpllkkuUphKFDqNfZKckmPZP6LRN7BGhe15DAONdirhLnH1n5QHFaqQ31eOs1nAleqln5MTzeG_YYdw4VhbQ53wve_b156SeMEvfm664js8fSQYsfTG_PBzkmkRaL62jcSaNmSWkKhzzT5bBeYlBd4lUBqGV1nw12Jj9WgF6oWoDHN786bSMQz25TWmkVyE1-082DHUdAjqnnVy7J_G-CU1Ozdv_6KUurUeVfqBj0D4irghcMnfnk75vBzFhyOShl2-RkXprRKvqjNo0u28Fd5BZ6ZKLAv6k_iUxK8rb-F3atozlFhdWaNL77w18XI4ZkZ2YieLPw","refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJlZTg2Y2MyYy04NjgxLTQ1OWEtYTk3Ny0zY2FmOTc1M2UxNGIiLCJleHAiOjE2MDg5NDY3NDksImp0aSI6IjIzODhkY2Y5LWVlODQtNGUzMy04ZGJlLTZmNTlkOGQ2NzFmNCIsImlhdCI6MTYwODY4NzU0OX0.Rsr2zFf62WYm2ExZXo5zVlq0Ot_jqnoUtL8xapjc75U"}}%
If we then send a request to the signin
endpoint with the same email and password, we should hopefully get a token pair and a response of status 200 (ok).
➜ curl -i --location --request POST 'http://malcorp.test/api/account/signin' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "guy01@guy.com",
"password": "avalidpassword123"
}'
HTTP/1.1 200 OK
Content-Length: 871
Content-Type: application/json; charset=utf-8
Date: Wed, 23 Dec 2020 01:39:51 GMT
{"tokens":{"idToken":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7InVpZCI6IjAwMDAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMCIsImVtYWlsIjoiZ3V5MDFAZ3V5LmNvbSIsIm5hbWUiOiIiLCJpbWFnZVVybCI6IiIsIndlYnNpdGUiOiIifSwiZXhwIjoxNjA4Njg4NDkxLCJpYXQiOjE2MDg2ODc1OTF9.YuGGc6m1ZaL7BMSGTwdS7hBt8QFIcxRn1MJ-PqjnOm9vtVUPrVsYbg0n_TcwypcqtAcuhsI3buIipFj9GJU657q3INZWcVzNzlWEzeaPPUKuoJtL2EUP6veGElKd8bAQWsg5eX1T48ff8x4CxW-s7PJ0ZLWMi2Al2TU4xbzz4wxGs6PfgD3T4UYuwnCvnC3GGRdL0htLmqc9EiGqs4M6fzu8HhrusKSRvDdbbKNBO6eELtOzRM8_YKcbBBKMGsS9gKxGnDY227_zqYsc1T1fpy7NYmz7SSxZjd4c6XEqcmItqG28L9tvELZk1HlMQvOI7_yTxW13ntCaLLKdkKnZ0A","refreshToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAiLCJleHAiOjE2MDg5NDY3OTEsImp0aSI6IjQ3MTFjMDE0LTVmMDgtNDZhNC04Njk3LWQwNGVjMDMwMTEyMCIsImlhdCI6MTYwODY4NzU5MX0.aG7Y01RldyOxxkmaFIT04iYhvb1joSkBw0bboIDSpmE"}}%
Conclusion
We now have the ability to sign up and sign in users. This was quite an effort, especially since we added unit tests and spent a great deal of time on architecture.
Next time, we'll create our second middleware, which will be used to extract the user from our idToken
. You might recall that at the very beginning of the tutorial, we created a me
handler and API endpoint, but didn't use this in our application. I did this deliberately to show that we could test the handler in isolation od middleware and service layers.
Next time, we'll create this middleware. This can be used to verify the user, and authorize them to do things like retrieve and update their user profile details.
Until next time, ¡chau!
Top comments (0)