项目地址:https://github.com/liwook/PublicReview

登录有两种方式:

  • 通过手机号码发送验证码登录。
  • 通过密码进行登录。

通过验证码登录,服务端就要存储该手机号码的验证码,这就是典型的键值对(一个号码对应一个验证码),还要给验证码设置过期时间,那可以存储在Redis中。

Go语言连接使用Redis

在config.yaml添加Redis的内容

Redis:
  Host: 127.0.0.1:6379
  Password: 123456
  PoolSize: 20
#.yaml文件添加的时候要留意,可能添加的格式不对导致程序访问不到配置的
#通过颜色来区分是否有错误。Host: 这个后面是需要空一格,颜色才正确,格式才对

在config.go文件添加Redis配置的结构体。

var (
	RedisOption  *RedisSetting
)

type RedisSetting struct {
	Host     string
	Password string
	PoolSize int
}

//InitConfig函数添加读取redis的配置
func InitConfig(path string) {
    ....................
	err = ReadSection("redis", &RedisOption)
	if err != nil {
		panic(err)
	}
}

在db目录创建redis.go文件。使用一个常用的go Redis客户端 go-redis来连接Redis

//redis.go
var RedisDb *redis.Client

func NewRedisClient(config *config.RedisSetting) (*redis.Client, error) {
	client := redis.NewClient(&redis.Options{
		Addr:     config.Host,     //自己的redis实例的ip和port
		Password: config.Password, //密码,有设置的话,就需要填写
		PoolSize: config.PoolSize, //最大的可连接数量
	})
	val, err := client.Ping(context.Background()).Result() //测试ping
	if err != nil {
		return nil, err
	}
	fmt.Println("redis测试: ", val)
	return client, err
}

在main.go中进行创建redis客户端。

func init() {
    ............................
	//初始化redis
	db.RedisClient, err = db.NewRedisClient(config.RedisOption)
	if err != nil {
		panic(err)
	}
}

添加关于登录的函数

创建handler/user目录,添加login.go文件和type.go文件(存储和user相关的一些结构体等)。

1.获取验证码的函数

步骤:

  1. 判断手机号是否合法(前端也可以做这个判断)
  2. 生成验证码,并使用redis的string类型保存在redis中,需设置过期时间
  3. 把验证码发送给客户
// hanler/user/type.go
const (
	userNickNamePrefix= "user"
	phoneKeyPrefix     = "phone:"
	codeExpiration     = 4 * time.Minute
)

type sendCodeRequeststruct {
	Phone string `json:"phone" binding:"required"`
}

// loginReq登录请求结构体(统一使用 phone 作为用户标识)
type loginReqstruct {
	Phone    string `json:"phone" binding:"required"`                  // 手机号(必填)
	Password string `json:"password" binding:"omitempty,min=6,max=20"` // 密码(仅密码登录时用)
	Code     string `json:"code" binding:"omitempty,min=4,max=6"`      // 验证码(仅验证码登录时用)
}

// Validate 校验登录方式是否合法(自定义校验)
func (l *loginReq) Validate() error {
	hasPassword := l.Password != ""
	hasCode := l.Code != ""

	if !hasPassword && !hasCode {
		return fmt.Errorf("请选择密码或验证码登录")
	}
	if hasPassword && hasCode {
		return fmt.Errorf("不能同时使用密码和验证码登录")
	}
	return nil
}
// login.go
// post /api/v1/send-code
func SendCode(c *gin.Context) {
	var codeRequest sendCodeRequest
	err := c.ShouldBindJSON(&codeRequest)
	if err != nil {
		response.Error(c, response.ErrBind, "")
		return
	}
	if !isPhoneInvalid(codeRequest.Phone) {
		response.Error(c, response.ErrValidation, "phone is invalid")
		return
	}

	//生成验证码,6位数的字符串
	code := strconv.Itoa(rand.Intn(1000000) + 100000)
	//用redis的string类型保存
	key := phoneKeyPrefix + codeRequest.Phone
	success, err := db.RedisDb.SetNX(context.Background(), key, code, codeExpiration).Result()
	if err != nil {
		slog.Error("redis setnx failed", "err", err, "phone", codeRequest.Phone)
		response.Error(c, response.ErrDatabase, "")
		return
	}
	if !success {
		response.Error(c, response.ErrValidation, "验证码已发送,请稍后再试")
		return
	}

	response.Success(c, gin.H{"code": code})
	//真实环境是调用短信服务商API(如阿里云、腾讯云短信服务)发送验证码到用户手机,不是直接在http请求中返回数据的。
	// 生产环境完整流程:
	// 1. 验证手机号格式和频率限制
	// 2. 生成6位随机验证码
	// 3. 将验证码存储到Redis(设置过期时间)
	// 4. 调用短信服务发送验证码:sms.Send(phone, code)
	// 5. 如果短信发送成功,返回:{"message": "验证码已发送"}
	// 6. 如果短信发送失败,清理Redis并返回错误
	// 7. 记录发送日志:时间、手机号、发送状态等
}

func isPhoneInvalid(phone string) bool {
	// 匹配规则: ^1第一位为一, [345789]{1} 后接一位345789 的数字
	// \\d \d的转义 表示数字 {9} 接9位 ,   $ 结束符
	regRuler := "^1[123456789]{1}\\d{9}$"
	reg := regexp.MustCompile(regRuler) // 正则调用规则
	// 返回 MatchString 是否匹配
	return reg.MatchString(phone)
}

2.登录

现在的登录/注册,基本都是通过手机号码进行的。而登录的时候选择密码登录,也是通过手机号码和密码一同登录的。

登录的数据是json格式,存储在请求体中。

// post /api/v1/login
func Login(c *gin.Context) {
	var loginRequest loginReq
	err := c.ShouldBindJSON(&loginRequest)
	if err != nil {
		slog.Error("bind failed", "err", err)
		response.Error(c, response.ErrBind, "")
		return
	}
	if !isPhoneInvalid(loginRequest.Phone) {
		response.Error(c, response.ErrValidation, "phone is invalid")
		return
	}

	if err := loginRequest.Validate(); err != nil {
		response.Error(c, response.ErrValidation, err.Error())
		return
	}

	//根据参数判断登录方式
	var user *model.TbUser
	if loginRequest.Password != "" {
		user, err = loginPassword(loginRequest)
	} else if loginRequest.Code != "" {
		user, err = loginCode(loginRequest)
	} else {
		response.Error(c, response.ErrValidation, "login method is invalid")
		return
	}

	if err != nil {
		response.HandleBusinessError(c, err)
		return
	}

	token, err := middleware.GenerateToken(loginRequest.Phone)
	if err != nil {
		slog.Error("generate token bad", "err", err)
		response.Error(c, response.ErrLoginFailed, "")
		return
	}
	response.Success(c, gin.H{
		"user": userResponse{    //返回给前端展示或者缓存的用户信息
			ID:       user.ID,
			Phone:    user.Phone,
			NickName: user.NickName,
			Icon:     user.Icon,
		},
	})
}


//type.go
type userResponse struct {
	ID       uint64 `json:"id"`
	Phone    string `json:"phone"`
	NickName string `json:"nick_name"`
	Icon     string `json:"icon"`
}

 验证码登录

  1. 从redis中得到phone保存的验证码进行对比
  2. 从MySQL中判断该用户是否是新用户,若是新用户,就需要创建用户,存储到数据库中
  3. 发送给客户端登录成功。
func loginCode(login loginReq) (*model.TbUser, error) {
	//为空是返回error中的,值为redis.Nil
	// 获取Redis中存储的验证码
	val, err := db.RedisDb.Get(context.Background(), phoneKeyPrefix+login.Phone).Result()
	if err != nil {
		// if err != redis.Nil
		if errors.Is(err, redis.Nil) { //这种写法更好,智能比较:会递归检查错误链;处理包装错误:即使错误被fmt.Errorf等函数包装,也能正确识别
			return nil, response.NewBusinessError(response.ErrExpired, "验证码过期或没有该验证码")
		}
		// Redis连接错误等系统错误
		return nil, response.WrapBusinessError(response.ErrDatabase, err, "")
	}
	if val != login.Code {
		return nil, response.NewBusinessError(response.ErrLoginFailed, "验证码错误")
	}
	// 验证码验证成功后,删除Redis中的验证码
	db.RedisDb.Del(context.Background(), phoneKeyPrefix+login.Phone)

	//之后判断是否是新用户,若是新用户,就创建
	u := query.TbUser
	user, err := u.Where(u.Phone.Eq(login.Phone)).First()
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			user = &model.TbUser{Phone: login.Phone, NickName: UserNickNamePrefix + strconv.Itoa(rand.Intn(100000000))}
			err := u.Create(user)
			if err != nil {
				return nil, response.WrapBusinessError(response.ErrDatabase, err, "")
			}
		}
		return nil, response.WrapBusinessError(response.ErrDatabase, err, "")
	}

	return user, nil
}

账号密码登录

在数据库中判断发送过来的phone和password是否正确,若正确,回复登录成功;否则回复登录失败。

注意的是:

1.数据库存储的是加密后的密码(BCrypt算法);

2.客户端通过HTTPS发送明文密码,传输过程中密码被SSL/TLS加密,别人拿到也没用。

3.bcrypt是一种专门为密码哈希设计的慢哈希算法。对比MD5/SHA1和SHA256/SHA512安全很多。

func loginPassword(login loginReq) (*model.TbUser, error) {
	//从mysql中判断账号和密码是否正确
	u := query.TbUser
	user, err := u.Where(u.Phone.Eq(login.Phone)).Select(u.Password).First()
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			// return response.NewBusinessError(response.ErrLoginFailed, "该用户不存在")
			return nil, response.NewBusinessError(response.ErrLoginFailed, "用户名或密码错误")
		}
		return nil, response.WrapBusinessError(response.ErrDatabase, err, "")
	}
	// 2. 校验密码(数据库存储 BCrypt 哈希值)
	err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(login.Password))
	if err != nil {
		if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
			return nil, response.NewBusinessError(response.ErrLoginFailed, "用户名或密码错误")
		}
		// response.Error(c, response.ErrPasswordIncorrect, "密码错误")
		return nil, response.WrapBusinessError(response.ErrUnknown, err, "")
	}

	//用户不存在和密码错误若是返回了不同的错误信息:1.用户不存在:"该用户不存在";2密码错误:"密码错误"
	//从安全角度考虑,这种区别可能被恶意用户利用来枚举系统中的有效账户。建议将两种情况都返回相同的通用错误信息。
	return user, nil
}

对接口进行访问控制,保持登录状态

用户登录后,如何让系统 “记住” 身份,避免重复登录?
开放的 API 接口,若不加限制,任何人知道地址就能调用,存在被滥用、数据泄露风险 ——需对接口做访问控制,保障安全同时维持登录态

    为什么选 JWT?

    JWT(JSON Web Token)是跨域认证的流行方案,本质是规范化的 Token

    • 自身包含身份信息,服务端无需存储 Session,契合 RESTful API “无状态” 设计;
    • 通过 Authorization: Bearer <token> 头传递,简洁通用。

    对比传统 Session 方案:

    • Session:服务端需存储会话,多实例部署时共享 Session 复杂,增加服务端压力。
    • JWT:Token 自包含信息,无状态化设计,跨域、集群部署更友好,减轻服务端负担,天生适配 API 场景。

    go语言jwt的用法

    假设jwt原始的payload如下,username,exp为过期时间,nbf为生效时间,iat为签发时间。第一个是业务非敏感参数,后三者是jwt标准的参数。

    {
      "username": "zhangsan",
      "exp": 1681869394,
      "nbf": 1681782994,
      "iat": 1681782994
    }

    创建middleware文件夹,在该文件夹添加jwt.go。添加如下结构体

    type userClaims struct {
    	UserId               int64
    	Phone                string
    	jwt.RegisteredClaims // v5版本新加的方法
    }

    在config.yaml添加关于jwt的配置

    JWT:
      Secret: hello
      Issuer: review-service
      Expire: 7200s  #带单位

    添加关于jwt的配置结构体和变量

    // config.go
    var (
        ..........
    	JwtOption    *JWTSetting
    )
    
    type JWTSetting struct {
    	Secret string
    	Issuer string
    	Expire time.Duration
    }
    
    func InitConfig(path string) {
        ..................
    	err = ReadSection("jwt", &JwtOption)
    	if err != nil {
    		panic(err)
    	}
    }

    生成并解析jwt

    入参就是上面结构体UserClaims中的Phone。

    • 避免在 JWT 的 payload 中存储敏感的用户信息。因为 JWT 通常是可解码的,虽然签名可以保证其完整性,但不能保证其保密性。如果需要存储一些用户相关的信息,可以使用加密的方式存储在服务器端,并在 JWT 中存储一个引用或标识符。
    • 所以要对号码进行加密,或者使用其他不敏感的信息。
    func GenerateToken(phone string, userId int64) (string, error) {
    	claims := userClaims{
    		Phone:  phone,
    		UserId: userId,
    		RegisteredClaims: jwt.RegisteredClaims{
    			ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.JwtOption.Expire)),
    			Issuer:    config.JwtOption.Issuer,
    			NotBefore: jwt.NewNumericDate(time.Now()), //生效时间
    		},
    	}
    
    	//使用指定的加密方式(hs256)和声明类型创建新令牌
    	tokenStruct := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    	//获得完整的签名的令牌
    	return tokenStruct.SignedString(GetJWTSecret())
    }
    
    func ParseToken(token string) (*userClaims, error) {
    	tokenClaims, err := jwt.ParseWithClaims(token, &userClaims{}, func(token *jwt.Token) (any, error) {
    		// 验证签名方法
    		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
    			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
    		}
    		return GetJWTSecret(), nil
    	})
    	if err != nil {
    		return nil, err
    	}
    
    	if tokenClaims != nil {
    		if claims, ok := tokenClaims.Claims.(*userClaims); ok && tokenClaims.Valid {
    			return claims, nil
    		}
    	}
    	return nil, err
    }

    中间件形式使用

    //强制认证的
    func JWT() gin.HandlerFunc {
    	return func(c *gin.Context) {
    		token := c.GetHeader("Authorization")
    		if token == "" {
    			response.Error(c, response.ErrInvalidAuthHeader, "")
    			c.Abort()
    			return
    		}
    
            ...........
    	}
    }

    这种设计存在一个核心问题:无法同时满足强制认证和可选认证的需求

    在实际业务场景中,我们会遇到两种不同的认证需求:

    1. 强制认证场景

    • 用户个人中心、修改密码、发布文章等功能
    • 必须登录才能访问,未登录直接拒绝

    2. 可选认证场景

    • 博客查看、商品浏览等功能
    • 未登录用户可以正常访问基础内容
    • 已登录用户能获得个性化体验(如查看是否点赞过)

    经过分析,我们发现认证过程实际包含两个独立的步骤:

    步骤1:Token解析与用户信息提取

    • 尝试从请求头获取token
    • 解析token并验证有效性
    • 提取用户信息并存储到上下文

    步骤2:认证状态检查

    • 检查用户是否已成功认证
    • 根据业务需求决定是否允许继续访问

    所以我们可以把它分拆成两个函数。 一个就负责Token解析与用户信息提取,另一个就负责认证状态检查。

    const (
    	CtxKeyUserPhone       = "userPhone"
    	CtxKeyUserId          = "userId"
    	CtxKeyIsAuthenticated = "isAuthenticated"
    )
    
    // OptionalJWT 可选的JWT认证中间件,总是尝试解析token
    func OptionalJWT() gin.HandlerFunc {
    	return func(c *gin.Context) {
    		token := c.GetHeader("Authorization")
    		if token == "" {
    			// 未提供token,设置未认证状态
    			c.Set(CtxKeyIsAuthenticated, false)
    			c.Next()
    			return
    		}
    
    		//处理Bearer token格式
    		if len(token) > 7 && token[:7] == "Bearer " {
    			token = token[7:]
    		} else {
    			// token格式错误,设置未认证状态
    			c.Set(CtxKeyIsAuthenticated, false)
    			c.Next()
    			return
    		}
    
    		claims, err := ParseToken(token)
    		if err != nil {
    			// token解析失败,设置未认证状态
    			c.Set(CtxKeyIsAuthenticated, false)
    			c.Next()
    			return
    		}
    
    		// 验证关键字段是否为空
    		if claims.Phone == "" || claims.UserId == 0 {
    			// 字段为空,设置未认证状态
    			c.Set(CtxKeyIsAuthenticated, false)
    			c.Next()
    			return
    		}
    
    		// token有效且字段完整,设置用户信息
    		c.Set(CtxKeyUserPhone, claims.Phone)
    		c.Set(CtxKeyUserId, claims.UserId)
    		c.Set(CtxKeyIsAuthenticated, true)
    		c.Next()
    	}
    }
    
    // RequireAuth 强制认证中间件,检查用户是否已登录
    func RequireAuth() gin.HandlerFunc {
    	return func(c *gin.Context) {
    		isAuthenticated := c.GetBool(CtxKeyIsAuthenticated)
    		if !isAuthenticated {
    			response.Error(c, response.ErrInvalidAuthHeader, "请先登录")
    			c.Abort()
    			return
    		}
    		c.Next()
    	}
    }

    总体实践逻辑

    1. 登录颁发 Token:用户登录校验通过后,服务端生成 JWT(包含用户身份、有效期等),返回给客户端。
    2. 请求携带 Token:客户端每次调用需鉴权的 API 时,在请求头加入 Authorization: Bearer <token>。
    3. 服务端验证 Token:拦截请求,校验 JWT 合法性(签名、有效期等),合法则放行,非法则拒绝。

    那就需要修改登录回复的流程,登录成功,服务端就返回该token,后续该客户使用的时候都要带上该token。

    func Login(c *gin.Context) {
        ..............
    	//根据参数判断登录方式
    	if loginRequest.Password != "" {
    		err = loginPassword(loginRequest)
    	} else if loginRequest.Code != "" {
    		err = loginCode(loginRequest)
    	} else {
    		response.Error(c, response.ErrValidation, "login method is invalid")
    		return
    	}
    
    	if err != nil {
    		response.HandleBusinessError(c, err)
    		return
    	}
        //添加生成token
    	token, err := middleware.GenerateToken(loginRequest.Phone, int64(user.ID))
    	if err != nil {
    		slog.Error("generate token bad", "err", err)
    		response.Error(c, response.ErrLoginFailed, "")
    		return
    	}
    	response.Success(c, gin.H{
    		"token": token,
    		"user": userResponse{
    			ID:       user.ID,
    			Phone:    user.Phone,
    			NickName: user.NickName,
    			Icon:     user.Icon,
    		},
    	})
    	// response.Success(c, gin.H{
    	// 	"user": userResponse{	//返回给前端展示或者缓存的用户信息
    	// 		ID:       user.ID,
    	// 		Phone:    user.Phone,
    	// 		NickName: user.NickName,
    	// 		Icon:     user.Icon,
    	// 	},
    	// })
    }

    在router.go中使用JWT中间件。但是目前的业务都是不需要进行jwt认证的。所以其使用在下一章节再展示了。

    func NewRouter() *gin.Engine {
    	gin.SetMode(gin.ReleaseMode)
    	r := gin.Default()
    	r.NoRoute(HandleNotFound)
    	r.GET("/ping", func(c *gin.Context) {
    		response.Success(c, "pong")
    	})
    
    	public := r.Group("/api/v1")
    	public.Use(middleware.OptionalJWT())
    	{
    		public.POST("/send-code", user.SendCode)
    		public.POST("/login", user.Login)
    	}
    
    	auth := r.Group("/api/v1")
    	// 强制认证:token解析 + 认证检查
    	auth.Use(middleware.OptionalJWT(), middleware.RequireAuth())
    	{
    	}
    
    	return r
    }
    
    func HandleNotFound(c *gin.Context) {
    	response.Error(c, response.ErrNotFound, "route not found")
    }

    登录成功后,用户每次发送请求都需要在header中添加token,值是服务器端返回的token。

    Logo

    有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

    更多推荐