柴少的官方网站 技术在学习中进步,水平在分享中升华

Nginx结合Lua实现弹窗二次验证(三)

承接上文,上文我们只显示了基于用户秘钥字符串的生成和白名单的功能,也算对二次认证有了个初步的了解,下面我们来一个完整的示例。

下面我们通过每一个程序文件的介绍来深入理解我们需要实现什么功能,如何去实现的。

一、二次验证代码介绍

1.1 config.lua 

这个配置文件是认证系统的核心配置,负责定义数据库连接、认证策略、Redis 配置、会话管理等关键参数。

#vi /usr/local/nginx/conf/auth/config.lua

local _M = {
    db = {
        host = "127.0.0.1",
        port = 3306,
        database = "auth_db",
        user = "auth",
        password = "auth123",
        max_packet_size = 1024 * 1024,
        max_idle_timeout = 30000,  -- 连接池空闲超时时间
        pool_size = 100,           -- 连接池大小
    },

    -- 白名单配置
    whitelist = {
        file = "/usr/local/nginx/conf/auth/ip_whitelist.txt",
        reload_interval = 86400,  -- 24小时重新加载
    },
    
    -- 认证配置
    auth = {
        max_attempts = 3,          -- 最大尝试次数
        ban_time = 1200,           -- 封禁时间(秒):20分钟
        attempt_ttl = 300,         -- 尝试计数有效期(秒):5分钟
        code_length = 6,           -- 验证码长度
        secret_length = 32,        -- 秘钥长度
	    totp_time_step = 30,       -- TOTP时间步长(秒):30秒
        default_secret_key = "604264e9fd0315c5cbe873106db78d7051d739894095867c4c2a9cbc05bfdf86",  -- 全局默认密钥
    },
    
    -- Redis配置
    redis = {
        host = "127.0.0.1",
        port = 6379,
        password = nil,
        timeout = 1000,            -- 连接超时(ms)
        keepalive = 10000,         -- 连接池保持时间(ms)
        pool_size = 100,            -- 连接池大小
        user_secret_ttl = 3600,    -- 用户秘钥缓存时间(秒):1小时
    },
    
    -- 会话配置
    session = {
        cookie_name = "auth_session",
        cookie_domain = ".test.com",
        cookie_path = "/",
        cookie_secure = false,
        cookie_http_only = true,
        expire_time = 86400,       -- 会话有效期(秒):1天
        cross_domain = false,  -- 设置为true如果是跨域应用
        local_cache_offset = 10,  -- 本地缓存比Redis提前过期的秒数
    },

    -- 全局免认证URL
    skip_auth_global = {
        ["/health"] = true,
        ["/metrics"] = true,
        ["/static/"] = true,
        ["/favicon.ico"] = true,
    },

    -- 域名级免认证URL
    skip_auth_domain = {
        ["lua.test.com"] = {
            ["/auth/login"] = true,
            ["/auth/logout"] = true,
        },
        ["api.test.com"] = {
            ["/public/"] = true,
        }
    },
    
    -- 模板路径
    templates = {
        auth_popup = "/usr/local/nginx/conf/auth/templates/auth_popup.html",
    },
    
    -- 日志配置
    log = {
        level = "info",  -- 全局日志级别
        access_file = "/opt/log/nginx/auth_access.log",  -- 正常访问日志
        error_file = "/opt/log/nginx/auth_error.log"     -- 错误日志
    }
}

return _M

#通过配置文件我们可以看到我们做了一个灵活的设置,那就是我们可能指定就算启用了这个lua文件,但是有些域名可能有很多location,有些location是不需要认证的,这就是域名级别的路径白名单,我们也有一些全局的,比如favicon.ico浏览器小图标,这就是全局的白名单。

#前面我们的前端页面是写到lua文件中的,现在呢我们将lua前端文件从lua代码中剥离了出来放到了templates目录下面,更规范修改更灵活。

#然后呢我们是希望认证日志跟nginx本身的日志文件区分开,默认都写到了error.log里面,我们是想成功/失败的认证日志记录到我们指定日志文件便于排查。

1.2 log_utils.lua

这是一个日志工具模块文件,为了规范的记录系统日志。亮点介绍:

上下文适配:避免非请求阶段调用请求阶段API导致的错误
安全健壮:通过pcall捕获所有可能的异常,确保日志操作不影响主流程
格式统一:标准化日志内容,便于后续分析和监控
配置驱动:日志路径、级别通过config.lua统一管理,便于维护

# vi /usr/local/nginx/conf/auth/log_utils.lua

-- 1.模块初始化与依赖引入
local config = require("config")  --引入全局配置(日志路径、级别等)
-- 为啥要local ngx = ngx这种呢?主要是性能优化:减少全局变量查找开销/代码健壮性:避免全局变量污染/代码可读性:明确依赖关系
-- Lua访问局部变量的速度远快于全局变量(约快30%)。对于高频调用的模块(如ngx、os),通过定义局部变量可以显著提升性能。
local ngx = ngx  --Nginx Lua 模块(提供请求上下文信息)
local os = os  --操作系统模块(获取时间戳)
local io = io  --输入输出模块(文件操作)

local _M = {} --定义模块对象,用于导出函数

-- 2.上下文判断:区分请求/非请求阶段
-- Nginx运行阶段分为请求阶段(处理HTTP请求)和非请求阶段(如启动、配置加载),两者可调用的API不同(例如ngx.var仅在请求阶段可用)
-- 判断是否处于请求上下文
local function is_request_context()
    -- 尝试访问ngx.var.request_id(仅请求阶段存在),失败则为非请求阶段,pcall捕获可能的错误
    local success, _ = pcall(function()
        return ngx.var.request_id  -- 尝试访问请求ID,非请求阶段会报错
    end)
    return success
end

-- 3.安全获取客户端IP
-- 获取客户端IP,客户端IP可能来自X-Forwarded-For(代理场景)、X-Real-IP或直接连接的remote_addr,需兼容非请求阶段
local function get_client_ip_safe()
    if not is_request_context() then
        return "N/A"  --非请求阶段无客户端IP,返回默认值
    end
    
    -- 仅在请求上下文时调用 ngx.req.get_headers()
    local headers = ngx.req.get_headers()
    local client_ip = headers["X-Forwarded-For"] or  --优先取代理传递的IP
                      headers["X-Real-IP"] or --次优先取真实IP
                      ngx.var.remote_addr or "unknown" --最后取直接连接IP   
    
    --处理 X-Forwarded-For多IP场景(格式:"ip1, ip2, ...",取第一个)
    if type(client_ip) == "string" and string.find(client_ip, ",") then
        client_ip = string.sub(client_ip, 1, string.find(client_ip, ",") - 1)
    end
    
    return client_ip
end

-- 4.安全获取请求 ID
-- 请求ID(request_id)Nginx为每个请求生成的唯一标识,用于追踪请求链路,仅在请求阶段可用。
local function get_request_id_safe()
    if not is_request_context() then
        return "N/A" 
    end
    return ngx.var.request_id or "unknown"  --返回请求ID,默认"unknown"
end

-- 5.日志格式化:统一日志格式
-- 格式化日志:确保包含时间戳、级别、客户端 IP、请求 ID、具体信息,便于日志分析工具解析。
local function format_log(level, message)
    local client_ip = get_client_ip_safe()
    local timestamp = os.date("%Y-%m-%d %H:%M:%S") -- 时间戳(年-月-日 时:分:秒)
    local request_id = get_request_id_safe()
    
    -- 拼接日志字符串:[时间] [级别] [IP] [请求ID] 信息
    return string.format("[%s] [%s] [%s] [%s] %s",
        timestamp,
        level,
        client_ip,
        request_id,
        tostring(message)  -- 确保message是字符串类型
    )
end

-- 安全格式化日志(纯非请求阶段专用)
local function format_log_safe(level, message)
    local timestamp = os.date("%Y-%m-%d %H:%M:%S")
    return string.format("[%s] [%s] [N/A] [N/A] %s",
        timestamp,
        level,
        tostring(message)  -- 确保message是字符串类型
    )
end

-- 6.日志写入:安全操作文件
-- 负责将格式化后的日志写入配置文件指定的路径,包含错误处理(如文件无法打开时降级打印到控制台)
local function write_log(file_path, message)
    -- 参数校验
    if type(file_path) ~= "string" or file_path == "" then
        print("[LOG_ERROR] 日志文件路径不能为空")
        return false
    end
    
    -- 尝试打开日志文件("a"表示追加模式)
    local file, err = io.open(file_path, "a")
    if not file then
        print("[LOG_ERROR] 无法打开日志文件: " .. file_path .. ", 错误: " .. (err or "未知错误"))
        print("[FALLBACK] " .. message)
        return false
    end
    
    -- 安全写入日志(使用pcall捕获写入过程中的错误)
    local ok, write_err = pcall(function()
        file:write(message .. "\n")  -- 写入日志并换行
        file:flush()  -- 强制刷新到磁盘(避免缓存导致日志丢失)
    end)
    file:close()  -- 无论成功与否都关闭文件
    
    -- 处理写入错误
    if not ok then
        print("[LOG_ERROR] 写入日志失败: " .. (write_err or "未知错误"))
        return false
    end
    
    return true -- 写入成功
end

-- 7. 日志级别控制:按配置输出
-- 根据config.log.level控制不同级别的日志是否输出,避免冗余日志。

-- 调试日志(自动适配上下文)
function _M.debug(message)
    -- 要注意~=在lua中是不等于的比较运算符,跟其他语音中的!=类似
    if config.log.level ~= "debug" then
        return false  -- 日志级别不匹配,不输出
    end
    -- 根据上下文选择日志格式,写入访问日志文件
    local log_msg = is_request_context() 
        and format_log("DEBUG", message) 
        or format_log_safe("DEBUG", message)
    return write_log(config.log.access_file, log_msg)
end

-- 信息日志(自动适配上下文)
function _M.info(message)
    if config.log.level ~= "debug" and config.log.level ~= "info" then
        return false
    end
    local log_msg = is_request_context() 
        and format_log("INFO", message) 
        or format_log_safe("INFO", message)
    return write_log(config.log.access_file, log_msg)
end

-- 警告日志(自动适配上下文)
function _M.warn(message)
    local log_msg = is_request_context() 
        and format_log("WARN", message) 
        or format_log_safe("WARN", message)
    return write_log(config.log.error_file, log_msg)
end

-- 错误日志(自动适配上下文)
function _M.error(message)
    local log_msg = is_request_context() 
        and format_log("ERROR", message) 
        or format_log_safe("ERROR", message)
    return write_log(config.log.error_file, log_msg)
end

-- 8. 兼容旧版本:提供安全日志方法
-- 为兼容可能存在的旧代码调用,保留info_safe和error_safe别名。
function _M.info_safe(message)
    return _M.info(message) -- 等同于 info 方法
end

function _M.error_safe(message)
    return _M.error(message) -- 等同于 error 方法
end

return _M  -- 导出模块,供其他脚本通过require("log_utils")调用

1.3 db_utils.lua

该文件是数据库操作模块,基于 resty.mysql 库封装了MySQL数据库的常用操作,主要用于认证系统中用户信息、会话数据的存储与查询。其核心功能包括:

1.安全连接MySQL数据库并管理连接池
2.提供防SQL注入的查询方法
3.初始化数据库表结构(用户表、会话表)
4.封装用户密钥查询等业务相关的数据库操作

# vi /usr/local/nginx/conf/auth/db_utils.lua  #这个文件不多做介绍了,前面有介绍

-- 1.模块依赖与初始化
local mysql = require("resty.mysql")
local config = require("config").db
local log = require("log_utils")  -- 使用新的日志模块

local _M = {}

-- 2. 数据库连接管理
-- 连接数据库并设置超时,使用连接池减少频繁创建连接的开销
function _M.connect()
    local db, err = mysql:new()
    if not db then
        log.error("创建MySQL实例失败: " .. err)
        return nil, "创建MySQL实例失败: " .. err
    end
    
    -- 设置超时
    db:set_timeout(1000)  -- 1秒超时
    
    -- 连接数据库
    local ok, err, errno, sqlstate = db:connect({
        host = config.host,
        port = config.port,
        database = config.database,
        user = config.user,
        password = config.password,
        max_packet_size = config.max_packet_size
    })
    
    if not ok then
        local error_msg = string.format("连接数据库失败: %s, 错误码: %d, SQL状态: %s", err, errno, sqlstate)
        log.error(error_msg)
        return nil, error_msg
    end
    
    log.info("数据库连接成功")
    return db
end

-- 3.安全执行SQL查询
-- 执行SQL语句并自动将连接放回连接池,避免连接泄漏。
function _M.execute_query(sql)
    log.info("执行SQL查询: " .. sql)
    local db, err = _M.connect()
    if not db then
        log.error("获取数据库连接失败: " .. err)
        return nil, err
    end
    
    local res, err, errno, sqlstate = db:query(sql)
    
    -- 将连接放回连接池
    db:set_keepalive(config.max_idle_timeout, config.pool_size)
    
    if not res then
        local error_msg = string.format("执行SQL失败: %s, 错误码: %d, SQL状态: %s", err, errno, sqlstate)
        log.error(error_msg)
        return nil, error_msg
    end
    
    return res
end

-- 4.防SQL注入:字符串转义
-- 对用户输入的字符串进行转义(如单引号替换为双引号),避免恶意SQL注入。
function _M.escape_literal(str)
    if str == nil then
        return "NULL"
    end
    
    -- 示例:若用户输入user' OR '1'='1,转义后变为 'user'' OR ''1''=''1',避免注入攻击。
    if type(str) == "boolean" then
        return str and "1" or "0"
    end
    
    if type(str) == "number" then
        return tostring(str)
    end
    
    str = tostring(str)
    str = string.gsub(str, "\\", "\\\\")
    str = string.gsub(str, "'", "''")
    return "'" .. str .. "'"
end

-- 5.数据库表初始化
-- 创建users(用户表)和sessions(会话表),确保系统启动时表结构存在。
function _M.init_tables()
    log.info("开始初始化数据库表")
    
    -- 创建用户表
    local create_users_sql = [[
        CREATE TABLE IF NOT EXISTS users (
            id INT AUTO_INCREMENT PRIMARY KEY, -- 用户名(唯一)
            username VARCHAR(50) UNIQUE NOT NULL,
            otp_secret VARCHAR(32) NOT NULL,
            login_attempts INT DEFAULT 0,
            last_attempt_time DATETIME,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )
    ]]
    
    local res, err = _M.execute_query(create_users_sql)
    if not res then
        log.error("创建用户表失败: " .. err)
        return false, "创建用户表失败: " .. err
    end
    
    log.info("用户表创建成功")
    
    -- 创建会话表
    local create_sessions_sql = [[
        CREATE TABLE IF NOT EXISTS sessions (
            session_id VARCHAR(64) PRIMARY KEY, -- 会话ID(唯一)
            user_id INT NOT NULL,
            expires_at DATETIME NOT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            FOREIGN KEY (user_id) REFERENCES users(id) -- 外键关联用户表
        )
    ]]
    
    local res, err = _M.execute_query(create_sessions_sql)
    if not res then
        log.error("创建会话表失败: " .. err)
        return false, "创建会话表失败: " .. err
    end
    
    log.info("会话表创建成功")
    return true
end

-- 6.业务方法:获取用户密钥
-- 查询指定用户名对应的OTP密钥(用于TOTP验证码验证)
function _M.get_user_secret(username)
    log.info("开始获取用户秘钥: " .. username)
    
    -- 安全转义用户名
    local escaped_username = _M.escape_literal(username)
    
    -- 构建SQL查询
    local sql = string.format([[
        SELECT otp_secret FROM users WHERE username = %s]], escaped_username)  -- 使用转义后的用户名

    -- 执行查询
    local res, err = _M.execute_query(sql)
    
    -- 记录查询错误
    if err then
        log.error("查询用户秘钥失败: " .. err)
        return nil
    end
    
    -- 处理结果(用户不存在则返回 nil)
    if not res or #res == 0 then
        log.info("用户不存在,用户名: " .. username)
        return nil
    end
    
    -- 返回用户密钥
    log.info("成功获取用户秘钥: " .. username)
    return res[1]["otp_secret"]  -- 取第一条结果的otp_secret字段
end

return _M

博文来自:www.51niux.com

1.4 修改Nginx增加共享内存和白名单初始化

# vi /usr/local/nginx/conf/nginx.conf

    #gzip  on;
    # 共享内存定义
    lua_shared_dict ip_whitelist_cache 5m;
    lua_shared_dict session_cache 100m;
    # 定义一个自定义变量用于存储自定义标识(内存中)
    lua_shared_dict custom_request_id 10m;
    # Lua模块路径
    lua_package_path "/usr/local/lua_core/lib/lua/?.lua;/usr/local/nginx/conf/auth/?.lua;;";
    # 白名单初始化(修正:移除直接执行的初始化,改为显式调用)
    init_by_lua_file /usr/local/nginx/conf/auth/init_by_lua.lua;

    # worker进程初始化(修正:使用完整的init_worker.lua文件)
    init_worker_by_lua_file /usr/local/nginx/conf/auth/init_worker.lua;

1.5 redis_utils.lua

# vi /usr/local/nginx/conf/auth/redis_utils.lua

-- 第一部分: 基础工具与连接管理
-- 1.1.模块依赖与初始化
local redis = require("resty.redis")  -- 引入 OpenResty Redis 客户端
local config = require("config")      -- 引入全局配置(包含 Redis 连接信息等)
local log = require("log_utils")      -- 引入日志工具

local _M = {}  -- 定义模块表,用于导出公共方法
-- 1.2.安全日志处理函数
-- 作用:处理空值,统一显示为 [空值]/通过正则替换非数字、字母、标点和空格的字符为?,避免日志被特殊字符破坏
local function safe_log_value(value)
    if not value or value == "" then
        return "[空值]"
    end
    -- 过滤不可见字符(如控制字符),防止日志格式混乱
    -- ^在字符类中表示"取反",%w表示字母和数字,%p表示标点符号,这就是匹配除了字母、数字、标点符号和空格之外的所有字符替换为?
    return tostring(value):gsub("[^%w%p ]", "?")
end
-- 1.3.Redis连接管理函数
local function connect()
    local red = redis:new()  -- 创建 Redis 客户端实例
    red:set_timeout(config.redis.timeout or 1000)  -- 设置超时时间(默认1秒)
    
    -- 连接 Redis 服务器(使用配置的 host 和 port,默认本地 127.0.0.1:6379)
    local ok, err = red:connect(
        config.redis.host or "127.0.0.1",
        config.redis.port or 6379
    )
    
    -- 连接失败处理
    if not ok then
        log.error(
            "[Redis连接] 失败," ..
            "host: [" .. safe_log_value(config.redis.host) .. "], " ..
            "port: [" .. safe_log_value(config.redis.port) .. "], " ..
            "error: [" .. safe_log_value(err) .. "]"
        )
        return nil, err
    end
    
    -- 选择数据库(默认0号库)
    local db = config.redis.db or 0
    local ok, err = red:select(db)
    if not ok then
        log.error(
            "[Redis操作] 选择数据库失败," ..
            "db: [" .. safe_log_value(db) .. "], " ..
            "error: [" .. safe_log_value(err) .. "]"
        )
        red:close()  -- 失败时关闭连接释放资源
        return nil, err
    end
    
    log.info(
        "[Redis连接] 成功," ..
        "host: [" .. safe_log_value(config.redis.host) .. "], " ..
        "port: [" .. safe_log_value(config.redis.port) .. "], " ..
        "db: [" .. safe_log_value(db) .. "]"
    )
    
    return red, nil
end

-- 第二部分:用户会话管理
-- 2.1.会话查询(多级缓存机制)
-- 设计点:多级缓存(本地缓存+Redis减少网络开销)/数据双重检验,防止脏数据污染/域名隔离(通过cookie_domain区分不同域名的会话)
-- 从配置中获取会话相关参数(默认空表避免 nil 错误)
local session_conf = config.session or {}
function _M.get_user_session(session_id)
    if not session_id or session_id == "" then
        log.info("[会话查询] session_id为空")
        return nil
    end
    
    -- 构建缓存键(格式:session:域名:session_id,支持多域名隔离)
    local cache_key = "session:" .. (session_conf.cookie_domain or "") .. ":" .. session_id
    
    -- 第一步:检查本地缓存(ngx.shared.session_cache,内存级缓存)
    local username = ngx.shared.session_cache and ngx.shared.session_cache:get(cache_key)
    if username then
        -- 获取剩余有效期,便于监控缓存状态
        local remaining_time = ngx.shared.session_cache:ttl(cache_key)
        log.info(
            "[会话查询] 命中本地缓存," ..
            "session_id: [" .. safe_log_value(session_id) .. "], " ..
            "username: [" .. safe_log_value(username) .. "], " ..
            "剩余有效期: " .. remaining_time .. "秒"
        )
        
        -- 数据有效性校验:防止缓存脏数据(如非字符串类型或包含非法标识)
        if type(username) ~= "string" or string.find(username, "userdata:") then
            log.warn("[会话查询] 本地缓存数据无效,已删除")
            ngx.shared.session_cache:delete(cache_key)
            return nil
        end
        
        return username  -- 本地缓存命中直接返回
    end
    
    -- 第二步:本地缓存未命中,查询 Redis
    log.info("[会话查询] 本地缓存未命中,查询Redis")
    local red, err = connect()
    if not red then
        log.error("[会话查询] Redis连接失败:" .. err)
        return nil
    end
    
    -- 从 Redis 获取会话数据
    local username, redis_err = red:get(cache_key)
    red:close()  -- 及时关闭连接释放资源
    
    -- 处理 Redis 查询错误
    if redis_err then
        log.error("[会话查询] Redis查询失败:" .. redis_err)
        return nil
    end
    
    -- 第三步:验证 Redis 数据并同步到本地缓存
    if username and username ~= "" then
        -- 校验数据格式(同本地缓存逻辑)
        if type(username) ~= "string" or string.find(username, "userdata:") then
            log.warn("[会话查询] Redis数据无效,已删除")
            red:del(cache_key)  -- 清理脏数据
            return nil
        end
        
        -- 同步到本地缓存(默认有效期300秒,可通过配置修改)
        if ngx.shared.session_cache then
            ngx.shared.session_cache:set(cache_key, username, session_conf.cache_time or 300)
            log.info("[本地缓存-写入] 成功")
        end
        
        return username
    else
        log.info("[会话查询] Redis未命中")
        return nil
    end
end
-- 2.2.会话创建(双向同步机制)
function _M.create_user_session(session_id, username, expire_seconds)
    -- 参数校验:必填参数缺失直接返回失败
    if not session_id or not username or not expire_seconds then
        log.error("[会话创建] 参数缺失")
        return false
    end
    
    -- 数据格式校验:拒绝无效用户名
    if type(username) ~= "string" or string.find(username, "userdata:") then
        log.error("[会话创建] 用户名格式无效,拒绝创建")
        return false
    end
    
    local cache_key = "session:" .. (session_conf.cookie_domain or "") .. ":" .. session_id
    
    -- 第一步:写入 Redis(带过期时间)
    local red, err = connect()
    if not red then
        log.error("[会话创建] Redis连接失败:" .. err)
        return false
    end
    
    -- 使用 setex 原子操作:同时设置值和过期时间
    local ok, set_err = red:setex(cache_key, expire_seconds, username)
    red:close()
    
    if not ok then
        log.error("[会话创建] Redis写入失败:" .. set_err)
        return false
    end
    
    -- 第二步:同步到本地缓存(有效期更短,避免与 Redis 数据不一致)
    if ngx.shared.session_cache then
        local local_cache_time = math.min(expire_seconds, session_conf.cache_time or 300)
        ngx.shared.session_cache:set(cache_key, username, local_cache_time)
        log.info("[会话创建] 本地缓存同步成功,有效期:" .. local_cache_time .. "秒")
    end
    
    log.info("[会话创建] 成功,有效期:" .. expire_seconds .. "秒")
    return true
end
-- 2.3. 本地缓存专用查询函数
function _M.get_session_from_cache(session_id)
    if not session_id or session_id == "" then
        return nil
    end
    
    local cache_key = "session:" .. (session_conf.cookie_domain or "") .. ":" .. session_id
    local username = ngx.shared.session_cache and ngx.shared.session_cache:get(cache_key)
    
    if username then
        -- 同 get_user_session 的数据校验逻辑
        local remaining_time = ngx.shared.session_cache:ttl(cache_key)
        log.info("[本地缓存-读取] 命中,剩余有效期:" .. remaining_time .. "秒")
        
        if type(username) ~= "string" or string.find(username, "userdata:") then
            log.warn("[本地缓存-读取] 数据无效,已删除")
            ngx.shared.session_cache:delete(cache_key)
            return nil
        end
        
        return username
    else
        log.info("[本地缓存-读取] 未命中")
        return nil
    end
end

-- 第三部分:TOTP 密钥管理
-- 3.1.获取用户 TOTP 密钥
function _M.get_user_secret(username)
    if not username or username == "" then
        log.error("[获取密钥] 用户名为空")
        return nil, "username_empty"
    end
    
    local key = "user_secret:" .. username  -- 密钥存储键格式:user_secret:用户名
    local red, err = connect()
    
    if not red then
        log.error("[获取密钥] Redis连接失败")
        return nil, "redis_connect_failed"
    end
    
    -- 从 Redis 获取密钥
    local secret, redis_err = red:get(key)
    red:close()
    
    if redis_err then
        log.error("[获取密钥] Redis查询失败:" .. redis_err)
        return nil, "redis_query_failed"
    end
    
    -- 明确区分"用户不存在"和"查询成功但无数据"
    if secret == nil then
        log.info("[获取密钥] 用户不存在:" .. username)
        return nil, "user_not_found"
    end
    
    log.info("[获取密钥] 成功:" .. username)
    return secret, nil
end
-- 3.2.设置用户TOTP密钥
function _M.set_user_secret(username, secret)
    if not username or not secret then
        log.error("[设置密钥] 参数缺失")
        return false
    end
    
    local key = "user_secret:" .. username
    local red, err = connect()
    
    if not red then
        log.error("[设置密钥] Redis连接失败")
        return false
    end
    
    -- 长期存储(1年有效期),适合TOTP密钥的持久化需求
    local ok, redis_err = red:setex(key, 365 * 24 * 3600, secret)
    red:close()
    
    if not ok then
        log.error("[设置密钥] Redis写入失败:" .. redis_err)
        return false
    end
    
    log.info("[设置密钥] 成功:" .. username)
    return true
end

-- 第四部分:认证安全控制
-- 4.1.认证尝试次数记录与自动封禁
local auth_conf = config.auth or {}  -- 认证配置(最大尝试次数、封禁时间等)

function _M.record_attempt(username)
    if not username or username == "" then
        log.error("[记录尝试] 用户名为空")
        return nil
    end
    
    local key = "auth_attempts:" .. username  -- 尝试次数存储键
    local max_attempts = auth_conf.max_attempts or 5  -- 默认最大5次尝试
    local ban_time = auth_conf.ban_time or 1200  -- 默认封禁20分钟(1200秒)
    
    local red, err = connect()
    if not red then
        log.error("[记录尝试] Redis连接失败")
        return nil
    end
    
    -- 先检查用户是否已被封禁
    local is_banned, redis_err = red:get("user_banned:" .. username)
    if redis_err then
        log.error("[记录尝试] 检查封禁状态失败:" .. redis_err)
        red:close()
        return nil
    end
    
    if is_banned and is_banned == "1" then
        log.warn("[记录尝试] 用户已被封禁:" .. username)
        red:close()
        return 0  -- 已封禁返回0
    end
    
    -- 自增尝试次数(原子操作,支持并发场景)
    local attempts, redis_err = red:incr(key)
    if redis_err then
        log.error("[记录尝试] 自增失败:" .. redis_err)
        red:close()
        return nil
    end
    
    -- 首次记录时设置过期时间(与封禁时间一致)
    if attempts == 1 then
        red:expire(key, ban_time)
    end
    
    -- 达到最大尝试次数时自动封禁
    if attempts >= max_attempts then
        red:setex("user_banned:" .. username, ban_time, "1")  -- 封禁标记
        log.warn("[记录尝试] 达到最大次数,已封禁:" .. username)
        red:close()
        return 0
    end
    
    log.info(
        "[记录尝试] 成功,剩余次数:" .. (max_attempts - attempts) ..
        ",用户:" .. username
    )
    red:close()
    return max_attempts - attempts  -- 返回剩余尝试次数
end
-- 4.2.辅助封禁管理函数
-- 重置认证尝试次数(如登录成功后调用)
function _M.reset_attempts(username)
    if not username or username == "" then
        log.error("[重置尝试] 用户名为空")
        return false
    end
    
    local key = "auth_attempts:" .. username
    local red, err = connect()
    
    if not red then
        log.error("[重置尝试] Redis连接失败")
        return false
    end
    
    -- 删除尝试次数记录和封禁标记
    red:del(key)
    red:del("user_banned:" .. username)
    red:close()
    
    log.info("[重置尝试] 成功:" .. username)
    return true
end

-- 检查用户是否被封禁
function _M.is_user_banned(username)
    if not username or username == "" then
        log.error("[检查封禁] 用户名为空")
        return false
    end
    
    local key = "user_banned:" .. username
    local red, err = connect()
    
    if not red then
        log.error("[检查封禁] Redis连接失败")
        return false
    end
    
    local is_banned = red:get(key)
    red:close()
    
    log.info("[检查封禁] " .. (is_banned and "已封禁" or "未封禁") .. ":" .. username)
    return is_banned and is_banned == "1"  -- 明确返回布尔值
end

-- 手动封禁用户(支持自定义封禁时间)
function _M.ban_user(username, ban_time)
    if not username or username == "" then
        log.error("[封禁用户] 用户名为空")
        return false
    end
    
    -- 封禁时间默认使用配置的ban_time(20分钟),支持自定义
    ban_time = ban_time or (auth_conf.ban_time or 1200)
    local key = "user_banned:" .. username  -- 封禁标记存储键
    local red, err = connect()
    
    if not red then
        log.error(
            "[封禁用户] Redis连接失败," ..
            "username: [" .. safe_log_value(username) .. "], " ..
            "error: [" .. safe_log_value(err) .. "]"
        )
        return false
    end
    
    -- 写入封禁标记(值为"1",带过期时间)
    local ok, redis_err = red:setex(key, ban_time, "1")
    red:close()
    
    if not ok then
        log.error(
            "[封禁用户] Redis操作失败," ..
            "username: [" .. safe_log_value(username) .. "], " ..
            "error: [" .. safe_log_value(redis_err) .. "]"
        )
        return false
    end
    
    log.info(
        "[封禁用户] 成功," ..
        "username: [" .. safe_log_value(username) .. "], " ..
        "封禁时间: [" .. safe_log_value(ban_time) .. "秒]"
    )
    return true
end

-- 第五部分:无效会话清理
function _M.clean_invalid_sessions()
    -- 连接Redis
    local red, err = connect()
    if not red then
        log.error("[清理无效会话] Redis连接失败")
        return false
    end
    
    local cursor = "0"  -- 游标初始值(用于SCAN命令分页)
    local deleted = 0   -- 记录删除的无效会话数
    local processed = 0 -- 记录处理的总会话数
    
    log.info("[清理无效会话] 开始扫描会话数据...")
    
    -- 循环扫描所有会话键(使用SCAN避免阻塞Redis)
    --  repeat...until是一种循环结构,类似于其他语言中的 do...while 循环。它的特点是先执行循环体,再判断条件,因此循环体至少会执行一次
    repeat
        -- 扫描匹配"session:*"的键,每次返回部分结果
        -- 安全扫描:使用SCAN命令替代KEYS,避免一次性扫描大量键导致Redis阻塞
        local res, err = red:scan(cursor, "MATCH", "session:*")
        if not res then
            log.error("[清理无效会话] Redis扫描失败:" .. safe_log_value(err))
            break
        end
        
        cursor = res[1]  -- 更新游标(用于下一次扫描)
        local keys = res[2]  -- 当前批次的键列表
        processed = processed + #keys  -- 累计处理数
        
        -- 检查每个会话键的值是否有效
        for _, key in ipairs(keys) do
            local username, err = red:get(key)
            -- 无效判定:非字符串类型或包含"userdata:"(脏数据标识)
            if username and (type(username) ~= "string" or string.find(username, "userdata:")) then
                red:del(key)  -- 删除无效会话
                deleted = deleted + 1
                log.info("[清理无效会话] 已删除无效会话: " .. key)
            end
        end
    until cursor == "0"  -- 游标为0表示扫描完成
    
    red:close()
    log.info(
        "[清理无效会话] 完成," ..
        "共处理 " .. processed .. " 个会话," ..
        "删除 " .. deleted .. " 个无效会话"
    )
    return true
end
        
return _M  -- 导出模块表,供其他脚本调用

1.6 totp_utils.lua

# vi /usr/local/nginx/conf/auth/totp_utils.lua

#该文件是TOTP(时间同步验证码)工具模块,基于RFC6238标准实现了TOTP验证码的生成与验证功能。TOTP 广泛用于双因素认证(2FA),其核心原理是通过密钥和当前时间生成短期有效的验证码。

-- 1.模块初始化与常量定义
local bit32 = require("bit")  -- 位操作库(用于哈希计算)
local log = require("log_utils")  -- 日志工具

local _M = {}  -- 模块对象

-- Base32 字符集(遵循 RFC 4648 标准)
-- 包含大写字母、小写字母(用于兼容)和数字 2-7
local BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz234567"
local BASE32_UPPER = string.upper(BASE32_CHARS)  -- 大写字符集(用于解码基准)
local BASE32_LOWER = string.lower(BASE32_CHARS)  -- 小写字符集(用于兼容)
local BASE32_PAD = "="  -- Base32 填充符

-- 2. Base32解码(TOTP 密钥解码)
-- TOTP密钥通常以Base32编码存储(如JBSWY3DPEHPK3PXP),需解码为原始字节才能用于加密。
function _M.base32_decode(base32_str, allow_lowercase)
    -- 参数校验:必须为字符串
    if type(base32_str) ~= "string" then
        log.error("base32_decode: 非法参数类型,期望字符串")
        return nil, "INVALID_PARAM_TYPE"
    end
    
    -- 清理输入:移除空格和填充符(=)
    local cleaned = base32_str:gsub("%s+", ""):gsub(BASE32_PAD, "")
    
    -- 空输入处理
    if #cleaned == 0 then
        log.error("base32_decode: 空输入字符串")
        return nil, "EMPTY_INPUT"
    end
    
    -- 非法字符校验
    local valid_chars = allow_lowercase and BASE32_CHARS or BASE32_UPPER  -- 允许小写时放宽校验
    for i = 1, #cleaned do
        local char = cleaned:sub(i, i)
        if valid_chars:find(char, 1, true) == nil then  -- 精确匹配字符
            log.error("base32_decode: 发现非法字符 '", char, "'")
            return nil, "INVALID_CHAR"
        end
    end
    
    -- 其实避免麻烦正式环境就直接字母生成的时候就全大写字母就好了,就没有转义兼容方面的问题了
    -- 统一转为大写(确保解码逻辑一致)
    cleaned = string.upper(cleaned)
    
    -- 解码核心逻辑:将 Base32 字符转为字节流
    local result = {}  -- 存储解码后的字节
    local bits = 0     -- 累计位数(Base32 每字符 5 位)
    local value = 0    -- 累计数值(用于拼接字节)
    
    for i = 1, #cleaned do
        local char = cleaned:sub(i, i)
        local pos = BASE32_UPPER:find(char, 1, true)  -- 查找字符在大写集的位置(1-32)
        if not pos then
            log.error("base32_decode: 字符集查找失败")
            return nil, "CHARSET_LOOKUP_FAILED"
        end
        local val = pos - 1  -- 转为 0-31 的数值
        
        -- 累计 5 位,凑满 8 位则生成一个字节
        value = (value * 32) + val  -- 左移 5 位,加上新值
        bits = bits + 5
        
        -- 当累计位数 >=8 时,提取一个字节
        while bits >= 8 do
            local byte = math.floor(value / (2 ^ (bits - 8)))  -- 取高 8 位
            table.insert(result, string.char(byte))
            value = value % (2 ^ (bits - 8))  -- 保留剩余位数
            bits = bits - 8
        end
    end
    
    -- 处理剩余不足 8 位的情况(补 0 凑整)
    if bits > 0 then
        local byte = math.floor(value / (2 ^ (8 - bits)))  -- 补 0 后取 8 位
        table.insert(result, string.char(byte))
    end
    
    return table.concat(result), nil  -- 返回解码后的字节字符串
end

-- 3. SHA-1哈希算法(TOTP 加密基础)
-- SHA-1用于生成消息摘要,是HMAC-SHA1的基础。
-- 左旋转函数(SHA-1 核心操作)
local function rotl32(n, b)
    b = b % 32  -- 旋转位数取模(防止溢出)
    -- 左旋转 b 位,溢出部分补到右侧
    return bit32.band(bit32.lshift(n, b), 0xFFFFFFFF) + bit32.rshift(n, 32 - b)
end

-- 消息填充(SHA-1 要求消息长度为 512 位的倍数)
local function pad_message(message)
    local length_bits = #message * 8  -- 消息长度(位)
    if length_bits > 0x7FFFFFFFFFFFFFFF then  -- 超过 64 位表示范围
        error("Message too long for SHA-1 padding")
    end
    
    -- 步骤1:添加 0x80 标记
    local padded = message .. string.char(0x80)
    -- 步骤2:补 0 直到长度 ≡ 448 mod 512(留出 64 位存长度)
    while (#padded * 8) % 512 ~= 448 do
        padded = padded .. string.char(0x00)
    end    
    -- 步骤3:添加 64 位长度(大端序)
    local length_bytes = {}
    for i = 7, 0, -1 do  -- 从高位到低位
        local byte_val = bit32.band(math.floor(length_bits / (256 ^ i)), 0xFF)
        table.insert(length_bytes, string.char(byte_val))
    end    
    return padded .. table.concat(length_bytes)
end

-- 将 64 字节消息块转为 16 个 32 位字(SHA-1 处理单位)
local function words_from_block(block)
    local words = {}
    for j = 1, 16 do  -- 64字节 = 16 * 4字节
        local start = (j - 1) * 4 + 1
        local end_pos = start + 3
        -- 按大端序拼接 4 字节为 32 位字
        local byte1, byte2, byte3, byte4 = block:byte(start, end_pos)
        byte2 = byte2 or 0  -- 处理不足 4 字节的情况
        byte3 = byte3 or 0
        byte4 = byte4 or 0
        
        words[j] = bit32.bor(
            bit32.lshift(byte1, 24),  -- 最高位字节
            bit32.lshift(byte2, 16),
            bit32.lshift(byte3, 8),
            byte4  -- 最低位字节
        )
    end
    return words
end

-- SHA-1 哈希函数
function _M.sha1(message)
    -- 初始化哈希值(FIPS 180-4 标准)
    local H0 = 0x67452301
    local H1 = 0xEFCDAB89
    local H2 = 0x98BADCFE
    local H3 = 0x10325476
    local H4 = 0xC3D2E1F0

    -- 消息填充
    local padded = pad_message(message)

    -- 分块处理(每块 512 位 = 64 字节)
    for i = 1, #padded, 64 do
        local block = padded:sub(i, i + 63)  -- 取 64 字节块
        local words = words_from_block(block)  -- 转为 16 个 32 位字
        
        -- 扩展为 80 个 32 位字
        for t = 17, 80 do
            -- 第 t 个字 = 前 t-3、t-8、t-14、t-16 个字异或后左旋转 1 位
            local word = bit32.bxor(words[t - 3], words[t - 8], words[t - 14], words[t - 16])
            words[t] = rotl32(word, 1)
        end
        
        -- 主循环:更新哈希值
        local A, B, C, D, E = H0, H1, H2, H3, H4  -- 临时变量
        
        for t = 1, 80 do
            local temp, f, k  -- f 为函数,k 为常量(分 4 轮)
            local t_idx = t - 1  -- 0-79
            
            if t_idx < 20 then  -- 第 1 轮(0-19)
                f = bit32.bor(bit32.band(B, C), bit32.band(bit32.bnot(B), D))
                k = 0x5A827999
            elseif t_idx < 40 then  -- 第 2 轮(20-39)
                f = bit32.bxor(B, C, D)
                k = 0x6ED9EBA1
            elseif t_idx < 60 then  -- 第 3 轮(40-59)
                f = bit32.bor(bit32.band(B, C), bit32.band(B, D), bit32.band(C, D))
                k = 0x8F1BBCDC
            else  -- 第 4 轮(60-79)
                f = bit32.bxor(B, C, D)
                k = 0xCA62C1D6
            end
            
            -- 核心计算:A = (A 左旋 5 位) + f + E + 第 t 个字 + k
            temp = bit32.band(rotl32(A, 5) + f + E + words[t] + k, 0xFFFFFFFF)  -- 限制为 32 位
            
            -- 寄存器移位
            E = D
            D = C
            C = rotl32(B, 30)  -- B 左旋 30 位
            B = A
            A = temp
        end
        
        -- 累加哈希值(更新全局哈希值)
        H0 = bit32.band(H0 + A, 0xFFFFFFFF)
        H1 = bit32.band(H1 + B, 0xFFFFFFFF)
        H2 = bit32.band(H2 + C, 0xFFFFFFFF)
        H3 = bit32.band(H3 + D, 0xFFFFFFFF)
        H4 = bit32.band(H4 + E, 0xFFFFFFFF)
    end
    
    -- 转换哈希值为字节字符串(每个哈希值 4 字节,共 20 字节)
    local hash_bytes = {}
    for i, h in ipairs({H0, H1, H2, H3, H4}) do
        for j = 3, 0, -1 do  -- 大端序
            local byte_val = bit32.band(bit32.rshift(h, j * 8), 0xFF)
            table.insert(hash_bytes, string.char(byte_val))
        end
    end   
    return table.concat(hash_bytes)
end

-- 4.HMAC-SHA1算法(TOTP核心)
-- HMAC是基于哈希的消息认证码,TOTP通过HMAC-SHA1(密钥,时间步)生成验证码。
function _M.hmac_sha1(key, data)
    -- 参数校验
    if type(key) ~= "string" or type(data) ~= "string" then
        log.error("hmac_sha1: 非法参数类型")
        return nil, "INVALID_PARAM_TYPE"
    end
    
    -- 处理密钥:若长度 >64 字节,先用 SHA-1 压缩为 20 字节
    if #key > 64 then
        key = _M.sha1(key)
    end
    
    -- 填充密钥到 64 字节(HMAC 要求)
    key = key .. string.rep("\0", 64 - #key)  -- 不足部分补 0
    
    -- 生成内键(与 0x36 异或)和外键(与 0x5C 异或)
    local inner_key = {}
    local outer_key = {}
    for i = 1, 64 do
        local byte_val = key:byte(i)
        table.insert(inner_key, string.char(bit32.bxor(byte_val, 0x36)))  -- 内键异或 0x36
        table.insert(outer_key, string.char(bit32.bxor(byte_val, 0x5C)))  -- 外键异或 0x5C
    end    
    local inner_key_str = table.concat(inner_key)
    local outer_key_str = table.concat(outer_key)
    
    -- 计算 HMAC 值:HMAC = SHA1(外键 + SHA1(内键 + 数据))
    local inner_hash = _M.sha1(inner_key_str .. data)
    if not inner_hash then
        return nil, "INNER_HASH_FAILED"
    end
    
    local outer_hash = _M.sha1(outer_key_str .. inner_hash)
    return outer_hash, nil
end

-- 5.TOTP 验证码生成与验证
-- 5.1生成TOTP验证码
function _M.generate_totp(base32_secret, time_step, digits, allow_lowercase)
    -- 参数默认值
    time_step = time_step or 30  -- 时间步长(默认30秒)
    digits = digits or 6         -- 验证码位数(默认6位)
    allow_lowercase = allow_lowercase or true  -- 允许小写密钥
    
    -- 参数校验
    if type(base32_secret) ~= "string" or base32_secret == "" then
        log.error("generate_totp: 非法密钥")
        return nil, "INVALID_SECRET"
    end
    if time_step <= 0 or digits < 6 or digits > 8 then
        log.error("generate_totp: 非法参数范围")
        return nil, "INVALID_PARAMS"
    end
    
    -- 解码 Base32 密钥
    local secret, err = _M.base32_decode(base32_secret, allow_lowercase)
    if not secret then
        log.error("generate_totp: 密钥解码失败")
        return nil, err
    end
    
    -- 计算当前时间步(counter = 当前时间 / 时间步长,取整数)
    local current_time = os.time()
    local counter = math.floor(current_time / time_step)
    
    -- 将 counter 转为 8 字节大端序(TOTP 要求)
    local counter_bytes = {}
    for i = 7, 0, -1 do  -- 从高位到低位
        local byte_val = bit32.band(math.floor(counter / (256 ^ i)), 0xFF)
        counter_bytes[#counter_bytes + 1] = string.char(byte_val)
    end
    local counter_str = table.concat(counter_bytes)
    
    -- 计算 HMAC-SHA1(密钥, counter)
    local hash, err = _M.hmac_sha1(secret, counter_str)
    if not hash then
        log.error("generate_totp: HMAC 计算失败")
        return nil, err
    end
    
    -- 动态截断(DT,取哈希值的一部分生成数字)
    local offset = bit32.band(hash:byte(20), 0x0F)  -- 取第20字节的低4位作为偏移量
    
    -- 从偏移量开始取 4 字节,最高位设为 0(避免符号问题)
    local binary_code = 0
    for i = 1, 4 do
        local byte_val = hash:byte(offset + i)
        binary_code = bit32.bor(binary_code, bit32.lshift(byte_val, (4 - i) * 8))
    end
    binary_code = bit32.band (binary_code, 0x7FFFFFFF) -- 清除最高位,确保为正数

    -- 计算验证码(取模 10^digits,不足位数补 0)
    local otp = binary_code % (10 ^ digits)
    return string.format ("%0" .. digits .. "d", otp), nil
end

-- 5.2验证TOTP验证码* 
-- 支持时间窗口容错(默认前后各1个时间步,共3个窗口),解决网络延迟导致的验证失败问题。  
function _M.verify_totp(base32_secret, code, time_step, digits, window, allow_lowercase)
    -- 参数默认值
    time_step = time_step or 30
    digits = digits or 6
    window = window or 1  -- 时间窗口(默认±1个时间步)
    allow_lowercase = allow_lowercase or true
    
    -- 验证码格式校验(必须为数字)
    if type(code) ~= "string" or not code:match("^%d+$") then
        log.error("verify_totp: 验证码格式非法")
        return false, "INVALID_CODE_FORMAT"
    end
    
    -- 计算当前时间步
    local current_time = os.time()
    local current_counter = math.floor(current_time / time_step)
    
    -- 验证时间窗口内的所有可能时间步(如 window=1 则验证 current_counter-1、current_counter、current_counter+1)
    for i = -window, window do
        local counter_try = current_counter + i
        
        -- 生成对应时间步的验证码
        local otp, err = _M.generate_totp(base32_secret, time_step, digits, allow_lowercase)
        if not otp then
            log.error("verify_totp: 生成验证码失败")
            return false, err
        end
        
        -- 安全比较验证码(防止时序攻击)
        if _M.safe_compare(otp, code) then
            log.info("verify_totp: 验证成功,时间步=", counter_try)
            return true, "VALID", counter_try
        end
    end
    
    log.info("verify_totp: 所有时间窗口验证失败")
    return false, "INVALID", current_counter
end
-- 6.安全辅助函数
-- 6.1安全比较(防止时序攻击)
-- 普通字符串比较(如 ==)可能因比较时长泄露字符串差异信息,安全比较确保无论匹配与否,耗时一致。
function _M.safe_compare(a, b)
    if #a ~= #b then  -- 长度不同直接返回 false
        return false
    end
    
    local result = 0
    -- 逐字节比较,累计差异(即使中途发现差异,仍会比较完所有字节)
    for i = 1, #a do
        result = result + (string.byte(a, i) ~= string.byte(b, i) and 1 or 0)
    end
    return result == 0  -- 无差异则返回 true
end
-- 6.2生成Base32密钥(用于初始化用户TOTP密钥)
function _M.generate_secret(length, use_lowercase)
    length = length or 16  -- 密钥长度(默认16字节,128位)
    use_lowercase = use_lowercase or true  -- 生成小写密钥(兼容常见工具)
    
    -- 生成随机字节(优先使用 Nginx 共享内存的随机源)
    local bytes = ngx.shared.random and ngx.shared.random:bytes(length, true)
    if not bytes then
        -- 备选方案:使用 math.random(安全性较低,适合测试)
        bytes = ""
        for i = 1, length do
            bytes = bytes .. string.char(math.random(0, 255))
        end
    end
    
    -- 编码为 Base32
    local encoded, err = _M.base32_encode(bytes, use_lowercase)
    if not encoded then
        error("生成密钥失败: " .. (err or "未知错误"))
    end
    
    return encoded
end
-- 6.3Base32编码(用于生成密钥)
function _M.base32_encode(input, use_lowercase)
    if not input or #input == 0 then
        return nil, "EMPTY_INPUT"
    end
    
    -- 转换输入为字节数组
    local bytes = {}
    for i = 1, #input do
        bytes[i] = string.byte(input, i)
    end
    
    -- 计算输出长度(每5字节输入对应8字节Base32输出)
    local len = #bytes
    local rem = len % 5
    local out_len = ((len - rem) / 5) * 8
    if rem > 0 then
        out_len = out_len + 8  -- 不足5字节的部分也按8字符处理
    end
    
    -- 编码核心逻辑
    local output = ""
    -- 按5字节块处理
    for i = 0, len - 5, 5 do
        local chunk = {}
        for j = 0, 4 do  -- 取5字节
            if i + j < len then
                chunk[j + 1] = bytes[i + j + 1]
            else
                chunk[j + 1] = 0  -- 补0
            end
        end
        
        -- 将5字节(40位)拆分为8个5位值
        local value = bit32.bor(
            bit32.lshift(chunk[1], 32),  -- 第1字节左移32位
            bit32.lshift(chunk[2], 24),
            bit32.lshift(chunk[3], 16),
            bit32.lshift(chunk[4], 8),
            chunk[5]
        )
        
        -- 提取8个5位值,映射到Base32字符
        for j = 0, 7 do
            local idx = bit32.band(bit32.rshift(value, (7 - j) * 5), 0x1F) + 1  -- 1-32
            local char = string.sub(BASE32_UPPER, idx, idx)
            -- 转为小写(如果需要)
            if use_lowercase and idx <= 26 then  -- 字母部分(A-Z)转为小写
                char = string.lower(char)
            end
            output = output .. char
        end
    end
    
    -- 添加填充符(=)确保输出长度为8的倍数
    if rem > 0 then
        output = output .. string.rep(BASE32_PAD, 8 - ((len * 8) % 40) / 5)
    end
    
    return output, nil
end
-- 6. 模块导出
return _M

博文来自:www.51niux.com

1.7 ip_utils.lua

#该文件是IP地址校验工具模块,主要用于检查客户端IP是否在预定义的白名单中。支持两种匹配模式(精确匹配:直接判断IP是否存在于白名单/CIDR匹配:判断IP是否属于某个网段(如192.168.1.0/24))

# vi /usr/local/nginx/conf/auth/ip_utils.lua

-- 1. 模块初始化与依赖
local whitelist_cache = ngx.shared.ip_whitelist_cache  -- Nginx 共享内存缓存
local log = require("log_utils")  -- 日志工具
local bit = require("bit")  -- 位操作库(用于 CIDR 计算)

local _M = {}  -- 模块对象

-- 主校验函数:检查IP是否在白名单
function _M.check_ip_whitelist(ip)
    -- 高效缓存机制,使用Nginx共享内存缓存白名单,避免重复读取配置
    -- 检查白名单是否已初始化(缓存中应有 "initialized" 标记)
    if not whitelist_cache:get("initialized") then
        log.error("白名单未初始化")
        return false
    end
    
    -- 步骤1:精确匹配
    -- 先精确匹配再CIDR匹配,减少不必要的计算
    if whitelist_cache:get("entry:" .. ip) then
        log.debug("IP在白名单中(精确匹配): " .. ip)
        return true
    end
    
    -- 步骤2:CIDR匹配(遍历所有 CIDR 格式的白名单条目)
    -- 批量获取缓存键(get_keys),提升CIDR匹配效率
    local keys = whitelist_cache:get_keys(1000)  -- 获取缓存中的前1000个键
    for _, key in ipairs(keys) do
        if key:find("entry:", 1, true) == 1 then  -- 只处理以 "entry:" 开头的键
            local cidr = key:sub(7)  -- 提取实际的 IP/CIDR
            if cidr:find("/") and _M.ip_in_cidr(ip, cidr) then  -- 检查是否为 CIDR 格式并调用匹配函数
                log.debug("IP在白名单中(CIDR匹配): " .. ip .. " in " .. cidr)
                return true
            end
        end
    end
    
    -- 未匹配任何白名单条目
    return false
end

-- 3.CIDR 匹配核心函数
-- 判断一个IP是否属于某个CIDR网段(如192.168.1.0/24)
function _M.ip_in_cidr(ip, cidr)
    -- 解析输入IP为四个十进制数(如 "192.168.1.1" → {192, 168, 1, 1})
    local ip_octets = {}
    for octet in ip:gmatch("%d+") do
        table.insert(ip_octets, tonumber(octet))
    end
    if #ip_octets ~= 4 then return false end  -- 非合法IPv4地址
    
    -- 将IP转换为32位整数(用于位运算)
    local function ip_to_int(octets)
        return octets[1] * 256^3 + octets[2] * 256^2 + octets[3] * 256 + octets[4]
    end
    local ip_int = ip_to_int(ip_octets)
    
    -- 解析CIDR格式(如 "192.168.1.0/24" → base_ip="192.168.1.0", mask=24)
    local base_ip, mask = cidr:match("^([%d.]+)/(%d+)$")
    if not base_ip then
        return ip == cidr  -- 非CIDR格式(如纯IP),直接精确匹配
    end
    
    -- 解析基础IP(CIDR中的网络地址部分)
    local base_octets = {}
    for octet in base_ip:gmatch("%d+") do
        table.insert(base_octets, tonumber(octet))
    end
    if #base_octets ~= 4 then return false end  -- 非合法IPv4地址
    
    local base_int = ip_to_int(base_octets)
    mask = tonumber(mask)  -- CIDR掩码位数(如24)    
    -- 通过位运算(bit.band、bit.lshift)实现高效网段匹配 
    -- 计算32位掩码(如 /24 → 255.255.255.0 → 0xFFFFFF00)
    local mask_int = mask == 0 and 0 or bit.lshift(0xFFFFFFFF, 32 - mask)
    
    -- 判断IP是否在CIDR网段内:
    -- 1. 将IP和基础IP分别与掩码做AND运算
    -- 2. 比较结果是否相同(相同则表示在同一网段)
    return bit.band(ip_int, mask_int) == bit.band(base_int, mask_int)
end

return _M

1.8 init_whitelist.lua

#该文件是IP白名单加载模块,负责从配置文件读取IP白名单规则,并将其加载到Nginx共享内存中

# vi /usr/local/nginx/conf/auth/init_whitelist.lua

local config = require("config")  -- 全局配置模块(包含白名单文件路径)
local whitelist_cache = ngx.shared.ip_whitelist_cache  -- Nginx共享内存
local log = require("log_utils")  -- 日志工具
local ip_utils = require("ip_utils")  -- IP处理工具(此处未直接使用,但可能用于验证)

local _M = {}  -- 模块对象
-- 2. 白名单加载函数
function _M.load_whitelist() 
    log.info("加载IP白名单...")
    
    -- 步骤1:打开白名单文件
    local file, err = io.open(config.whitelist.file, "r")
    if not file then 
        log.error("无法打开白名单文件: " .. err)
        return false
    end
    
    -- 步骤2:清空现有缓存(避免旧规则残留)
    whitelist_cache:flush_all()
    
    -- 步骤3:逐行读取文件并加载到缓存
    local count = 0  -- 记录加载的规则数量
    for line in file:lines() do 
        line = line:gsub("^%s+", ""):gsub("%s+$", "")  -- 去除首尾空格
        
        -- 跳过空行和注释(以#开头的行)
        if line ~= "" and not line:find("^#") then 
            -- 将IP/CIDR存入缓存,键格式为 "entry:IP/CIDR",值为true
            -- 共享内存高效存储:支持多进程共享/每条规则以entry:IP/CIDR为键,避免键冲突并便于后续查询
            whitelist_cache:set("entry:" .. line, true)
            count = count + 1
        end
    end
    
    file:close()  -- 关闭文件
    
    -- 步骤4:存储元数据(标记初始化状态和统计信息)
    whitelist_cache:set("initialized", true)  -- 标记白名单已初始化
    whitelist_cache:set("last_loaded", ngx.time())  -- 记录加载时间戳,便于监控配置更新频率
    whitelist_cache:set("count", count)  -- 记录规则总数,用于运行时校验
            
    log.info("白名单加载完成,共 " .. count .. " 条记录")
    return true
end

return _M

# echo "127.0.0.1" >> /usr/local/nginx/conf/auth/ip_whitelist.txt   #创建一个白名单文件防止启动报错

1.9 init_by_lua.lua

#该文件是Nginx启动时的初始化脚本,其核心功能是在Nginx启动阶段加载IP白名单配置到共享内存,并标记初始化状态,为后续请求的 IP 校验做准备。

# vi /usr/local/nginx/conf/auth/init_by_lua.lua

-- 加载白名单初始化模块
-- 模块化设计:引入独立模块,将白名单加载逻辑与初始化入口分离,提高可维护性。
local init_whitelist = require("init_whitelist")

-- 执行白名单加载,返回布尔值表示成功/失败
local success = init_whitelist.load_whitelist()

-- 根据加载结果设置状态并记录日志
if not success then
    ngx.log(ngx.ERR, "白名单初始化失败,系统将拒绝所有请求")
    -- 设置初始化标记为 false(关键!后续请求校验会拒绝所有IP)
    -- 共享内存缓存:使用ngx.shared.ip_whitelist_cache存储白名单数据,这是Nginx进程间共享的内存区域,所有工作进程均可访问。
    ngx.shared.ip_whitelist_cache:set("initialized", false)
else
    ngx.log(ngx.INFO, "白名单初始化成功")
end

1.10 init_worker.lua

#该文件是 Nginx 工作进程初始化脚本,其核心功能是:在每个Nginx 工作进程启动时执行初始化任务、实现IP白名单的定时自动刷新机制、通过分布式锁保证多进程

#该脚本与 init_by_lua.lua 互补,前者负责全局初始化,后者负责工作进程级别的初始化和定时任务

# vi /usr/local/nginx/conf/auth/init_worker.lua

local config = require("config")  -- 全局配置
local whitelist_cache = ngx.shared.ip_whitelist_cache  -- 共享内存缓存
local log = require("log_utils")  -- 日志工具
local MAX_RETRIES = 3  -- 最大重试次数

-- 2.安全加载模块工具函数
-- 作用:使用 pcall 安全加载 Lua 模块,捕获并记录加载异常,避免因模块缺失导致进程崩溃。
local function safe_require(module_name)
    local status, result = pcall(require, module_name)
    if not status then
        log.error("模块加载失败: " .. module_name .. ", 错误: " .. result)
        return nil
    end
    return result
end

-- 3.白名单重载函数
-- 核心逻辑:检查是否达到配置的刷新间隔、通过共享内存锁(reload_lock)实现分布式锁机制、仅获取锁的进程执行白名单刷新,避免多进程重复操作
local function reload_whitelist()
    local init_whitelist = safe_require("init_whitelist")
    if not init_whitelist then
        return false
    end
    
    local now = ngx.time()  -- 当前时间戳
    local last_load_time = whitelist_cache:get("last_loaded") or 0  -- 上次加载时间
    
    -- 判断是否达到刷新间隔(配置中定义,如 300 秒)
    if now - last_load_time >= config.whitelist.reload_interval then
        -- 亮点1:分布式锁机制:通过whitelist_cache:add("reload_lock")实现跨进程同步使用、锁有效期(30秒)防止死锁
        local ok, err = whitelist_cache:add("reload_lock", true, 30)
        if ok then
            log.info("工作进程 " .. ngx.worker.pid() .. " 重新加载白名单")
            local success = init_whitelist.load_whitelist()  -- 调用实际加载函数
            whitelist_cache:delete("reload_lock")  -- 释放锁
            return success
        elseif err ~= "exists" then
            log.error("获取白名单加载锁失败: " .. err)
        end
    end
    
    return true  -- 时间未到或锁被占用,视为成功
end

-- 4.定时器初始化函数
-- 关键点:使用ngx.timer.every创建周期性定时器\配置刷新间隔动态调整、处理premature标志,避免定时器提前终止时的异常
local function init_timers()
    -- 亮点2:定时自动刷新:基于ngx.timer.every的非阻塞定时器,不影响正常请求处理、刷新间隔可配置,支持动态调整
    local ok, err = ngx.timer.every(config.whitelist.reload_interval, function(premature)
        if premature then return end  -- 定时器提前终止时退出
        reload_whitelist()  -- 执行白名单刷新
    end)
    
    if not ok then
        log.error("创建白名单定时任务失败: " .. err)
        return false
    end
    
    log.info("白名单定时任务创建成功,间隔: " .. config.whitelist.reload_interval .. " 秒")
    return true
end

-- 5.工作进程初始化主函数
-- 重试机制:首次加载白名单失败时自动重试(最多3次)、每次重试间隔0.5秒,提高初始化成功率
local function init_worker()
    log.info("工作进程初始化开始 (PID: " .. ngx.worker.pid() .. ")")
    
    -- 首次加载白名单(带重试机制)
    local retries = 0
    local success = false
    while retries < MAX_RETRIES and not success do
        success = reload_whitelist()
        retries = retries + 1
        if not success then 
            ngx.sleep(0.5)  -- 失败时等待 0.5 秒后重试
        end
    end
    
    if not success then
        log.error("白名单首次加载失败,尝试次数: " .. MAX_RETRIES)
    end
    
    -- 初始化定时器
    init_timers()
    
    log.info("工作进程初始化完成 (PID: " .. ngx.worker.pid() .. ")")
end

-- 6.安全执行初始化
local status, err = pcall(init_worker)
if not status then
    log.error("工作进程初始化异常: " .. err)
end

1.11 auth.lua

#该文件是认证网关核心模块,负责对客户端请求进行多层级认证(IP 白名单、会话验证、TOTP 验证码),并实现免认证URL控制、会话管理、安全防护等功能。作为 Nginx 请求处理链路的关键环节,它决定请求是否被允许访问后端服务。

# vi /usr/local/nginx/conf/auth/auth.lua

-- 第一部分:模块依赖与初始化
local config = require("config")  -- 全局配置(含免认证规则、会话设置等)
local redis_utils = require("redis_utils")  -- Redis操作工具(会话存储、用户状态)
local db_utils = require("db_utils")  -- 数据库工具(用户密钥查询)
local ip_utils = require("ip_utils")  -- IP白名单校验工具
local totp_utils = require("totp_utils")  -- TOTP验证码生成与验证
local log = require("log_utils")  -- 日志工具

-- 第二部分:基础工具函数
-- 2.1. 安全日志处理
local function safe_log_value(value)
    if not value or value == "" then
        return "[空值]"  -- 明确标记空值,避免日志显示空白
    end
    -- 过滤不可见字符(如控制字符、特殊符号),防止日志格式混乱
    return tostring(value):gsub("[^%w%p ]", "?")
end
-- 2.2.免认证URL检查
-- 2.2.1 免认证规则匹配逻辑
local function is_skip_auth(url, skip_rules)
    -- 精确匹配(URL完全一致)
    if skip_rules[url] then
        return true
    end
    -- 前缀匹配(规则以"/"结尾时,匹配所有以此为前缀的URL)
    for pattern, _ in pairs(skip_rules) do
        if string.sub(pattern, -1) == "/" and string.sub(url, 1, #pattern) == pattern then
            return true
        end
    end
    return false
end

-- 2.2.2 免认证检查入口
local function check_skip_auth()
    local current_url = ngx.var.uri  -- 当前请求URL
    local current_host = ngx.var.host  -- 当前请求域名
    local log_prefix = "[免认证检查] "

    -- 检查全局免认证URL(所有域名生效)
    if config.skip_auth_global and type(config.skip_auth_global) == "table" then
        if is_skip_auth(current_url, config.skip_auth_global) then
            log.info(log_prefix .. "全局免认证URL,允许访问(url: " .. safe_log_value(current_url) .. ")")
            return true
        end
    end

    -- 检查域名级免认证URL(仅当前域名生效)
    if config.skip_auth_domain and type(config.skip_auth_domain) == "table" then
        local domain_rules = config.skip_auth_domain[current_host]
        if domain_rules and type(domain_rules) == "table" then
            if is_skip_auth(current_url, domain_rules) then
                log.info(log_prefix .. "域名级免认证URL,允许访问(host: " .. safe_log_value(current_host) .. ", url: " .. safe_log_value(current_url) .. ")")
                return true
            end
        end
    end

    return false  -- 需要认证
end
-- 2.3. 客户端 IP 获取
local function get_client_ip()
    local ip = ngx.var.remote_addr  -- 直接客户端IP
    local xff = ngx.var.http_x_forwarded_for  -- 代理链IP列表(X-Forwarded-For)
    if xff then
        -- 取最原始客户端IP(X-Forwarded-For格式:client_ip, proxy1_ip, proxy2_ip)
        local first_ip = string.match(xff, "^([^,]+)")
        ip = first_ip or ip
    end
    return ip
end

-- 第三部分:认证页面渲染
-- 3.1 HTML模板加载
-- 从文件系统加载HTML模板,支持通过配置动态指定模板路径,便于前端页面定制
local function load_template(name)
    if not name then
        log.error("模板名称为空")
        return "<p>系统错误:模板名称为空</p>"
    end

    local path = config.templates and config.templates[name]  -- 从配置获取模板路径
    if not path then
        log.error("模板路径未配置,模板名称: " .. name)
        return "<p>系统错误:模板未配置</p>"
    end

    -- 读取模板文件
    local file, err = io.open(path, "r")
    if not file then
        log.error("模板加载失败(路径: " .. path .. "):" .. (err or "未知错误"))
        return "<p>系统错误:模板加载失败</p>"
    end

    local content = file:read("*a")
    file:close()
    return content or "<p>系统错误:模板内容为空</p>"
end
-- 3.2. 认证弹窗渲染
-- 生成认证弹窗页面,支持动态显示错误信息和剩余尝试次数,同时通过响应头禁用缓存,确保页面实时性。
local function show_auth_popup(redirect_url, error_msg, remaining_attempts)
    error_msg = error_msg or ""
    remaining_attempts = remaining_attempts or (config.auth and config.auth.max_attempts or 3)
    -- 错误信息显示控制(有错误时显示,否则隐藏)
    local display = error_msg ~= "" and "block" or "none"

    local template = load_template("auth_popup")  -- 加载弹窗模板
    -- 替换模板变量(将动态内容注入HTML)
    template = string.gsub(template, "{redirect_url}", redirect_url or "")
    template = string.gsub(template, "{error_msg}", error_msg)
    template = string.gsub(template, "{error_display}", display)
    template = string.gsub(template, "{remaining_attempts}", tostring(remaining_attempts))

    -- 设置响应头(禁用缓存、指定HTML类型)
    ngx.header["Content-Type"] = "text/html; charset=utf-8"
    ngx.header["Cache-Control"] = "no-store, no-cache, must-revalidate"
    ngx.header["Pragma"] = "no-cache"
    ngx.header["Expires"] = "0"
    ngx.say(template)  -- 输出HTML内容
    return false
end

-- 第四部分:用户状态管理
-- 4.1.封禁状态检查
-- 作用:检查用户是否因多次认证失败被封禁,为登录限流提供支持
local function check_ban_status(username)
    if not username then
        return false, "用户名不能为空"
    end
    -- 调用redis_utils查询封禁状态(封装Redis操作)
    local is_banned = redis_utils.is_user_banned(username)
    if is_banned then
        return true, "账号已封禁,请20分钟后重试"
    end
    return false, nil
end
-- 4.2.认证尝试次数记录
-- 核心机制:通过Redis的INCR命令原子记录失败次数,达到阈值时自动封禁用户,防止暴力破解。
local function record_attempt(username)
    if not username then
        log.error("记录尝试次数失败:用户名为空")
        return (config.auth and config.auth.max_attempts or 3)
    end
    -- 调用redis_utils记录失败次数(封装Redis自增操作)
    local attempts = redis_utils.record_attempt(username)
    if not attempts then
        log.error("记录尝试次数失败,用户: " .. safe_log_value(username))
        return (config.auth and config.auth.max_attempts or 3)
    end
    -- 达到最大尝试次数时自动封禁
    if attempts <= 0 then
        redis_utils.ban_user(username)
        log.warn("用户尝试次数超限,已封禁: " .. safe_log_value(username))
        return 0
    end
    return attempts
end

-- 第五部分:Cookie操作(会话管理核心)
-- 5.1. Cookie 读取
local function get_cookie(name)
    if not name then
        log.error("获取Cookie失败:Cookie名称为空")
        return nil
    end
    local cookie_header = ngx.var.http_cookie  -- 原始Cookie头
    log.info("请求Cookie头: " .. safe_log_value(cookie_header))
    
    if not cookie_header then
        log.info("未找到Cookie头")
        return nil
    end
    
    -- 解析Cookie(按";"分割,提取目标Cookie)
    local values = {}
    for cookie in string.gmatch(cookie_header, "[^;]+") do
        local key, value = cookie:match("^%s*(.-)%s*=%s*(.-)%s*$")
        if key and key == name then
            table.insert(values, value or "")
        end
    end
    
    -- 处理多值Cookie(取第一个)
    if #values == 0 then
        log.info("未找到指定Cookie: " .. name)
        return nil
    elseif #values > 1 then
        log.warn("发现" .. #values .. "个同名Cookie(名称: " .. name .. "),使用第一个值")
        return values[1]
    end
    
    -- 清洗Cookie值(仅保留字母数字,避免注入风险)
    local clean_value = string.gsub(values[1], "%s+", "")
    log.info("找到Cookie: " .. name .. "(清洗后: " .. safe_log_value(clean_value) .. ")")
    return clean_value
end
-- 5.2. Cookie 设置
local function set_cookie(name, value, expire_seconds)
    if not name or not value then
        log.error("设置Cookie失败:名称或值为空")
        return nil
    end
    -- 从配置读取Cookie属性(带默认值)
    local session_conf = config.session or {}
    local domain = session_conf.cookie_domain or ""
    local path = session_conf.cookie_path or "/"
    local secure = session_conf.cookie_secure or false  -- HTTPS时启用Secure标记
    local http_only = session_conf.cookie_http_only or true  -- 防止JS读取

    -- 清洗会话ID(仅保留字母数字,避免注入风险)
    local safe_value = string.gsub(value, "[^a-zA-Z0-9]", "")
    if safe_value ~= value then
        log.warn("会话ID包含特殊字符,已清洗: " .. safe_log_value(value) .. " → " .. safe_value)
        value = safe_value
    end

    -- 构建Cookie字符串
    local cookie = string.format("%s=%s", name, value)
    
    -- 添加domain(支持子域名共享)
    if domain and domain ~= "" then
        if not string.match(domain, "^%.") and domain ~= ngx.var.host then
            domain = "." .. domain  -- 自动补全前缀点(如 "example.com" → ".example.com")
        end
        cookie = cookie .. "; Domain=" .. domain
    end
    
    -- 添加路径和过期时间
    cookie = cookie .. "; Path=" .. path
    if expire_seconds and expire_seconds > 0 then
        local expire_time = ngx.cookie_time(ngx.time() + expire_seconds)  -- 转换为Cookie时间格式
        cookie = cookie .. "; Expires=" .. expire_time .. "; Max-Age=" .. expire_seconds
    end
    
    -- 安全属性
    if secure and ngx.var.scheme == "https" then
        cookie = cookie .. "; Secure"  -- 仅HTTPS传输
    end
    if http_only then
        cookie = cookie .. "; HttpOnly"  -- 禁止JS访问
    end
    
    -- SameSite属性(防CSRF)
    cookie = cookie .. "; SameSite=" .. (secure and "None" or "Lax")

    ngx.header["Set-Cookie"] = cookie
    log.info("设置Cookie: " .. cookie)
    return value
end
-- 5.3. Cookie删除
-- 通过多路径/多域名组合删除 Cookie,确保在各种配置下都能彻底清除无效会话,避免残留的无效Cookie导致认证异常
local function delete_cookie(name)
    if not name then
        log.error("删除Cookie失败:名称为空")
        return
    end
    local session_conf = config.session or {}
    local domain = session_conf.cookie_domain or ""
    local path = session_conf.cookie_path or "/"
    
    -- 构建删除Cookie(过期时间设为过去)
    local cookie = string.format("%s=deleted; Path=%s; Expires=Thu, 01 Jan 1970 00:00:00 GMT", name, path)
    if domain and domain ~= "" then
        if not string.match(domain, "^%.") and domain ~= ngx.var.host then
            domain = "." .. domain
        end
        cookie = cookie .. "; Domain=" .. domain
    end
    
    -- 关键修复:覆盖所有可能的Cookie路径/域名组合(避免残留)
    local cookies = {cookie}
    -- 添加根路径Cookie删除(防止路径不匹配)
    if path ~= "/" then
        table.insert(cookies, string.format("%s=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Domain=%s", name, domain))
    end
    -- 添加无域名Cookie删除(兼容不同域名配置)
    table.insert(cookies, string.format("%s=deleted; Path=%s; Expires=Thu, 01 Jan 1970 00:00:00 GMT", name, path))
    
    ngx.header["Set-Cookie"] = cookies
    log.info("删除Cookie: " .. table.concat(cookies, "; "))
end

-- 第六部分:主认证流程(核心逻辑)
local function authenticate()
    local client_ip = get_client_ip()
    local log_prefix = "IP:" .. client_ip .. " - "

    -- 6.1. 全局禁用缓存(避免认证页面被缓存)
    ngx.header["Cache-Control"] = "no-store, no-cache, must-revalidate"
    ngx.header["Pragma"] = "no-cache"
    ngx.header["Expires"] = "0"

    -- 6.2. 免认证URL检查(优先级最高)
    if check_skip_auth() then
        log.info(log_prefix .. "URL在免认证列表中,允许访问")
        return true
    end

    -- 6.3. IP白名单检查(跳过认证)
    if ip_utils.check_ip_whitelist(client_ip) then
        log.info(log_prefix .. "IP在白名单中,允许访问")
        return true
    end

    -- 6.4. 会话验证(从Cookie获取session_id)
    local session_conf = config.session or {}
    local session_id = get_cookie(session_conf.cookie_name or "session_id")
    log.info(log_prefix .. "当前会话ID: " .. safe_log_value(session_id))

    if session_id and session_id ~= "" then
        -- 调用redis_utils统一处理会话验证(封装本地缓存+Redis查询)
        local username = redis_utils.get_user_session(session_id)
        
        -- 验证用户名有效性(防脏数据)
        if username and type(username) == "string" and username ~= "" and not string.find(username, "userdata:") then
            log.info(
                log_prefix .. "会话有效," ..
                "session_id: [" .. safe_log_value(session_id) .. "], " ..
                "用户: [" .. safe_log_value(username) .. "]"
            )
            return true
        else
            log.info(
                log_prefix .. "会话无效(用户名无效),清理资源," ..
                "session_id: [" .. safe_log_value(session_id) .. "], " ..
                "用户名: [" .. safe_log_value(username) .. "]"
            )
            -- 会话无效:强制删除Cookie并清理本地缓存
            delete_cookie(session_conf.cookie_name or "session_id")
            -- 额外清理本地缓存(双重保障)
            if ngx.shared.session_cache then
                local cache_key = "session:" .. (session_conf.cookie_domain or "") .. ":" .. session_id
                ngx.shared.session_cache:delete(cache_key)
                log.info("[本地缓存-清理] 主动删除无效会话(session_id: [" .. safe_log_value(session_id) .. "])")
            end
        end
    else
        log.info(
            log_prefix .. "未获取到有效session_id(session_id为空或空白)"
        )
    end

    -- 6.5. 处理POST认证请求(提交用户名和验证码)
    if ngx.var.request_method == "POST" then
        ngx.req.read_body()  -- 读取POST请求体
        local args = ngx.req.get_post_args()
        local username = args.username
        local code = args.code
        local redirect_url = args.redirect_url or ngx.var.request_uri

        -- 参数校验:用户名和验证码不能为空
        if not username or not code then
            log.warn(
                log_prefix .. "参数缺失," ..
                "username: [" .. safe_log_value(username) .. "], " ..
                "code: [" .. safe_log_value(code) .. "]"
            )
            return show_auth_popup(redirect_url, "请输入用户名和验证码")
        end

        -- 检查用户是否被封禁
        local is_banned, ban_msg = check_ban_status(username)
        if is_banned then
            log.warn(
                log_prefix .. "用户已封禁," ..
                "username: [" .. safe_log_value(username) .. "], " ..
                "封禁原因: [" .. safe_log_value(ban_msg) .. "]"
            )
            return show_auth_popup(redirect_url, ban_msg, 0)
        end

        -- 获取用户TOTP密钥(Redis优先,数据库兜底)
        local secret, err = redis_utils.get_user_secret(username)
        
        -- 明确判断用户不存在(Redis无记录)
        if not secret then
            if err == "user_not_found" then
                log.warn(
                    log_prefix .. "用户不存在(Redis无记录)," ..
                    "username: [" .. safe_log_value(username) .. "]"
                )
            else
                log.error(
                    log_prefix .. "获取用户密钥失败," ..
                    "username: [" .. safe_log_value(username) .. "], " ..
                    "错误: [" .. safe_log_value(err) .. "]"
                )
            end
            local attempts = record_attempt(username)
            return show_auth_popup(redirect_url, "用户不存在", attempts)
        end

        -- 过滤无效密钥(如null/空值)
        if secret == "null" or secret == "NULL" or secret == "" then
            log.error(
                log_prefix .. "用户密钥无效," ..
                "username: [" .. safe_log_value(username) .. "], " ..
                "secret: [" .. safe_log_value(secret) .. "]"
            )
            local attempts = record_attempt(username)
            return show_auth_popup(redirect_url, "用户状态异常,请联系管理员", attempts)
        end

        -- 数据库兜底查询(确保数据一致性)
        local db_secret = db_utils.get_user_secret(username)
        if not db_secret then
            log.warn(
                log_prefix .. "用户不存在(数据库无记录)," ..
                "username: [" .. safe_log_value(username) .. "]"
            )
            -- 清理Redis无效记录
            redis_utils.set_user_secret(username, nil)
            local attempts = record_attempt(username)
            return show_auth_popup(redirect_url, "用户不存在", attempts)
        end

        -- 同步数据库中的密钥到Redis(双向一致性保障)
        if db_secret and db_secret ~= secret then
            log.info(
                log_prefix .. "更新Redis中的用户密钥," ..
                "username: [" .. safe_log_value(username) .. "]"
            )
            redis_utils.set_user_secret(username, db_secret)
            secret = db_secret
        end

        -- 验证TOTP验证码
        log.info(
            log_prefix .. "开始验证TOTP," ..
            "username: [" .. safe_log_value(username) .. "]"
        )
        local auth_conf = config.auth or {}
        local is_valid, err = totp_utils.verify_totp(
            secret,
            code,
            auth_conf.totp_time_step or 30,  -- 时间步长(秒)
            auth_conf.code_length or 6        -- 验证码长度
        )

        if is_valid then
            -- 认证成功:创建新会话
            redis_utils.reset_attempts(username)  -- 重置失败次数
            -- 生成随机会话ID(包含时间戳和随机数,确保唯一性)
            local new_session_id = ngx.md5(username .. ngx.time() .. math.random() .. client_ip)
            
            -- 使用redis_utils统一创建会话
            local expire_seconds = session_conf.expire_time or 3600
            local create_ok = redis_utils.create_user_session(new_session_id, username, expire_seconds)
            
            if not create_ok then
                log.error(
                    log_prefix .. "会话创建失败," ..
                    "username: [" .. safe_log_value(username) .. "], " ..
                    "session_id: [" .. safe_log_value(new_session_id) .. "]"
                )
                return show_auth_popup(redirect_url, "系统错误:会话创建失败")
            end
            
            -- 设置会话Cookie(与Redis有效期一致)
            set_cookie(session_conf.cookie_name or "session_id", new_session_id, expire_seconds)
            
            -- 重定向到认证前的URL
            log.info(
                log_prefix .. "认证成功,重定向到: [" .. safe_log_value(redirect_url) .. "], " ..
                "username: [" .. safe_log_value(username) .. "], " ..
                "session_id: [" .. safe_log_value(new_session_id) .. "]"
            )
            ngx.header["Location"] = redirect_url
            ngx.exit(302)  -- 执行重定向
        else
            -- 认证失败:记录尝试次数并提示错误
            local attempts = record_attempt(username)
            log.warn(
                log_prefix .. "TOTP验证失败," ..
                "username: [" .. safe_log_value(username) .. "], " ..
                "剩余次数: " .. attempts
            )
            local error_msg = "验证码错误"
            if attempts <= 0 then
                error_msg = "尝试次数过多,账号已被封禁20分钟"
            end
            return show_auth_popup(redirect_url, error_msg, attempts)
        end
    end

    -- 6.6. 显示认证弹窗(GET请求或认证失败时)
    log.info(
        log_prefix .. "显示认证弹窗," ..
        "当前URL: [" .. safe_log_value(ngx.var.request_uri) .. "]"
    )
    return show_auth_popup(ngx.var.request_uri)
end

-- 第七部分:认证结果处理     
-- 执行认证流程并返回结果
local auth_result = authenticate()
if not auth_result then
    ngx.exit(ngx.HTTP_UNAUTHORIZED)  -- 认证失败,返回401
else
    ngx.exit(ngx.DECLINED)  -- 认证成功,继续后续处理
end

博文来自:www.51niux.com

1.12 auth_popup.html

# mkdir /usr/local/nginx/conf/auth/templates

#该文件是认证系统的前端弹窗模板,用于展示用户登录界面。当用户访问需要认证的资源且尚未登录时,系统会加载此模板生成一个全屏遮罩层,要求用户输入用户名和TOTP动态验证码进行身份验证。

# vi /usr/local/nginx/conf/auth/templates/auth_popup.html

<!DOCTYPE html>
<html>
<head>
    <title>身份验证</title>
    <style>
        /* 全屏遮罩层:覆盖整个页面,半透明黑色背景,居中显示认证框 */
        .auth-overlay { 
            position: fixed; 
            top: 0; 
            left: 0; 
            width: 100%; 
            height: 100%; 
            background: rgba(0,0,0,0.5); /* 半透明黑色,阻止用户操作底层页面 */
            display: flex; 
            justify-content: center; /* 水平居中 */
            align-items: center; /* 垂直居中 */
            z-index: 9999; /* 确保遮罩层在最上层 */
        }
        
        /* 认证框:白色背景,带阴影和圆角,固定宽度 */
        .auth-box { 
            background: white; 
            padding: 20px; 
            border-radius: 5px; /* 圆角边框 */
            width: 300px; 
            box-shadow: 0 0 10px rgba(0,0,0,0.3); /* 阴影效果增强层次感 */
        }
        
        /* 错误提示区域:红色文字,由后端控制显示/隐藏 */
        .auth-error { 
            color: red; 
            margin-bottom: 10px; 
            display: {error_display}; /* {error_display}由后端替换为block/none */
        }
        
        /* 安全提示:灰色小字,告知用户封禁规则 */
        .auth-note { 
            color: #666; 
            font-size: 12px; 
            margin-bottom: 15px; 
        }
        
        /* 表单样式:输入框和按钮占满宽度 */
        .auth-form input { 
            width: 100%; 
            padding: 8px; 
            margin-bottom: 10px; 
            box-sizing: border-box; /* 确保padding不影响总宽度 */
        }
        
        /* 提交按钮:绿色背景,白色文字,圆角 */
        .auth-form button { 
            width: 100%; 
            padding: 10px; 
            background: #4CAF50; 
            color: white; 
            border: none; 
            border-radius: 3px; 
            cursor: pointer; /* 鼠标悬停显示手型 */
        }
        
        /* 剩余尝试次数:橙色文字,提示用户剩余机会 */
        .auth-attempts { 
            color: orange; 
            margin-top: 10px; 
            font-size: 14px; 
        }
    </style>
</head>
<body>
    <!-- 全屏遮罩层:阻止用户操作页面其他内容 -->
    <div class="auth-overlay">
        <!-- 认证框:包含所有表单元素 -->
        <div class="auth-box">
            <h3>系统认证</h3>
            
            <!-- 错误提示区域:显示后端返回的错误信息(如验证码错误) -->
            <div class="auth-error" id="error-message">{error_msg}</div> <!-- {error_msg}由后端替换为具体错误文本 -->
            
            <!-- 安全提示文本:告知用户连续错误的后果 -->
            <div class="auth-note">提示:连续错误3次,账号将被临时锁定20分钟</div>
            
            <!-- 认证表单:提交用户名和验证码到后端 -->
            <form class="auth-form" method="post" action="{redirect_url}"> <!-- {redirect_url}由后端替换为原始请求URL -->
                <!-- 用户名输入框:必填,使用localStorage保存 -->
                <input type="text" name="username" placeholder="用户名" required>
                
                <!-- 验证码输入框:必填,6位动态码 -->
                <input type="text" name="code" placeholder="6位动态验证码" required>
                
                <!-- 隐藏字段:保存原始URL,用于认证成功后重定向 -->
                <input type="hidden" name="redirect_url" value="{redirect_url}">
                
                <!-- 提交按钮:触发表单提交到后端 -->
                <button type="submit">验证</button>
            </form>
            
            <!-- 剩余尝试次数:显示后端计算的剩余次数 -->
            <div class="auth-attempts">剩余尝试次数: {remaining_attempts}</div> <!-- {remaining_attempts}由后端替换为数字 -->
        </div>
    </div>
    
    <script>
        // 页面加载完成后执行:优化用户体验
        document.addEventListener('DOMContentLoaded', function() {
            // 获取用户名和验证码输入框元素
            const usernameInput = document.querySelector('input[name="username"]');
            const codeInput = document.querySelector('input[name="code"]');
            
            // 从localStorage读取上次保存的用户名并自动填充
            const savedUsername = localStorage.getItem('auth_username');
            if (savedUsername) {
                usernameInput.value = savedUsername;
            }
            
            // 每次加载页面时清空验证码输入框(避免重复提交旧验证码)
            codeInput.value = '';
            
            // 监听用户名输入变化,实时保存到localStorage(避免用户重复输入)
            usernameInput.addEventListener('input', function() {
                localStorage.setItem('auth_username', this.value);
            });
        });
    </script>
</body>
</html>

二、验证测试

2.1 IP不在白名单中登录弹窗

#vi /usr/local/nginx/conf/conf.d/lua.test.com.conf   #配置一个最简单的域名进行测试,记得自己配置下hosts文件哦

server {
    listen 80;
    server_name lua.test.com;
    root /opt/web;
    charset utf-8;
    error_log /usr/local/nginx/logs/error.log debug;
    
    access_by_lua_file /usr/local/nginx/conf/auth/auth.lua;
    
    location / {
        root /opt/web;
        index index.html;
        # 确保不干扰Cookie
        proxy_set_header Cookie $http_cookie;
    }
}

#vi /opt/web/index.html  #最简单的测试首页

<!DOCTYPE html>
<html>
<head>
    <title>Hello World</title>
</head>
<body>
    <h1>Hello, World!</h1>
</body>
</html>

#浏览器访问http://lua.test.com/index.html

# tail -f /usr/local/nginx/logs/error.log  #从错误日志可以看到缺少授权

[error] 588087#0: *3945 attempt to set status 401 via ngx.exit after sending out the response status 200, 
client: 192.168.1.101, server: lua.test.com, request: "POST /index.html HTTP/1.1", 
host: "lua.test.com", referrer: "http://lua.test.com/index.html"


image.png  

#上图为故意输错验证码可以看到有错误提示

# tail -f /opt/log/nginx/auth_error.log  #看看错误日志怎么体现的呢?

[ERROR] [192.168.1.101] [1b90880fecccc634198dfa86824295d2] [获取密钥] Redis返回非字符串类型: 
[WARN] [192.168.1.101] [19dd9cb762b43c17096c689ac39260e0] IP:192.168.1.101 - TOTP验证失败(username: test3, 剩余尝试次数: 2)
[ERROR] [192.168.1.101] [6302ec80939f6e311ae7aa1edd30a8f2] [获取密钥] Redis返回非字符串类型: 
[WARN] [192.168.1.101] [a5136d3ddf1ddd619c1ca5af523e7e48] IP:192.168.1.101 - TOTP验证失败(username: test1, 剩余尝试次数: 2)
[ERROR] [192.168.1.101] [33885994750b49fa74e8182796c3d349] [获取密钥] Redis返回非字符串类型: 
[WARN] [192.168.1.101] [72cf3b0a64321ca26f77f63d19c42c39] IP:192.168.1.101 - 用户不存在(username: test12313)

# tail -f /opt/log/nginx/auth_access.log  #可以看到完整的认证过程,这里就不粘贴日志了

image.png
#也可以自行查看redis内的key信息变化跟代码里面的逻辑一一对应哈

#然后我们输入正确的验证码,再看下变化,这里就不截图了哈,hello world页面也没啥好截图的。

# tail -f /opt/log/nginx/auth_access.log  #可以看看日志最后的输出

[INFO] [192.168.1.101] [8de87d8836845f5ffd70d9776bafd7e5] IP:192.168.1.101 - 认证成功,重定向到: /index.html
[INFO] [192.168.1.101] [245518fd33b5eb2adf168ffb531219ee] 请求Cookie头: auth_session=15d4063c6ef40e65d9d35a590155e541
[INFO] [192.168.1.101] [147fd95be0607f4952edf94d78ebf495] 找到Cookie: auth_session(清洗后: 15d4063c6ef40e65d9d35a590155e541)
[INFO] [192.168.1.101] [4e036267e7ec4429748630c463853cc6] IP:192.168.1.101 - 当前会话ID: 15d4063c6ef40e65d9d35a590155e541
[INFO] [192.168.1.101] [7e34c6f7aebafcd4f0d1623450320c03] [本地缓存-读取] 命中(session_id: 15d4063c6ef40e65d9d35a590155e541)
[INFO] [192.168.1.101] [416f8fd60145319091b5a8b7a488a72b] IP:192.168.1.101 - 本地缓存会话有效,用户: test3

#127.0.0.1:6379> get session:.test.com:15d4063c6ef40e65d9d35a590155e541  #现在redis中也有一个以固定格式命名的key

"test3"

#现在你在反复刷新页面,浏览器也不会再有弹窗认证了,可以看到cookie已经存储到浏览器中了:
image.png

# tail -f /opt/log/nginx/auth_access.log  #那么日志中怎么体现的呢?

[INFO] [192.168.1.101] [453edb8fc44ac3a8cf0ca6f753bfa9b9] 请求Cookie头: auth_session=15d4063c6ef40e65d9d35a590155e541
[INFO] [192.168.1.101] [9d974c9d8ada2092942e941a205f9751] 找到Cookie: auth_session(清洗后: 15d4063c6ef40e65d9d35a590155e541)
[INFO] [192.168.1.101] [69e8896b5171232d8feb13ccba2c5026] IP:192.168.1.101 - 当前会话ID: 15d4063c6ef40e65d9d35a590155e541
[INFO] [192.168.1.101] [ab5dcb76450c19e1a9af413fcec13ab6] [本地缓存-读取] 命中(session_id: 15d4063c6ef40e65d9d35a590155e541)
[INFO] [192.168.1.101] [3545864b2b5a1c4627d8ec1f3c896f9f] IP:192.168.1.101 - 本地缓存会话有效,用户: test3

2.2 验证锁定状态

image.png

#故意输错三次验证码就会进入封禁状态
# tail -f /opt/log/nginx/auth_access.log  #我们看看日志怎么显示的

[INFO] [192.168.1.101] [5401fe6bb3378ce4007ad43e24e0191f] IP:192.168.1.101 - 开始验证TOTP,username: [test1]
[INFO] [192.168.1.101] [7f738a71d6186657b0bbc884c796ff34] verify_totp: 所有时间窗口验证失败
[INFO] [192.168.1.101] [1b0bdbe5124b9830ac7392d3c6da5505] [Redis连接] 成功,host: [127.0.0.1], port: [6379], db: [0]
[INFO] [192.168.1.101] [be3e9ed357cd56cb26c9c7e6cc56965c] [Redis连接] 成功,host: [127.0.0.1], port: [6379], db: [0]
[INFO] [192.168.1.101] [7b847c78ca94d76c60aa1d0ee996e347] [封禁用户] 成功,username: [test1], 封禁时间: [1200秒]

2.3 手工清理cookie让用户再次认证

#如果你想让一个用户重新认证,或者某些用户重新认证怎么搞呢?

127.0.0.1:6379> del session:.test.com:365439d445da4afa5a2957829980262c   #删掉redis中的cookie

#然后nginx -s reload一下吗,切记reload是不行的,因为reload只是加载配置这些,共享缓存区内的数据是不是清理的,想要把共享缓存区里面的cookie缓存也清理掉应该怎么做呢,最简单粗暴的方法就是restart nginx,但是线上环境那会让你动不动就restart,最靠谱的方案就是写个小工具小脚本或者小页面,把redis和nginx共享缓存中的此用户的cookie键都删掉。

2.4 免认证url访问

#我们有些静态的url比如图片之类的是不需要验证的,我们前面也做了此类设计,下面简单验证一下

#浏览器访问一下  http://lua.test.com/static/1.html   #因为没有所以是404页面哈,但是只要没有认证弹窗就说明调过认证了

# tail -f /opt/log/nginx/auth_access.log  #我们看看日志怎么显示的

[INFO] [192.168.1.101] [cda930e363f8b9034dfccef8d41ebd67] [免认证检查] 全局免认证URL,允许访问(url: /static/1.html)
[INFO] [192.168.1.101] [8ad2d26a34d4e544d89bade39dc94665] IP:192.168.1.101- URL在免认证列表中,允许访问

作者:忙碌的柴少 分类:Lua 浏览:138 评论:0
留言列表
发表评论
来宾的头像