优秀的编程知识分享平台

网站首页 > 技术文章 正文

Golang Gin 入门 (三)(golang简明教程)

nanyue 2024-11-02 12:18:06 技术文章 4 ℃

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

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

  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 中的登录状态信息,如果用户已登录,则允许访问,否则返回错误信息。





最近发表
标签列表