Add files via upload
This commit is contained in:
9
403.lua
Normal file
9
403.lua
Normal file
@@ -0,0 +1,9 @@
|
||||
function emptyPrint()
|
||||
end
|
||||
|
||||
function say_html()
|
||||
ngx.header.content_type = "text/html"
|
||||
ngx.status = ngx.HTTP_FORBIDDEN
|
||||
ngx.say(html)
|
||||
ngx.exit(ngx.status)
|
||||
end
|
||||
36
aes.lua
Normal file
36
aes.lua
Normal file
@@ -0,0 +1,36 @@
|
||||
local aes = require "resty.aes"
|
||||
local str = require "resty.string"
|
||||
local iv = 'ABCDEF1234123412'
|
||||
|
||||
|
||||
-- 需要自己写一个函数将16进制转2进制
|
||||
function hex2bin(hexstr)
|
||||
local str = ""
|
||||
for i = 1, string.len(hexstr) - 1, 2 do
|
||||
local doublebytestr = string.sub(hexstr, i, i+1);
|
||||
local n = tonumber(doublebytestr, 16);
|
||||
if 0 == n then
|
||||
str = str .. '\00'
|
||||
else
|
||||
str = str .. string.format("%c", n)
|
||||
end
|
||||
end
|
||||
return str
|
||||
end
|
||||
|
||||
-- 加密函数,返回16进制
|
||||
function encrypT(content, key)
|
||||
local aes_128_cbc_with_iv = assert(aes:new(key, nil, aes.cipher(128,"cbc"), {iv=iv}))
|
||||
local encrypted = aes_128_cbc_with_iv:encrypt(content)
|
||||
return str.to_hex(encrypted)
|
||||
end
|
||||
|
||||
-- 解密函数 返回解密字符串
|
||||
function dencrypT(content, key)
|
||||
local aes_128_cbc_with_iv = assert(aes:new(key, nil, aes.cipher(128,"cbc"), {iv=iv}))
|
||||
local dencrypted = aes_128_cbc_with_iv:decrypt(hex2bin(content))
|
||||
return dencrypted
|
||||
end
|
||||
|
||||
--ngx.say(encrypt('123456'))
|
||||
--ngx.say(dencrypt('32e29ba66134e3d8f2c149a2b93006c7'))
|
||||
86
b64.lua
Normal file
86
b64.lua
Normal file
@@ -0,0 +1,86 @@
|
||||
function encodeBase64(source_str)
|
||||
local b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
local s64 = ''
|
||||
local str = source_str
|
||||
|
||||
while #str > 0 do
|
||||
local bytes_num = 0
|
||||
local buf = 0
|
||||
|
||||
for byte_cnt=1,3 do
|
||||
buf = (buf * 256)
|
||||
if #str > 0 then
|
||||
buf = buf + string.byte(str, 1, 1)
|
||||
str = string.sub(str, 2)
|
||||
bytes_num = bytes_num + 1
|
||||
end
|
||||
end
|
||||
|
||||
for group_cnt=1,(bytes_num+1) do
|
||||
local b64char = math.fmod(math.floor(buf/262144), 64) + 1
|
||||
s64 = s64 .. string.sub(b64chars, b64char, b64char)
|
||||
buf = buf * 64
|
||||
end
|
||||
|
||||
for fill_cnt=1,(3-bytes_num) do
|
||||
s64 = s64 .. '='
|
||||
end
|
||||
end
|
||||
|
||||
return s64
|
||||
end
|
||||
|
||||
function decodeBase64(str64)
|
||||
local b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
local temp={}
|
||||
for i=1,64 do
|
||||
temp[string.sub(b64chars,i,i)] = i
|
||||
end
|
||||
temp['=']=0
|
||||
local str=""
|
||||
for i=1,#str64,4 do
|
||||
if i>#str64 then
|
||||
break
|
||||
end
|
||||
local data = 0
|
||||
local str_count=0
|
||||
for j=0,3 do
|
||||
local str1=string.sub(str64,i+j,i+j)
|
||||
if not temp[str1] then
|
||||
return
|
||||
end
|
||||
if temp[str1] < 1 then
|
||||
data = data * 64
|
||||
else
|
||||
data = data * 64 + temp[str1]-1
|
||||
str_count = str_count + 1
|
||||
end
|
||||
end
|
||||
for j=16,0,-8 do
|
||||
if str_count > 0 then
|
||||
str=str..string.char(math.floor(data/math.pow(2,j)))
|
||||
data=math.fmod(data,math.pow(2,j))
|
||||
str_count = str_count - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local last = tonumber(string.byte(str, string.len(str), string.len(str)))
|
||||
if last == 0 then
|
||||
str = string.sub(str, 1, string.len(str) - 1)
|
||||
end
|
||||
return str
|
||||
end
|
||||
|
||||
|
||||
--url解码
|
||||
local function decodeURI(s)
|
||||
s = string.gsub(s, '%%(%x%x)', function(h) return string.char(tonumber(h, 16)) end)
|
||||
return s
|
||||
end
|
||||
|
||||
--url加码
|
||||
local function encodeURI(s)
|
||||
s = string.gsub(s, "([^%w%.%- ])", function(c) return string.format("%%%02X", string.byte(c)) end)
|
||||
return string.gsub(s, " ", "+")
|
||||
end
|
||||
51
config.lua
Normal file
51
config.lua
Normal file
@@ -0,0 +1,51 @@
|
||||
attacklog = "off"
|
||||
shiroProtect = "off"
|
||||
toolsProtect = "off"
|
||||
jsProtect = "off"
|
||||
sensitiveProtect = "on"
|
||||
|
||||
|
||||
logdir = "/gate/log" -- log文件夹需要有写权限
|
||||
aesKey = '1231231231234567' -- 16位
|
||||
cookieA = 'h0yGbdRv'
|
||||
cookieB = 'kQpFHdoh'
|
||||
cookieC = 'aLoFjX4v'
|
||||
cookieD = 'x9i7RDYX23'
|
||||
jsPath = 'zE48AHvK/index.html'
|
||||
whiteExt = {'js', 'css', 'png', 'jpg'}
|
||||
|
||||
|
||||
|
||||
html=[[
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<title>网站防火墙</title>
|
||||
<style>
|
||||
p {
|
||||
line-height:20px;
|
||||
}
|
||||
ul{ list-style-type:none;}
|
||||
li{ list-style-type:none;}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style=" padding:0; margin:0; font:14px/1.5 Microsoft Yahei, 宋体,sans-serif; color:#555;">
|
||||
|
||||
<div style="margin: 0 auto; width:1000px; padding-top:70px; overflow:hidden;">
|
||||
|
||||
|
||||
<div style="width:600px; float:left;">
|
||||
<div style=" height:40px; line-height:40px; color:#fff; font-size:16px; overflow:hidden; background:#6bb3f6; padding-left:20px;">网站防火墙 </div>
|
||||
<div style="border:1px dashed #cdcece; border-top:none; font-size:14px; background:#fff; color:#555; line-height:24px; height:220px; padding:20px 20px 0 20px; overflow-y:auto;background:#f3f7f9;">
|
||||
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-weight:600; color:#fc4f03;">您的请求带有不合法参数,已被网站管理员设置拦截!</span></p>
|
||||
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">可能原因:您提交的内容包含危险的攻击请求</p>
|
||||
<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:1; text-indent:0px;">如何解决:</p>
|
||||
<ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">1)检查提交内容;</li>
|
||||
<li style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">2)如网站托管,请联系空间提供商;</li>
|
||||
<li style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">3)普通网站访客,请联系网站管理员;</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
]]
|
||||
|
||||
21
fileio.lua
Normal file
21
fileio.lua
Normal file
@@ -0,0 +1,21 @@
|
||||
function readfile(path)
|
||||
local file = io.open(path, "r")
|
||||
if file then
|
||||
local content = file:read("*a")
|
||||
io.close(file)
|
||||
return content
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function writefile(path, content, mode)
|
||||
mode = mode or "w+b"
|
||||
local file = io.open(path, mode)
|
||||
if file then
|
||||
if file:write(content) == nil then return false end
|
||||
io.close(file)
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
135
init.lua
Normal file
135
init.lua
Normal file
@@ -0,0 +1,135 @@
|
||||
require 'config'
|
||||
require 'b64'
|
||||
require 'aes'
|
||||
require 'log'
|
||||
require '403'
|
||||
require 'tableXstring'
|
||||
require 'fileio'
|
||||
require 'randomStr'
|
||||
require 'whiteList'
|
||||
|
||||
local optionIsOn = function (options) return options == "on" and true or false end
|
||||
ToolsProtect = optionIsOn(toolsProtect)
|
||||
ShiroProtect = optionIsOn(shiroProtect)
|
||||
JsProtect = optionIsOn(jsProtect)
|
||||
JsConfuse = false
|
||||
SensitiveProtect = optionIsOn(sensitiveProtect)
|
||||
|
||||
|
||||
-- cookie加密
|
||||
function reqCookieParse()
|
||||
if ShiroProtect then
|
||||
local userCookieX9 = ngx.var.cookie_x9i7RDYX23
|
||||
if not userCookieX9 then -- 没有cookie
|
||||
log('0-cookie 无cookie', '')
|
||||
ngx.req.set_header('Cookie', '') -- 移除其他cookie
|
||||
elseif #userCookieX9 < 32 then -- 判断cookie长度
|
||||
log('1-cookie 不符合要求', userCookieX9)
|
||||
ngx.say('4')
|
||||
say_html()
|
||||
else --有cookie
|
||||
local result = xpcall(dencrypT, emptyPrint, userCookieX9, aesKey)
|
||||
if not result then --解密失败
|
||||
log('2-cookie 无法解密', userCookieX9)
|
||||
ngx.say('5')
|
||||
say_html()
|
||||
else --解密成功
|
||||
local originCookie = StrToTable(dencrypT(userCookieX9, aesKey))
|
||||
ngx.req.set_header('Cookie', transTable(originCookie))
|
||||
log('3-cookie 解密成功', userCookieX9)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function respCookieEncrypt()
|
||||
if ShiroProtect then
|
||||
local value = ngx.resp.get_headers()["Set-Cookie"]
|
||||
if value then
|
||||
local encryptedCookie = cookieD.."="..encrypT(TableToStr(value), aesKey)
|
||||
ngx.header["Set-Cookie"] = encryptedCookie
|
||||
log('4-cookie 加密成功',encryptedCookie)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- reload机制
|
||||
function toolsInfoSpider()
|
||||
if ToolsProtect and not whiteExtCheck() then
|
||||
local clientCookieA = ngx.var.cookie_h0yGbdRv
|
||||
local clientCookieB = ngx.var.cookie_kQpFHdoh
|
||||
if not (clientCookieA and clientCookieB) then --没有cookieA进入reload,302至html生成cookie后再请求原地址
|
||||
local ip = 'xxx'
|
||||
local finalPath = 'http://'..ip..'/'..jsPath..'?origin='..encodeBase64(ngx.var.request_uri)
|
||||
log('1-tools 无cookieA/B', '')
|
||||
ngx.redirect(finalPath, 302)
|
||||
else
|
||||
local result = xpcall(dencrypT, emptyPrint, clientCookieB, clientCookieA)
|
||||
if not result then
|
||||
log('2-tools 解密失败', clientCookieA..', '..clientCookieB)
|
||||
ngx.say('1')
|
||||
say_html() -- 解密失败
|
||||
else-- 可以解密,提取数据
|
||||
local result2 = dencrypT(clientCookieB, clientCookieA)
|
||||
if #result2 < 1 then
|
||||
log('3-tools 解密失败', result2)
|
||||
else
|
||||
local srs = split(result2, ',')
|
||||
local _,e = string.find(srs[1], '0')
|
||||
if e ~= nil then
|
||||
log('4-tools 工具请求', result2)
|
||||
ngx.say('2')
|
||||
say_html()
|
||||
else
|
||||
log('0-tools 工具验证通过, 记录浏览器指纹', '', srs[2])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- js文件混淆
|
||||
function jsExtDetect()
|
||||
if JsProtect then
|
||||
local ext = string.match(ngx.var.uri, ".+%.(%w+)$")
|
||||
if ext == 'js' then -- 加入检查,js文件是否存在
|
||||
JsConfuse = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function jsConfuse()
|
||||
if JsConfuse then
|
||||
local originBody = ngx.arg[1]
|
||||
if #originBody > 200 then -- 筛选空js
|
||||
local s = getRandom(8)
|
||||
local path = '/tmp/'..s
|
||||
writefile(path, originBody, 'w+')
|
||||
local t = io.popen('export NODE_PATH=/usr/lib/node_modules && node /gate/node/js_confuse.js '..path)
|
||||
local a = t:read("*all")
|
||||
ngx.arg[1] = a
|
||||
os.execute('rm -f '..path)
|
||||
end
|
||||
JsConfuse = false
|
||||
end
|
||||
end
|
||||
|
||||
-- 响应包过滤
|
||||
function dateReplace()
|
||||
if SensitiveProtect then
|
||||
local replaceTelephone = string.gsub(ngx.arg[1], "[1][3,4,5,7,8]%d%d%d%d%d%d%d%d%d", "******")
|
||||
ngx.arg[1] = replaceTelephone
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
41
log.lua
Normal file
41
log.lua
Normal file
@@ -0,0 +1,41 @@
|
||||
require 'config'
|
||||
|
||||
|
||||
local optionIsOn = function (options) return options == "on" and true or false end
|
||||
local Attacklog = optionIsOn(attacklog)
|
||||
local logpath = logdir
|
||||
|
||||
local function getClientIp()
|
||||
IP = ngx.var.remote_addr
|
||||
if IP == nil then
|
||||
IP = "unknown"
|
||||
end
|
||||
return IP
|
||||
end
|
||||
|
||||
local function write(logfile,msg)
|
||||
local fd = io.open(logfile,"ab")
|
||||
if fd == nil then return end
|
||||
fd:write(msg)
|
||||
fd:flush()
|
||||
fd:close()
|
||||
end
|
||||
|
||||
function log(data, ruletag, fp)
|
||||
if Attacklog then
|
||||
local fingerprint = fp or ''
|
||||
local realIp = getClientIp()
|
||||
local method = ngx.var.request_method
|
||||
local ua = ngx.var.http_user_agent
|
||||
local servername=ngx.var.server_name
|
||||
local url = ngx.var.request_uri
|
||||
local time=ngx.localtime()
|
||||
if ua then
|
||||
line = realIp.." ["..time.."] \""..method.." "..servername..url.."\" \""..ruletag.."\" \""..ua.."\" \""..data.."\" \""..fingerprint.."\"\n"
|
||||
else
|
||||
line = realIp.." ["..time.."] \""..method.." "..servername..url.."\" \""..ruletag.."\" - \""..data.."\" \""..fingerprint.."\"\n"
|
||||
end
|
||||
local filename = logpath..'/'..servername.."_"..ngx.today().."_sec.log"
|
||||
write(filename,line)
|
||||
end
|
||||
end
|
||||
14
randomStr.lua
Normal file
14
randomStr.lua
Normal file
@@ -0,0 +1,14 @@
|
||||
math.randomseed(os.time())
|
||||
|
||||
function getRandom(n)
|
||||
local t = {
|
||||
"0","1","2","3","4","5","6","7","8","9",
|
||||
"a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z",
|
||||
"A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z",
|
||||
}
|
||||
local s = ""
|
||||
for i =1, n do
|
||||
s = s .. t[math.random(#t)]
|
||||
end;
|
||||
return 't'..s
|
||||
end
|
||||
76
readme.md
Normal file
76
readme.md
Normal file
@@ -0,0 +1,76 @@
|
||||
### 0x01 简介
|
||||
|
||||
这是一个基于openresty的安全网关,使用提供的指令和接口将lua代码插入至nginx处理http请求的不同阶段来实现包过滤效果,相较于传统waf该网关侧重于业务方面的防护,特点之一是具备防自动化工具请求能力,但目前还只是一个demo项目,具体的开发过程记录在[这里](https://ainrm.cn/2022/safegate.html),后续可能会增加如文中所规划的功能
|
||||
|
||||

|
||||
|
||||
### 0x02 文件说明
|
||||
|
||||
核心文件是`init.lua`,包含了具体的处理逻辑,`config.lua`为配置文件,决定是否启用某些功能,然后在nginx中配置`access_by_lua_file`、`header_filter_by_lua_file`、`body_filter_by_lua_file`来调用具体函数
|
||||
|
||||
```bash
|
||||
.
|
||||
├── 403.lua # 403页面
|
||||
├── aes.lua # aes加解密
|
||||
├── b64.lua # base64转码
|
||||
├── config.lua # 配置文件
|
||||
├── fileio.lua # 文件io相关
|
||||
├── init.lua # 处理请求的具体逻辑
|
||||
├── log.lua # 日志相关
|
||||
├── log # 保存日志的路径
|
||||
│ ├── error.log
|
||||
│ └── localhost_2022-02-11_sec.log
|
||||
├── nginx
|
||||
│ ├── nginx.conf # 示例配置
|
||||
│ └── zE48AHvK # 下发cookie相关文件
|
||||
│ ├── crypto-js.min.js
|
||||
│ ├── index.html
|
||||
│ ├── info.html
|
||||
│ ├── info.js
|
||||
│ ├── jump.js
|
||||
│ └── webdriver.js
|
||||
├── node # babel混淆规则
|
||||
│ └── js_confuse.js
|
||||
├── randomStr.lua # 产生随机字符串
|
||||
├── req.lua # 处理发来的请求,由access_by_lua_file调用
|
||||
├── resty -> /usr/local/openresty/lualib/resty # 软链接过来的库文件
|
||||
├── rsp_body.lua # 处理返回包体内容,由body_filter_by_lua_file调用
|
||||
├── rsp_header.lua # 处理返回包头内容,header_filter_by_lua_file调用
|
||||
├── tableXstring.lua # table与string转换
|
||||
└── whiteList.lua # 白名单相关
|
||||
```
|
||||
|
||||
### 0x03 使用说明
|
||||
|
||||
首先在http块中引入lua文件:
|
||||
|
||||
- lua_package_path
|
||||
- lua_shared_dict
|
||||
- init_by_lua_file
|
||||
|
||||
然后在server块中引用具体lua文件:
|
||||
|
||||
- access_by_lua_file
|
||||
- header_filter_by_lua_block
|
||||
- body_filter_by_lua_file
|
||||
|
||||
示例:
|
||||
|
||||
```lua
|
||||
http {
|
||||
lua_package_path "/gate/?.lua";
|
||||
lua_shared_dict limit 10m;
|
||||
init_by_lua_file /gate/init.lua;
|
||||
|
||||
server {
|
||||
location /test {
|
||||
access_by_lua_file /gate/req.lua;
|
||||
proxy_pass http://127.0.0.1:8000/req;
|
||||
proxy_connect_timeout 2s;
|
||||
header_filter_by_lua_file /gate/rsp_header.lua;
|
||||
body_filter_by_lua_file /gate/rsp_body.lua;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2
rsp_body.lua
Normal file
2
rsp_body.lua
Normal file
@@ -0,0 +1,2 @@
|
||||
jsConfuse()
|
||||
dateReplace()
|
||||
2
rsp_header.lua
Normal file
2
rsp_header.lua
Normal file
@@ -0,0 +1,2 @@
|
||||
ngx.header.content_length = nil
|
||||
respCookieEncrypt()
|
||||
73
tableXstring.lua
Normal file
73
tableXstring.lua
Normal file
@@ -0,0 +1,73 @@
|
||||
|
||||
function ToStringEx(value)
|
||||
if type(value)=='table' then
|
||||
return TableToStr(value)
|
||||
elseif type(value)=='string' then
|
||||
return "\'"..value.."\'"
|
||||
else
|
||||
return tostring(value)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function TableToStr(t)
|
||||
if t == nil then return "" end
|
||||
local retstr= "{"
|
||||
|
||||
local i = 1
|
||||
for key,value in pairs(t) do
|
||||
local signal = ","
|
||||
if i==1 then
|
||||
signal = ""
|
||||
end
|
||||
|
||||
if key == i then
|
||||
retstr = retstr..signal..ToStringEx(value)
|
||||
else
|
||||
if type(key)=='number' or type(key) == 'string' then
|
||||
retstr = retstr..signal..'['..ToStringEx(key).."]="..ToStringEx(value)
|
||||
else
|
||||
if type(key)=='userdata' then
|
||||
retstr = retstr..signal.."*s"..TableToStr(getmetatable(key)).."*e".."="..ToStringEx(value)
|
||||
else
|
||||
retstr = retstr..signal..key.."="..ToStringEx(value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
i = i+1
|
||||
end
|
||||
|
||||
retstr = retstr.."}"
|
||||
return retstr
|
||||
end
|
||||
|
||||
|
||||
function StrToTable(str)
|
||||
if str == nil or type(str) ~= "string" then
|
||||
return
|
||||
end
|
||||
|
||||
return loadstring("return " .. str)()
|
||||
end
|
||||
|
||||
function transTable(xxx)
|
||||
local yyy = {}
|
||||
for i=#xxx,1,-1 do
|
||||
if #yyy ~= 0 then
|
||||
yyy = xxx[i].."; "..yyy
|
||||
else
|
||||
yyy = xxx[i]
|
||||
end
|
||||
end
|
||||
return yyy
|
||||
end
|
||||
|
||||
|
||||
function split( str,reps )
|
||||
local resultStrList = {}
|
||||
string.gsub(str,'[^'..reps..']+',function ( w )
|
||||
table.insert(resultStrList,w)
|
||||
end)
|
||||
return resultStrList
|
||||
end
|
||||
9
whiteList.lua
Normal file
9
whiteList.lua
Normal file
@@ -0,0 +1,9 @@
|
||||
function whiteExtCheck()
|
||||
local reqExt = string.match(ngx.var.uri, ".+%.(%w+)$") --js
|
||||
for _,e in ipairs(whiteExt) do -- js、css、png
|
||||
if reqExt == e then -- 在白名单里
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
Reference in New Issue
Block a user