上次,我们创建并测试了一个用于登录用户的处理程序。此处理程序接受并验证在 JSON 请求正文中收到的电子邮件和密码。让我们看一下我们的进度图,看看我们今天要做什么!

[15 - 登录服务和存储库层](https://res.cloudinary.com/practicaldev/image/fetch/s--HCfV23HD--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https:/ /dev-to-uploads.s3.amazonaws.com/i/i5b88v7k854pqvyx9dl3.png)

我们将添加功能以在服务层中登录用户。为此,我们需要能够通过电子邮件在数据库中找到用户,然后验证用户输入的密码是否与数据库中加密存储的密码相匹配。为了使这成为可能,我们将在我们的UserRepository接口、模拟和 Postgres 实现中添加一个FindByEmail方法。此方法将检索尝试登录的用户,其中包括他们的哈希密码。我们还将查看并使用我们之前创建的函数将用户提供的密码与存储在数据库中的密码进行比较。

在本教程结束时,我们将启动应用程序,使用已知密码创建一个新用户,然后尝试以该用户身份登录。

与往常一样,请查看 Github](https://github.com/JacobSNGoodwin/memrizr)上的[存储库,其中包含本教程的所有代码,包括完整测试每个课程的分支!

如果您喜欢视频,请查看下面的视频版本!

我将继续为处理程序层和服务层方法创建单元测试。但是,除非我觉得它们提供了一些新信息,否则我不会将这些内容包含在书面或视频教程中。我觉得教程中有很长的部分专门用于测试。我故意这样做是因为有时设置测试比实际的编码逻辑更困难(严重),而且只有少数教程比基本测试写得更多。

将 FindByEmail 添加到 UserRepository 接口

~/model/interfaces.go中,我们添加一个期望,即UserRepository可以通过电子邮件地址找到用户。

// 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)
}

进入全屏模式 退出全屏模式

您现在应该有错误,因为 orpGUserRepositorymockUserRepository没有实现这个新添加的方法。让我们添加这些实现!

将 FindByEmail 添加到 mockUserRepository

尽管我提到除非必要,否则我不会进行单元测试,但我仍然会添加模拟实现,这样您就不会出现任何错误。

将以下内容添加到~/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
}

进入全屏模式 退出全屏模式

将 FindByEmail 添加到 pGUserRepository

现在让我们将该实现添加到我们的 Postgres 用户存储库实现中,该实现位于~/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
}

进入全屏模式 退出全屏模式

在这个代码块中,我们简单地创建一个选择查询来获取具有给定电子邮件的用户,如果找不到用户,则返回NotFound错误(以及所有可能的错误,这可能有点懒惰)。如果找到该用户,它将在*model.User上填充并返回。

用户服务登录实现

我们现在可以在UserService中访问FindByEmail。让我们在~/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
}

进入全屏模式 退出全屏模式

在将用户存储在uFetch之后,我们调用comparePasswords比较提供的密码(来自 HTTP 请求)和检索到的密码(来自数据库)。我们之前在~/service/passwords.go中创建了这个函数。这个函数提取密码 salt,pwsalt[1],然后用这个 salt 对 supplied 密码进行哈希处理。如果这与数据库中的散列密码pwsalt[0]匹配,我们就知道用户输入了有效密码!我们将密码是否匹配作为布尔值返回。如果解码存储的密码失败,我们也可以返回错误。

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
}

进入全屏模式 退出全屏模式

运行应用程序

从项目的根目录,您现在应该能够运行docker-compose up!让我们通过向我们的signup端点发布请求来创建一个新用户(在本教程之前,我已经从 Postgres 中删除了所有以前的用户)。我将在此处使用 curl 以使事情变得简单,如果您愿意查看,我将在视频中使用 Postman!

➜  curl --location --request POST 'http://malcorp.test/api/account/signup' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "guy01@guy.com",
    "password": "avalidpassword123"
}'

进入全屏模式 退出全屏模式

我们得到一个状态 201,已创建,响应如下。

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"}}%

进入全屏模式 退出全屏模式

如果我们然后使用相同的电子邮件和密码向signin端点发送请求,我们应该希望得到一个令牌对和状态为 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"}}% 

进入全屏模式 退出全屏模式

结论

我们现在可以注册 ** 和 ** 登录用户。这是一项相当大的努力,特别是因为我们添加了单元测试并在架构上花费了大量时间。

下一次,我们将创建第二个中间件,用于从idToken中提取用户。您可能还记得在本教程的最开始,我们创建了me处理程序和 API 端点,但没有使用它在我们的应用程序中。我故意这样做是为了表明我们可以在隔离的中间件和服务层中测试处理程序。

下一次,我们将创建这个中间件。这可用于验证用户,并授权他们执行检索和更新其用户配置文件详细信息等操作。

直到下一次,¡chau!

Logo

PostgreSQL社区为您提供最前沿的新闻资讯和知识内容

更多推荐