【Go Web开发】用户注册
上一篇文章我们已经为用户添加打下了基础,现在开始使用它,创建一个新的API接口来管理新用户注册的过程。
| Method | URL模式 | Handler | 操作 |
|---|---|---|---|
| POST | /v1/users | registerUserHandler | 注册一个新用户 |
当用户调用POST /v1/users接口时,接口需要用户提供如下用户信息作为请求内容:
{
"name": "Alice Smith",
"email": "alice@example.com",
"password": "pa55word"
}
当服务端收到这些请求内容时,registerUserHandler处理函数会创建一个User结构体来接收这些信息,并调用ValidateUser()帮助函数对各个字段进行校验,然后传给UserModel.Insert()方法写入数据库。
实际上,registerUserHandler处理函数大部分代码前面已经完成了,现在只需要将其整合起来即可。首先创建cmd/api/users.go文件:
$ touch cmd/api/users.go
添加如下代码到文件当中:
File: cmd/api/user.go
package main
import (
"errors"
"net/http"
"greenlight.alexedwards.net/internal/data"
"greenlight.alexedwards.net/internal/validator"
)
func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
//创建匿名结构体接收客户端发送用户信息
var input struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
//解析请求内容到匿名结构体实例中
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
//将input中的用户信息拷贝到User结构体。注意需要将激活信息设置为false,
//该操作是非必需的因为默认值就是false,单独设置下可读性更好。
user := &data.User{
Name: input.Name,
Email: input.Email,
Activated: false,
}
//使用Password.Set方法处理密码
err = user.Password.Set(input.Password)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
v := validator.New()
//校验user结构体
if data.ValidateUser(v, user); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
//插入用户信息到数据库
err = app.models.Users.Insert(user)
if err != nil {
switch {
//如果错误是ErrDuplicateEmail,使用v.AddError()方法手动添加校验失败信息
case errors.Is(err, data.ErrDuplicateEmail):
v.AddError("email", "a user with this email address already exists")
app.failedValidationResponse(w, r, v.Errors)
default:
app.serverErrorResponse(w, r, err)
}
return
}
//正常返回201
err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
在测试接口之前,需要为注册用户接口添加路由:
File: cmd/api/routes.go
package main
import (
"github.com/julienschmidt/httprouter"
"net/http"
)
func (app *application) routes() http.Handler {
router := httprouter.New()
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
router.HandlerFunc(http.MethodGet, "/v1/movies", app.listMoviesHandler)
router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)
// 为/v1/users接口添加路由
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
return app.recoverPanic(app.rateLimit(router))
}
添加完路由后,确保所有到文件保存然后启动服务。向POST /v1/users接口发送请求,email地址为alice@example.com,你应该会看到201 Created返回用户信息:
$ BODY='{"name": "Alice Smith", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 201 Created
Content-Type: application/json
Date: Tue, 21 Dec 2021 15:05:44 GMT
Content-Length: 151
{
"user": {
"id": 1,
"create_at": "2021-12-21T23:05:44+08:00",
"name": "Alice Smith",
"email": "alice@example.com",
"activated": false
}
}
提示:如果您按照上面的请求进行操作,请记住上面的请求中使用的密码——稍后需要使用到!
根据响应结果发现接口正常处理了请求。我们可以从返回状态码中看到,用户信息已经成功创建,在JSON响应中,我们可以看到系统为新用户生成的信息——包括用户的ID和激活状态。可以登录到PostgreSQL数据库中确认下,写入到用户信息:
psql $GREENLIGHT_DB_DSN
psql (13.4)
Type "help" for help.
greenlight=> select * from users;
id | create_at | name | email | password_hash | activated | version
----+------------------------+-------------+-------------------+-------------------------+-----------+---------
1 | 2021-12-21 23:05:44+08 | Alice Smith | alice@example.com | \x243261243132243256... | f | 1
(1 row)
注意:psql显示bytea值都是以16进制编码字符串。因此password_hash字段显示就是16进制编码到哈希值。可以执行select *, encode(password_hash, 'escape') from users;查询语句打印出password_hash实际字符串内容。
好了,下面我们尝试向API发出一个请求,带有无效的用户信息。我们的校验功能会生效,并返回错误响应:
$ BODY='{"name": "", "email": "bob@invalid.", "password": "pass"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Date: Tue, 21 Dec 2021 15:21:51 GMT
Content-Length: 139
{
"error": {
"email": "must be a valid email address",
"name": "must be provided",
"password": "must be at least 8 bytes long"
}
}
最后,尝试用alice@example.com注册第二个帐户。你应该会得到一个验证错误,其中包含一个“用户与此电子邮件地址已经存在”的消息,像这样:
$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Date: Tue, 21 Dec 2021 15:26:06 GMT
Content-Length: 78
{
"error": {
"email": "a user with this email address already exists"
}
}
如果你愿意,可以尝试使用大小写不同的alice@example.com发送一些请求,比如ALICE@example.com或Alice@Example.com。因为数据库中的电子邮件列是citext类型,所以这些替代版本也会被识别为重复邮箱地址。
附加内容
Email大小写敏感
我们快速详细地讨论一下邮件地址的大小写敏感性:
- 根据RRC 2821,电子邮件地址(username@domain)的域名部分不区分大小写。这意味着我们可以很确信的说在实际使用中alice@example.com和alice@EXAMPLE.COM是同一个人的邮箱。
- 邮箱地址的用户名部分可能是大小写敏感的,这需要根据不同的邮箱服务提供商来确定。几乎所有的主流邮箱服务提供商都对用户名也不区分大小写,但不是绝对的。我们只能说实际使用中alice@example.com很可能和ALICE@example.com是同一个邮箱地址。
因此这对于我们应用来说意味着什么呢?
从安全角度来看,我们应该始终使用用户在注册时提供的确切格式来存储邮件地址,并且我们应该只使用确切的格式向他们发送电子邮件。如果不这样做,邮件有可能被发送给错误的用户。尤其是在使用邮件进行身份验证时(如密码重置过程)中,特别需要注意这一点。
因此,在对比alice@example.com和ALICE@example.com时有可能是同一个邮箱地址,因此在应用开发时需要不区分大小写对比。
在用户注册流程中,通过不区分大小写对比邮件地址,防止同一个邮箱注册多个账号。从用户体验角度来看,在登录、激活或密码重置等流程中,如果不要求用户提交请求时使用与注册时完全相同的电子邮件格式,用户就会更容易接受。
用户枚举
需要注意的是用户注册接口很容易受到用户枚举攻击。例如,如果攻击者想知道alice@example.com是否在我们这里有账户,他们会发送这样的请求:
$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
"error": {
"email": "a user with this email address already exists"
}
}
问题就在这里。响应明确地告诉攻击者alice@example.com已经是一个用户。因此泄漏这些信息有什么风险呢?
第一个也是最明显的风险,与用户隐私有关。对于敏感或机密的服务,您可能不希望显示谁有帐户。第二个风险是,它使攻击者更容易侵入用户的帐户。一旦他们知道用户的电子邮件地址,他们可以:
- 通过社会工程或其他类型的定制攻击来锁定用户。
- 在泄露的密码库中搜索电子邮件地址,并在我们的服务中尝试使用相同的密码。
防止枚举攻击通常需要两个条件:
1、确保发送给客户端的响应总是相同的,不管用户是否存在。一般来说,这意味着需要将响应措辞修改得模棱两可,并在其他渠道通知用户任何问题(例如发送电子邮件通知用户已经有一个帐户)。
2、确保发送响应所花费的时间总是相同的,不管用户是否存在。在Go中,这通常意味着将工作转移到一个后台程序。
不幸的是,这些缓解措施往往会增加应用程序的复杂性。对于那些不是攻击者的普通用户来说,从用户体验的角度来看,是不好的。你不得不问:这样的代价值得吗?
在回答这个问题时,有一些事情需要考虑。用户隐私在应用程序中有多重要?对于攻击者来说,一个被攻破的账户有多大的吸引力(高价值)?减少用户使用中的不便有多重要?这些问题的答案因项目的不同而不同,这将有助于形成您的决策因素。
值得注意的是,许多常用的服务,包括Twitter、GitHub和Amazon,都没有防止用户枚举(至少在他们的注册页面上没有)。我并不是说这样做就可以了——只是这些公司已经决定,在他们的特定情况下,对用户来说额外的冲突比隐私和安全风险更糟糕。