现在的网络游戏大部分是需要登录的,一般会有一个专门的登录服务来处理,登录服务要解决的问题:1、用户登录信息保密工作。2、实际登录点分配工作。
DHexchange密钥交换算法主要用来协商一个服务器与客户端的密钥。云风已经帮我们封装好了这个加密方法,可以直接这么使用:
package.cpath = "luaclib/?.so" local crypt = require "client.crypt" --如果在skynet中使用直接 local crypt = require "skynet.crypt" --dhexchange转换8字节的key crypt.dhexchange(key) --通过key1与key2得到密钥 crypt.dhsecret(key1, key2)
示例代码testdhexchange.lua:
package.cpath = "luaclib/?.so" local crypt = require "client.crypt" local clientkey = "11111111" --8byte random print("clientkey:" , clientkey) local ckey = crypt.dhexchange(clientkey) print("ckey:\t" , crypt.hexencode(ckey) local serverkey = "22222222" print("serverkey:" , serverkey) local skey = crypt.dhexchange(serverkey) print("skey:\t" , crypt.hexencode(skey)) local csecret = crypt.dhsecret(skey, clientkey) print("use skey clientkey dhsecret:", crypt.hexencode(csecret)) --交换成功 local ssecret = crypt.dhsecret(ckey, serverkey) print("use ckey serverkey dhsecret:", crypt.hexencode(ssecret)) --交换成功 local ssecret = crypt.dhsecret(ckey, skey) --交换失败 print("use ckey skey dhsecret:\t", crypt.hexencode(ssecret))
直接在终端运行结果:
$ ./3rd/lua/lua my_workspace/testdhexchange.lua clientkey: 11111111 ckey: D5 8A 46 9C FD ED 70 5E serverkey: 22222222 skey: B3 60 21 D9 C4 C5 1B 0C use skey clientkey dhsecret: 95 69 12 B6 88 B3 3B 42 #交换成功 use ckey serverkey dhsecret: 95 69 12 B6 88 B3 3B 42 #交换成功 use ckey skey dhsecret: C7 19 E5 5F 0A 34 DC E8 #交换不成功
需要注意的是,这个库是独立的库,不需要在skynet的lua虚拟机里运行,普通虚拟机也运行使用。
package.cpath = "luaclib/?.so" local crypt = require "client.crypt" --如果在skynet中使用直接 local crypt = require "skynet.crypt" --产生一个8字节的随机数,一般作为对称加密算法的随机密钥 crypt.randomkey()
hmac64算法主要用于密钥验证。
package.cpath = "luaclib/?.so" local crypt = require "client.crypt" --如果在skynet中使用直接 local crypt = require "skynet.crypt" --HMAC64运算利用哈希算法,以一个密钥secret和一个消息challenge为输入,生成一个消息摘要hmac作为输出。 local hmac = crypt.hmac64(challenge, secret)
Base64就是一种基于64个可打印字符来表示二进制数据的方法。
package.cpath = "luaclib/?.so" local crypt = require "client.crypt" --如果在skynet中使用直接 local crypt = require "skynet.crypt" --编码 crypt.base64encode(str) --解码 crypt.base64decode(str)
package.cpath = "luaclib/?.so" local crypt = require "client.crypt" --如果在skynet中使用直接 local crypt = require "skynet.crypt" --用key加密plaintext得到密文,key必须是8字节 crypt.desencode(key, plaintext) --用key解密ciphertext得到明文,key必须是8字节 crypt.desdecode(key, ciphertext)
云风自实现的hash算法,只能哈希小于8字节的数据,返回8字节数据的hash
package.cpath = "luaclib/?.so" local crypt = require "client.crypt" --如果在skynet中使用直接 local crypt = require "skynet.crypt" --云风自实现的hash算法,只能哈希小于8字节的数据,返回8字节数据的hash crypt.hashkey(str)
skynet 提供了一个通用的登陆服务器模版 snax.loginserver 。框架原理如下图:
login服务开启监听,客户端主动去连接login服务,他们之间的通信协议是行结尾协议(即:每个数据包都是一行ascii字符,如果要发送byte字节流,则通过base64编码)。这假如称login服务为L,客户端为C。
(1)L产生随机数challenge,并发送给C,主要用于最后验证密钥secret是否交换成功。
(2)C产生随机数clientkey,clientkey是保密的,只有C知道,并通过dhexchange算法换算clientkey,得到ckey。 把base64编码的ckey发送给L。
(3)L也产生随机数serverkey,serverkey是保密的,只有L知道,并通过dhexchange算法换算serverkey,得到skey。把base64编码的skey发送给C。
(4)C使用clientkey与skey,通过dhsecret算法得到最终安全密钥secret。
(5)L使用serverKey与ckey, 通过dhsecret算法得到最终安全密钥secret。C 和 L最终得到的secret是一样的,而传输过程只有ckey skey是通过网络公开的,即使ckey skey泄露了,也无法推算出secret。
(6)密钥交换完成后,需要验证一下双方的密钥是否是一致的。C使用密钥secret通过hmac64哈希算法加密第1步中接收到的challenge,得到CHmac,然后转码成base64 CHmac发送给L。
(7)L收到CHmac后,自己也使用密钥secret通过hmac64哈希算法加密第1步中发送出去的challenge,得到SHmac,对比SHmac与CHmac是否一致,如果一致,则密钥交换成功。不成功就断开连接。
(8)C组合base64 user@base64 server:base64 passwd字符串(server为客户端具体想要登录的登录点,远端服务器可能有多个实际登录点),使用secret通过DES加密,得到etoken,发送base64 etoken。
(9)使用secret通过DES解密etoken,得到user@server:passwd,校验user与passwd是否正确,通知实际登录点server,传递user与secret给server,server生成subid返回。发送状态码 200 base64 subid给C。
(10)C得到subid后就可以断开login服务的连接,然后去连接实际登录点server了。(实际登录点server,可以由L通知C,也可以C指定想要登录哪个点,将在下一个章提到)
local login = require "snax.loginserver" local server = { host = "127.0.0.1", port = 8001, multilogin = false, -- disallow multilogin name = "login_master", -- config, etc } login(server) --服务启动点
host 是监听地址,通常是 "0.0.0.0" 。
port 是监听端口。
name 是一个内部使用的名字,不要和 skynet 其它服务重名。在上面的例子,登陆服务器会注册为 .login_master
这个名字,相当于skynet.register(".login_master")
multilogin 是一个 boolean ,默认是 false 。关闭后,当一个用户正在走登陆流程时,禁止同一用户名进行登陆。如果你希望用户可以同时登陆,可以打开这个开关,但需要自己处理好潜在的并行的状态管理问题。
同时,你还需要注册一系列业务相关的必要方法。
--你需要实现这个方法,对一个客户端发送过来的 token 做验证。如果验证不能通过,可以通过 error 抛出异常。如果验证通过,需要返回用户希望进入的登陆点以及用户名。(登陆点可以是包含在 token 内由用户自行决定,也可以在这里实现一个负载均衡器来选择) function server.auth_handler(token) end --你需要实现这个方法,处理当用户已经验证通过后,该如何通知具体的登陆点(server )。框架会交给你用户名(uid)和已经安全交换到的通讯密钥。你需要把它们交给登陆点,并得到确认(等待登陆点准备好后)才可以返回。 function server.login_handler(server, uid, secret) end --实现command_handler,用来处理lua消息,必须注册 function server.command_handler(command, ...) end
登录服务返回给客户端的状态码:
200 [base64(subid)] --登录成功会返回一个subid,这个subid是这次登录的唯一标识 400 Bad Request --握手失败 401 Unauthorized --自定义的 auth_handler 不认可 token 403 Forbidden --自定义的 login_handler 执行失败 406 Not Acceptable --该用户已经在登陆中。(只发生在 multilogin 关闭时)
示例代码:myloginserver.lua
local login = require "snax.loginserver" local crypt = require "skynet.crypt" local skynet = require "skynet" local server = { host = "127.0.0.1", port = 8001, multilogin = false, -- disallow multilogin name = "login_master", } function server.auth_handler(token) -- the token is base64(user)@base64(server):base64(password) --通过正则表达式,解析出各个参数 local user, server, password = token:match("([^@]+)@([^:]+):(.+)") user = crypt.base64decode(user) server = crypt.base64decode(server) password = crypt.base64decode(password) skynet.error(string.format("%s@%s:%s", uid, server, password)) --密码不对直接报错中断当前协程,千万不要返回nil值,一定要用assert中断或者error报错终止掉当前协程 assert(password == "password", "Invalid password") return server, user end local subid = 0 function server.login_handler(server, uid, secret) skynet.error(string.format("%s@%s is login, secret is %s", uid, server, crypt.hexencode(secret))) subid = subid + 1 --分配一个唯一的subid return subid end local CMD = {} function CMD.register_gate(server, address) skynet.error("cmd register_gate") end --实现command_handler,必须要实现,用来处理lua消息 function server.command_handler(command, ...) local f = assert(CMD[command]) return f(...) end login(server) --服务启动需要参数
示例代码:myclient.lua
package.cpath = "luaclib/?.so" local socket = require "client.socket" local crypt = require "client.crypt" if _VERSION ~= "Lua 5.3" then error "Use lua 5.3" end local fd = assert(socket.connect("127.0.0.1", 8001)) local function writeline(fd, text) socket.send(fd, text .. "\n") end local function unpack_line(text) local from = text:find("\n", 1, true) if from then return text:sub(1, from-1), text:sub(from+1) end return nil, text end local last = "" local function unpack_f(f) local function try_recv(fd, last) local result result, last = f(last) if result then return result, last end local r = socket.recv(fd) if not r then return nil, last end if r == "" then error "Server closed" end return f(last .. r) end return function() while true do local result result, last = try_recv(fd, last) if result then return result end socket.usleep(100) end end end local readline = unpack_f(unpack_line) local challenge = crypt.base64decode(readline()) --接收challenge local clientkey = crypt.randomkey() --把clientkey换算后比如称它为ckeys,发给服务器 writeline(fd, crypt.base64encode(crypt.dhexchange(clientkey))) --服务器也把serverkey换算后比如称它为skeys,发给客户端,客户端用clientkey与skeys所出secret local secret = crypt.dhsecret(crypt.base64decode(readline()), clientkey) --secret一般是8字节数据流,需要转换成16字节的hex字符串来显示。 print("sceret is ", crypt.hexencode(secret)) --加密的时候还是需要直接传递secret字节流 local hmac = crypt.hmac64(challenge, secret) writeline(fd, crypt.base64encode(hmac)) local token = { server = "sample", user = "hello", pass = "password", } local function encode_token(token) return string.format("%s@%s:%s", crypt.base64encode(token.user), crypt.base64encode(token.server), crypt.base64encode(token.pass)) end --使用DES加密token得到etoken, etoken是字节流 local etoken = crypt.desencode(secret, encode_token(token)) etoken = crypt.base64encode(etoken) --发送etoken,mylogin.lua将会调用auth_handler回调函数, 以及login_handler回调函数。 writeline(fd, etoken) local result = readline() --读取最终的返回结果。 print(result) local code = tonumber(string.sub(result, 1, 3)) assert(code == 200) socket.close(fd) --可以关闭链接了 local subid = crypt.base64decode(string.sub(result, 5)) --解析出subid print("login ok, subid=", subid)
先运行服务器端:
$ ./skynet examples/config mylogin #终端输入 [:01000010] LAUNCH snlua mylogin #会发现默认会启动多个服务 [:01000012] LAUNCH snlua mylogin [:01000019] LAUNCH snlua mylogin [:0100001a] LAUNCH snlua mylogin [:0100001b] LAUNCH snlua mylogin [:0100001c] LAUNCH snlua mylogin [:0100001d] LAUNCH snlua mylogin [:0100001e] LAUNCH snlua mylogin [:0100001f] LAUNCH snlua mylogin
再运行客户端:
$ 3rd/lua/lua my_workspace/myclient.lua sceret is 57943de9381fce1e 200 MQ== login ok, subid= 1 #登录成功了
回头再来看看服务器输出:
$ ./skynet examples/config mylogin #终端输入 [:01000010] LAUNCH snlua mylogin #会发现默认会启动多个服务 [:01000012] LAUNCH snlua mylogin [:01000019] LAUNCH snlua mylogin [:0100001a] LAUNCH snlua mylogin [:0100001b] LAUNCH snlua mylogin [:0100001c] LAUNCH snlua mylogin [:0100001d] LAUNCH snlua mylogin [:0100001e] LAUNCH snlua mylogin [:0100001f] LAUNCH snlua mylogin [:01000010] login server listen at : 127.0.0.1 8001 #第一个启动的服务去监听 [:01000012] connect from 127.0.0.1:47964 (fd = 9) #有链接来就去处理 [:01000012] hello@sample:password [:01000010] hello@sample is login, secret is 57943de9381fce1e
账户核对失败,无非是账户密码不匹配时,那么这个时候,我们来观察一下返回客户端的状态码。
在14.4的myclient.lua的基础上修改passwd,再次登录,例如:
local token = { server = "sample", user = "hello", pass = "wrongpasswd", }
客户端运行:
$ 3rd/lua/lua my_workspace/myclient.lua sceret is 66ec31781d628739 401 Unauthorized #密码错误,认证不成功返回错误401 3rd/lua/lua: my_workspace/myclient.lua:88: assertion failed! stack traceback: [C]: in function 'assert' my_workspace/myclient.lua:88: in main chunk [C]: in ?
服务器端状况:
$ ./skynet examples/config mylogin [:01000010] LAUNCH snlua mylogin [:01000012] LAUNCH snlua mylogin [:01000019] LAUNCH snlua mylogin [:0100001a] LAUNCH snlua mylogin [:0100001b] LAUNCH snlua mylogin [:0100001c] LAUNCH snlua mylogin [:0100001d] LAUNCH snlua mylogin [:0100001e] LAUNCH snlua mylogin [:0100001f] LAUNCH snlua mylogin [:01000010] login server listen at : 127.0.0.1 8001 [:01000012] connect from 127.0.0.1:48260 (fd = 9) [:01000012] hello@sample:passwords [:01000010] invalid client (fd = 9) error = ./lualib/snax/loginserver.lua:127: ./my_workspace/mylogin.lua:20: Invalid password #报错终止掉当前协程。
需要注意,一旦有登录请求进来,在调用回调函数server.auth_handlery
以及 server.login_handler
都是开启了一个协程来处理,assert与error都能终止掉当前协程,并不是终止掉整个服务。
虽然我们在启动mylogin服务的时候一下启动的了9个服务,但这9个服务中一个是监听使用,其他服务负责与客户端交换密钥以及处理账号验证,八个服务共同分担处理任务,可以通过不断启动客户端来观察。八个服务轮流处理一次登录请求。
例如:运行9次客户端:
$ ./skynet examples/config mylogin [:01000010] LAUNCH snlua mylogin [:01000012] LAUNCH snlua mylogin [:01000019] LAUNCH snlua mylogin [:0100001a] LAUNCH snlua mylogin [:0100001b] LAUNCH snlua mylogin [:0100001c] LAUNCH snlua mylogin [:0100001d] LAUNCH snlua mylogin [:0100001e] LAUNCH snlua mylogin [:0100001f] LAUNCH snlua mylogin [:01000010] login server listen at : 127.0.0.1 8001 [:01000012] connect from 127.0.0.1:48268 (fd = 9) [:01000012] hello@sample:password [:01000010] hello@sample is login, secret is b7d37b00ed49bf50 [:01000019] connect from 127.0.0.1:48270 (fd = 10) [:01000019] hello@sample:password [:01000010] hello@sample is login, secret is 373dc4f4876d7636 [:0100001a] connect from 127.0.0.1:48272 (fd = 11) [:0100001a] hello@sample:password [:01000010] hello@sample is login, secret is 9a667284488692b8 [:0100001b] connect from 127.0.0.1:48274 (fd = 12) [:0100001b] hello@sample:password [:01000010] hello@sample is login, secret is 19178e716fb886ff [:0100001c] connect from 127.0.0.1:48276 (fd = 13) [:0100001c] hello@sample:password [:01000010] hello@sample is login, secret is 039badb8016f59e6 [:0100001d] connect from 127.0.0.1:48278 (fd = 14) [:0100001d] hello@sample:password [:01000010] hello@sample is login, secret is 636e4ed64a797d36 [:0100001e] connect from 127.0.0.1:48280 (fd = 15) [:0100001e] hello@sample:password [:01000010] hello@sample is login, secret is e3ad3e8f070fe6c6 [:0100001f] connect from 127.0.0.1:48282 (fd = 16) [:0100001f] hello@sample:password [:01000010] hello@sample is login, secret is ee5d95258b470809 [:01000012] connect from 127.0.0.1:48284 (fd = 17) [:01000012] hello@sample:password [:01000010] hello@sample is login, secret is bf88c2f81ab14031
上面可以看到服务轮流着去处理请求。
修改14.4中的mylogin.lua的skynet.login_handler
函数:
function server.login_handler(server, uid, secret) skynet.error(string.format("%s@%s is login, secret is %s", uid, server, crypt.hexencode(secret))) error("login_handler") --加入这一行,终止掉当前协程 subid = subid + 1 --分配一个唯一的subid return subid end
然后重启mylogin.lua,在启动myclient.lua:
$ 3rd/lua/lua my_workspace/myclient.lua sceret is 312fab4e6cd9908d 403 Forbidden #自定义的 login_handler 执行失败 3rd/lua/lua: my_workspace/myclient.lua:88: assertion failed! stack traceback: [C]: in function 'assert' my_workspace/myclient.lua:88: in main chunk [C]: in ?
服务器端显示:
$ ./skynet examples/config mylogin [:01000010] LAUNCH snlua mylogin [:01000012] LAUNCH snlua mylogin [:01000019] LAUNCH snlua mylogin [:0100001a] LAUNCH snlua mylogin [:0100001b] LAUNCH snlua mylogin [:0100001c] LAUNCH snlua mylogin [:0100001d] LAUNCH snlua mylogin [:0100001e] LAUNCH snlua mylogin [:0100001f] LAUNCH snlua mylogin [:01000010] login server listen at : 127.0.0.1 8001 [:01000012] connect from 127.0.0.1:48288 (fd = 9) [:01000012] hello@sample:password [:01000010] hello@sample is login, secret is 312fab4e6cd9908d [:01000010] invalid client (fd = 9) error = ./lualib/snax/loginserver.lua:148: ./my_workspace/mylogin.lua:26: login_handler
与skynet.auth_handler的处理一样,一旦错误,也不需要我们返回任何值,只要终止掉当前协程,skynet.loginserver框架就会自动发送406 Not Acceptable
给客户端。
在mylogin.lua中,multilogin
设置为false表示不允许同时重复登录,这里的同时重复登录不是说一个client登录完成之后,另一个client端使用相同的账号密码就不能登录。而是说在登录过程当中,还没完成登录,这个时候突然又有一个client尝试登录。那么会报给这个客户端406 Not Acceptable .
由于正常情况下,同时登录比较难模拟,所以我们在login_handler(不能在auth_handler)里面添加一个sleep延时5秒钟。例如在14.4的基础上:
function server.login_handler(server, uid, secret) skynet.error(string.format("%s@%s is login, secret is %s", uid, server, crypt.hexencode(secret))) subid = subid + 1 skynet.sleep(500) --添加延时 return subid end
先运行服务,再运行两个客户端,查看第二客户端的运行结果:
$ 3rd/lua/lua my_workspace/myclient.lua sceret is 1b739592afbcc437 406 Not Acceptable #该用户已经在登陆中 3rd/lua/lua: my_workspace/myclient.lua:88: assertion failed! stack traceback: [C]: in function 'assert' my_workspace/myclient.lua:88: in main chunk [C]: in ?
其实不允许重复登录主要是login_handler不允许重入,因为如果重入了login_handler会造成subid分配出现并入。
下面就来模拟一下,允许重入的情况,我们把multilogin
改为true:
先运行服务,再运行两个客户端,查看两个客户端的运行结果:
$ 3rd/lua/lua my_workspace/myclient.lua sceret is 33769a939dc21114 200 Mg== login ok, subid= 2 #分配到的subid为2 $ $ 3rd/lua/lua my_workspace/myclient.lua sceret is 01588be8f9aeee99 200 Mg== login ok, subid= 2 #分配到的subid也为2 $
所以为了减少这种麻烦事的出现,大家尽量让multilogin为false。
密钥交换失败一般不会发生在前几个步骤,因为前几个步骤不会去验证双方的数据是否正确,只要在交换完密钥使用密钥加密challenge的时候才会验证一下,如果这个时候验证不成功将会返回给客户端一个:400 Bad Request
。
下面我们就来模拟一下,修改14.4中的myclient.lua
local hmac = crypt.hmac64(challenge, secret) --加密的时候还是需要直接传递secret字节流 --改为 local hmac = crypt.hmac64("11111111", secret) --加密的时候还是需要直接传递secret字节流
运行服务,再运行myclient:
$ 3rd/lua/lua my_workspace/myclient.lua sceret is da1f3e758d04fc56 400 Bad Request #握手失败 3rd/lua/lua: my_workspace/myclient.lua:88: assertion failed! stack traceback: [C]: in function 'assert' my_workspace/myclient.lua:88: in main chunk [C]: in ? $