基于 Go 语言构建在线论坛(四):通过 Cookie + Session 实现用户认证
上篇教程我们演示了首页路由、处理器、视图模板的实现,接下来,我们需要在网站页面上实现群组、主题的增改改查,不过,我们的需求是用户认证之后才能执行这些操作,所以需要先实现用户认证相关功能。
编写全局辅助函数
在此之前,我们现在 handlers
目录下创建一个 helper.go
文件,用于定义一些全局辅助函数(主要用在处理器中):
package handlers
import (
"errors"
"fmt"
"github.com/xueyuanjun/chitchat/models"
"html/template"
"net/http"
)
// 通过 Cookie 判断用户是否已登录
func session(writer http.ResponseWriter, request *http.Request) (sess models.Session, err error) {
cookie, err := request.Cookie("_cookie")
if err == nil {
sess = models.Session{Uuid: cookie.Value}
if ok, _ := sess.Check(); !ok {
err = errors.New("Invalid session")
}
}
return
}
// 解析 HTML 模板(应对需要传入多个模板文件的情况,避免重复编写模板代码)
func parseTemplateFiles(filenames ...string) (t *template.Template) {
var files []string
t = template.New("layout")
for _, file := range filenames {
files = append(files, fmt.Sprintf("views/%s.html", file))
}
t = template.Must(t.ParseFiles(files...))
return
}
// 生成响应 HTML
func generateHTML(writer http.ResponseWriter, data interface{}, filenames ...string) {
var files []string
for _, file := range filenames {
files = append(files, fmt.Sprintf("views/%s.html", file))
}
templates := template.Must(template.ParseFiles(files...))
templates.ExecuteTemplate(writer, "layout", data)
}
// 返回版本号
func Version() string {
return "0.1"
}
目前提供了版本信息,判断用户是否登录,HTML 模板的解析与生成等逻辑,我们将 HTML 模板解析与生成逻辑提取出来,主要是为了避免重复编写类似的模板代码,比如现在,我们可以将 handlers/index.go
中的 Index
方法改写如下:
func Index(w http.ResponseWriter, r *http.Request) {
threads, err := models.Threads();
if err == nil {
generateHTML(w, threads, "layout", "navbar", "index")
}
}
是不是看起来简单多了,更重要的是提高了代码的复用性。
在 session
函数中,通过从请求中获取指定 Cookie 字段里面存放的 Session ID,然后从 Session 存储器(这里存储驱动是数据库)查询对应 Session 是否存在来判断用户是否已认证,如果已认证则返回的 sess
不为空。
用户认证相关处理器
编写处理器代码
接下来,在 handlers
目录下创建一个 auth.go
来存放用户认证相关处理器:
package handlers
import (
"fmt"
"github.com/xueyuanjun/chitchat/models"
"net/http"
)
// GET /login
// 登录页面
func Login(writer http.ResponseWriter, request *http.Request) {
t := parseTemplateFiles("auth.layout", "navbar", "login")
t.Execute(writer, nil)
}
// GET /signup
// 注册页面
func Signup(writer http.ResponseWriter, request *http.Request) {
generateHTML(writer, nil, "auth.layout", "navbar", "signup")
}
// POST /signup
// 注册新用户
func SignupAccount(writer http.ResponseWriter, request *http.Request) {
err := request.ParseForm()
if err != nil {
fmt.Println("Cannot parse form")
}
user := models.User{
Name: request.PostFormValue("name"),
Email: request.PostFormValue("email"),
Password: request.PostFormValue("password"),
}
if err := user.Create(); err != nil {
fmt.Println("Cannot create user")
}
http.Redirect(writer, request, "/login", 302)
}
// POST /authenticate
// 通过邮箱和密码字段对用户进行认证
func Authenticate(writer http.ResponseWriter, request *http.Request) {
err := request.ParseForm()
user, err := models.UserByEmail(request.PostFormValue("email"))
if err != nil {
fmt.Println("Cannot find user")
}
if user.Password == models.Encrypt(request.PostFormValue("password")) {
session, err := user.CreateSession()
if err != nil {
fmt.Println("Cannot create session")
}
cookie := http.Cookie{
Name: "_cookie",
Value: session.Uuid,
HttpOnly: true,
}
http.SetCookie(writer, &cookie)
http.Redirect(writer, request, "/", 302)
} else {
http.Redirect(writer, request, "/login", 302)
}
}
// GET /logout
// 用户退出
func Logout(writer http.ResponseWriter, request *http.Request) {
cookie, err := request.Cookie("_cookie")
if err != http.ErrNoCookie {
fmt.Println("Failed to get cookie")
session := models.Session{Uuid: cookie.Value}
session.DeleteByUUID()
}
http.Redirect(writer, request, "/", 302)
}
上述代码中定义了用户注册、登录、退出相关业务逻辑,非常简单,和 Laravel 认证脚手架生成的默认认证相关控制器非常相似。
用户注册
用户注册逻辑比较简单,无非是填写注册表单(Signup
处理器方法),提交注册按钮将用户信息保存到数据库(SignupAccount
处理器方法)。
用户登录
接下来,服务端会将用户重定向到登录页面(Login
处理器方法),用户填写登录表单后,就可以通过 Authenticate
处理器方法执行认证操作。
用户认证是基于 Cookie + Session 实现的,Session 的数据结构如下所示:
type Session struct {
Id int
Uuid string
Email string
UserId int
CreatedAt time.Time
}
通过 Uuid
字段可以唯一标识这个 Session,因此可以看作是对外可见的全局 Session ID,在客户端 Cookie 存储的 Session ID 也是这个 Uuid
。当用户认证成功之后,就会创建 Session,有了 Session 之后,就可以创建 Cookie 并写到响应中:
cookie := http.Cookie{
Name: "_cookie",
Value: session.Uuid,
HttpOnly: true,
}
http.SetCookie(writer, &cookie)
这样,下次用户访问在线论坛页面就会在请求头中带上包含 Session ID 的 Cookie,服务端通过解析这个 Uuid 并查询 Session 存储器(这里存储驱动是数据库)判断该用户 Session 是否存在,如果存在则用户认证通过,也就是前面辅助函数 session
所做的事情。
用户退出
上述 Cookie 未设置过期时间,所以生命周期和 Session 一致,当浏览器关闭时,Cookie 就自动删除,下次打开浏览器需要重新认证。
最后用户退出处理器方法 Logout
方法则是方便用户主动退出,当用户点击退出按钮,可以执行该处理器方法销毁当前用户 Session 和认证 Cookie,并将用户重定向到首页。
用户认证相关视图模板
定义好认证处理器后,我们来编写与认证相关的视图模板,主要是登录页面和注册页面,在 views
目录下新增 login.html
编写登录页面:
{{ define "content" }}
<form class="form-signin center" role="form" action="/authenticate" method="post">
<h2 class="form-signin-heading">
<i class="fa fa-comments-o">
ChitChat
</i>
</h2>
<input type="email" name="email" class="form-control" placeholder="Email address" required autofocus>
<input type="password" name="password" class="form-control" placeholder="Password" required>
<br/>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
<br/>
<a class="lead pull-right" href="/signup">Sign up</a>
</form>
{{ end }}
然后创建 signup.html
编写注册页面:
{{ define "content" }}
<form class="form-signin" role="form" action="/signup_account" method="post">
<h2 class="form-signin-heading">
<i class="fa fa-comments-o">
ChitChat
</i>
</h2>
<div class="lead">Sign up for an account below</div>
<input id="name" type="text" name="name" class="form-control" placeholder="Name" required autofocus>
<input type="email" name="email" class="form-control" placeholder="Email address" required>
<input type="password" name="password" class="form-control" placeholder="Password" required>
<button class="btn btn-lg btn-primary btn-block" type="submit">Sign up</button>
</form>
{{ end }}
此外,我们还为登录和注册页面定义了单独的布局模板 auth.layout.html
:
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=9">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChitChat</title>
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<link href="/static/css/login.css" rel="stylesheet">
</head>
<body>
<div class="container">
{{ template "content" . }}
</div> <!-- /container -->
<script src="/static/js/jquery-2.1.1.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
</body>
</html>
{{ end }}
以上视图模板已经在认证处理器方法中引用。
注册用户认证路由
最后,我们需要在 routes/routes.go
中注册用户认证相关路由:
// 定义所有 Web 路由
var webRoutes = WebRoutes{
... // 其他路由
{
"signup",
"GET",
"/signup",
handlers.Signup,
},
{
"signupAccount",
"POST",
"/signup_account",
handlers.SignupAccount,
},
{
"login",
"GET",
"/login",
handlers.Login,
},
{
"auth",
"POST",
"/authenticate",
handlers.Authenticate,
},
{
"logout",
"GET",
"/logout",
handlers.Logout,
},
}
测试用户认证功能
这样一来,我们就可以重启应用并访问用户注册页面 http://localhost:8080/signup
进行注册了:
注册成功后,页面会跳转到登录页面 http://localhost:8080/login
:
输入刚才填写的注册邮箱和密码,点击「SIGN IN」按钮登录成功后,页面跳转到首页。
我们还没有对首页做额外的认证判断和处理,所以此时显示的页面效果和之前一样,为了区别用户认证与未认证状态,我们可以基于认证状态渲染不同的导航模板,对于认证用户,渲染 auth.navbar
模板,对于未认证用户,还是保持和之前一样,为此,我们需要在 views 目录下新增 auth.navbar.html
视图:
{{ define "navbar" }}
<div class="navbar navbar-default navbar-static-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">
<i class="fa fa-comments-o"></i>
ChitChat
</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a href="/">Home</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="/logout">Logout</a></li>
</ul>
</div>
</div>
</div>
{{ end }}
同时还要修改 handlers.Index
处理器方法实现:
func Index(writer http.ResponseWriter, request *http.Request) {
threads, err := models.Threads();
if err == nil {
_, err := session(writer, request)
if err != nil {
generateHTML(writer, threads, "layout", "navbar", "index")
} else {
generateHTML(writer, threads, "layout", "auth.navbar", "index")
}
}
}
再次重启应用,刷新首页,导航条的展示效果就不一样了:
此时显示的是「Logout」链接,点击即可退出应用:
5 Comments
请问一下,获取登录页面和获取注册页面为什么用helper里不同的方法?
if err != http.ErrNoCookie { fmt.Println("Failed to get cookie") 这里是否有问题??应该是err==http.ErrNoCookie才是找不到cookie吧
逻辑上没有什么问题 如果cookie不存在 也没有必要执行清除session的逻辑了 直接跳转到首页即可 但是这个时候从语义上来说 把报错信息放到 else 里面执行更佳
感谢回复,烦请把第三部分关于引用css文件的地址解释一下。这里我弄明白了,此处这种判断是当用户的传过来的cookie非空,那么我们就删除具有该cookie值的会话,切换到index,index处理器判断cookie为空,执行未登录的首页,这就完成了退出。但此处判断内部应该打印“用户准备退出,而不是找不到cookie更符合语境”
发现index处理那里回复有误,index依然能找到cookie值,但是cookie值相等的seesion uuid由于已经删除,找不到对应session才判断有误,跳转到用户未登录的首页