基于Session和Redis实现短信验证码登录
基于Session和Redis实现短信验证码登录
一. 基于Session实现短信验证码登录
1.1 发送短信验证码
//基本流程
提交手机号->校验手机号->如果符合,生成验证码->保存验证码->发送验证码|不符合返回到提交环节
代码模拟
package serviceimport ("log""math/rand""net/http""regexp""time""github.com/gin-gonic/gin"
)type User struct {Phone stringNickName string
}// 模拟数据库
var userDB = make(map[string]*User)// 常量定义
const (VerifyCodeKey = "verify_code"LoginUserKey = "login_user"
)// 工具函数:手机号校验
func IsPhoneInvalid(phone string) bool {reg := regexp.MustCompile(`^1[3-9]\d{9}$`)return !reg.MatchString(phone)
}// 工具函数:生成6位随机验证码
func RandomCode() string {rand.Seed(time.Now().UnixNano())return fmt.Sprintf("%06d", rand.Intn(1000000))
}// ---------------------- Handler ----------------------// 发送验证码
func SendCodeHandler(c *gin.Context) {phone := c.Query("phone")// 1. 校验手机号if IsPhoneInvalid(phone) {c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})return}// 2. 生成验证码并保存到 sessioncode := RandomCode()session := sessions.Default(c)session.Set(VerifyCodeKey, code)session.Save()log.Printf("验证码: %s", code)// 3. 返回结果c.JSON(http.StatusOK, gin.H{"msg": "验证码已发送"})
}// 登录
func LoginHandler(c *gin.Context) {type LoginForm struct {Phone string `json:"phone"`Code string `json:"code"`}var form LoginFormif err := c.ShouldBindJSON(&form); err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})return}// 1. 校验手机号if IsPhoneInvalid(form.Phone) {c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})return}// 2. 验证验证码session := sessions.Default(c)sessionCode := session.Get(VerifyCodeKey)if sessionCode == nil || form.Code != sessionCode.(string) {c.JSON(http.StatusBadRequest, gin.H{"error": "验证码不正确"})return}// 3. 查询用户是否存在user, ok := userDB[form.Phone]if !ok {user = &User{Phone: form.Phone,NickName: "user_" + RandomString(8),}userDB[form.Phone] = user}// 4. 保存登录状态session.Set(LoginUserKey, user.Phone)session.Save()c.JSON(http.StatusOK, gin.H{"msg": "登录成功","user": user,})
}// 生成随机昵称,用于创建用户
func RandomString(n int) string {letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")rand.Seed(time.Now().UnixNano())s := make([]rune, n)for i := range s {s[i] = letters[rand.Intn(len(letters))]}return string(s)
}
1.手机号校验函数分析
func IsPhoneInvalid(phone string) bool {reg := regexp.MustCompile(`^1[3-9]\d{9}$`)return !reg.MatchString(phone)
}
//regexp.MustCompile(...) 处理正则表达式,MustCompile 会编译正则表达式字符串;如果正则表达式写错,会在程序启动时直接 panic;
//reg.MatchString(phone) 表示用这个正则去匹配传入的字符串 phone,返回 true 或 false。//取反的原因是:函数名叫 IsPhoneInvalid(是否非法),所以当匹配成功(合法)时要返回 false,当匹配失败(非法)时返回 true。
这里运用了正则表达式,这里简单介绍一下各部分含义
^1[3-9]\d{9}$
逐个解释:
符号 含义 ^
匹配字符串开始(防止前面有空格或其他字符) 1
手机号第一位必须是 1 [3-9]
第二位只能是 3、4、5、6、7、8、9 中的一个 \d{9}
后面必须是 9 位数字( \d
表示 0-9,{9}
表示重复9次)$
匹配字符串结束(防止后面多字符)
- 生成6位数字验证码函数分析
func RandomCode() string {rand.Seed(time.Now().UnixNano())return fmt.Sprintf("%06d", rand.Intn(1000000))
}
/*rand.Seed(time.Now().UnixNano())Go 的 math/rand 是伪随机数生成器。
如果不设置种子 (Seed),它每次运行都会生成相同的随机序列。所以我们每次调用前用当前时间作为种子,让结果每次不同:time.Now().UnixNano() // 当前时间的纳秒数这行代码相当于:“告诉随机数引擎:用当前时间纳秒作为随机种子。”这样每次执行都会得到不同的随机序列。rand.Intn(1000000)rand.Intn(n) 会返回一个 [0, n) 区间内的整数。这里传入 1000000,即 0 ≤ 随机数 < 1000000。fmt.Sprintf("%06d", rand.Intn(1000000))这一行的作用是把数字格式化成固定长度 6 位的字符串,不足前面补零。
*/
3.生成验证码部分函数
code := RandomCode()//调用之前的函数生成随机6位验证码session := sessions.Default(c)//取出当前http请求的Session对象session.Set(VerifyCodeKey, code)//存入session中,用于登录时对比session.Save()//把刚才设置的session数据写入存储,实现数据持久化
- 生成随机昵称部分函数
// 生成随机昵称
func RandomString(n int) string {letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")rand.Seed(time.Now().UnixNano())s := make([]rune, n)for i := range s {s[i] = letters[rand.Intn(len(letters))]/*遍历 s 的每一个索引。
rand.Intn(len(letters)) 会生成一个 0 ~ len(letters)-1 的随机整数。
用这个随机索引去 letters 里取一个字符,赋值给 s[i]。*/}return string(s)
}//这里首先定义一个rune类型的切片存储所有可能的字符,然后设置随机数避免每次运行都得到相同的值,再创建一个切片用于存放随机字符,最后是用for循环使得切片的每一个位置都能存储一个随机字符
1.2 登录拦截器
//校验登录状态
1.从session中获取用户
2.判断用户是否存在,若不存在进行拦截
3.若存在,保存到context中
这里使用gin框架写一个中间件函数用于拦截
func LoginRequired() gin.HandlerFunc {return func(c *gin.Context) {session := sessions.Default(c)user := session.Get("user")if user == nil {c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})c.Abort()return}//保存用户到 Contextc.Set("user", user)// 放行c.Next()}
}
1.3 数据脱敏
脱敏(Desensitization) 的目的:
防止敏感信息在日志、接口返回或数据库中被暴露。
常见的脱敏字段:
- 手机号
- 邮箱
- 身份证
- 用户名 / 姓名
- 密码:绝对不能返回
只要系统对外提供接口、写日志、暴露数据,都需要脱敏。
语言不同,但原则一样。
可以写一个脱敏函数
type User struct {ID int64 `json:"id"`Phone string `json:"phone"`NickName string `json:"nick_name"`
}// 脱敏函数
func (u *User) Desensitize() {if len(u.Phone) >= 7 {u.Phone = u.Phone[:3] + "****" + u.Phone[7:]}
}// 登录返回时使用
func LoginHandler(c *gin.Context) {user := User{ID: 1, Phone: "13812345678", NickName: "cr7xin"}user.Desensitize()c.JSON(200, gin.H{"user": user})
}
也可以定义一个类型和方法,可用于全局
type Phone string//这里定义了一个新的类型Phone,底层类型是string,因为之后我们需要绑定脱敏的方法,如果单纯定义string类型无法绑定方法func (p Phone) MarshalJSON() ([]byte, error) {
//这里实现了MarshalJSON() 方法,当我们的类型被json.Marshal() 序列化时,就会自动调用定义的这个方法s := string(p)//需要拿到实际内容,强转为普通字符串if len(s) >= 7 {s = s[:3] + "****" + s[7:]//逻辑即为保留前三位和后四位,用*替换中间部分}return json.Marshal(s)//转换为标准的JSON格式
}type User struct {ID int64 `json:"id"`Phone Phone `json:"phone"`NickName string `json:"nick_name"`
}
1.4 Session集群共享问题
当你的应用部署成多台服务器(集群)时,用户的 Session 存在单台机器的内存里,导致不同机器之间无法共享登录状态。
如何解决?
这里推荐使用Redis缓存的方法,把 Session 存放到 Redis,所有节点共用同一个 Redis,不论请求打到哪台服务器,都会去 Redis 拿 Session
Redis 相较于传统 Session 的 6 大核心优点
维度 传统 Session(内存) Redis Session 1.集群共享 每台服务器独立维护 所有服务器共享同一份 Session 2. 可扩展性 增加节点会导致 Session 不一致 新节点自动共享,无需额外同步 3. 高可用性 服务重启 Session 丢失 Redis 可持久化 + 主从备份 4. 性能 访问快(本地内存) 接近内存访问速度(Redis内存级) 5. 可控性 Session 生命周期受进程影响 TTL 由 Redis 控制,可动态调整 6. 存储容量 受限于单机内存 Redis 可分布式扩容(Cluster 模式)
二. 基于Redis实现短信验证码的登录
//在短信验证码登录注册环节
1.提交手机号时以手机号为key读取验证码
2.保存用户时以随机token为key存储用户数据
3.返回token//校验登录环节
1.拿到请求中的token
2.以随机token为key获取用户数据
3.判断逻辑同上
这里使用hash结构,可以灵活对单个字段进行查删
package Redisimport ("context""fmt""log""math/rand""net/http""regexp""time""github.com/gin-gonic/gin""github.com/redis/go-redis/v9"
)var ctx = context.Background()
var rdb *redis.Client// 初始化 redis 客户端
func initRedis() {rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379",Password: "", // 无密码填空DB: 0,})if err := rdb.Ping(ctx).Err(); err != nil {panic(fmt.Sprintf("Redis连接失败: %v", err))}
}type User struct {Phone string `json:"phone"`NickName string `json:"nickname"`
}// 模拟数据库
var userDB = make(map[string]*User)// 工具函数:手机号校验
func IsPhoneInvalid(phone string) bool {reg := regexp.MustCompile(`^1[3-9]\\d{9}$`)return !reg.MatchString(phone)
}// 工具函数:随机验证码
func RandomCode() string {rand.Seed(time.Now().UnixNano())return fmt.Sprintf("%06d", rand.Intn(1000000))
}// 工具函数:随机昵称
func RandomString(n int) string {letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")rand.Seed(time.Now().UnixNano())s := make([]rune, n)for i := range s {s[i] = letters[rand.Intn(len(letters))]}return string(s)
}// ----------------- handler -----------------// 发送验证码
func SendCodeHandler(c *gin.Context) {phone := c.Query("phone")if IsPhoneInvalid(phone) {c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})return}code := RandomCode()// 保存到 Redis,key = login:code:<手机号>,有效期 5 分钟err := rdb.Set(ctx, "login:code:"+phone, code, 5*time.Minute).Err()if err != nil {c.JSON(http.StatusInternalServerError, gin.H{"error": "Redis 写入失败"})return}log.Printf("验证码: %s", code)c.JSON(http.StatusOK, gin.H{"msg": "验证码已发送"})
}// 登录接口
func LoginHandler(c *gin.Context) {var form struct {Phone string `json:"phone"`Code string `json:"code"`}if err := c.ShouldBindJSON(&form); err != nil {c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})return}if IsPhoneInvalid(form.Phone) {c.JSON(http.StatusBadRequest, gin.H{"error": "手机号格式不正确"})return}// 从 Redis 取验证码val, err := rdb.Get(ctx, "login:code:"+form.Phone).Result()if err == redis.Nil || val != form.Code {c.JSON(http.StatusBadRequest, gin.H{"error": "验证码不正确或已过期"})return}// 验证通过后删除验证码(防止复用)rdb.Del(ctx, "login:code:"+form.Phone)// 模拟数据库查找用户user, ok := userDB[form.Phone]if !ok {user = &User{Phone: form.Phone,NickName: "user_" + RandomString(8),}userDB[form.Phone] = user}// 生成登录凭证(这里简化为打印)log.Printf("用户 %s 登录成功", user.Phone)c.JSON(http.StatusOK, gin.H{"msg": "登录成功","user": user,})
}/*func main() {initRedis()r := gin.Default()r.GET("/send_code", SendCodeHandler)r.POST("/login", LoginHandler)r.Run(":8080")
}*/
2.1 配置刷新token的拦截器
为了防止在操作网站时突然由于Redis中的token过期,这里把刷新的操作放在拦截器中
func RefreshToken() gin.HandlerFunc {return func(c *gin.Context) {ctx := context.Background()token := c.GetHeader("Authorization")//从请求头中获取tokenif token == "" {c.Next() // 没 token 不做刷新,交给 LoginRequired 拦截return}userID, err := rdb.Get(ctx, token).Result()if err == nil {// 刷新 Redis TTLrdb.Expire(ctx, token, 30*time.Minute)// 将用户信息放入 contextc.Set("userID", userID)}c.Next() // 放行到下一个中间件或 handler}
}
注册中间件顺序
请求 → RefreshToken() → LoginRequired() → Handler| |负责拦截一切路径 拦截需要登录的路径
authGroup := r.Group("/api")
authGroup.Use(RefreshToken()) // 先刷新 TTL 并把用户信息放入 context
authGroup.Use(LoginRequired()) // 再判断是否登录
{authGroup.GET("/userinfo", UserInfoHandler)
}
思路总结
1.拦截器
如果采用Redis缓存,我们需要配置两个拦截器,第一个拦截器负责刷新token,用于一切路径,第二个用于拦截用户不存在的的请求
2.核心函数部分
1.首先我们通过querystring参数拿到手机号进行校验,检查手机号格式是否合法,这里用regexp.MustCompile函数接收正则表达式进行检验
2.然后我们通过
rand.Seed(time.Now().UnixNano())
生成随机验证码保存到redis中并发送3.根据手机号从redis中取到相应的验证码验证,通过后删除验证码
4.如果是注册用户,生成随机昵称
5.如果最后返回手机号等私密信息,需要进行数据脱敏