项目地址:https://github.com/liwook/PublicReview.git
登录有两种方式:
- 通过手机号码发送验证码登录。
- 另一种是通过密码进行登录。
通过验证码登录的话,服务端就要存储该手机号码的验证码,这就是典型的键值对(一个号码对应一个验证码),还有要给验证码设置过期时间,所以可以存储在Redis中。
Go语言连接使用Redis
在config.yaml添加Redis的内容
Redis:Host: 43.139.27.107:6379Password: wook1847PoolSize: 20
#.yaml文件添加的时候要留意,可能添加的格式不对导致程序访问不到配置的
#通过颜色来区分是否有错误。Host: 这个后面是需要空一格,颜色才正确,格式才对
在config.go文件添加Redis配置的结构体。
var (RedisOption *RedisSetting
)type RedisSetting struct {Host stringPassword stringPoolSize 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.Clientfunc NewRedisClient(config *config.RedisSetting) (*redis.Client, error) {client := redis.NewClient(&redis.Options{Addr: config.Host, //自己的redis实例的ip和portPassword: config.Password, //密码,有设置的话,就需要填写PoolSize: config.PoolSize, //最大的可连接数量})val, err := client.Ping(context.Background()).Result() //测试pingif err != nil {return nil, err}fmt.Println("redis测试: ", val)return client, err
}
在main.go中进行创建redis客户端。
func init() {............................//初始化redisdb.RedisClient, err = db.NewRedisClient(config.RedisOption)if err != nil {panic(err)}
}
添加关于登录的函数
在internal目录创建user目录,添加login.go文件。
1.获取验证码的函数
步骤:
- 判断手机号是否合法
- 生成验证码,并使用redis的string类型保存在redis中,需设置过期时间
- 把验证码发送给客户
const (UserNickNamePrefix = "user"phoneKey = "phone:"loginMethod = "loginMethod"
)// 得到验证码
// get /user/verificationcode/:phone
func GetVerificationCode(c *gin.Context) {phone := c.Param("phone")if phone == "" || !isPhoneInvalid(phone) {code.WriteResponse(c, code.ErrValidation, "phone is empty or invalid")return}//生成验证码,6位数num := rand.Intn(1000000) + 100000//用redis的string类型保存key := phoneKey + phonesuccess, err := db.RedisClient.SetNX(context.Background(), key, num, 4*time.Minute).Result()if !success || err != nil {code.WriteResponse(c, code.ErrDatabase, nil)return}code.WriteResponse(c, code.ErrSuccess, gin.H{"VerificationCode": num})
}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格式,存储在请求体中。
const (UserNickNamePrefix = "user"phoneKey = "phone:"
)type LoginRequest struct {Phone string `json:"name" binding:"required"`CodeOrPwd string `json:"codeOrPwd" binding:"required"`LoginMethod string `json:"loginMethod" binding:"required"`
}// post /user/login
func Login(c *gin.Context) {var login LoginRequesterr := c.BindJSON(&login)if err != nil {slog.Error("codelogin bind bad", "err", err)code.WriteResponse(c, code.ErrBind, nil)return}if !isPhoneInvalid(login.Phone) {code.WriteResponse(c, code.ErrValidation, "phone is invalid")return}switch login.LoginMethod {case "code":loginCode(c, login, token)case "password":loginPassword(c, login, token)default:code.WriteResponse(c, code.ErrValidation, "loginMethod bad")}
}
验证码登录
- 从redis中得到phone保存的验证码进行对比
- 之后从MySQL中判断该用户是否是新用户,若是新用户,就需要创建用户,存储到数据库中
- 发送给客户端登录成功。
func loginCode(c *gin.Context, login LoginRequest) {//为空是返回error中的,值为redis.Nil//对比号码是否有验证码val, err := db.RedisClient.Get(context.Background(), phoneKey+login.Phone).Result()if err == redis.Nil {code.WriteResponse(c, code.ErrExpired, "验证码过期或没有该验证码")return}if err != nil {slog.Error("redis get bad", "err", err)code.WriteResponse(c, code.ErrDatabase, nil)return}if val != login.CodeOrPwd {code.WriteResponse(c, code.ErrExpired, "验证码错误")return}//之后判断是否是新用户,若是新用户,就创建u := query.TbUsercount, err := u.Where(u.Phone.Eq(login.Phone)).Count()if err != nil {slog.Error("find by phone bad", "err", err)code.WriteResponse(c, code.ErrDatabase, nil)return}if count == 0 {err := u.Create(&model.TbUser{Phone: login.Phone, NickName: UserNickNamePrefix + strconv.Itoa(rand.Intn(100000))})if err != nil {slog.Error("create user failed", "err", err)code.WriteResponse(c, code.ErrDatabase, "create user failed")return}}code.WriteResponse(c, code.ErrSuccess, "login success")
}
账号密码登录
在数据库中判断发送过来的phone和password是否正确,若正确,回复登录成功;否则回复登录失败
func loginPassword(c *gin.Context, login LoginRequest) {if login.Password == "" {code.WriteResponse(c, code.ErrValidation, "password is empty")return}//从mysql中判断账号和密码是否正确u := query.TbUsercount, err := u.Where(u.Phone.Eq(login.Phone), u.Password.Eq(login.CodeOrPwd)).Count()if err != nil {slog.Error("find by phone and password bad", "err", err)code.WriteResponse(c, code.ErrDatabase, nil)return}if count == 0 {code.WriteResponse(c, code.ErrPasswordIncorrect, "phone or password is Incorrect")return}code.WriteResponse(c, code.ErrSuccess, "login success")
}
对接口进行访问控制,保存登录状态
大家在使用软件的时候,一般是登录一次,以后多次使用或者在一段时间内是不用再次登录的。这个是怎么做到的呢?在网页登录后,每次请求都会带有可以证明该客户端身份的token。服务端会进行判断,从而每次请求正常。
还有,在完成了相关的业务接口的开发后,我们正打算放到服务器上给其他同事查看时,你又想到了一个问题,这些 API 接口,没有鉴权功能,那就是所有知道地址的人都可以请求该项目的 API 接口,甚至有可能会被网络上的端口扫描器扫描到后滥用,这非常的不安全,怎么办呢。实际上,我们应该要考虑做纵深防御,对 API 接口进行访问控制。
这里就可以用到JWT。
JWT
JSON Web 令牌(JWT)是一个开放标准(RFC7519),它定义了一种紧凑且自包含的方式,用于在各方之间作为 JSON 对象安全地传输信息。 由于此信息是经过数字签名的,因此可以被验证和信任。 可以使用使用 RSA 或 ECDSA 的公用/专用密钥对对 JWT 进行签名。
jwt的结构体
假设jwt原始的payload如下,username,exp为过期时间,nbf为生效时间,iat为签发时间。第一个是业务非敏感参数,后三者是jwt标准的参数。
{"username": "zhangsan","exp": 1681869394,"nbf": 1681782994,"iat": 1681782994
}
创建internal/middleware文件夹,在该文件夹添加jwt.go。添加如下结构体
type UserClaims struct {Phone stringjwt.RegisteredClaims // v5版本新加的方法
}
在config.yaml添加关于jwt的配置
JWT:Secret: helloIssuer: dianping-serviceExpire: 7200 #秒
添加关于jwt的配置结构体和变量
// config.go
var (..........JwtOption *JWTSetting
)type JWTSetting struct {Secret stringIssuer stringExpire time.Duration
}func InitConfig(path string) {..................err = ReadSection("jwt", &JwtOption)if err != nil {panic(err)}
}
生成并解析jwt
入参就是上面结构体UserClaims中的Phone。
- 避免在 JWT 的 payload 中存储敏感的用户信息。因为 JWT 通常是可解码的,虽然签名可以保证其完整性,但不能保证其保密性。如果需要存储一些用户相关的信息,可以使用加密的方式存储在服务器端,并在 JWT 中存储一个引用或标识符。
- 所以要对号码进行加密,或者使用其他不敏感的信息。
func GetJWTSecret() []byte {return []byte(config.JwtOption.Secret)
}func GenerateToken(phone string) (string, error) {//sha1加密phonehash := sha1.New()hash.Write([]byte(phone))claims := UserClaims{Phone: string(hash.Sum(nil)),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) {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
}
使用形式
以中间件形式使用。要注意的一点是登录和获取验证码是不用JWT验证的。
func JWT() gin.HandlerFunc {return func(c *gin.Context) {//登录和获取验证码是不用JWT验证的if c.Request.RequestURI == "/user/login" || c.Request.RequestURI == "/user/getcode" {return}ecode := code.ErrSuccesstoken := c.GetHeader("token")if token == "" {ecode = code.ErrInvalidAuthHeader} else {_, err := ParseToken(token)if err != nil {ecode = code.ErrTokenInvalid}}if ecode != code.ErrSuccess {code.WriteResponse(c, ecode, nil)c.Abort()return}c.Next()}
}
使用jwt
那就需要修改登录回复的流程,登录成功,服务端就返回该token,后续该客户使用的时候都要带上该token。
func loginCode(c *gin.Context, login LoginRequest) {..................if count == 0 {err := u.Create(&model.TbUser{Phone: login.Phone, NickName: UserNickNamePrefix + strconv.Itoa(rand.Intn(100000))})if err != nil {slog.Error("create user failed", "err", err)code.WriteResponse(c, code.ErrDatabase, "create user failed")return}}generateTokenResponse(c, login.Phone)
}func loginPassword(c *gin.Context, login LoginRequest) {//从mysql中判断账号和密码是否正确...................if count == 0 {code.WriteResponse(c, code.ErrPasswordIncorrect, "phone or password is Incorrect")return}generateTokenResponse(c, login.Phone)
}func generateTokenResponse(c *gin.Context, phone string) {token, err := middleware.GenerateToken(phone)if err != nil {slog.Error("generate token bad", "err", err)code.WriteResponse(c, code.ErrTokenGenerationFailed, nil)return}code.WriteResponse(c, code.ErrSuccess, gin.H{"token": token})
}
在router.go中使用JWT中间件。
func NewRouter() *gin.Engine {gin.SetMode(gin.ReleaseMode)r := gin.Default()r.Use(middleware.JWT()) //使用jwt中间件r.GET("/ping", func(c *gin.Context) {code.WriteResponse(c, code.ErrSuccess, "pong")})r.GET("/user/verificationcode/:phone", user.GetVerificationCode)r.POST("/user/login", user.Login)return r
}
登录成功后,用户每次发送请求都需要在header中添加token,值是服务器端返回的token。