Go工程化实践:设计一个注册登录服务

最近项目要做个管理后台,这边使用Go做了个简单的注册登录框架,本文记录一下这个注册登录通用框架的设计和实现过程。这个注册登录服务有以下特点:

  • 用session来管理登录态
  • 基于redis来管理session
  • 支持黑名单、分布式限流中间件
  • xorm操作mysql
  • viper管理配置文件
  • gin为web server框架

项目地址:

数据结构和数据库表设计

User结构体

type User struct {
	Uid    int64  `xorm:"pk autoincr"`     //主键,UID
	Name   string `xorm:"UNIQUE NOT NULL"` // 用户名,唯一键
	Email  string `xorm:"UNIQUE NOT NULL"` // 邮箱,唯一键
	Passwd string `xorm:"NOT NULL"`        //已使用Salt进行加密的密码串MD5(原始password+Salt)
	Salt string `xorm:"VARCHAR(32)"` // 用于密码加盐哈希,注册时随机生成
	CreatedUnix   int64 `xorm:"INDEX created"` // 账号创建时间
	UpdatedUnix   int64 `xorm:"INDEX updated"` // 账号更新时间
	LastLoginUnix int64 `xorm:"INDEX"`         // 账号上次登录时间
	IsAdmin bool `xorm:"NOT NULL DEFAULT false"` // 管理员标记
	ProhibitLogin bool `xorm:"NOT NULL DEFAULT false"` // 禁止登录标记
	LastLoginIp string `xorm:"VARCHAR(32) INDEX"` // 登录Ip


数据库表user

CREATE TABLE IF NOT EXISTS `user` (
	`uid` int unsigned NOT NULL AUTO_INCREMENT,
	`email` varchar(128) NOT




    
 NULL DEFAULT '',
	`name` varchar(20) NOT NULL COMMENT '用户名',
	`salt` char(32) NOT NULL DEFAULT '' COMMENT '加密随机数',
	`passwd` char(32) NOT NULL DEFAULT '' COMMENT 'md5密码',
	`last_login_ip` varchar(31) NOT NULL DEFAULT '' COMMENT '最后登录 IP',
	`last_login_unix` int unsigned COMMENT '最后一次登录时间(主动登录)',
	`created_unix` int unsigned COMMENT '账号创建时间',
	`updated_unix` int unsigned COMMENT '账号数据修改时间',
	`is_admin` BOOLEAN DEFAULT FALSE COMMENT '管理员标记',
	`prohibit_login` BOOLEAN DEFAULT FALSE COMMENT '禁止登录标记',
	PRIMARY KEY (`uid`),
	UNIQUE KEY (`name`),
	UNIQUE KEY (`email`),
	KEY `logintime` (`last_login_unix`)
  ) ENGINE=InnoDB AUTO_INCREMENT 1000000 DEFAULT CHARSET=utf8mb4 COMMENT '用户表';

几个要点:

  • uid为主键,从1000000开始递增。
  • 用户名name,邮箱email是唯一索引。
  • 密码passwd非明文存储,是经过加盐后做的加密存储。
  • last_login_ip可以用于校验本次登录是否异地登录,用于提示用户登录安全。

注册设计

用户注册时,我们设定用户必须填入以下内容:

  • 账户名
  • 密码
  • 密码再次输入
  • 邮箱

注册界面设计如下:


注册时的流程如下:

  1. 用户提交填写的账户名、密码、再次输入密码、邮箱。
  2. 服务器检验通过IP是否位于黑名单中,是则拦截。
  3. 服务器检验通过IP检查注册请求的频率,请求频率限制在1分钟10次。
  4. 校验用户名:用户名不区分大小写,统一转为小写来进行校验,检查是否被占用、长度是否合法、用户名是否是系统保留的用户名(类似admin的用户名是不允许的)、是否包含违规敏感词。
  5. 检验邮箱:是否是邮箱地址(得包含@)、检查是否被占用、长度是否合法。
  6. 检验密码和再次输入密码是否一致。
  7. 检验通过,生成默认的用户数据实例,写入用户用户名、password、随机生成的salt,salt+passsword计算MD5串,邮箱、创建时间。
  8. 把user结构体插入数据库user表,user表中的username、email都是unique key,如果有重复则会报错。
  9. 返回数据库user表自增主键作为用户的UID。我们可以把自增主键的初始位置可以设置为100000。
  10. 注册成功后,跳转到登录页面。


具体代码实现:

1. 分布式限流

分布式限流的组件使用redis,通过reids lua脚本保证原子性,这里把限流器封装为gin中间件。

//限流,粒度分为IP
func CommonRateLimit() gin.HandlerFunc {
	return func(c *gin.Context) {
		clientIP := c.ClientIP()
		if IsReachLimit(clientIP, "ip") {
			WriteResponseWithCode(c, "访问次数过多", nil, http.StatusTooManyRequests)
			c.Abort()
			return
		c.Next()
		return
needLimit.Use(CommonRateLimit()) // 频率控制


2. 用户名有效性检查

用户名的有效性检查包括:

  • 是否含有特殊字符:可通过正则判断
  • 是否长度合法
  • 是否含有暴力色情等违规字符:可通过敏感字库过滤
var nameMatch = regexp.MustCompile(`\A((@[^\s\/~'!\(\)\*]+?)[\/])?([^_.][^\s\/~'!\(\)\*]+)\z`)
func IsValidName(name string) error {
	if strings.TrimSpace(name) != name {
		return errors.New("username contains space")
	if len(name) == 0 || len(name) > Conf.Common.MaxUsernameLen {
		return errors.New("username invalid len")
	if !nameMatch.MatchString(name) {
		return errors.New("username invalid pattern")
	return nil


3. 密码有效性检查

密码有效性检查包括:

  • 是否长度合法
  • 是否足够复杂:密码复杂度可以自己定义,比如必须包含大小写、数字、特殊字符等
func IsValidPasswd(passwd string) error {
	if len(passwd) < Conf.Common.MinPasswordLength {
		return errors.New("password too short")
	if !IsComplexEnough(passwd) {
		return errors.New("password too simple")
	return nil
charComplexities = map[string]complexity{
    "lower": {
        `abcdefghijklmnopqrstuvwxyz`,
        "form.password_lowercase_one",
    "upper": {
        `ABCDEFGHIJKLMNOPQRSTUVWXYZ`,
        "form.password_uppercase_one",
    "digit": {
        `0123456789`,
        "form.password_digit_one",
    "spec": {
        ` !"#$%&'()*+,-./:;<=>?@[\]^_{|}~` + "`",
        "form.password_special_one",
func IsComplexEnough(pwd string) bool {
	NewComplexity()
	if len(validChars) > 0 {
		for _, req := range requiredList {
			if !strings.ContainsAny(req.ValidChars, pwd) {
				return false
	return true


4. 用户初始化

func CreateUser(username, password, email string) *User {
	newUser := User{}
	newUser.Email = email
	newUser.Name = username
	newUser.Salt = GenRandomSalt()
	newUser.Passwd = GenMD5WithSalt(password, newUser.Salt)
	newUser.CreatedUnix = time.Now().Unix()
	newUser.UpdatedUnix = time.Now().Unix()
	newUser




    
.IsAdmin = false
	newUser.ProhibitLogin = false
	return &newUser

4. 密码加密存储

加密密码 = MD5(原密码+salt)

func GenMD5WithSalt(passwd, salt string) string {
	s := passwd + "::" + salt
	md5Hash := md5.New()
	md5Hash.Write([]byte(s))
	// 转16进制
	return hex.EncodeToString(md5Hash.Sum(nil))

登录设计

用户账密登录获得登录态的流程:

  1. 用户提交用户名和密码
  2. 服务器检验通过IP是否位于黑名单中,是则拦截。
  3. 服务器检验通过IP检查注册请求的频率,请求频率限制在1分钟10次。
  4. 检查账号和密码格式是否正确,其中包含检测长度、是否包含特殊字符等。
  5. 前面检查通过后,从数据库中获取到用户的数据,包括UID, Username。
  6. 生成session结构体实例,通过uuid库生成唯一的session_id。将session_id、uid、username赋值给session结构体。
  7. session结构体json序列化,写入redis,key过期时间设置为15分钟超时。
  8. 将登录IP,登录时间记录到数据库中。
  9. 将username, sessionid, uid设置到cookie返回给客户端。


session结构体

// session 登录态管理
type Session struct {
	ID         string `json:"id"` // sessionid
	Username   string `json:"username"`
	CreateTime int64  `json:"create_time"`
	IsAdmin    bool   `json:"is_admin"`
	UID        int64  `json:"uid"`


登录界面如下:



1. 密码检验

拿到用户输入的password后,MD5(密码+salt) 获得加盐MD5后的字符串,用此字符串跟数据库的Passwd进行对比。

	if user.Passwd != GenMD5WithSalt(password, user.Salt) {
		WriteResponseWithCode(c, "密码不正确", nil, 0)
		return


2. 创建session

通过NewSession给用户生成一个新的session,并设置到redis里,key过期时间设置为15分钟。

//账密验证通过,生成session
session := NewSession(&user)
err = session.Store() // 存储session到redis
if err != nil {
    WriteResponseWithCode(c, "登录失败,请重试", nil, 0)
    return
// session信息存储到redis
func (s *Session) Store() error {
	key := GetSessionKey(s.Username)
	jdata, _ := json.Marshal(s)
	err := RedisDb.Set(ctx, key, string(jdata), time.Duration(Conf.Session.TTL)*time.Second).Err()
	if err != nil {
		fmt.Printf("redis set fail %v\n", err)
		return err
	fmt.Println("session set key ", key)
	fmt.Println("session set val ", string(jdata))
	return nil

3. 登录成功后更新登录IP

// 更新登录IP和登录时间
var updates = map[string]interface{}{
    "last_login_unix": time.Now().Unix(),
    "last_login_ip":   c.ClientIP(),
err = DBUpdateUser(session.Username, updates)
if err != nil {
    fmt.Println("DBUpdateUser err: ", err)


4. 登录成功后设置cookie

//登录成功
c.SetCookie("SESSION", session.ID, 0, "", "", false, true)
c.SetCookie("USERNAME", session.Username, 0, "", "", false, true)
c.SetCookie("UID", string(session.UID), 0, "", "", false, true)
WriteResponseWithCode(c, "", nil, 0)


5. 设置登录态检查的中间件

我们把可访问的接口分为需要登录态访问的接口以及不需要登录就能访问的接口,这里就需要开发一个登录态检查的中间件,给不同权限下的接口进行绑定。

//必须登录的请求,从session读user写入context
func NeedLogin() gin.HandlerFunc {
	return func(c *gin.Context) {
		err, sess := GetUserFromSession(c)
		if err == nil && sess != nil {
			fmt.Println("NeedLogin in login, ", sess)
			c.Set("USER", sess)
			sess.Store() // 已登录,每次请求都会续期
			c.Next()
			return
		} else {
			// 未登录
			WriteResponseWithCode(c, "未登录", nil, http.StatusTooManyRequests)
			//c.Redirect(http.StatusFound, Conf.Common.EnterPage)
			c.Abort()
			return
func GetUserFromSession(c *gin.Context) (error, *Session) {
	sessionid, _ := c.Cookie("SESSION")
	username, _ := c.Cookie("USERNAME")
	if len(sessionid) <= 0 || len(username) <= 0 {
		return errors.New("cookie session not exist"), nil
	sess := GetSession(username)
	if sess == nil {
		return errors.New("session not exist"), nil
	// 带上来的sessionid跟redis存的sessionid不一致
	if sess.ID != sessionid {
		return errors.New("session not match"), nil
	return nil, sess

本质就是拿出请求中的cookie里的username和session_id,拿出username去redis里查是否有这个key,有这个key就把值拿回来反序列化,拿session里的session_id跟带上来的cookie的session_id进行对比,一致就表明登录态有效,把session的有效期延期15分钟。

登录成功后,会自动跳转到平台首页。

特别地,若判断到用户上次和本次的登录IP是异地登录或者登录设备ID不一致,需要开启TOTP(Time-Based One-Time Password)登录策略,即要求给用户邮箱发一次登录code,用户需要从邮箱内点击确认,完成登录授权。

登出设计

登出的本质就是删除session,并重置cookie。

// 登出
func SignOut(c *gin.Context) {
	sess := GetCtxUser(c)
	if sess == nil {
		WriteResponseWithCode(c, "尚未登录", nil, 0)
		return
	err := sess.Del()
	if err != nil {
		WriteResponseWithCode(c, "注销失败,请重试", nil, 0)
		return
	c.SetCookie("SESSION", "", 0, "", "", false, true)
	c.SetCookie("USERNAME", "", 0, "", "", false, true)
	c.SetCookie("UID", "", 0, "", "", false, true)
	WriteResponseWithCode(c, "", nil, 0)
// 删除Session
func (s *Session) Del() error {
	key := GetSessionKey(s.Username)
	err := RedisDb.Del(ctx, key).Err()
	if err != nil {
		fmt.Printf("redis del fail %v\n", err)
		return err