safesession

package module
v0.4.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 25, 2025 License: BSD-3-Clause Imports: 8 Imported by: 2

README

safesession (安全登录会话)

A safe login session library. (一个安全登录会话库。)

本文的session表示保持登录的session。

背景

因为http协议是无状态的,所以在网站或APP中需要专门设计如何保持登录。

cookie是浏览器提供的,可以将少量数据保存到客户设备,在访问网站时自动发送给服务器。

常见的保持登录方法根据此包括:

session和jwt。

session的常见做法将cookie值设为一个session id,服务器根据这个值记录是否登录

jwt以json的格式将登录状态信息保存到cookie。

我在做网站的时候修改session的常见做法实现了一个更安全的session,我基于此实现了这个开源的安全登录会话库。

生成流程

graph LR
    A[创建Session] --> B[编码为字符串]
    B --> C[AES256-GCM加密]
    C --> D[URL安全编码]
    D --> E[Cookie存储]

验证流程

graph LR
    A[从Cookie解密与解码] --> B[会话ID验证]
    B --> C[会话有效期检查]
    C --> D[IP属地一致性验证]
    D --> E[设备信息一致性验证]
    E --> F[用户登录有效性验证]

具体实现

Control 结构体管理所有Session。可以被多个goroutine使用,详情参见相应函数文档。

一个Session由这些信息组成:ID,用户名,创建时间,ip属地,设备信息(系统类型、系统版本、设备型号、浏览器名称),CSRF_TOKEN。

用户首次登录时:

  • 使用加密安全的随机数生成器生成Session ID。
  • 通过调用者提供的方法和客户端ip获取ip属地。
  • 通过user-agent获取系统类型,系统版本,设备型号,浏览器名称。
  • 通过调用者提供的方法将Session ID和创建时间保存到服务器。
  • 使用自定义编码器将Session编码为字符串
  • 经过AES256-GCM加密和转义为能安全地放置在URL查询的文本后,保存到一个名为session的cookie。
  • cookie
    • 默认samesite为Lax,确保从浏览器搜索结果进入网站时,能够自动登录。
    • Secure和HttpOnly为true,禁止在未加密的http连接或js脚本中被访问。
    • Domain为空使得只能在同一域名被访问。
    • Path为/使得在整个网站下的所有路径中都是可用的。

用户后续登录时:

  • 从cookie解密并解码得到Session。
  • 验证Session ID是否在服务器存在。
  • 验证Session本身是否过期。
  • 验证ip属地是否在两次登录时一致。
  • 验证设备信息是否在两次登录时一致。
  • 验证是否存在并符合只允许在一台设备登录等情况。

调用者自行设置并验证CSRF_TOKEN以防范跨站请求伪造攻击。

一个例子:

对于银行网站,可以先在登录时,响应一个没有CSRF_TOKEN的Session。

然后在进行敏感操作,比如转账时,先通过一个GET请求获取输入账号密码等信息的表单网页,其中有一个隐藏字段包含随机生成的CSRF_TOKEN,Session设置同样的CSRF_TOKEN。

填好后通过一个POST请求提交,验证表单中的CSRF_TOKEN和Session中的CSRF_TOKEN是否一致。

安全性分析

对cookie属性的一系列设置确保了cookie的安全。

随机生成ID和AES-256-GCM加密使得Session难以被伪造,目前没有公开的有效攻击方法能够破解正确实现的AES-256-GCM,服务器保存Session ID使得即使出现伪造的Session,也会因为在服务器数据库没有而被发现。

ip属地检查使得即使Session被窃取,也要使用同一属地ip才可能攻击成功。

设备信息的检查使得使用窃取的Session更加困难,要上述user-agent提供的设备信息相同。

由于ip属地和设备信息被加密存储在cookie,即使窃取了Session甚至黑入了服务器数据库,也无法得到这些信息来实现Session劫持。

CSRF_TOKEN的存在使得即使利用浏览器的cookie自动发送机制实现跨站请求伪造攻击,也能被防范。

非浏览器环境如何使用

此实现可以在非浏览器环境使用,只需要客户端模拟实现Cookie和User-Agent。

User-Agent可以按这个模板生成:

电脑:

Mozilla/5.0 (操作系统; 系统版本; CPU指令集) AppleWebKit/0 (KHTML, like Gecko) APP名/版本号

示例: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/0 (KHTML, like Gecko) appname/0.1.0

Mozilla/5.0 (Linux; ; x64) AppleWebKit/0 (KHTML, like Gecko) appname/0.1.0

Mozilla/5.0 (Macintosh; Intel Mac OS X 13.6; ) AppleWebKit/0 (KHTML, like Gecko) appname/0.1.0

手机: Mozilla/5.0 (Linux; Android 系统版本; 设备型号 Build/系统版本号) AppleWebKit/0 (KHTML, like Gecko) APP名/版本号

Mozilla/5.0 (iPhone; CPU iPhone OS 系统版本 like Mac OS X) AppleWebKit/WebKit版本 (KHTML, like Gecko) APP名/版本号

示例:

Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/TQ3A.230805.001) AppleWebKit/0 (KHTML, like Gecko) appname/0.1.0

Mozilla/5.0 (iPhone; CPU iPhone OS 16——6 like Mac OS X) AppleWebKit/0 (KHTML, like Gecko) appname/0.1.0

Cookie最简单的模拟方法是用一个文件保存Cookie信息,在每次HTTPS请求时带上Cookie。 更安全的做法是利用操作系统提供的机密存储,例如windows的凭据管理器,安卓的Keystore。

TODO (代办事项)

  • 验证ip的网络运营商。
  • 验证ip的ASN类型。
  • 验证浏览器指纹(这样更安全,但无法仅用一个https请求完成,会增加网页加载延时)

FAQ (常见问题)

  1. 更新系统是否会因两次登录的系统版本不同导致登录会话失效?

    非苹果设备一般不会,因为浏览器自动发送的user-agent关于系统版本的部分,分为以下情况

    • 在windows,只有从低于windows10升级到至少windows10会改变。
    • 在linux,一般提供的是cpu指令集种类,比如x86_64,这只在像x86换arm的CPU时会改变。
    • 在android,一般提供的是主版本号,只有像安卓12升到安卓13会导致改变。
    • 在macos和ios,提供的是完整的版本号,系统更新会导致改变。(可以改为只采用相对稳定的主版本号,只是安全性更差)
  2. 更换手机或电脑是否会因两次登录的设备型号不同导致登录会话失效?

    分情况,因为浏览器自动发送的user-agent关于设备型号的部分

    • 在电脑和苹果手机一般不提供设备型号。
    • 使用Chrome和Edge的安卓手机一般不提供设备型号。
    • 其他安卓浏览器会提供设备型号。只有这种情况,即使使用换机软件将所有的数据迁移了,也会导致登录会话失效
  3. 像从联通改用移动的宽带是否会因两次登录的网络运营商不同导致登录会话失效?

    目前不会,但未来实现了验证ip的网络运营商会的。

  4. 在不同城市登录是否会因两次登录的ip属地不同导致登录会话失效?

    取决于调用者使用的ip属地数据库。

    • 有些数据库只提供到地区,这种不会,因为只有在前往境外地区时会改变。比如从中国内地前往澳门(来源:《中华人民共和国出入境管理法》第八十九条)
    • 有些数据库会提供到省份、城市甚至经纬度,这种可能会的,取决于调用者选择提供多高精度的ip属地数据给库。
  5. 使用代理等不同网络是否会因两次登录的ip的ASN类型不同导致登录会话失效?

    目前不会,但实现验证ip的ASN类型可能会,不过非金融等需要极高安全性的场景,可以不启用。

    代理通常通过云服务器搭建,它的ip的ASN类型可能是business(商业)例如阿里云这种有自己ASN号的企业,或Hosting(托管)某些没有自己ASN号的企业。

    商业宽带的的ip的ASN类型可能是business(商业)。

    个人办理的流量卡的宽带的ip的ASN类型通常是isp(住宅),但广电的流量卡的ip的ASN类型可能是business(商业)。

    这可能导致这些场景的正常用户登录会话失效:

    1. 在家用自己wifi,在公司用公司wifi,其中公司wifi用的商业宽带。
    2. 因访问公司内网需要,有时使用代理上网,有时不使用代理上网,同时代理未使用X-Real-IP或X-Forwarded-For标头提供真实ip。
    3. 同时使用广电的流量卡和其他运营商的宽带。

使用示例

package main

import (
    "net/http"
    "time"

    "github.com/qiulaidongfeng/safesession"
)

func main() {
    // 初始化数据库操作
    db := safesession.DB{
        Store: func(ID string, CreateTime time.Time) bool {
            // 实现会话存储逻辑
            return true
        },
        Delete: func(ID string) {
            // 实现会话删除逻辑
        },
        Exist: func(ID string) bool {
            // 实现会话存在检查逻辑
            return true
        },
        Valid: func(UserName string, SessionID string) error {
            // 实现会话表示用户登录状态的有效性验证逻辑
            return nil
        },
    }

    // 初始化控制实例
    control := safesession.NewControl(
        [32]byte{},              // AES-256密钥 应随机生成
        24*time.Hour,            // 会话有效期
        http.SameSiteLaxMode,    // SameSite模式
        getIPInfo,               // IP信息获取函数
        db,                      // 数据库操作
    )

    // 登录处理函数
    http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
        // 验证用户身份
        username := "testuser"

        // 获得不带端口号的ip
        // 如需支持使用代理时,通过X-Forwarded-For等请求标头获取真实来源ip,可参考gin.Context.ClientIP的实现。
        ip, _, _ := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
        
        // 创建新会话
        session := control.NewSession(
            ip,
            r.UserAgent(),
            username,
        )
        
        // 设置会话Cookie
        control.SetSession(&session, w)
        
        http.Redirect(w, r, "/dashboard", http.StatusFound)
    })

    // 受保护的路由
    http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
        // 获取会话Cookie
        cookie, err := r.Cookie("session")
        if err != nil {
            http.Redirect(w, r, "/login", http.StatusFound)
            return
        }
        
        // 检查会话有效性
        ok, err, session := control.CheckLogined(
            r.RemoteAddr,
            r.UserAgent(),
            cookie,
        )
        
        if !ok {
            http.Error(w, err.Error(), http.StatusUnauthorized)
            return
        }
        
        // 会话有效,处理请求
        w.Write([]byte("Welcome, " + session.Name))
    })

    http.ListenAndServe(":8080", nil)
}

// 获取IP信息的示例实现
func getIPInfo(clientIP string) safesession.IPInfo {
    // 实现IP信息获取逻辑,例如调用IP归属地API
    return safesession.IPInfo{Country: "CN"}
}

贡献指南

欢迎提交Issue和PR,请确保:

  • 通过所有单元测试
  • 更新相关文档

Documentation

Overview

Package safesession 实现安全登录会话。

这里的session表示保持登录的session。

Index

Constants

This section is empty.

Variables

View Source
var LoginExpired = errors.New("登录已过期,请重新登录")
View Source
var RegionErr = errors.New("IP属地在两次登录时不在同一个地区,请重新登录")
View Source
var Test = false

Test 为true将在创建 Session 时不获取ip属地。

Functions

This section is empty.

Types

type Control

type Control struct {
	// contains filtered or unexported fields
}

Control 管理所有 Session

零值无效,必须使用 NewControl 初始化。

func NewControl

func NewControl(encrypt, decrypt func(string) string, sessionMaxAge time.Duration, sameSite http.SameSite, getIPInfo func(clientIp string) IPInfo, Db DB) *Control

NewControl 创建一个 Control 。 数据库应自行实现清除过期的 Session ·。

func (*Control) Check

func (c *Control) Check(clientIP, userAgent string, s *Session) (bool, error)

Check 检查用户的 Session 是否有效。 从多个goroutine调用是安全的。

func (*Control) CheckLogined

func (c *Control) CheckLogined(clientIP, userAgent string, cookie *http.Cookie) (bool, error, Session)

CheckLogined 检查是否已经登录。 从多个goroutine调用是安全的。 如果err!=nil,调用者应该删除cookie(响应MaxAge<0)。

func (*Control) NewSession

func (c *Control) NewSession(clientIP, userAgent, UserName string) Session

NewSession 创建一个 Session ,保证ID不重复。 从多个goroutine调用是安全的。

func (*Control) SetSession

func (c *Control) SetSession(se *Session, w http.ResponseWriter)

SetSession 设置已创建的登录会话。 只能在https时使用。 只要每次调用的w不同,从多个goroutine调用是安全的。

type DB

type DB struct {
	// Store 存储验证 [Session] 本身有效的必要信息到数据库,
	// 返回false表示ID重复。
	Store func(ID string, CreateTime time.Time) bool
	// Delete 从数据库删除 [Session] 。
	Delete func(ID string)
	// Exist 查询是否有指定的 [Session] 。
	Exist func(ID string) bool
	// Valid 验证 [Session] 表示的用户登录状态有效。
	Valid func(UserName string, SessionID string) error
}

DB 包含需要的数据库操作。

从多个goroutine调用里面的字段方法应该是安全的。

type IPInfo

type IPInfo struct {
	// Country 是ip属地。
	// 正确命名应该是Region,为了向后兼容,所以不修改。
	Country string `json:"country"`
}

IPInfo 是ip信息。

type Session

type Session struct {
	// ID 对每个登录会话是唯一的。
	ID string `gorm:"primaryKey;type:char(64)"`
	// CreateTime 是创建登录会话的时间。
	CreateTime time.Time
	// Ip 是创建登录会话时的ip信息。
	Ip IPInfo `json:"-" gorm:"-:all"`
	// CSRF_TOKEN 用来防范跨站请求伪造攻击。
	CSRF_TOKEN string `json:"-" gorm:"-:all"`
	// 下列字段是创建登录会话时的客户端设备信息,
	// 和ip信息以及CSRF_TOKEN一起保存在客户浏览器,不在服务器保存。
	Os, OsVersion string `json:"-" gorm:"-:all"`
	Name          string `json:"-" gorm:"-:all"`
	Device        string `json:"-" gorm:"-:all"`
	Broswer       string `json:"-" gorm:"-:all"`
}

Session 表示一个登录会话。

Directories

Path Synopsis
Package codec 实现编解码。
Package codec 实现编解码。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL