项目地址:https://github.com/liwook/PublicReview
登录有两种方式:
- 通过手机号码发送验证码登录。
- 另一种是通过密码进行登录。
通过验证码登录的话,服务端就要存储该手机号码的验证码,这就是典型的键值对(一个号码对应一个验证码),还有要给验证码设置过期时间,所以可以存储在Redis中。
Go语言连接使用Redis
在config.yaml添加Redis的内容
Redis:
Host: 43.139.27.107:6379
Password: wook1847
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)
}
}
添加关于登录的函数
在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 + phone
success, 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 LoginRequest
err := 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.TbUser
count, 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.TbUser
count, 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
JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。
JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。
可以看出,JWT 更符合设计 RESTful API 时的「Stateless(无状态)」原则 。
jwt的结构体
假设jwt原始的payload如下,username,exp为过期时间,nbf为生效时间,iat为签发时间。第一个是业务非敏感参数,后三者是jwt标准的参数。
{
"username": "zhangsan",
"exp": 1681869394,
"nbf": 1681782994,
"iat": 1681782994
}
创建internal/middleware文件夹,在该文件夹添加jwt.go。添加如下结构体
type UserClaims struct {
Phone string
jwt.RegisteredClaims // v5版本新加的方法
}
在config.yaml添加关于jwt的配置
JWT:
Secret: hello
Issuer: dianping-service
Expire: 7200 #秒
添加关于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 GetJWTSecret() []byte {
return []byte(config.JwtOption.Secret)
}
func GenerateToken(phone string) (string, error) {
//sha1加密phone
hash := 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.ErrSuccess
token := 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。