10-Email-Support
这是Go-Mega系列的第十部分,本章我将告诉你,应用如何向你的用户发送电子邮件,以及如何在电子邮件支持之上构建密码重置功能。
现在,应用在数据库方面做得相当不错,所以在本章中,我想抛开这个主题,开始添加发送电子邮件的功能,这是大多数Web应用必需的另一个重要部分。
为什么应用需要发送电子邮件给用户? 原因很多,但其中一个常见的原因是解决与认证相关的问题。 在本章中,我将为忘记密码的用户添加密码重置功能。 当用户请求重置密码时,应用将发送包含特制链接的电子邮件。 用户然后需要点击该链接才能访问设置新密码的表单。
本章的GitHub链接为: Source, Diff, Zip

第三方库支持

本章我们需要两个第三方插件 gomail 以及 jwt-go
1
# gomail
2
$ go get gopkg.in/gomail.v2
3
4
# jwt-go
5
$ go get github.com/dgrijalva/jwt-go
Copied!

加入mail支持

在 config 中增加 mail 设置
config.yml
1
mysql:
2
charset: utf8
3
db: dbname
4
host: localhost
5
password: password
6
user: root
7
mail:
8
smtp: smtp-server
9
smtp-port: 587
10
user: user
11
password: pwd
Copied!
这里的 smtp server, 请查看你的邮件提供商的文档,比如 zoho mail 的 smtp 是 smtp.zoho.com
config/g.go
1
...
2
3
// GetSMTPConfig func
4
func GetSMTPConfig() (server string, port int, user, pwd string) {
5
server = viper.GetString("mail.smtp")
6
port = viper.GetInt("mail.smtp-port")
7
user = viper.GetString("mail.user")
8
pwd = viper.GetString("mail.password")
9
return
10
}
Copied!
在 controller/utils.go 封装 sendMail 函数,方便调用
controller/utils.go
1
...
2
3
4
// Email
5
6
// sendEmail func
7
func sendEmail(target, subject, content string) {
8
server, port, usr, pwd := config.GetSMTPConfig()
9
d := gomail.NewDialer(server, port, usr, pwd)
10
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
11
12
m := gomail.NewMessage()
13
m.SetHeader("From", usr)
14
m.SetHeader("To", target)
15
m.SetAddressHeader("Cc", usr, "admin")
16
m.SetHeader("Subject", subject)
17
m.SetBody("text/html", content)
18
19
if err := d.DialAndSend(m); err != nil {
20
log.Println("Email Error:", err)
21
return
22
}
23
}
Copied!
本小节 Diff

请求重置密码

我上面提到过,用户有权利重置密码。因此我将在登录页面提供一个链接:
templates/content/login.html
1
...
2
<p>New User? <a href="/register">Click to Register!</a></p>
3
<p>Forget Password? <a href="/reset_password_request">Click to Reset Password!</a></p>
4
...
Copied!
10-01
当用户点击链接时,会出现一个新的Web表单,要求用户输入注册的电子邮件地址,以启动密码重置过程。
vm/reset_password_request.go
1
package vm
2
3
import (
4
"log"
5
6
"github.com/bonfy/go-mega-code/model"
7
)
8
9
// ResetPasswordRequestViewModel struct
10
type ResetPasswordRequestViewModel struct {
11
LoginViewModel
12
}
13
14
// ResetPasswordRequestViewModelOp struct
15
type ResetPasswordRequestViewModelOp struct{}
16
17
// GetVM func
18
func (ResetPasswordRequestViewModelOp) GetVM() ResetPasswordRequestViewModel {
19
v := ResetPasswordRequestViewModel{}
20
v.SetTitle("Forget Password")
21
return v
22
}
23
24
// CheckEmailExist func
25
func CheckEmailExist(email string) bool {
26
_, err := model.GetUserByEmail(email)
27
if err != nil {
28
log.Println("Can not find email:", email)
29
return false
30
}
31
return true
32
}
Copied!
templates/content/reset_password_request.html
1
{{define "content"}}
2
<h1>Input your email address:</h1>
3
<form action="/reset_password_request" method="post" name="reset_password_request">
4
<p><input type="text" class="form-control" name="email" value="" placeholder="Email"></p>
5
<p><input type="submit" class="btn btn-outline-primary" name="submit" value="Submit" ></p>
6
</form>
7
8
{{if .Errs}}
9
<ul>
10
{{range .Errs}}
11
<li>{{.}}</li>
12
{{end}}
13
</ul>
14
{{end}}
15
{{end}}
Copied!
controller/home.go
1
...
2
3
r.HandleFunc("/reset_password_request", resetPasswordRequestHandler)
4
...
5
6
func resetPasswordRequestHandler(w http.ResponseWriter, r *http.Request) {
7
tpName := "reset_password_request.html"
8
vop := vm.ResetPasswordRequestViewModelOp{}
9
v := vop.GetVM()
10
11
if r.Method == http.MethodGet {
12
templates[tpName].Execute(w, &v)
13
}
14
if r.Method == http.MethodPost {
15
r.ParseForm()
16
email := r.Form.Get("email")
17
18
errs := checkResetPasswordRequest(email)
19
v.AddError(errs...)
20
21
if len(v.Errs) > 0 {
22
templates[tpName].Execute(w, &v)
23
24
} else {
25
log.Println("Send mail to", email)
26
vopEmail := vm.EmailViewModelOp{}
27
vEmail := vopEmail.GetVM(email)
28
var contentByte bytes.Buffer
29
tpl, _ := template.ParseFiles("templates/email.html")
30
31
if err := tpl.Execute(&contentByte, &vEmail); err != nil {
32
log.Println("Get Parse Template:", err)
33
w.Write([]byte("Error send email"))
34
return
35
}
36
content := contentByte.String()
37
go sendEmail(email, "Reset Password", content)
38
http.Redirect(w, r, "/login", http.StatusSeeOther)
39
}
40
}
41
}
Copied!
10-02
controller 里涉及到了 email 的template
vm/email.go
1
package vm
2
3
import (
4
"github.com/bonfy/go-mega-code/config"
5
"github.com/bonfy/go-mega-code/model"
6
)
7
8
// EmailViewModel struct
9
type EmailViewModel struct {
10
Username string
11
Token string
12
Server string
13
}
14
15
// EmailViewModelOp struct
16
type EmailViewModelOp struct{}
17
18
// GetVM func
19
func (EmailViewModelOp) GetVM(email string) EmailViewModel {
20
v := EmailViewModel{}
21
u, _ := model.GetUserByEmail(email)
22
v.Username = u.Username
23
v.Token, _ = u.GenerateToken()
24
v.Server = config.GetServerURL()
25
return v
26
}
Copied!
templates/email.html
1
<p>Dear {{.Username}},</p>
2
<p>
3
To reset your password
4
<a href="{{.Server}}/reset_password/{{.Token}}">
5
click here
6
</a>.
7
</p>
8
<p>Alternatively, you can paste the following link in your browser's address bar:</p>
9
<p>{{.Server}}/reset_password/{{.Token}}</p>
10
<p>If you have not requested a password reset simply ignore this message.</p>
11
<p>This Email will expire in 2 hours.</p>
12
<p>Sincerely,</p>
13
<p>BONFY</p>
Copied!
10-03
上面简单的一封邮件,其实蕴含着两个 非常重要的 知识点:

goroutine

原来 Flask-Mega 里面非常复杂的多线程操作,这里只用了一个 go function() 完成了
goroutine 可以说是 Go 这个语言的特色之一了,当然资料也比较多,大家可以深入了解下
这里我简单说下,就是 function 前面加个 go 关键字,就实现了 协程,用于高并发,而且性能非常好,是不是很cool!

jwt

具体可以通过 jwt.io了解下
或者中文的可以通过这片文章具体了解下 直通车
这里的邮件可以说是 JWT 的一个非常典型的应用场景,jwt 加密后的 URL 就是图中的一长串,其中其实隐含着 2 hour 过期时间
我们通过在 user.go 里加入两个function 就能实现, 密钥 secret 这里直接写在代码里,其实更优还是通过配置文件配置,又偷懒了
model/user.go
1
...
2
3
// GenerateToken func
4
func (u *User) GenerateToken() (string, error) {
5
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
6
"username": u.Username,
7
"exp": time.Now().Add(time.Hour * 2).Unix(), // 可以添加过期时间
8
})
9
return token.SignedString([]byte("secret"))
10
}
11
12
// CheckToken func
13
func CheckToken(tokenString string) (string, error) {
14
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
15
// Don't forget to validate the alg is what you expect:
16
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
17
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
18
}
19
20
// hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key")
21
return []byte("secret"), nil
22
})
23
24
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
25
return claims["username"].(string), nil
26
} else {
27
return "", err
28
}
29
}
30
31
...
Copied!
本小节 Diff

重置密码

目前我们点击邮件中的邮件是不能操作的,因为我们其实并没有 /reset_password 这个handler,那这里我们来完成它
其实密码重置就是一个简单的表单, 两个密码框,检验输入一致以及符合密码规范就可以了,操作起来不算太复杂。
vm/reset_password.go
1
package vm
2
3
import (
4
"github.com/bonfy/go-mega-code/model"
5
)
6
7
// ResetPasswordViewModel struct
8
type ResetPasswordViewModel struct {
9
LoginViewModel
10
Token string
11
}
12
13
// ResetPasswordViewModelOp struct
14
type ResetPasswordViewModelOp struct{}
15
16
// GetVM func
17
func (ResetPasswordViewModelOp) GetVM(token string) ResetPasswordViewModel {
18
v := ResetPasswordViewModel{}
19
v.SetTitle("Reset Password")
20
v.Token = token
21
return v
22
}
23
24
// CheckToken func
25
func CheckToken(tokenString string) (string, error) {
26
return model.CheckToken(tokenString)
27
}
28
29
// ResetUserPassword func
30
func ResetUserPassword(username, password string) error {
31
return model.UpdatePassword(username, password)
32
}
Copied!
templates/content/reset_password.html
1
{{define "content"}}
2
<h1>Register</h1>
3
<form action="/reset_password/{{.Token}}" method="post" name="reset_password">
4
<p><input type="password" name="pwd1" value="" placeholder="Password"></p>
5
<p><input type="password" name="pwd2" value="" placeholder="Confirm Password"></p>
6
<p><input type="submit" name="submit" value="Reset"></p>
7
</form>
8
9
10
{{if .Errs}}
11
<ul>
12
{{range .Errs}}
13
<li>{{.}}</li>
14
{{end}}
15
</ul>
16
{{end}}
17
{{end}}
Copied!
controller/home.go
1
...
2
3
r.HandleFunc("/reset_password/{token}", resetPasswordHandler)
4
5
...
6
7
func resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
8
vars := mux.Vars(r)
9
token := vars["token"]
10
username, err := vm.CheckToken(token)
11
if err != nil {
12
w.Write([]byte("The token is no longer valid, please go to the login page."))
13
}
14
15
tpName := "reset_password.html"
16
vop := vm.ResetPasswordViewModelOp{}
17
v := vop.GetVM(token)
18
19
if r.Method == http.MethodGet {
20
templates[tpName].Execute(w, &v)
21
}
22
23
if r.Method == http.MethodPost {
24
log.Println("Reset password for ", username)
25
r.ParseForm()
26
pwd1 := r.Form.Get("pwd1")
27
pwd2 := r.Form.Get("pwd2")
28
29
errs := checkResetPassword(pwd1, pwd2)
30
v.AddError(errs...)
31
32
if len(v.Errs) > 0 {
33
templates[tpName].Execute(w, &v)
34
} else {
35
if err := vm.ResetUserPassword(username, pwd1); err != nil {
36
log.Println("reset User password error:", err)
37
w.Write([]byte("Error update user password in database"))
38
return
39
}
40
http.Redirect(w, r, "/login", http.StatusSeeOther)
41
}
42
}
43
}
Copied!
10-04
就这样我们完成了重置密码的功能,虽然设计到的页面比较多,不过只要我们思路清晰,一步一个脚印,还是能非常容易就能实现了。
现在输入新的密码,保存后,就可以用新密码登陆了。
本小节 Diff
Notice: 本章还涉及到一些后端验证,在这里没有一一列举,大家还是请看下源码diff,可以更完整的了解代码

Links

Last modified 3yr ago