remote-login.test.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import {test} from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import {createServer} from 'node:http'
  4. import {randomUUID,randomBytes} from 'node:crypto'
  5. import {remoteSession,enrollDesktopHost,normalizeServerURL,inspectServer,persistRemoteSession} from './remote-login.mjs'
  6. test('server inspection requests local login metadata without binding Hermes', async () => {
  7. const calls = []
  8. const info = { authenticated: true, csrfToken: 'fixture', serverKind: 'yaoyao-web', user: { username: 'admin' } }
  9. const result = await inspectServer('http://127.0.0.1:15300', async (url, options) => {
  10. calls.push({ path: url.pathname + url.search, method: options.method ?? 'GET' })
  11. return Response.json(info)
  12. })
  13. assert.deepEqual(result, info)
  14. assert.deepEqual(calls, [{ path: '/api/app/bootstrap?inspectOnly=1', method: 'GET' }])
  15. })
  16. test('server addresses are normalized like browser URLs',()=>{
  17. assert.equal(normalizeServerURL('192.168.1.10:15300'),'http://192.168.1.10:15300')
  18. assert.equal(normalizeServerURL('http://192.168.1.10:15300/'),'http://192.168.1.10:15300')
  19. assert.equal(normalizeServerURL('https://yaoyao.example.com'),'https://yaoyao.example.com')
  20. assert.throws(()=>normalizeServerURL(''),/服务器地址/)
  21. assert.throws(()=>normalizeServerURL('https://user:pass@example.com'),/无效/)
  22. assert.throws(()=>normalizeServerURL('http://example.com/path'),/无效/)
  23. assert.throws(()=>normalizeServerURL('ftp://example.com'),/无效/)
  24. })
  25. async function fakeServer(){
  26. const csrf=randomUUID(),session=randomBytes(24).toString('base64url'),token=randomBytes(32).toString('base64url'),hostId=randomUUID()
  27. const requests=[]
  28. const server=createServer((req,res)=>{
  29. const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{
  30. const body=Buffer.concat(chunks).toString()
  31. requests.push({method:req.method,url:req.url,origin:req.headers.origin,csrf:req.headers['x-csrf-token'],cookie:req.headers.cookie??'',body})
  32. const json=payload=>{res.setHeader('content-type','application/json');res.end(JSON.stringify(payload))}
  33. if(req.url==='/api/app/bootstrap'&&(req.method==='GET'||req.method==='HEAD')){res.setHeader('set-cookie','csrf=test-csrf; Path=/; HttpOnly');return json({authRequired:true,csrfToken:csrf})}
  34. if(req.url==='/api/app/login'&&req.method==='POST'){
  35. const credentials=JSON.parse(body||'{}')
  36. if(credentials.username!=='admin'||credentials.password!=='pass'){res.statusCode=401;return json({error:'invalid'})}
  37. if(req.headers['x-csrf-token']!==csrf||!String(req.headers.cookie).includes('csrf=')){res.statusCode=403;return json({error:'csrf'})}
  38. res.setHeader('set-cookie',[`session=${session}; Path=/; HttpOnly`,`csrf=${csrf}.sig; Path=/; HttpOnly`])
  39. return json({user:{id:randomUUID(),username:'admin',role:'admin'},csrfToken:csrf+'-rotated'})
  40. }
  41. if(req.url==='/api/app/admin/desktop-hosts'&&req.method==='POST'){
  42. if(req.headers['x-csrf-token']!==csrf+'-rotated'||!String(req.headers.cookie).includes(`session=${session}`)){res.statusCode=403;return json({error:'session'})}
  43. return json({host:{id:hostId,name:JSON.parse(body||'{}').name,enabled:true},token})
  44. }
  45. res.statusCode=404;json({error:'not found'})
  46. })
  47. })
  48. await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
  49. return {server,url:`http://127.0.0.1:${server.address().port}`,requests,csrf,session,token,hostId}
  50. }
  51. test('logs in with username and password and enrolls this Mac as a computer',async()=>{
  52. const fake=await fakeServer()
  53. try{
  54. const auth=await remoteSession(fake.url,{username:'admin',password:'pass'})
  55. assert.equal(auth.user.role,'admin')
  56. assert.equal(auth.origin,fake.url)
  57. assert.ok(auth.cookies.some(([name])=>name==='session'))
  58. const installId='22222222-2222-4222-8222-222222222222',previousHostId='33333333-3333-4333-8333-333333333333'
  59. const enrollment=await enrollDesktopHost(auth,{name:'书房 iMac',installId,previousHostId})
  60. assert.equal(enrollment.host.id,fake.hostId)
  61. assert.equal(enrollment.token,fake.token)
  62. const [bootstrap,login,enroll]=fake.requests
  63. assert.equal(bootstrap.url,'/api/app/bootstrap')
  64. assert.equal(login.origin,fake.url)
  65. assert.equal(login.csrf,fake.csrf)
  66. assert.equal(JSON.parse(login.body).desktop,true)
  67. assert.equal(enroll.csrf,fake.csrf+'-rotated')
  68. assert.ok(enroll.cookie.includes(`session=${fake.session}`))
  69. assert.deepEqual(JSON.parse(enroll.body),{name:'书房 iMac',installId,previousHostId})
  70. }finally{
  71. fake.server.close();fake.server.closeAllConnections?.()
  72. }
  73. })
  74. test('existing desktop sessions upgrade with CSRF and retain only server-issued cookie policy', async () => {
  75. const cookies = [{name:'hermes_yaoyao_session',value:'old'},{name:'csrf',value:'csrf-cookie'}]
  76. const info = {csrfToken:'csrf-token'}
  77. const upgraded = await persistRemoteSession('https://server.test', info, cookies, async (url, options) => {
  78. assert.equal(url.pathname,'/api/app/desktop-session')
  79. assert.equal(options.method,'POST')
  80. assert.equal(options.headers.origin,'https://server.test')
  81. assert.equal(options.headers['x-csrf-token'],'csrf-token')
  82. assert.equal(options.headers.cookie,'hermes_yaoyao_session=old; csrf=csrf-cookie')
  83. return Response.json({}, {headers:{'set-cookie':'hermes_yaoyao_session=new; Path=/; Max-Age=34560000; HttpOnly; Secure; SameSite=Strict'}})
  84. })
  85. assert.equal(upgraded[0].value,'new')
  86. assert.equal(upgraded[0].httpOnly,true)
  87. assert.equal(upgraded[0].sameSite,'strict')
  88. assert.deepEqual(await persistRemoteSession('https://server.test', info, cookies, async () => new Response(null,{status:404})),cookies)
  89. await assert.rejects(() => persistRemoteSession('https://server.test', info, cookies, async () => new Response(null,{status:401})),/重新登录/)
  90. })
  91. test('wrong credentials and non-admin accounts surface clear errors',async()=>{
  92. const fake=await fakeServer()
  93. try{
  94. await assert.rejects(()=>remoteSession(fake.url,{username:'admin',password:'wrong'}),/用户名或密码不正确/)
  95. await assert.rejects(()=>remoteSession(fake.url,{username:'',password:'pass'}),/用户名和密码/)
  96. const auth={origin:fake.url,csrfToken:'stale',cookies:[['session','x']]}
  97. await assert.rejects(()=>enrollDesktopHost(auth,{name:'x'}),/管理员账号|注册电脑失败/)
  98. }finally{
  99. fake.server.close();fake.server.closeAllConnections?.()
  100. }
  101. })
  102. test('native login preserves cookie expiry, scope and same-site policy', async () => {
  103. let request = 0
  104. const before = Date.now() / 1000
  105. const auth = await remoteSession('https://server.test', { username: 'admin', password: 'password' }, async () => {
  106. if (++request === 1) return new Response(JSON.stringify({ csrfToken: 'fixture' }), { headers: { 'set-cookie': 'csrf=one; Path=/; HttpOnly; Secure; SameSite=Strict' } })
  107. return new Response(JSON.stringify({ user: { id: 'admin', role: 'admin' } }), { headers: { 'set-cookie': 'session=two; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Strict' } })
  108. })
  109. const session = auth.cookieDetails.find(cookie => cookie.name === 'session')
  110. assert.equal(session.secure, true); assert.equal(session.httpOnly, true); assert.equal(session.sameSite, 'strict')
  111. assert.equal(session.path, '/')
  112. assert.ok(session.expirationDate >= before + 3600 && session.expirationDate <= Date.now() / 1000 + 3600)
  113. assert.equal(auth.cookieDetails.find(cookie => cookie.name === 'csrf').expirationDate, undefined)
  114. })
  115. test('folded Electron response cookies preserve the 30-day login and rotated CSRF token', async () => {
  116. let request = 0
  117. const before = Date.now() / 1000
  118. const auth = await remoteSession('https://server.test', { username: 'admin', password: 'password' }, async () => {
  119. if (++request === 1) return Response.json({ csrfToken: 'initial' }, { headers: { 'set-cookie': 'csrf=initial; Path=/; Max-Age=28800; HttpOnly; SameSite=Strict' } })
  120. // Electron net.fetch joins the server's Set-Cookie array before creating Headers.
  121. return Response.json({ user: { id: 'admin', role: 'admin' }, csrfToken: 'rotated' }, { headers: { 'set-cookie': [
  122. 'session=login; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Strict',
  123. 'csrf=rotated; Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Lax',
  124. ].join(', ') } })
  125. })
  126. const session = auth.cookieDetails.find(cookie => cookie.name === 'session')
  127. const csrf = auth.cookieDetails.find(cookie => cookie.name === 'csrf')
  128. assert.ok(session.expirationDate >= before + 2592000 && session.expirationDate <= Date.now() / 1000 + 2592000)
  129. assert.ok(csrf.expirationDate >= before + 28800 && csrf.expirationDate <= Date.now() / 1000 + 28800)
  130. assert.equal(session.sameSite, 'strict'); assert.equal(csrf.sameSite, 'lax')
  131. assert.equal(session.secure, true); assert.equal(csrf.secure, true)
  132. assert.deepEqual(Object.fromEntries(auth.cookies), { csrf: 'rotated', session: 'login' })
  133. })
  134. test('folded cookies keep Expires date commas and separate cookie values and paths', async () => {
  135. let request = 0
  136. const expires = 'Wed, 21 Oct 2037 07:28:00 GMT'
  137. const auth = await remoteSession('https://server.test', { username: 'admin', password: 'password' }, async () => {
  138. if (++request === 1) return Response.json({ csrfToken: 'initial' })
  139. return Response.json({ user: { id: 'admin', role: 'admin' } }, { headers: { 'set-cookie': [
  140. `session=login==; Expires=${expires}; Path=/; HttpOnly; Secure; SameSite=Strict`,
  141. 'csrf=rotated; Path=/api; HttpOnly; SameSite=Lax',
  142. 'device=fixture; Path=/device; Max-Age=0; HttpOnly',
  143. ].join(', ') } })
  144. })
  145. const session = auth.cookieDetails.find(cookie => cookie.name === 'session')
  146. const csrf = auth.cookieDetails.find(cookie => cookie.name === 'csrf')
  147. const device = auth.cookieDetails.find(cookie => cookie.name === 'device')
  148. assert.equal(session.expirationDate, Date.parse(expires) / 1000)
  149. assert.equal(session.value, 'login=='); assert.equal(session.path, '/')
  150. assert.equal(csrf.path, '/api'); assert.equal(csrf.expirationDate, undefined)
  151. assert.equal(csrf.secure, false); assert.equal(csrf.sameSite, 'lax')
  152. assert.equal(device.path, '/device'); assert.ok(device.expirationDate <= Date.now() / 1000)
  153. assert.equal(auth.cookieDetails.length, 3)
  154. })
  155. test('pending approval is not mislabeled as a bad password', async () => {
  156. let calls = 0
  157. await assert.rejects(() => remoteSession('https://server.test', { username: 'child', password: 'password' }, async () => {
  158. if (++calls === 1) return Response.json({ csrfToken: 'csrf' })
  159. return Response.json({ code: 'account_pending_approval', error: '账号尚未开通,请等待管理员开通。' }, { status: 403 })
  160. }), /等待管理员开通/)
  161. })
  162. test('registration uses only anonymous bootstrap and does not return a session', async () => {
  163. const { remoteRegistration } = await import('./remote-login.mjs')
  164. const calls = []
  165. await remoteRegistration('https://server.test', { username: ' child ', password: 'password' }, async (url, options) => {
  166. calls.push(url.pathname)
  167. if (url.pathname.endsWith('bootstrap')) return Response.json({ registrationAvailable: true, csrfToken: 'csrf' }, { headers: { 'set-cookie': 'csrf-cookie=value; Path=/; HttpOnly' } })
  168. assert.equal(options.headers.origin, 'https://server.test')
  169. assert.equal(options.headers['x-csrf-token'], 'csrf')
  170. assert.match(options.headers.cookie, /csrf-cookie=value/)
  171. assert.deepEqual(JSON.parse(options.body), { username: 'child', password: 'password' })
  172. return Response.json({ registrationStatus: 'pending' }, { status: 201 })
  173. })
  174. assert.deepEqual(calls, ['/api/app/bootstrap', '/api/app/register'])
  175. })