2.登录业务
登录有两种方式,一种是通过手机号码发送验证码登录,另一种是通过账号密码进行登录。通过验证码登录的话,服务端就要存储该手机号码的验证码,这就键值对,还有要给验证码设置过期时间,这个就可以存储在Redis中。在config.yaml添加Redis的内容在config.go文件添加Redis配置的结构体。在db目录创建redis.go文件。使用一个常用的go在main.go中进行创建redis客户端。
项目地址: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.获取验证码的函数
步骤:
- 判断手机号是否合法(前端也可以做这个判断)
- 生成验证码,并使用redis的string类型保存在redis中,需设置过期时间
- 把验证码发送给客户
// 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"`
}
验证码登录
- 从redis中得到phone保存的验证码进行对比
- 从MySQL中判断该用户是否是新用户,若是新用户,就需要创建用户,存储到数据库中
- 发送给客户端登录成功。
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()
}
}
总体实践逻辑
- 登录颁发 Token:用户登录校验通过后,服务端生成 JWT(包含用户身份、有效期等),返回给客户端。
- 请求携带 Token:客户端每次调用需鉴权的 API 时,在请求头加入 Authorization: Bearer <token>。
- 服务端验证 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。
更多推荐



所有评论(0)