网站首页 > 技术文章 正文
接上,预期中的调用流程如下图
简单逻辑代码
web/user.go
func (c *UserHandler) RegisterRoutes(server *gin.Engine) {
// 分组注册
up := server.Group("/users")
up.POST("/signup", c.SignUp)
up.POST("/login", c.Login)
up.POST("/edit", c.Edit)
up.GET("/profile", c.Profile)
}
type UserHandler struct {
svc *service.UserService
emailRegexExp *regexp2.Regexp
PasswordRegexExp *regexp2.Regexp
}
const (
// 定义邮箱的正则表达式
emailRegex = `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}Golang Gin 入门 (三)-今日头条
// 定义密码的正则表达式 至少8个字符 //包含大写字母 //包含小写字母 //包含数字 //包含特殊字符
passwordRegex = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}Golang Gin 入门 (三)-今日头条
)
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{
svc: svc,
emailRegexExp: regexp2.MustCompile(emailRegex, regexp2.None),
PasswordRegexExp: regexp2.MustCompile(passwordRegex, regexp2.None),
}
}
func (u *UserHandler) SignUp(context *gin.Context) {
type SingUpReq struct {
Email string `json:"email"`
ConfirmPassword string `json:"confirmPassword"`
Password string `json:"password"`
}
var req SingUpReq
// Bind 方法会根据 Content-Type 来解析你的数据req里面
// 解析错了,就直接回写一个 400(http.StatusBadRequest)的错误. c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind)
if err := context.Bind(&req); err != nil {
return
}
isEmail, err := u.emailRegexExp.MatchString(req.Email)
if err != nil {
context.String(http.StatusOK, "系统错误 %v", err)
return
}
if !isEmail {
context.String(http.StatusOK, "邮箱校验不通过")
return
}
isPassword, err := u.PasswordRegexExp.MatchString(req.Password)
if err != nil {
context.String(http.StatusOK, "系统错误 %v", err)
return
}
if !isPassword {
context.String(http.StatusOK, "密码校验不通过")
return
}
if req.ConfirmPassword != req.Password {
context.String(http.StatusOK, "两次输入的密码不一致")
return
}
err = u.svc.Signup(context, domain.User{
Email: req.Email,
Password: req.Password,
})
if err != nil {
context.String(http.StatusOK, "系统错误 %v", err)
return
}
context.String(http.StatusOK, "注册成功")
//log.Printf("%v\n", req)
}
service/user.go
type UserService struct {
repo *repository.UserRepository
}
func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (scv *UserService) Signup(ctx context.Context, u domain.User) error {
// 要考虑加密放在哪 然后存起来
return scv.repo.Create(ctx, u)
}
repository/user.go
type UserRepository struct {
dao *dao.UserDAO
}
func NewUserRepository(dao *dao.UserDAO) *UserRepository {
return &UserRepository{dao: dao}
}
func (r *UserRepository) Create(ctx context.Context, u domain.User) error {
return r.dao.Insert(ctx, dao.User{
Email: u.Email,
Password: u.Password,
})
}
func (r *UserRepository) FindById(int64) {
// 先从cache里找
// 再从dao里面找
// 找到了在回写cache
}
repository/dao/user.go
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
func (dao *UserDAO) Insert(ctx context.Context, u User) error {
now := time.Now().UnixMilli() // 存毫秒
u.Ctime = now
u.Utime = now
return dao.db.WithContext(ctx).Create(&u).Error
}
func InitTable(db *gorm.DB) error {
return db.AutoMigrate(&User{})
}
// User在DAO里直接对应数据库表
type User struct {
Id int64
Email string
Password string
// 创建时间和更新时间毫秒数,用time.time会涉及到时区,在数据库和应用层都会涉及到时区,可以选择用UTC时间戳,在前端转时区
Ctime int64
Utime int64
}
main.go组装好全部的东西,在抽取到不同的方法里面
func initWebServer() *gin.Engine {
engine := gin.Default()
engine.Use(cors.New(cors.Config{
//AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"PUT", "PATCH", "GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "authorization"},
//ExposeHeaders: []string{"x-jwt-token"},
AllowCredentials: true, //是否允许你带cookie之类的
AllowOriginFunc: func(origin string) bool {
if strings.HasPrefix(origin, "http://localhost") {
return true // 开发环境
}
return strings.Contains(origin, "your.company.com")
},
MaxAge: 12 * time.Hour,
}))
return engine
}
func initDB() *gorm.DB {
db, err := gorm.Open(mysql.Open("root:ssqj@easyviews.pw@tcp(10.1.125.32:3307)/webook"))
if err != nil {
panic(err)
}
err = dao.InitTable(db)
if err != nil {
panic(err)
}
return db
}
func initUser(server *gin.Engine, db *gorm.DB) {
ud := dao.NewUserDAO(db)
ur := repository.NewUserRepository(ud)
us := service.NewUserService(ur)
c := web.NewUserHandler(us)
c.RegisterRoutes(server)
}
func main() {
db := initDB()
server := initWebServer()
initUser(server, db)
if err := server.Run(":8080"); err != nil {
log.Printf("Listen err: %v\n", err)
}
}
调用注册接口,查看数据库是否写入
mysql> show tables;
+------------------+
| Tables_in_webook |
+------------------+
| users |
+------------------+
1 row in set (0.01 sec)
mysql> select * from users;
+----+-------------------+-------------+---------------+---------------+
| id | email | password | ctime | utime |
+----+-------------------+-------------+---------------+---------------+
| 1 | 2864048202@qq.com | Rrootroot1* | 1730300023836 | 1730300023836 |
+----+-------------------+-------------+---------------+---------------+
1 row in set (0.01 sec)
密码加密
密码怎么加密? 代码看上去没有问题,但是好像忘了一件事情:密码是敏感信息,需要加密存储。 问题来了:谁来加密?service 还是 repository 还是 dao? 怎么加密?怎么选择一个安全的加密算法? 敏感信息你要防两类人:研发,包括你和你的同事。攻击者。 PS:敏感信息应该是连日志都不能打。
关于密码加密的位置存在多种选择且各有理由:
- service 加密:加密被认为是业务概念,不是存储概念,这里选择 service 加密是因为认为加密是业务逻辑的一部分但不是 domain 应该管的。
- repository 加密:加密可被看作是存储概念,即 “加密存储”。
- dao 加密:加密是数据库概念,可以利用数据库本身的加密功能实现。
- domain 加密:加密是业务概念,认为用户(“User”)自己才知道怎么加密。这是编程中比较无正确答案的实践问题,不同的加密位置会影响到其他接口(如登录)的实现细节。
这种就是编程里面比较无聊的、没有正确答案的实践问题。这里我选择 service 加密,也就是认为加密是业务逻辑的一部分,但是它不是 domain 应该管的。如果你选择不同的加密位置,那么它会影响到你别的接口的实现细节,比如说登录。
加密算法的选择对系统安全性至关重要,攻击者拿到密码可能为所欲为。选择加密算法的标准是难破解,需考虑以下问题:
- 相同密码加密后结果应不同,防止很多用户使用简单密码(如 123456)时数据库存储值相同。
- 难以通过碰撞、彩虹表来破解。
常见加密算法安全性逐步提高的有:
- md5之类的哈希算法。
- 在哈希算法基础上引入盐值或进行多次哈希。
- PBKDF2、BCrypt这一类随机盐值的加密算法,同样的文本加密后的结果都不同。
bcrypt被称为号称最安全的加密算法,具有以下优点:
- 不需要自己生成盐值。
- 不需要额外存储盐值。
- 可以通过控制cost来控制加密性能。
- 同样的文本加密后的结果不同。 如果要使用它,需要使用golang.org/x/crypto。由于bcrypt限制密码长度不能超过 72 字节,所以在校验时要校验这个长度,只需要修改一下正则表达式即可。
使用bcrypt加密后无法解密,只能通过比较加密后的值来确定两个值是否相等。
func (scv *UserService) Signup(ctx context.Context, u domain.User) error {
// 要考虑加密放在哪 然后存起来
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.Password = string(hash)
return scv.repo.Create(ctx, u)
}
登录功能
大多数网站的资源需要登录才能访问,比如编辑和查看用户信息。
登录功能分为两件事:
- 实现登录功能
- 登录态校验
登录请求会被发送到/users/login。
在登录接口实现中,可以看出 service 和 repository 的分界线。service 会调用 repository 查找邮箱对应的用户,然后 service 会匹配输入的密码和数据库中保存的密码是否一致。如果用户没找到或者密码错误,都返回同一个 error。
// dao层
func (dao *UserDAO) FindByEmail(ctx context.Context, email string) (u User, err error) {
// err = dao.db.WithContext(ctx).First(&u,"email = ?",email).Error // 二选一
err = dao.db.WithContext(ctx).Where("email = ?", email).First(&u).Error
return u, err
}
// repository层
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (domain.User, error) {
u, err := r.dao.FindByEmail(ctx, email)
if err != nil {
return domain.User{}, err
}
return domain.User{
Email: u.Email,
Password: u.Password,
}, err
}
//service层
func (scv *UserService) Login(ctx context.Context, email, password string) error {
// 先看有没有这个用户
u, err := scv.repo.FindByEmail(ctx, email)
if err != nil {
return ErrInvalidUserOrPaswword
}
//
err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
if err != nil {
return ErrInvalidUserOrPaswword
}
return nil
}
//web
func (c *UserHandler) Login(context *gin.Context) {
type LoginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
var req LoginReq
if err := context.Bind(&req); err != nil {
context.String(http.StatusOK, "系统错误")
return
}
err := c.svc.Login(context, req.Email, req.Password)
if err != nil {
context.String(http.StatusOK, "账户或密码错误")
return
}
context.String(http.StatusOK, "登录成功")
return
}
Cookie 和 Session
在登录成功后,当访问/users/profile时,面临如何确定用户是否已登录的问题。
HTTP 协议是无状态的,即连续发送两次请求时,HTTP 无法知道这两个请求都是同一个用户发出的,无法将上一次请求和这一次请求关联起来。
因此需要一种机制来记录状态,于是就有了 Cookie 和 Session。
Cookie 是浏览器存储在本地的一些数据,简单理解为存储在电脑上的键值对。由于 Cookie 存放在浏览器本地,所以很不安全。
在使用 Cookie 时要注意安全配置:
- Domain:设定 Cookie 可用的域名,按照最小化原则设置。
- Path:设定 Cookie 可用的路径,同样按照最小化原则设置。
- Max-Age 和 Expires:设置 Cookie 的过期时间,只保留必要时间。
- Http-Only:设置为 true 时,浏览器上的 JS 代码无法使用该 Cookie,应永远设置为 true。
- Secure:仅用于 HTTPS 协议,在生产环境永远设置为 true。
- SameSite:决定是否允许跨站发送 Cookie,尽量避免。
在面试初级工程师岗位时,能详细解释这些参数含义会赢得微小竞争优势。
由于 Cookie 具有不安全的特性,所以大部分时候只在 Cookie 中放一些不太关键的数据。关键数据希望放在后端存储,这个存储的东西叫做 Session。在登录场景中,可以通过 Session 来记录登录状态。
在使用 Session 进行登录时,关键在于服务器要给浏览器一个 sess_id(即 Session 的 ID)。后续每次请求都带上这个 Session ID,服务端就能通过这个 ID 知道发出请求的用户是谁。
Session 机制中后端服务器认 ID 不认人。这意味着如果攻击者拿到了用户的 Session ID,那么服务器就会把攻击者当成该用户。例如在图中,攻击者窃取到了 sess_id 后,就可以冒充用户。
由于 sess_id 是标识用户身份的东西,需要在每一次访问系统时携带。有以下几种方式:
- 最佳方式:使用 Cookie,将 sess_id 放到 Cookie 里面。因为 sess_id 自身没有任何敏感信息,所以放在 Cookie 中是可行的。
- 另一种方式:放在 Header 中,例如在 Header 里面带一个 sess_id,但这需要前端研发人员记得在 Header 中带上。
- 还可以:放在查询参数中,即 ?sess_id=xxx。
- 理论上也可以放在请求体(body)中,但基本没人这么做。
- 在一些禁用了 Cookie 功能的浏览器上,只能考虑后两种方式(放在 Header 中或查询参数中)。
在浏览器上,可以通过插件 cookieEditor 查看某个网站的 Cookie 信息。
可以使用 Gin 的 Session 插件来实现登录功能。一般遇到问题可以找插件,热门功能 Gin 通常有插件可用。这里提到的 Gin 的 Session 插件使用分为两部分:
- 在 middleware(中间件)里面接入,它会从 Cookie 中找到 sess_id,再根据 sess_id 找到对应的 Session。
- 拿到 Session 之后可以进行各种操作,例如在这里用来校验是否登录。其插件地址为 https://github.com/gin-contrib/sessions。
func initWebServer() *gin.Engine {
engine := gin.Default()
engine.Use(cors.New(cors.Config{
//AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"PUT", "PATCH", "GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "authorization"},
//ExposeHeaders: []string{"x-jwt-token"},
AllowCredentials: true, //是否允许你带cookie之类的
AllowOriginFunc: func(origin string) bool {
if strings.HasPrefix(origin, "http://localhost") {
return true // 开发环境
}
return strings.Contains(origin, "your.company.com")
},
MaxAge: 12 * time.Hour,
}))
store := cookie.NewStore([]byte("secret"))
// cookie 的名字叫ssid
engine.Use(sessions.Sessions("ssid", store))
engine.Use(middleware.NewLoginMiddlewareBuilder().Build())
return engine
}
// middleware.go
type LoginMiddlewareBuilder struct {
}
func NewLoginMiddlewareBuilder() *LoginMiddlewareBuilder {
return &LoginMiddlewareBuilder{}
}
func (l *LoginMiddlewareBuilder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
if ctx.Request.URL.Path == "/users/login" || ctx.Request.URL.Path == "/users/signup" {
return // 登录和注册不需要校验
}
sess := sessions.Default(ctx)
if sess.Get("userId") == nil {
// 没有登录
ctx.AbortWithStatus(http.StatusUnauthorized)
return
}
}
}
// web
func (c *UserHandler) Login(context *gin.Context) {
type LoginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
var req LoginReq
if err := context.Bind(&req); err != nil {
context.String(http.StatusOK, "系统错误")
return
}
user, err := c.svc.Login(context, req.Email, req.Password)
if err != nil {
context.String(http.StatusOK, "账户或密码错误")
return
}
sess := sessions.Default(context)
sess.Set("userId", user.Id)
sess.Save()
context.String(http.StatusOK, "登录成功")
return
}
面试题
一、什么是 Cookie,什么是 Session?
- Cookie:
- Cookie 是浏览器存储在本地的一些数据,简单理解为存储在电脑上的键值对。例如,一些网站会将用户的偏好设置、登录状态等信息存储在 Cookie 中。
- Cookie 可以在不同的请求中被发送到服务器,服务器可以根据 Cookie 中的信息识别用户。但是,由于 Cookie 存储在客户端,所以安全性较低,容易被篡改或窃取。
- 在使用 Cookie 时,要注意安全配置,如按照最小化原则设置 Domain 和 Path、合理设置 Max-Age 和 Expires、将 Http-Only 设置为 true(防止浏览器上的 JS 代码使用 Cookie)、在生产环境将 Secure 设置为 true(仅用于 HTTPS 协议)、尽量避免使用 SameSite 允许跨站发送 Cookie。
- Session:
- 由于 Cookie 本身不安全的特性,大部分时候我们只在 Cookie 里面放一些不太关键的数据。关键数据我们希望放在后端,这个存储关键数据的东西就叫做 Session。
- 在登录场景中,可以通过 Session 来记录登录状态。服务器给浏览器一个 sess_id(Session 的 ID),后续每一次请求都带上这个 Session ID,服务端就能通过这个 ID 识别用户身份。
二、Cookie 和 Session 比起来有什么缺点?
- 安全性低:Cookie 存储在客户端,容易被篡改或窃取。而 Session 数据存储在服务器端,相对安全。
- 存储容量有限:Cookie 的存储容量通常较小,一般为 4KB 左右。而 Session 可以存储更多的数据。
- 性能影响:每次请求都需要发送 Cookie 数据,会增加网络传输量,对性能有一定影响。而 Session ID 通常较小,对网络传输的影响较小。
三、Session ID 可以放在哪里?这个问题,你要记得提起 Cookie 禁用的问题。
Session ID 可以放在以下几个地方:
- Cookie 中:这是最常见的方式。将 Session ID 放在 Cookie 中,每次请求时浏览器会自动发送 Cookie,服务器可以根据 Session ID 找到对应的 Session。但是,如果用户禁用了 Cookie,这种方式就不可行了。
- Header 中:可以在请求的 Header 中添加一个自定义的字段,用来携带 Session ID。例如,可以在 Header 里面带一个 sess_id。这需要前端的研发人员记得在每次请求时在 Header 中带上 Session ID。
- 查询参数中:可以将 Session ID 放在查询参数中,即 ?sess_id=xxx。但是这种方式不太安全,容易被用户看到和篡改。
- 理论上也可以放在请求体(body)中,但基本没人这么做。
在一些禁用了 Cookie 功能的浏览器上,可以考虑使用 Header 或查询参数的方式来携带 Session ID。
四、用户密码加密算法选取有什么注意事项?你用的是什么?
用户密码加密算法选取的注意事项:
- 难破解:选择加密算法的标准是难破解。要考虑相同的密码加密后的结果应该不同,防止很多用户使用简单密码(如 123456)时数据库存储值相同。同时,难以通过碰撞、彩虹表来破解。
- 安全性高:常见加密算法安全性逐步提高的有 md5 之类的哈希算法,但 md5 相对不安全;在哈希算法基础上引入盐值或进行多次哈希可以提高安全性;PBKDF2、BCrypt 这一类随机盐值的加密算法安全性更高,同样的文本加密后的结果都不同。
这里可以选择使用 BCrypt 加密算法,它有以下优点:
- 不需要自己生成盐值。
- 不需要额外存储盐值。
- 可以通过控制 cost 来控制加密性能。
- 同样的文本,加密后的结果不同。
如果要使用 BCrypt,需要使用 golang.org/x/crypto。由于 BCrypt 限制密码长度不能超过 72 字节,所以在校验时要校验这个长度,只需要修改一下正则表达式即可。
五、怎么做登录校验?核心是利用 Gin 的 middleware。
登录校验可以通过以下步骤实现:
登录接口实现:
- 在登录请求被发到 /users/login 上时,service 会调用 repository 查找邮箱所对应的用户。
- 然后 service 会匹配输入的密码和数据库中保存的是否一致。如果用户没找到或者密码错误,都返回同一个 error。
- 利用 Gin 的 middleware 进行登录校验:
Gin 的 Session 插件用起来分成两部分:
- 一个是在 middleware(中间件)里面接入,它会帮你从 Cookie 里面找到 sess_id,再根据 sess_id 找到对应的 Session。
- 另外一部分就是你拿到这个 Session 之后,就可以进行登录校验等操作。例如,可以在 Session 中存储用户的登录状态信息,当用户访问需要登录才能访问的资源(如 /users/profile)时,通过 middleware 检查 Session 中的登录状态信息,如果用户已登录,则允许访问,否则返回错误信息。
猜你喜欢
- 2024-11-02 武汉课工场大数据培训:Java正则表达式入坑指南
- 2024-11-02 Go语言进阶之路:并发爬虫,爬取空姐网所有相册图片
- 2024-11-02 golang常用库:gorilla/mux-http路由库使用
- 2024-11-02 golang 使用pprof和go-torch做性能分析
- 2024-11-02 Golang Gin 入门 (一)(golang官方教程)
- 2024-11-02 日志文件转运工具Filebeat笔记(日志转载)
- 2024-11-02 Linux 命令行下搜索工具大盘点,效率提高不止一倍
- 2024-11-02 SlimTrie:战胜Btree单机百亿文件的极致索引-实现篇
- 2024-11-02 Go的安全编程和防御性编程(输入验证和过滤)
- 2024-11-02 清华学神尹成带你学习golang2021(56)(Go语言测试命令)
- 最近发表
- 标签列表
-
- cmd/c (57)
- c++中::是什么意思 (57)
- sqlset (59)
- ps可以打开pdf格式吗 (58)
- phprequire_once (61)
- localstorage.removeitem (74)
- routermode (59)
- vector线程安全吗 (70)
- & (66)
- java (73)
- org.redisson (64)
- log.warn (60)
- cannotinstantiatethetype (62)
- js数组插入 (83)
- resttemplateokhttp (59)
- gormwherein (64)
- linux删除一个文件夹 (65)
- mac安装java (72)
- reader.onload (61)
- outofmemoryerror是什么意思 (64)
- flask文件上传 (63)
- eacces (67)
- 查看mysql是否启动 (70)
- java是值传递还是引用传递 (58)
- 无效的列索引 (74)