当前位置:首页 > 技术分析 > 正文内容

Golang Gin 入门 (三)

ruisui882个月前 (03-03)技术分析15

Golang Gin 入门 (二)

接上,预期中的调用流程如下图

简单逻辑代码

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,}$`
    // 定义密码的正则表达式 至少8个字符    //包含大写字母    //包含小写字母    //包含数字  //包含特殊字符
    passwordRegex = `^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$`
)

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)时数据库存储值相同。
  • 难以通过碰撞、彩虹表来破解。

常见加密算法安全性逐步提高的有:

  1. md5之类的哈希算法。
  2. 在哈希算法基础上引入盐值或进行多次哈希。
  3. PBKDF2BCrypt这一类随机盐值的加密算法,同样的文本加密后的结果都不同。

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 插件使用分为两部分:

  1. 在 middleware(中间件)里面接入,它会从 Cookie 中找到 sess_id,再根据 sess_id 找到对应的 Session。
  2. 拿到 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 相对不安全;在哈希算法基础上引入盐值或进行多次哈希可以提高安全性;PBKDF2BCrypt 这一类随机盐值的加密算法安全性更高,同样的文本加密后的结果都不同。

这里可以选择使用 BCrypt 加密算法,它有以下优点:

  • 不需要自己生成盐值。
  • 不需要额外存储盐值。
  • 可以通过控制 cost 来控制加密性能。
  • 同样的文本,加密后的结果不同。

如果要使用 BCrypt,需要使用 golang.org/x/crypto。由于 BCrypt 限制密码长度不能超过 72 字节,所以在校验时要校验这个长度,只需要修改一下正则表达式即可。

五、怎么做登录校验?核心是利用 Gin 的 middleware。

登录校验可以通过以下步骤实现:


登录接口实现

  • 在登录请求被发到 /users/login 上时,service 会调用 repository 查找邮箱所对应的用户。
  • 然后 service 会匹配输入的密码和数据库中保存的是否一致。如果用户没找到或者密码错误,都返回同一个 error。
  1. 利用 Gin 的 middleware 进行登录校验

Gin 的 Session 插件用起来分成两部分:

  • 一个是在 middleware(中间件)里面接入,它会帮你从 Cookie 里面找到 sess_id,再根据 sess_id 找到对应的 Session。
  • 另外一部分就是你拿到这个 Session 之后,就可以进行登录校验等操作。例如,可以在 Session 中存储用户的登录状态信息,当用户访问需要登录才能访问的资源(如 /users/profile)时,通过 middleware 检查 Session 中的登录状态信息,如果用户已登录,则允许访问,否则返回错误信息。





扫描二维码推送至手机访问。

版权声明:本文由ruisui88发布,如需转载请注明出处。

本文链接:http://www.ruisui88.com/post/2453.html

分享给朋友:

“Golang Gin 入门 (三)” 的相关文章

供热收费管理系统一户多标准版

供热收费管理系统一户多标准版headerfooter《供热收费管理系统一户对标准版》是一款针对冬季供暖收费的管理软件,针对用户相同地址,存在不同的收费方式。同样为供热公司的规范收费、加强管理、提高服务档次必备的管理系统。 本系统包括:基础信息、收费管理等二个大模块。主要功能:1.基础信息(供热区设置...

vue3父子组件传对象,子组件访问修改父组件对象中的属性值

在Vue 3中,父子组件之间的数据传输通常通过props和emit进行。父组件可以通过props向下传递数据给子组件,子组件则可以通过emit向上通知父组件更新数据。如果需要在子组件中修改父组件对象中的属性值,可以使用一个名为ref的Vue 3新特性。以下是一个示例,演示了如何在Vue 3中实现父子...

「干货」通俗易懂的Deno 入门教程

作者: semlinker转发链接:https://mp.weixin.qq.com/s/2eqRTsf_z7Bcs6dziXe73Q一、Deno 简介Deno 是一个 JavaScript/TypeScript 的运行时,默认使用安全环境执行代码,有着卓越的开发体验。Deno 含有以下功能亮点:默...

内存问题探微

这篇文章是我在公司 TechDay 上分享的内容的文字实录版,本来不想写这么一篇冗长的文章,因为有不少的同学问是否能写一篇相关的文字版,本来没有的也就有了。说起来这是我第二次在 TechDay 上做的分享,四年前第一届 TechDay 不知天高地厚,上去讲了一个《MySQL 最佳实践》,现在想起来那...

10款超牛Vim插件,爱不释手了

我是一个忠实的Vim编辑器用户,从事开发工作多年,我一直都非常喜欢使用Vim。轻量、便捷,而且,熟悉了Vim相关的快捷键之后,效率能够成倍的提升。除了这些之外,Vim像很多知名的IDE、编辑器一样,也支持插件配置,通过这些插件,可以实现更多高级、高效的操作。今天,就来给大家分享10个我特别喜欢的Vi...

Gemini应用在Android上广泛推出2.0闪电模式切换器

#头条精品计划# 快速导读谷歌(搜索)应用的测试频道在安卓设备的双子应用中推出了2.0闪电实验功能,现已向稳定用户开放。双子应用通过谷歌应用运行,目前推出的15.50版本中,用户可通过模型选择器体验不同选项,包括1.5专业版、1.5闪电版和2.0闪电实验版。2.0闪电实验模型提供了更快的响应速度和优...