@koishi-ce/plugin-auth 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{Schema as e,deepEqual as t,icons as n,message as r,pick as i,router as a,send as o,store as s,useConfig as c,useStorage as l}from"@koishi-ce/client";import{Fragment as u,computed as d,createBlock as f,createCommentVNode as p,createElementBlock as m,createElementVNode as h,createTextVNode as g,createVNode as _,defineComponent as v,h as y,isRef as b,openBlock as x,ref as S,renderList as C,resolveComponent as w,toDisplayString as T,unref as E,watch as D,withCtx as O,withKeys as k,withModifiers as A}from"vue";import{useRouter as j}from"vue-router";var M=l(`auth`,2,()=>({authType:0})),N=S(!1),P=S(!1),F={class:`login-form text-center`},ee={key:0},te={key:1},I={class:`hint`},L={class:`token`},R={class:`control`},z={key:0},B={key:1},V={key:0,class:`error`},H={class:`control`},U={key:0,class:`error`},W={class:`control`},G=v({__name:`login-form`,setup(e){let t=S(),n=S(),r=S(!1),i=0;async function a(){let e=Date.now();if(e<i)return;let{platform:r,userId:a}=M.value;if(!(!r||!a)){i=e+1e3;try{n.value=await o(`login/platform`,r,a)}catch(e){t.value=e.message}}}async function c(){let{name:e,password:n}=M.value;try{await o(`login/password`,e,n)}catch(e){t.value=e.message}}let l=j();function d(){s.user?N.value=!1:l.back()}return(e,i)=>{let o=w(`k-button`),l=w(`k-tab`),f=w(`k-icon`),v=w(`el-input`);return x(),m(`div`,F,[n.value?(x(),m(u,{key:0},[E(s).user?(x(),m(`h1`,ee,[...i[7]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,te,[...i[8]||=[h(`span`,null,`平台账户登录`,-1)]])),h(`p`,I,`欢迎你,`+T(n.value.name||`Koishi 用户`)+`!`,1),i[10]||=h(`p`,{class:`hint`},`请用上述账号将下面的验证码发送给任意正在运行的机器人`,-1),h(`p`,L,T(n.value.token),1),h(`div`,R,[_(o,{onClick:i[0]||=e=>n.value.token=null},{default:O(()=>[...i[9]||=[g(`返回上一步`,-1)]]),_:1})])],64)):(x(),m(u,{key:1},[E(s).user?(x(),m(`h1`,z,[...i[11]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,B,[_(l,{data:[`用户密码登录`,`平台账户登录`],modelValue:E(M).authType,"onUpdate:modelValue":i[1]||=e=>E(M).authType=e},null,8,[`modelValue`])])),E(s).user||E(M).authType===1?(x(),m(u,{key:2},[_(v,{placeholder:`平台名`,modelValue:E(M).platform,"onUpdate:modelValue":i[2]||=e=>E(M).platform=e},{prefix:O(()=>[_(f,{name:`at`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`账号`,modelValue:E(M).userId,"onUpdate:modelValue":i[3]||=e=>E(M).userId=e,onKeypress:k(A(a,[`stop`]),[`enter`])},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`,`onKeypress`]),t.value?(x(),m(`p`,V,T(t.value),1)):p(`v-if`,!0),h(`div`,H,[_(o,{onClick:d},{default:O(()=>[...i[12]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:a},{default:O(()=>[...i[13]||=[g(`获取验证码`,-1)]]),_:1})])],64)):(x(),m(u,{key:3},[_(v,{placeholder:`用户名`,modelValue:E(M).name,"onUpdate:modelValue":i[4]||=e=>E(M).name=e},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`密码`,modelValue:E(M).password,"onUpdate:modelValue":i[6]||=e=>E(M).password=e,onKeypress:k(A(c,[`stop`]),[`enter`]),type:r.value?`text`:`password`},{prefix:O(()=>[_(f,{name:`lock`})]),suffix:O(()=>[_(f,{name:r.value?`eye`:`eye-slash`,onClick:i[5]||=e=>r.value=!r.value},null,8,[`name`])]),_:1},8,[`modelValue`,`onKeypress`,`type`]),t.value?(x(),m(`p`,U,T(t.value),1)):p(`v-if`,!0),h(`div`,W,[_(o,{onClick:d},{default:O(()=>[...i[14]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:c},{default:O(()=>[...i[15]||=[g(`登录`,-1)]]),_:1})])],64))],64))])}}}),K=v({__name:`bind-dialog`,setup(e){return(e,t)=>{let n=w(`el-dialog`);return x(),m(u,null,[p(` 绑定平台账户对话框:已登录状态下从个人资料页唤起,内嵌登录表单 `),_(n,{center:``,modelValue:E(N),"onUpdate:modelValue":t[0]||=e=>b(N)?N.value=e:null,class:`bind-dialog`},{default:O(()=>[_(G)]),_:1},8,[`modelValue`])],2112)}}}),q=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},J={},Y={class:`k-icon k-icon-at`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function X(e,t){return x(),m(u,null,[p(` @ 图标:登录表单"平台名"输入框的前缀 `),(x(),m(`svg`,Y,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 8C118.941 8 8 118.919 8 256c0 137.059 110.919 248 248 248 48.154 0 95.342-14.14 135.408-40.223 12.005-7.815 14.625-24.288 5.552-35.372l-10.177-12.433c-7.671-9.371-21.179-11.667-31.373-5.129C325.92 429.757 291.314 440 256 440c-101.458 0-184-82.542-184-184S154.542 72 256 72c100.139 0 184 57.619 184 160 0 38.786-21.093 79.742-58.17 83.693-17.349-.454-16.91-12.857-13.476-30.024l23.433-121.11C394.653 149.75 383.308 136 368.225 136h-44.981a13.518 13.518 0 0 0-13.432 11.993l-.01.092c-14.697-17.901-40.448-21.775-59.971-21.775-74.58 0-137.831 62.234-137.831 151.46 0 65.303 36.785 105.87 96 105.87 26.984 0 57.369-15.637 74.991-38.333 9.522 34.104 40.613 34.103 70.71 34.103C462.609 379.41 504 307.798 504 232 504 95.653 394.023 8 256 8zm-21.68 304.43c-22.249 0-36.07-15.623-36.07-40.771 0-44.993 30.779-72.729 58.63-72.729 22.292 0 35.601 15.241 35.601 40.77 0 45.061-33.875 72.73-58.161 72.73z`},null,-1)]]))],2112)}var Z=q(J,[[`render`,X]]),Q={},ne={class:`k-icon check`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function re(e,t){return x(),m(u,null,[p(` 对勾图标:个人资料页"应用更改"菜单项 `),(x(),m(`svg`,ne,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M438.6 105.4C451.1 117.9 451.1 138.1 438.6 150.6L182.6 406.6C170.1 419.1 149.9 419.1 137.4 406.6L9.372 278.6C-3.124 266.1-3.124 245.9 9.372 233.4C21.87 220.9 42.13 220.9 54.63 233.4L159.1 338.7L393.4 105.4C405.9 92.88 426.1 92.88 438.6 105.4H438.6z`},null,-1)]]))],2112)}var ie=q(Q,[[`render`,re]]),ae={},oe={class:`k-icon k-icon-lock`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function se(e,t){return x(),m(u,null,[p(` 挂锁图标:登录表单"密码"输入框的前缀 `),(x(),m(`svg`,oe,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z`},null,-1)]]))],2112)}var ce=q(ae,[[`render`,se]]),le={},ue={class:`k-icon k-icon-sign-in`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function de(e,t){return x(),m(u,null,[p(` 登入箭头图标:登录页活动入口 `),(x(),m(`svg`,ue,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M384 256c0-8.188-3.125-16.38-9.375-22.62l-128-128C237.5 96.22 223.7 93.47 211.8 98.44C199.8 103.4 192 115.1 192 128v64H48C21.49 192 0 213.5 0 240v32C0 298.5 21.49 320 48 320H192v64c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C380.9 272.4 384 264.2 384 256zM224 384V288H48C39.18 288 32 280.8 32 272v-32C32 231.2 39.18 224 48 224H224L223.1 128l128 128L224 384zM432 32h-96C327.2 32 320 39.16 320 48S327.2 64 336 64h96C458.5 64 480 85.53 480 112v288c0 26.47-21.53 48-48 48h-96c-8.844 0-16 7.156-16 16s7.156 16 16 16h96c44.13 0 80-35.88 80-80v-288C512 67.88 476.1 32 432 32z`},null,-1)]]))],2112)}var fe=q(le,[[`render`,de]]),pe={},me={class:`k-icon k-icon-sign-out`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function he(e,t){return x(),m(u,null,[p(` 登出箭头图标:个人资料页"退出登录"菜单项 `),(x(),m(`svg`,me,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M176 448h-96C53.53 448 32 426.5 32 400v-288C32 85.53 53.53 64 80 64h96C184.8 64 192 56.84 192 48S184.8 32 176 32h-96C35.88 32 0 67.88 0 112v288C0 444.1 35.88 480 80 480h96C184.8 480 192 472.8 192 464S184.8 448 176 448zM502.6 233.4l-128-128c-9.156-9.156-22.91-11.91-34.88-6.938C327.8 103.4 320 115.1 320 128l.0918 63.1L176 192C149.5 192 128 213.5 128 240v32C128 298.5 149.5 320 176 320l144.1-.001L320 384c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C508.9 272.4 512 264.2 512 256S508.9 239.6 502.6 233.4zM352 384V288H176C167.2 288 160 280.8 160 272v-32C160 231.2 167.2 224 176 224H352l-.0039-96l128 128L352 384z`},null,-1)]]))],2112)}var ge=q(pe,[[`render`,he]]),_e={},ve={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function ye(e,t){return x(),m(u,null,[p(` 人像图标:个人资料页活动入口 `),(x(),m(`svg`,ve,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 480c-47.24 0-91.04-14.78-127.2-39.84C132.9 390.9 173.8 352 224 352h64c50.25 0 91.14 38.94 95.21 88.16C347 465.2 303.2 480 256 480zM411.7 416.7C397.6 361.3 347.7 320 288 320H224c-59.73 0-109.6 41.3-123.7 96.72C58.27 375.1 32 319 32 256c0-123.5 100.5-224 224-224s224 100.5 224 224C480 319 453.7 375.1 411.7 416.7zM256 128C211.8 128 176 163.8 176 208C176 252.2 211.8 288 256 288s80-35.82 80-80C336 163.8 300.2 128 256 128zM256 256C229.5 256 208 234.5 208 208S229.5 160 256 160s48 21.53 48 48S282.5 256 256 256z`},null,-1)]]))],2112)}var be=q(_e,[[`render`,ye]]),$=v({__name:`login`,setup(e){return(e,t)=>{let n=w(`k-card`),r=w(`k-layout`);return x(),m(u,null,[p(` 登录页:居中卡片内嵌登录表单 `),_(r,{main:`darker page-login`},{default:O(()=>[_(n,null,{default:O(()=>[_(G)]),_:1})]),_:1})],2112)}}}),xe={class:`k-schema-header`},Se={class:`header`},Ce={class:`left`},we={class:`right`},Te=v({__name:`profile`,setup(t){let n={platform:`平台账户`,password:`用户密码`},i=S({}),a=d(()=>e.object({name:e.string().description(`用户名`).default(M.value.name),password:e.string().role(`secret`).description(`密码`).default(M.value.password)}).description(`基本资料`));async function c(){return s.user=null,delete M.value.id,delete M.value.token,delete M.value.expiredAt,o(`user/logout`)}async function l(){try{await o(`user/update`,i.value),r.success(`修改成功!`),Object.assign(M.value,i.value),Object.assign(s.user,i.value),i.value={}}catch(e){r.error(e.message)}}let v=d(()=>s.user?.bindings.filter(e=>s.user.id===e.bid)),y=d(()=>[{icon:`check`,label:`应用更改`,disabled:!i.value||!Object.keys(i.value).length,action:l},{type:`danger`,icon:`sign-out`,label:`退出登录`,action:c}]);return(e,t)=>{let r=w(`k-form`),c=w(`el-button`),l=w(`k-content`),d=w(`k-layout`);return x(),f(d,{main:`page-profile`,menu:y.value},{default:O(()=>[_(l,null,{default:O(()=>[_(r,{schema:a.value,modelValue:i.value,"onUpdate:modelValue":t[0]||=e=>i.value=e},null,8,[`schema`,`modelValue`]),h(`h2`,xe,[t[3]||=g(` 平台绑定 `,-1),_(c,{solid:``,class:`right`,onClick:t[1]||=e=>N.value=!0},{default:O(()=>[...t[2]||=[g(`添加`,-1)]]),_:1})]),(x(!0),m(u,null,C(E(s).user.bindings,({platform:e,pid:n,bid:r})=>(x(),m(`div`,{class:`k-schema-item`,key:`${e}:${n}`},[h(`div`,Se,[h(`div`,Ce,T(e)+` (`+T(n)+`)`,1),h(`div`,we,[v.value.length>1||r!==E(s).user.id?(x(),f(c,{key:0,onClick:A(t=>E(o)(`user/unbind`,e,n),[`stop`,`prevent`])},{default:O(()=>[...t[4]||=[g(`解绑`,-1)]]),_:1},8,[`onClick`])):p(`v-if`,!0)])])]))),128)),t[6]||=h(`h2`,{class:`k-schema-header`},`登录历史`,-1),h(`ul`,null,[(x(!0),m(u,null,C(E(s).user.tokens,({inc:e,type:r,createdAt:i,lastUsedAt:a,address:s,userAgent:l})=>(x(),m(`li`,{key:e},[h(`div`,null,`登录类型:`+T(n[r]),1),h(`div`,null,`登录时间:`+T(i),1),h(`div`,null,`最后访问:`+T(a),1),h(`div`,null,`IP 地址:`+T(s),1),h(`div`,null,`客户端:`+T(l),1),h(`div`,null,[_(c,{onClick:t=>E(o)(`user/delete-token`,e)},{default:O(()=>[...t[5]||=[g(`移除会话`,-1)]]),_:1},8,[`onClick`])])]))),128))])]),_:1})]),_:1},8,[`menu`])}}}),Ee=q(v({__name:`sync-dialog`,setup(e){let t=c();async function n(e){if(M.value.sync=!!e,P.value=!1,e){if(e===`download`){t.value=s.user.config;return}try{await o(`user/update`,{config:t.value})}catch(e){r.error(e.message)}}}return(e,r)=>{let i=w(`el-button`),a=w(`el-button-group`),o=w(`el-dialog`);return x(),f(o,{class:`sync-dialog`,onClose:r[3]||=e=>E(t).sync??=!1,"show-close":!1,"close-on-click-modal":!1,"close-on-press-escape":!1,modelValue:E(P),"onUpdate:modelValue":r[4]||=e=>b(P)?P.value=e:null},{default:O(()=>[r[8]||=h(`p`,{class:`text-center`},`检测到你本地的配置与云端不同步,是否同步配置?`,-1),_(a,{class:`text-center`},{default:O(()=>[_(i,{onClick:r[0]||=e=>n(`upload`)},{default:O(()=>[...r[5]||=[g(`上传当前配置`,-1)]]),_:1}),_(i,{onClick:r[1]||=e=>n(`download`)},{default:O(()=>[...r[6]||=[g(`下载云端配置`,-1)]]),_:1}),_(i,{onClick:r[2]||=e=>n()},{default:O(()=>[...r[7]||=[g(`关闭配置同步`,-1)]]),_:1})]),_:1})]),_:1},8,[`modelValue`])}}}),[[`__scopeId`,`data-v-440ef018`]]);n.register(`at`,Z),n.register(`check`,ie),n.register(`lock`,ce),n.register(`sign-in`,fe),n.register(`sign-out`,ge),n.register(`user-full`,be);var De=n=>{let{token:l,id:u,expiredAt:d}=M.value;l&&u!==void 0&&d!==void 0&&d>Date.now()&&o(`login/token`,u,l).catch(e=>r.error(e.message)),n.on(`activity`,e=>{let t=e.authority??0;return t>0&&(!s.user||s.user.authority<t)}),n.scope.disposables.push(a.beforeEach(e=>{let{activity:t}=e.meta;if(t){if((t.authority||(t.fields??[]).includes(`user`))&&!s.user)return history.state.forward===`/login`?`/`:`/login`;if(t.authority&&s.user&&t.authority>s.user.authority)return r.error(`权限不足。`),!1}})),n.page({path:`/login`,name:`登录`,icon:`sign-in`,position:`bottom`,order:500,disabled:()=>!!s.user,component:$}),n.page({path:`/profile`,name:`用户资料`,icon:`user-full`,fields:[`user`],position:`bottom`,order:500,component:Te}),n.slot({type:`global`,component:K}),n.slot({type:`global`,component:Ee}),n.settings({id:`user`,title:`用户设置`,component:v(()=>()=>y(w(`k-form`),{schema:e.object({sync:e.boolean().description(`在多个客户端间同步设置。`)}).description(`同步设置`),initial:M.value,modelValue:M.value,"onUpdate:modelValue":e=>M.value=e}))});let f=c();function p(){s.user&&(t(s.user.config,f.value)||(P.value=!0))}n.on(`dispose`,D(f,async e=>{!e||!s.user||!M.value.sync||await o(`user/update`,{config:e})},{deep:!0})),n.on(`dispose`,D(()=>M.value.sync,async e=>{e&&s.user&&p()})),n.on(`dispose`,D(()=>s.user,(e,t)=>{if(N.value=!1,!e)return a.push(`/login`);if(M.value.sync&&p(),t)return;Object.assign(M.value,i(e,[`id`,`name`,`token`,`expiredAt`])),r.success(`欢迎回来,${e.name||`Koishi 用户`}!`);let n=a.currentRoute.value.redirectedFrom;n&&!n.path.startsWith(`/login`)?a.push(n):a.push(`/profile`)}))};export{De as default};
1
+ import{Schema as e,deepEqual as t,icons as n,message as r,pick as i,router as a,send as o,store as s,useConfig as c,useStorage as l}from"@koishi-ce/client";import{Fragment as u,computed as d,createBlock as f,createCommentVNode as p,createElementBlock as m,createElementVNode as h,createTextVNode as g,createVNode as _,defineComponent as v,h as y,isRef as b,openBlock as x,ref as S,renderList as C,resolveComponent as w,toDisplayString as T,unref as E,watch as D,withCtx as O,withKeys as k,withModifiers as A}from"vue";import{useRouter as j}from"vue-router";var M=l(`auth`,2,()=>({authType:0})),N=S(!1),P=S(!1),F={class:`login-form text-center`},ee={key:0},te={key:1},I={class:`hint`},L={class:`token`},R={class:`control`},z={key:0},B={key:1},V={key:0,class:`error`},H={class:`control`},U={key:0,class:`error`},W={class:`control`},G=v({__name:`login-form`,setup(e){let t=S(),n=S(),r=S(!1),i=0;async function a(){let e=Date.now();if(e<i)return;let{platform:r,userId:a}=M.value;if(!(!r||!a)){i=e+1e3;try{n.value=await o(`login/platform`,r,a)}catch(e){t.value=e.message}}}async function c(){let{name:e,password:n}=M.value;try{await o(`login/password`,e,n)}catch(e){t.value=e.message}}let l=j();function d(){s.user?N.value=!1:l.back()}return(e,i)=>{let o=w(`k-button`),l=w(`k-tab`),f=w(`k-icon`),v=w(`el-input`);return x(),m(`div`,F,[n.value?(x(),m(u,{key:0},[E(s).user?(x(),m(`h1`,ee,[...i[7]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,te,[...i[8]||=[h(`span`,null,`平台账户登录`,-1)]])),h(`p`,I,`欢迎你,`+T(n.value.name||`Koishi 用户`)+`!`,1),i[10]||=h(`p`,{class:`hint`},`请用上述账号将下面的验证码发送给任意正在运行的机器人`,-1),h(`p`,L,T(n.value.token),1),h(`div`,R,[_(o,{onClick:i[0]||=e=>n.value.token=null},{default:O(()=>[...i[9]||=[g(`返回上一步`,-1)]]),_:1})])],64)):(x(),m(u,{key:1},[E(s).user?(x(),m(`h1`,z,[...i[11]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,B,[_(l,{data:[`用户密码登录`,`平台账户登录`],modelValue:E(M).authType,"onUpdate:modelValue":i[1]||=e=>E(M).authType=e},null,8,[`modelValue`])])),E(s).user||E(M).authType===1?(x(),m(u,{key:2},[_(v,{placeholder:`平台名`,modelValue:E(M).platform,"onUpdate:modelValue":i[2]||=e=>E(M).platform=e},{prefix:O(()=>[_(f,{name:`at`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`账号`,modelValue:E(M).userId,"onUpdate:modelValue":i[3]||=e=>E(M).userId=e,onKeypress:k(A(a,[`stop`]),[`enter`])},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`,`onKeypress`]),t.value?(x(),m(`p`,V,T(t.value),1)):p(`v-if`,!0),h(`div`,H,[_(o,{onClick:d},{default:O(()=>[...i[12]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:a},{default:O(()=>[...i[13]||=[g(`获取验证码`,-1)]]),_:1})])],64)):(x(),m(u,{key:3},[_(v,{placeholder:`用户名`,modelValue:E(M).name,"onUpdate:modelValue":i[4]||=e=>E(M).name=e},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`密码`,modelValue:E(M).password,"onUpdate:modelValue":i[6]||=e=>E(M).password=e,onKeypress:k(A(c,[`stop`]),[`enter`]),type:r.value?`text`:`password`},{prefix:O(()=>[_(f,{name:`lock`})]),suffix:O(()=>[_(f,{name:r.value?`eye`:`eye-slash`,onClick:i[5]||=e=>r.value=!r.value},null,8,[`name`])]),_:1},8,[`modelValue`,`onKeypress`,`type`]),t.value?(x(),m(`p`,U,T(t.value),1)):p(`v-if`,!0),h(`div`,W,[_(o,{onClick:d},{default:O(()=>[...i[14]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:c},{default:O(()=>[...i[15]||=[g(`登录`,-1)]]),_:1})])],64))],64))])}}}),K=v({__name:`bind-dialog`,setup(e){return(e,t)=>{let n=w(`el-dialog`);return x(),f(n,{center:``,modelValue:E(N),"onUpdate:modelValue":t[0]||=e=>b(N)?N.value=e:null,class:`bind-dialog`},{default:O(()=>[_(G)]),_:1},8,[`modelValue`])}}}),q=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},J={},Y={class:`k-icon k-icon-at`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function X(e,t){return x(),m(`svg`,Y,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 8C118.941 8 8 118.919 8 256c0 137.059 110.919 248 248 248 48.154 0 95.342-14.14 135.408-40.223 12.005-7.815 14.625-24.288 5.552-35.372l-10.177-12.433c-7.671-9.371-21.179-11.667-31.373-5.129C325.92 429.757 291.314 440 256 440c-101.458 0-184-82.542-184-184S154.542 72 256 72c100.139 0 184 57.619 184 160 0 38.786-21.093 79.742-58.17 83.693-17.349-.454-16.91-12.857-13.476-30.024l23.433-121.11C394.653 149.75 383.308 136 368.225 136h-44.981a13.518 13.518 0 0 0-13.432 11.993l-.01.092c-14.697-17.901-40.448-21.775-59.971-21.775-74.58 0-137.831 62.234-137.831 151.46 0 65.303 36.785 105.87 96 105.87 26.984 0 57.369-15.637 74.991-38.333 9.522 34.104 40.613 34.103 70.71 34.103C462.609 379.41 504 307.798 504 232 504 95.653 394.023 8 256 8zm-21.68 304.43c-22.249 0-36.07-15.623-36.07-40.771 0-44.993 30.779-72.729 58.63-72.729 22.292 0 35.601 15.241 35.601 40.77 0 45.061-33.875 72.73-58.161 72.73z`},null,-1)]])}var Z=q(J,[[`render`,X]]),Q={},ne={class:`k-icon check`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function re(e,t){return x(),m(`svg`,ne,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M438.6 105.4C451.1 117.9 451.1 138.1 438.6 150.6L182.6 406.6C170.1 419.1 149.9 419.1 137.4 406.6L9.372 278.6C-3.124 266.1-3.124 245.9 9.372 233.4C21.87 220.9 42.13 220.9 54.63 233.4L159.1 338.7L393.4 105.4C405.9 92.88 426.1 92.88 438.6 105.4H438.6z`},null,-1)]])}var ie=q(Q,[[`render`,re]]),ae={},oe={class:`k-icon k-icon-lock`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function se(e,t){return x(),m(`svg`,oe,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z`},null,-1)]])}var ce=q(ae,[[`render`,se]]),le={},ue={class:`k-icon k-icon-sign-in`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function de(e,t){return x(),m(`svg`,ue,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M384 256c0-8.188-3.125-16.38-9.375-22.62l-128-128C237.5 96.22 223.7 93.47 211.8 98.44C199.8 103.4 192 115.1 192 128v64H48C21.49 192 0 213.5 0 240v32C0 298.5 21.49 320 48 320H192v64c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C380.9 272.4 384 264.2 384 256zM224 384V288H48C39.18 288 32 280.8 32 272v-32C32 231.2 39.18 224 48 224H224L223.1 128l128 128L224 384zM432 32h-96C327.2 32 320 39.16 320 48S327.2 64 336 64h96C458.5 64 480 85.53 480 112v288c0 26.47-21.53 48-48 48h-96c-8.844 0-16 7.156-16 16s7.156 16 16 16h96c44.13 0 80-35.88 80-80v-288C512 67.88 476.1 32 432 32z`},null,-1)]])}var fe=q(le,[[`render`,de]]),pe={},me={class:`k-icon k-icon-sign-out`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function he(e,t){return x(),m(`svg`,me,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M176 448h-96C53.53 448 32 426.5 32 400v-288C32 85.53 53.53 64 80 64h96C184.8 64 192 56.84 192 48S184.8 32 176 32h-96C35.88 32 0 67.88 0 112v288C0 444.1 35.88 480 80 480h96C184.8 480 192 472.8 192 464S184.8 448 176 448zM502.6 233.4l-128-128c-9.156-9.156-22.91-11.91-34.88-6.938C327.8 103.4 320 115.1 320 128l.0918 63.1L176 192C149.5 192 128 213.5 128 240v32C128 298.5 149.5 320 176 320l144.1-.001L320 384c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C508.9 272.4 512 264.2 512 256S508.9 239.6 502.6 233.4zM352 384V288H176C167.2 288 160 280.8 160 272v-32C160 231.2 167.2 224 176 224H352l-.0039-96l128 128L352 384z`},null,-1)]])}var ge=q(pe,[[`render`,he]]),_e={},ve={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function ye(e,t){return x(),m(`svg`,ve,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 480c-47.24 0-91.04-14.78-127.2-39.84C132.9 390.9 173.8 352 224 352h64c50.25 0 91.14 38.94 95.21 88.16C347 465.2 303.2 480 256 480zM411.7 416.7C397.6 361.3 347.7 320 288 320H224c-59.73 0-109.6 41.3-123.7 96.72C58.27 375.1 32 319 32 256c0-123.5 100.5-224 224-224s224 100.5 224 224C480 319 453.7 375.1 411.7 416.7zM256 128C211.8 128 176 163.8 176 208C176 252.2 211.8 288 256 288s80-35.82 80-80C336 163.8 300.2 128 256 128zM256 256C229.5 256 208 234.5 208 208S229.5 160 256 160s48 21.53 48 48S282.5 256 256 256z`},null,-1)]])}var be=q(_e,[[`render`,ye]]),$=v({__name:`login`,setup(e){return(e,t)=>{let n=w(`k-card`),r=w(`k-layout`);return x(),f(r,{main:`darker page-login`},{default:O(()=>[_(n,null,{default:O(()=>[_(G)]),_:1})]),_:1})}}}),xe={class:`k-schema-header`},Se={class:`header`},Ce={class:`left`},we={class:`right`},Te=v({__name:`profile`,setup(t){let n={platform:`平台账户`,password:`用户密码`},i=S({}),a=d(()=>e.object({name:e.string().description(`用户名`).default(M.value.name),password:e.string().role(`secret`).description(`密码`).default(M.value.password)}).description(`基本资料`));async function c(){return s.user=null,delete M.value.id,delete M.value.token,delete M.value.expiredAt,o(`user/logout`)}async function l(){try{await o(`user/update`,i.value),r.success(`修改成功!`),Object.assign(M.value,i.value),Object.assign(s.user,i.value),i.value={}}catch(e){r.error(e.message)}}let v=d(()=>s.user?.bindings.filter(e=>s.user.id===e.bid)),y=d(()=>[{icon:`check`,label:`应用更改`,disabled:!i.value||!Object.keys(i.value).length,action:l},{type:`danger`,icon:`sign-out`,label:`退出登录`,action:c}]);return(e,t)=>{let r=w(`k-form`),c=w(`el-button`),l=w(`k-content`),d=w(`k-layout`);return x(),f(d,{main:`page-profile`,menu:y.value},{default:O(()=>[_(l,null,{default:O(()=>[_(r,{schema:a.value,modelValue:i.value,"onUpdate:modelValue":t[0]||=e=>i.value=e},null,8,[`schema`,`modelValue`]),h(`h2`,xe,[t[3]||=g(` 平台绑定 `,-1),_(c,{solid:``,class:`right`,onClick:t[1]||=e=>N.value=!0},{default:O(()=>[...t[2]||=[g(`添加`,-1)]]),_:1})]),(x(!0),m(u,null,C(E(s).user.bindings,({platform:e,pid:n,bid:r})=>(x(),m(`div`,{class:`k-schema-item`,key:`${e}:${n}`},[h(`div`,Se,[h(`div`,Ce,T(e)+` (`+T(n)+`)`,1),h(`div`,we,[v.value.length>1||r!==E(s).user.id?(x(),f(c,{key:0,onClick:A(t=>E(o)(`user/unbind`,e,n),[`stop`,`prevent`])},{default:O(()=>[...t[4]||=[g(`解绑`,-1)]]),_:1},8,[`onClick`])):p(`v-if`,!0)])])]))),128)),t[6]||=h(`h2`,{class:`k-schema-header`},`登录历史`,-1),h(`ul`,null,[(x(!0),m(u,null,C(E(s).user.tokens,({inc:e,type:r,createdAt:i,lastUsedAt:a,address:s,userAgent:l})=>(x(),m(`li`,{key:e},[h(`div`,null,`登录类型:`+T(n[r]),1),h(`div`,null,`登录时间:`+T(i),1),h(`div`,null,`最后访问:`+T(a),1),h(`div`,null,`IP 地址:`+T(s),1),h(`div`,null,`客户端:`+T(l),1),h(`div`,null,[_(c,{onClick:t=>E(o)(`user/delete-token`,e)},{default:O(()=>[...t[5]||=[g(`移除会话`,-1)]]),_:1},8,[`onClick`])])]))),128))])]),_:1})]),_:1},8,[`menu`])}}}),Ee=q(v({__name:`sync-dialog`,setup(e){let t=c();async function n(e){if(M.value.sync=!!e,P.value=!1,e){if(e===`download`){t.value=s.user.config;return}try{await o(`user/update`,{config:t.value})}catch(e){r.error(e.message)}}}return(e,r)=>{let i=w(`el-button`),a=w(`el-button-group`),o=w(`el-dialog`);return x(),f(o,{class:`sync-dialog`,onClose:r[3]||=e=>E(t).sync??=!1,"show-close":!1,"close-on-click-modal":!1,"close-on-press-escape":!1,modelValue:E(P),"onUpdate:modelValue":r[4]||=e=>b(P)?P.value=e:null},{default:O(()=>[r[8]||=h(`p`,{class:`text-center`},`检测到你本地的配置与云端不同步,是否同步配置?`,-1),_(a,{class:`text-center`},{default:O(()=>[_(i,{onClick:r[0]||=e=>n(`upload`)},{default:O(()=>[...r[5]||=[g(`上传当前配置`,-1)]]),_:1}),_(i,{onClick:r[1]||=e=>n(`download`)},{default:O(()=>[...r[6]||=[g(`下载云端配置`,-1)]]),_:1}),_(i,{onClick:r[2]||=e=>n()},{default:O(()=>[...r[7]||=[g(`关闭配置同步`,-1)]]),_:1})]),_:1})]),_:1},8,[`modelValue`])}}}),[[`__scopeId`,`data-v-440ef018`]]);n.register(`at`,Z),n.register(`check`,ie),n.register(`lock`,ce),n.register(`sign-in`,fe),n.register(`sign-out`,ge),n.register(`user-full`,be);var De=n=>{let{token:l,id:u,expiredAt:d}=M.value;l&&u!==void 0&&d!==void 0&&d>Date.now()&&o(`login/token`,u,l).catch(e=>r.error(e.message)),n.on(`activity`,e=>{let t=e.authority??0;return t>0&&(!s.user||s.user.authority<t)}),n.scope.disposables.push(a.beforeEach(e=>{let{activity:t}=e.meta;if(t){if((t.authority||(t.fields??[]).includes(`user`))&&!s.user)return history.state.forward===`/login`?`/`:`/login`;if(t.authority&&s.user&&t.authority>s.user.authority)return r.error(`权限不足。`),!1}})),n.page({path:`/login`,name:`登录`,icon:`sign-in`,position:`bottom`,order:500,disabled:()=>!!s.user,component:$}),n.page({path:`/profile`,name:`用户资料`,icon:`user-full`,fields:[`user`],position:`bottom`,order:500,component:Te}),n.slot({type:`global`,component:K}),n.slot({type:`global`,component:Ee}),n.settings({id:`user`,title:`用户设置`,component:v(()=>()=>y(w(`k-form`),{schema:e.object({sync:e.boolean().description(`在多个客户端间同步设置。`)}).description(`同步设置`),initial:M.value,modelValue:M.value,"onUpdate:modelValue":e=>M.value=e}))});let f=c();function p(){s.user&&(t(s.user.config,f.value)||(P.value=!0))}n.on(`dispose`,D(f,async e=>{!e||!s.user||!M.value.sync||await o(`user/update`,{config:e})},{deep:!0})),n.on(`dispose`,D(()=>M.value.sync,async e=>{e&&s.user&&p()})),n.on(`dispose`,D(()=>s.user,(e,t)=>{if(N.value=!1,!e)return a.push(`/login`);if(M.value.sync&&p(),t)return;Object.assign(M.value,i(e,[`id`,`name`,`token`,`expiredAt`])),r.success(`欢迎回来,${e.name||`Koishi 用户`}!`);let n=a.currentRoute.value.redirectedFrom;n&&!n.path.startsWith(`/login`)?a.push(n):a.push(`/profile`)}))};export{De as default};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@koishi-ce/plugin-auth",
3
3
  "description": "Console User Authentication",
4
- "version": "1.0.1",
4
+ "version": "1.0.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
7
7
  "typings": "lib/index.d.ts",
@@ -54,7 +54,7 @@
54
54
  "@koishi-ce/koishi": "^1.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@koishi-ce/client": "^1.0.3",
57
+ "@koishi-ce/client": "^1.0.4",
58
58
  "vue": "^3.5.12",
59
59
  "vue-router": "^5.2.0"
60
60
  },
@@ -11,12 +11,35 @@
11
11
  * 令牌删除 / 登出 / 资料更新 / 解绑等用户管理事件。
12
12
  */
13
13
 
14
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
15
- import { createHash, pbkdf2Sync, randomBytes } from "node:crypto";
14
+ import {
15
+ afterAll,
16
+ beforeAll,
17
+ describe,
18
+ expect,
19
+ it,
20
+ } from "bun:test";
21
+ import {
22
+ createHash,
23
+ pbkdf2Sync,
24
+ randomBytes,
25
+ } from "node:crypto";
16
26
  import type { IncomingMessage } from "node:http";
17
- import { type Client, Console, type Entry } from "@koishi-ce/console";
18
- import { App, type Plugin, Time, type Universal } from "@koishi-ce/koishi";
19
- import auth, { type Auth, randomId } from "@koishi-ce/plugin-auth";
27
+ import {
28
+ type Client,
29
+ Console,
30
+ type Entry,
31
+ } from "@koishi-ce/console";
32
+ import {
33
+ App,
34
+ Logger,
35
+ type Plugin,
36
+ Time,
37
+ type Universal,
38
+ } from "@koishi-ce/koishi";
39
+ import auth, {
40
+ type Auth,
41
+ randomId,
42
+ } from "@koishi-ce/plugin-auth";
20
43
  import mockClient from "@koishi-ce/plugin-mock";
21
44
  import memory from "@koishijs/plugin-database-memory";
22
45
 
@@ -42,21 +65,32 @@ interface SentMessage {
42
65
  class FakeSocket {
43
66
  sent: string[] = [];
44
67
  // message 与 close 的监听器统一为同构签名(never 载荷),保证集合存取类型一致
45
- private messageHandlers = new Set<(event: never) => void>();
68
+ private messageHandlers = new Set<
69
+ (event: never) => void
70
+ >();
46
71
  private closeHandlers = new Set<(event: never) => void>();
47
72
 
48
73
  send(data: string) {
49
74
  this.sent.push(data);
50
75
  }
51
76
 
52
- addEventListener(type: string, listener: (event: never) => void) {
53
- if (type === "message") this.messageHandlers.add(listener);
77
+ addEventListener(
78
+ type: string,
79
+ listener: (event: never) => void,
80
+ ) {
81
+ if (type === "message")
82
+ this.messageHandlers.add(listener);
54
83
  if (type === "close") this.closeHandlers.add(listener);
55
84
  }
56
85
 
57
- removeEventListener(type: string, listener: (event: never) => void) {
58
- if (type === "message") this.messageHandlers.delete(listener);
59
- if (type === "close") this.closeHandlers.delete(listener);
86
+ removeEventListener(
87
+ type: string,
88
+ listener: (event: never) => void,
89
+ ) {
90
+ if (type === "message")
91
+ this.messageHandlers.delete(listener);
92
+ if (type === "close")
93
+ this.closeHandlers.delete(listener);
60
94
  }
61
95
 
62
96
  receive(text: string) {
@@ -67,7 +101,8 @@ class FakeSocket {
67
101
 
68
102
  shutdown() {
69
103
  // close 监听器签名统一带 never 载荷,调用时补占位实参
70
- for (const handler of this.closeHandlers) handler(undefined as never);
104
+ for (const handler of this.closeHandlers)
105
+ handler(undefined as never);
71
106
  }
72
107
 
73
108
  get socket(): Universal.WebSocket {
@@ -90,15 +125,23 @@ function tick(ms = 30) {
90
125
  class TestConsole extends Console {
91
126
  resolveEntry(files: Entry.Files, key: string): string[] {
92
127
  const list =
93
- typeof files === "string" || Array.isArray(files) ? files : files.prod;
128
+ typeof files === "string" || Array.isArray(files)
129
+ ? files
130
+ : files.prod;
94
131
  return [String(list), key];
95
132
  }
96
133
 
97
- acceptClient(socket: Universal.WebSocket, request: IncomingMessage): Client {
134
+ acceptClient(
135
+ socket: Universal.WebSocket,
136
+ request: IncomingMessage,
137
+ ): Client {
98
138
  let accepted: Client | undefined;
99
- const dispose = this.ctx.on("console/connection", (client) => {
100
- accepted = client;
101
- });
139
+ const dispose = this.ctx.on(
140
+ "console/connection",
141
+ (client) => {
142
+ accepted = client;
143
+ },
144
+ );
102
145
  this.accept(socket, request);
103
146
  dispose();
104
147
  if (!accepted) throw new Error("client not accepted");
@@ -112,7 +155,10 @@ class TestClient {
112
155
  readonly client: Client;
113
156
  private nextId = 0;
114
157
 
115
- constructor(service: TestConsole, headers: Record<string, string> = {}) {
158
+ constructor(
159
+ service: TestConsole,
160
+ headers: Record<string, string> = {},
161
+ ) {
116
162
  this.client = service.acceptClient(
117
163
  this.socket.socket,
118
164
  fakeRequest(headers),
@@ -120,13 +166,18 @@ class TestClient {
120
166
  }
121
167
 
122
168
  /** 发起一次 RPC 并轮询等待回执(PBKDF2 校验耗时较长,固定等待不可靠) */
123
- async call(type: string, args: unknown[], timeout = 5000) {
169
+ async call(
170
+ type: string,
171
+ args: unknown[],
172
+ timeout = 5000,
173
+ ) {
124
174
  const id = ++this.nextId;
125
175
  this.socket.receive(JSON.stringify({ type, args, id }));
126
176
  const deadline = Date.now() + timeout;
127
177
  for (;;) {
128
178
  const response = this.messages().find(
129
- (msg) => msg.type === "response" && msg.body.id === id,
179
+ (msg) =>
180
+ msg.type === "response" && msg.body.id === id,
130
181
  );
131
182
  if (response) return response.body;
132
183
  if (Date.now() > deadline) {
@@ -139,7 +190,10 @@ class TestClient {
139
190
  /** 最近一条 user 数据消息 */
140
191
  lastUserData() {
141
192
  const data = this.messages()
142
- .filter((msg) => msg.type === "data" && msg.body.key === "user")
193
+ .filter(
194
+ (msg) =>
195
+ msg.type === "data" && msg.body.key === "user",
196
+ )
143
197
  .at(-1);
144
198
  return data?.body.value as
145
199
  | (Auth & { tokens: unknown[] })
@@ -148,7 +202,9 @@ class TestClient {
148
202
  }
149
203
 
150
204
  messages(): SentMessage[] {
151
- return this.socket.sent.map((line) => JSON.parse(line) as SentMessage);
205
+ return this.socket.sent.map(
206
+ (line) => JSON.parse(line) as SentMessage,
207
+ );
152
208
  }
153
209
 
154
210
  close() {
@@ -161,9 +217,15 @@ const app = new App();
161
217
  app.plugin(memory as unknown as typeof memory.default);
162
218
  app.plugin(mockClient);
163
219
  // Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
164
- app.plugin(TestConsole as unknown as Plugin.Constructor<App>);
220
+ app.plugin(
221
+ TestConsole as unknown as Plugin.Constructor<App>,
222
+ );
165
223
  app.plugin(auth, {
166
- admin: { enabled: true, username: "root", password: "admin-pass" },
224
+ admin: {
225
+ enabled: true,
226
+ username: "root",
227
+ password: "admin-pass",
228
+ },
167
229
  authTokenExpire: Time.week,
168
230
  loginTokenExpire: Time.minute * 5,
169
231
  });
@@ -173,12 +235,18 @@ let aliceId = 0;
173
235
  let bobId = 0;
174
236
 
175
237
  beforeAll(async () => {
238
+ // 「creating admin account」是启动期一次性生命周期 info,收敛为仅错误级
239
+ (Logger.levels as Record<string, number>)["auth"] = 1;
176
240
  await app.start();
177
241
  // alice:用于平台验证码登录;bob:用于绑定改挂场景
178
- const alice = await app.database.createUser("mock", "111", {
179
- name: "alice",
180
- authority: 2,
181
- });
242
+ const alice = await app.database.createUser(
243
+ "mock",
244
+ "111",
245
+ {
246
+ name: "alice",
247
+ authority: 2,
248
+ },
249
+ );
182
250
  const bob = await app.database.createUser("mock", "222", {
183
251
  name: "bob",
184
252
  authority: 1,
@@ -194,6 +262,7 @@ afterAll(async () => {
194
262
  }
195
263
  await tick();
196
264
  await app.stop();
265
+ delete (Logger.levels as Record<string, number>)["auth"];
197
266
  });
198
267
 
199
268
  /** 以管理员身份登录并返回客户端 */
@@ -202,16 +271,23 @@ async function loginAdmin() {
202
271
  "user-agent": "test-agent",
203
272
  "x-forwarded-for": "1.2.3.4",
204
273
  });
205
- await client.call("login/password", ["root", "admin-pass"]);
274
+ await client.call("login/password", [
275
+ "root",
276
+ "admin-pass",
277
+ ]);
206
278
  return client;
207
279
  }
208
280
 
209
281
  describe("@koishi-ce/plugin-auth", () => {
210
282
  it("启动时创建管理员账户;randomId 生成随机令牌", async () => {
211
- const [admin] = await app.database.get("user", { id: 0 });
283
+ const [admin] = await app.database.get("user", {
284
+ id: 0,
285
+ });
212
286
  expect(admin?.name).toBe("root");
213
287
  expect(admin?.authority).toBe(5);
214
- expect(admin?.password?.startsWith("pbkdf2$")).toBe(true);
288
+ expect(admin?.password?.startsWith("pbkdf2$")).toBe(
289
+ true,
290
+ );
215
291
  expect(randomId(8)).toMatch(/^[0-9a-zA-Z]{8}$/);
216
292
  expect(randomId()).toHaveLength(40);
217
293
  });
@@ -219,9 +295,15 @@ describe("@koishi-ce/plugin-auth", () => {
219
295
  describe("密码登录", () => {
220
296
  it("密码错误与账户缺失时拒绝", async () => {
221
297
  const client = new TestClient(service());
222
- const wrong = await client.call("login/password", ["root", "wrong-pass"]);
298
+ const wrong = await client.call("login/password", [
299
+ "root",
300
+ "wrong-pass",
301
+ ]);
223
302
  expect(wrong.error).toContain("用户名或密码错误");
224
- const missing = await client.call("login/password", ["nobody", "x"]);
303
+ const missing = await client.call("login/password", [
304
+ "nobody",
305
+ "x",
306
+ ]);
225
307
  expect(missing.error).toContain("用户名或密码错误");
226
308
  client.close();
227
309
  });
@@ -256,8 +338,12 @@ describe("@koishi-ce/plugin-auth", () => {
256
338
  });
257
339
 
258
340
  it("旧版无盐 SHA-256 哈希校验通过后透明升级", async () => {
259
- const legacy = createHash("sha256").update("legacy-pass").digest("hex");
260
- await app.database.set("user", 0, { password: legacy });
341
+ const legacy = createHash("sha256")
342
+ .update("legacy-pass")
343
+ .digest("hex");
344
+ await app.database.set("user", 0, {
345
+ password: legacy,
346
+ });
261
347
 
262
348
  const client = new TestClient(service());
263
349
  const response = await client.call("login/password", [
@@ -265,36 +351,59 @@ describe("@koishi-ce/plugin-auth", () => {
265
351
  "legacy-pass",
266
352
  ]);
267
353
  expect(response.error).toBeUndefined();
268
- const [row] = await app.database.get("user", { id: 0 }, ["password"]);
269
- expect(row?.password?.startsWith("pbkdf2$")).toBe(true);
354
+ const [row] = await app.database.get(
355
+ "user",
356
+ { id: 0 },
357
+ ["password"],
358
+ );
359
+ expect(row?.password?.startsWith("pbkdf2$")).toBe(
360
+ true,
361
+ );
270
362
  client.close();
271
363
  });
272
364
 
273
365
  it("畸形哈希与空密码一律拒绝", async () => {
274
- await app.database.set("user", 0, { password: "pbkdf2$malformed" });
366
+ await app.database.set("user", 0, {
367
+ password: "pbkdf2$malformed",
368
+ });
275
369
  const malformed = new TestClient(service());
276
370
  expect(
277
- (await malformed.call("login/password", ["root", "x"])).error,
371
+ (
372
+ await malformed.call("login/password", [
373
+ "root",
374
+ "x",
375
+ ])
376
+ ).error,
278
377
  ).toContain("用户名或密码错误");
279
378
  malformed.close();
280
379
 
281
- await app.database.set("user", 0, { password: "not-a-hash-at-all" });
380
+ await app.database.set("user", 0, {
381
+ password: "not-a-hash-at-all",
382
+ });
282
383
  const alien = new TestClient(service());
283
384
  expect(
284
- (await alien.call("login/password", ["root", "x"])).error,
385
+ (await alien.call("login/password", ["root", "x"]))
386
+ .error,
285
387
  ).toContain("用户名或密码错误");
286
388
  alien.close();
287
389
 
288
390
  await app.database.set("user", 0, { password: "" });
289
391
  const empty = new TestClient(service());
290
392
  expect(
291
- (await empty.call("login/password", ["root", "x"])).error,
393
+ (await empty.call("login/password", ["root", "x"]))
394
+ .error,
292
395
  ).toContain("用户名或密码错误");
293
396
  empty.close();
294
397
 
295
398
  // 还原为合法管理员密码(与插件同格式的 PBKDF2 哈希)供后续用例使用
296
399
  const salt = randomBytes(16);
297
- const dk = pbkdf2Sync("admin-pass", salt, 600_000, 32, "sha256");
400
+ const dk = pbkdf2Sync(
401
+ "admin-pass",
402
+ salt,
403
+ 600_000,
404
+ 32,
405
+ "sha256",
406
+ );
298
407
  await app.database.set("user", 0, {
299
408
  password: `pbkdf2$600000$${salt.toString("hex")}$${dk.toString("hex")}`,
300
409
  });
@@ -308,11 +417,16 @@ describe("@koishi-ce/plugin-auth", () => {
308
417
  expect(token).toBeTruthy();
309
418
 
310
419
  const client = new TestClient(service());
311
- const response = await client.call("login/token", [0, token]);
420
+ const response = await client.call("login/token", [
421
+ 0,
422
+ token,
423
+ ]);
312
424
  expect(response.error).toBeUndefined();
313
425
  expect(client.client.auth?.id).toBe(0);
314
426
 
315
- const [row] = await app.database.get("token", { token: token! });
427
+ const [row] = await app.database.get("token", {
428
+ token: token!,
429
+ });
316
430
  expect(row?.lastUsedAt?.valueOf()).toBeGreaterThan(
317
431
  row?.createdAt?.valueOf() ?? 0,
318
432
  );
@@ -344,13 +458,28 @@ describe("@koishi-ce/plugin-auth", () => {
344
458
 
345
459
  const client = new TestClient(service());
346
460
  expect(
347
- (await client.call("login/token", [0, "expired-token"])).error,
461
+ (
462
+ await client.call("login/token", [
463
+ 0,
464
+ "expired-token",
465
+ ])
466
+ ).error,
348
467
  ).toContain("令牌已失效");
349
468
  expect(
350
- (await client.call("login/token", [0, "no-such-token"])).error,
469
+ (
470
+ await client.call("login/token", [
471
+ 0,
472
+ "no-such-token",
473
+ ])
474
+ ).error,
351
475
  ).toContain("令牌已失效");
352
476
  expect(
353
- (await client.call("login/token", [999, "ghost-token"])).error,
477
+ (
478
+ await client.call("login/token", [
479
+ 999,
480
+ "ghost-token",
481
+ ])
482
+ ).error,
354
483
  ).toContain("用户不存在");
355
484
  client.close();
356
485
  });
@@ -360,22 +489,39 @@ describe("@koishi-ce/plugin-auth", () => {
360
489
  it("账户不存在或已绑定同一账户时拒绝", async () => {
361
490
  const client = new TestClient(service());
362
491
  expect(
363
- (await client.call("login/platform", ["mock", "404"])).error,
492
+ (
493
+ await client.call("login/platform", [
494
+ "mock",
495
+ "404",
496
+ ])
497
+ ).error,
364
498
  ).toContain("找不到此账户");
365
499
 
366
500
  client.client.auth = { id: aliceId } as Auth;
367
501
  expect(
368
- (await client.call("login/platform", ["mock", "111"])).error,
502
+ (
503
+ await client.call("login/platform", [
504
+ "mock",
505
+ "111",
506
+ ])
507
+ ).error,
369
508
  ).toContain("你已经绑定了此账户");
370
509
  client.close();
371
510
  });
372
511
 
373
512
  it("未登录客户端经验证码完成登录", async () => {
374
513
  const client = new TestClient(service());
375
- const result = await client.call("login/platform", ["mock", "111"]);
514
+ const result = await client.call("login/platform", [
515
+ "mock",
516
+ "111",
517
+ ]);
376
518
  expect(result.error).toBeUndefined();
377
- expect(result.value).toMatchObject({ id: aliceId, name: "alice" });
378
- const code = (result.value as { token: string }).token;
519
+ expect(result.value).toMatchObject({
520
+ id: aliceId,
521
+ name: "alice",
522
+ });
523
+ const code = (result.value as { token: string })
524
+ .token;
379
525
  expect(code).toMatch(/^\d{6}$/);
380
526
 
381
527
  // 无关消息不消费验证码状态
@@ -396,9 +542,13 @@ describe("@koishi-ce/plugin-auth", () => {
396
542
  const client = new TestClient(service());
397
543
  // 已登录为 alice,把 bob 的平台账号绑到 alice 名下
398
544
  client.client.auth = { id: aliceId } as Auth;
399
- const result = await client.call("login/platform", ["mock", "222"]);
545
+ const result = await client.call("login/platform", [
546
+ "mock",
547
+ "222",
548
+ ]);
400
549
  expect(result.error).toBeUndefined();
401
- const code = (result.value as { token: string }).token;
550
+ const code = (result.value as { token: string })
551
+ .token;
402
552
 
403
553
  const user = app.mock.client("222");
404
554
  await user.receive(code);
@@ -417,10 +567,15 @@ describe("@koishi-ce/plugin-auth", () => {
417
567
  const keep = app.auth.config.loginTokenExpire;
418
568
  app.auth.config.loginTokenExpire = 50;
419
569
  const client = new TestClient(service());
420
- const result = await client.call("login/platform", ["mock", "111"]);
570
+ const result = await client.call("login/platform", [
571
+ "mock",
572
+ "111",
573
+ ]);
421
574
  expect(result.error).toBeUndefined();
422
575
  // 等待超时回调执行(状态清理与否取决于到期判定,此处验证回调不抛错)
423
- await new Promise((resolve) => setTimeout(resolve, 150));
576
+ await new Promise((resolve) =>
577
+ setTimeout(resolve, 150),
578
+ );
424
579
  client.close();
425
580
  app.auth.config.loginTokenExpire = keep;
426
581
  });
@@ -428,15 +583,19 @@ describe("@koishi-ce/plugin-auth", () => {
428
583
 
429
584
  describe("权限拦截(console/intercept)", () => {
430
585
  it("按登录态与权限等级拦截事件", async () => {
431
- app.console.addListener("test/admin-only", () => "secret", {
432
- authority: 4,
433
- });
586
+ app.console.addListener(
587
+ "test/admin-only",
588
+ () => "secret",
589
+ {
590
+ authority: 4,
591
+ },
592
+ );
434
593
 
435
594
  // 未登录:拦截
436
595
  const anonymous = new TestClient(service());
437
- expect((await anonymous.call("test/admin-only", [])).error).toBe(
438
- "unauthorized",
439
- );
596
+ expect(
597
+ (await anonymous.call("test/admin-only", [])).error,
598
+ ).toBe("unauthorized");
440
599
  anonymous.close();
441
600
 
442
601
  // 权限不足:拦截
@@ -447,9 +606,9 @@ describe("@koishi-ce/plugin-auth", () => {
447
606
  expiredAt: Date.now() + Time.hour,
448
607
  token: "t",
449
608
  } as Auth;
450
- expect((await low.call("test/admin-only", [])).error).toBe(
451
- "unauthorized",
452
- );
609
+ expect(
610
+ (await low.call("test/admin-only", [])).error,
611
+ ).toBe("unauthorized");
453
612
  low.close();
454
613
 
455
614
  // 令牌过期:拦截
@@ -460,14 +619,16 @@ describe("@koishi-ce/plugin-auth", () => {
460
619
  expiredAt: Date.now() - 1,
461
620
  token: "t",
462
621
  } as Auth;
463
- expect((await expired.call("test/admin-only", [])).error).toBe(
464
- "unauthorized",
465
- );
622
+ expect(
623
+ (await expired.call("test/admin-only", [])).error,
624
+ ).toBe("unauthorized");
466
625
  expired.close();
467
626
 
468
627
  // 管理员:放行
469
628
  const admin = await loginAdmin();
470
- expect((await admin.call("test/admin-only", [])).value).toBe("secret");
629
+ expect(
630
+ (await admin.call("test/admin-only", [])).value,
631
+ ).toBe("secret");
471
632
  admin.close();
472
633
  });
473
634
  });
@@ -475,21 +636,29 @@ describe("@koishi-ce/plugin-auth", () => {
475
636
  describe("用户管理事件", () => {
476
637
  it("user/delete-token 删除指定会话", async () => {
477
638
  const anonymous = new TestClient(service());
478
- expect((await anonymous.call("user/delete-token", [1])).error).toContain(
479
- "请先登录",
480
- );
639
+ expect(
640
+ (await anonymous.call("user/delete-token", [1]))
641
+ .error,
642
+ ).toContain("请先登录");
481
643
  anonymous.close();
482
644
 
483
645
  const admin = await loginAdmin();
484
646
  const [row] = await app.database.get("token", {
485
647
  token: admin.client.auth!.token,
486
648
  });
487
- const bad = await admin.call("user/delete-token", [99999]);
649
+ const bad = await admin.call(
650
+ "user/delete-token",
651
+ [99999],
652
+ );
488
653
  expect(bad.error).toContain("令牌不存在");
489
654
 
490
- const ok = await admin.call("user/delete-token", [row?.inc]);
655
+ const ok = await admin.call("user/delete-token", [
656
+ row?.inc,
657
+ ]);
491
658
  expect(ok.error).toBeUndefined();
492
- const [removed] = await app.database.get("token", { inc: row!.inc });
659
+ const [removed] = await app.database.get("token", {
660
+ inc: row!.inc,
661
+ });
493
662
  expect(removed).toBeUndefined();
494
663
  admin.close();
495
664
  });
@@ -500,7 +669,9 @@ describe("@koishi-ce/plugin-auth", () => {
500
669
  const response = await admin.call("user/logout", []);
501
670
  expect(response.error).toBeUndefined();
502
671
  // 令牌已被删除,其它设备无法再用它续期(loginAdmin 成功后令牌必存在)
503
- const [row] = await app.database.get("token", { token: token! });
672
+ const [row] = await app.database.get("token", {
673
+ token: token!,
674
+ });
504
675
  expect(row).toBeUndefined();
505
676
  // 移植偏差说明:上游 logout 以 setAuth(this, null) 显式清空登录态,
506
677
  // 本仓改为传 undefined,命中 setAuth 的默认参数(沿用 client.auth),
@@ -522,20 +693,32 @@ describe("@koishi-ce/plugin-auth", () => {
522
693
  it("user/update 修改资料(密码加哈希、配置透传)", async () => {
523
694
  const anonymous = new TestClient(service());
524
695
  expect(
525
- (await anonymous.call("user/update", [{ name: "x" }])).error,
696
+ (
697
+ await anonymous.call("user/update", [
698
+ { name: "x" },
699
+ ])
700
+ ).error,
526
701
  ).toContain("请先登录");
527
702
  anonymous.close();
528
703
 
529
704
  const admin = await loginAdmin();
530
705
  const response = await admin.call("user/update", [
531
- { name: "renamed", password: "new-pass", config: { theme: "dark" } },
706
+ {
707
+ name: "renamed",
708
+ password: "new-pass",
709
+ config: { theme: "dark" },
710
+ },
532
711
  ]);
533
712
  expect(response.error).toBeUndefined();
534
713
  // passive 更新:本地登录态同步但不推送数据
535
714
  expect(admin.client.auth?.name).toBe("renamed");
536
- const [row] = await app.database.get("user", { id: 0 });
715
+ const [row] = await app.database.get("user", {
716
+ id: 0,
717
+ });
537
718
  expect(row?.name).toBe("renamed");
538
- expect(row?.password?.startsWith("pbkdf2$")).toBe(true);
719
+ expect(row?.password?.startsWith("pbkdf2$")).toBe(
720
+ true,
721
+ );
539
722
  expect(row?.config).toEqual({ theme: "dark" });
540
723
  admin.close();
541
724
  });
@@ -543,13 +726,21 @@ describe("@koishi-ce/plugin-auth", () => {
543
726
  it("user/unbind 解绑平台账号的三种分支", async () => {
544
727
  const anonymous = new TestClient(service());
545
728
  expect(
546
- (await anonymous.call("user/unbind", ["mock", "111"])).error,
729
+ (
730
+ await anonymous.call("user/unbind", [
731
+ "mock",
732
+ "111",
733
+ ])
734
+ ).error,
547
735
  ).toContain("请先登录");
548
736
  anonymous.close();
549
737
 
550
738
  // 以 alice 身份登录
551
739
  const alice = new TestClient(service());
552
- const grant = await alice.call("login/platform", ["mock", "111"]);
740
+ const grant = await alice.call("login/platform", [
741
+ "mock",
742
+ "111",
743
+ ]);
553
744
  const code = (grant.value as { token: string }).token;
554
745
  await app.mock.client("111").receive(code);
555
746
  await tick();
@@ -557,12 +748,14 @@ describe("@koishi-ce/plugin-auth", () => {
557
748
 
558
749
  // 绑定不存在
559
750
  expect(
560
- (await alice.call("user/unbind", ["mock", "000"])).error,
751
+ (await alice.call("user/unbind", ["mock", "000"]))
752
+ .error,
561
753
  ).toContain("绑定不存在");
562
754
 
563
755
  // 仅剩一个自绑定(主账号):拒绝解绑
564
756
  expect(
565
- (await alice.call("user/unbind", ["mock", "111"])).error,
757
+ (await alice.call("user/unbind", ["mock", "111"]))
758
+ .error,
566
759
  ).toContain("无法解除绑定");
567
760
 
568
761
  // 追加第二个自绑定后可解绑
@@ -573,7 +766,8 @@ describe("@koishi-ce/plugin-auth", () => {
573
766
  pid: "333",
574
767
  });
575
768
  expect(
576
- (await alice.call("user/unbind", ["mock", "333"])).error,
769
+ (await alice.call("user/unbind", ["mock", "333"]))
770
+ .error,
577
771
  ).toBeUndefined();
578
772
  const [gone] = await app.database.get("binding", {
579
773
  platform: "mock",
@@ -589,7 +783,8 @@ describe("@koishi-ce/plugin-auth", () => {
589
783
  pid: "444",
590
784
  });
591
785
  expect(
592
- (await alice.call("user/unbind", ["mock", "444"])).error,
786
+ (await alice.call("user/unbind", ["mock", "444"]))
787
+ .error,
593
788
  ).toBeUndefined();
594
789
  const [moved] = await app.database.get("binding", {
595
790
  platform: "mock",
package/src/index.ts CHANGED
@@ -19,7 +19,10 @@ import {
19
19
  timingSafeEqual,
20
20
  } from "node:crypto";
21
21
  import { resolve } from "node:path";
22
- import type { Client, DataService } from "@koishi-ce/console";
22
+ import type {
23
+ Client,
24
+ DataService,
25
+ } from "@koishi-ce/console";
23
26
  import {
24
27
  type Binding,
25
28
  type Context,
@@ -66,10 +69,22 @@ declare module "@koishi-ce/console" {
66
69
  platform: string,
67
70
  pid: string,
68
71
  ): Promise<UserLogin>;
69
- "login/password"(this: Client, name: string, password: string): void;
70
- "login/token"(this: Client, id: number, token: string): void;
72
+ "login/password"(
73
+ this: Client,
74
+ name: string,
75
+ password: string,
76
+ ): void;
77
+ "login/token"(
78
+ this: Client,
79
+ id: number,
80
+ token: string,
81
+ ): void;
71
82
  "user/delete-token"(this: Client, inc: number): void;
72
- "user/unbind"(this: Client, platform: string, pid: string): void;
83
+ "user/unbind"(
84
+ this: Client,
85
+ platform: string,
86
+ pid: string,
87
+ ): void;
73
88
  "user/update"(this: Client, data: UserUpdate): void;
74
89
  "user/logout"(this: Client): void;
75
90
  }
@@ -120,18 +135,24 @@ const letters =
120
135
  export function randomId(length = 40) {
121
136
  return Array(length)
122
137
  .fill(0)
123
- .map(() => letters[Math.floor(Math.random() * letters.length)])
138
+ .map(
139
+ () =>
140
+ letters[Math.floor(Math.random() * letters.length)],
141
+ )
124
142
  .join("");
125
143
  }
126
144
 
127
145
  /** login/platform 事件的返回值:待登录用户信息 + 一次性验证码及其过期时间。 */
128
- export interface UserLogin extends Pick<User, "id" | "name"> {
146
+ export interface UserLogin
147
+ extends Pick<User, "id" | "name"> {
129
148
  token: string;
130
149
  expiredAt: number;
131
150
  }
132
151
 
133
152
  /** user/update 事件允许修改的用户字段。 */
134
- export type UserUpdate = Partial<Pick<User, "name" | "password" | "config">>;
153
+ export type UserUpdate = Partial<
154
+ Pick<User, "name" | "password" | "config">
155
+ >;
135
156
 
136
157
  /** PBKDF2-HMAC-SHA256 迭代次数(OWASP 2023 建议 600k;登录低频,开销可接受) */
137
158
  const PBKDF2_ROUNDS = 600_000;
@@ -139,7 +160,13 @@ const PBKDF2_ROUNDS = 600_000;
139
160
  /** 新格式密码哈希:`pbkdf2$<rounds>$<salt-hex>$<dk-hex>`(加盐 + 慢哈希)。 */
140
161
  function toHash(password: string) {
141
162
  const salt = randomBytes(16);
142
- const dk = pbkdf2Sync(password, salt, PBKDF2_ROUNDS, 32, "sha256");
163
+ const dk = pbkdf2Sync(
164
+ password,
165
+ salt,
166
+ PBKDF2_ROUNDS,
167
+ 32,
168
+ "sha256",
169
+ );
143
170
  return `pbkdf2$${PBKDF2_ROUNDS}$${salt.toString("hex")}$${dk.toString("hex")}`;
144
171
  }
145
172
 
@@ -151,7 +178,10 @@ function toHash(password: string) {
151
178
  * - 64 位十六进制旧格式:历史上无盐 SHA-256,仅用于校验(命中后由调用方
152
179
  * 透明升级为 PBKDF2),同样以恒定时间比较。
153
180
  */
154
- function verifyPassword(password: string, stored: string): boolean {
181
+ function verifyPassword(
182
+ password: string,
183
+ stored: string,
184
+ ): boolean {
155
185
  if (stored.startsWith("pbkdf2$")) {
156
186
  const [, rounds, saltHex, dkHex] = stored.split("$");
157
187
  if (!rounds || !saltHex || !dkHex) return false;
@@ -171,8 +201,13 @@ function verifyPassword(password: string, stored: string): boolean {
171
201
  // 命中后由调用方透明升级为 PBKDF2;新建密码一律走 toHash(pbkdf2$ 格式),
172
202
  // 故此处非弱哈希存储,属误报(Default setup 不支持注释抑制,须在平台 dismiss)。
173
203
  if (!/^[0-9a-f]{64}$/i.test(stored)) return false;
174
- const actual = createHash("sha256").update(password).digest();
175
- return timingSafeEqual(actual, Buffer.from(stored, "hex"));
204
+ const actual = createHash("sha256")
205
+ .update(password)
206
+ .digest();
207
+ return timingSafeEqual(
208
+ actual,
209
+ Buffer.from(stored, "hex"),
210
+ );
176
211
  }
177
212
 
178
213
  /**
@@ -189,37 +224,41 @@ class AuthService extends Service {
189
224
  // 的导出值迁为静态成员(合并到 class 的 namespace 本就编译为静态属性,运行时等价)
190
225
  static filter = false;
191
226
 
192
- static Admin: Schema<AuthService.Admin> = Schema.intersect([
193
- Schema.object({
194
- enabled: Schema.boolean().default(true),
195
- }),
196
- Schema.union([
227
+ static Admin: Schema<AuthService.Admin> =
228
+ Schema.intersect([
197
229
  Schema.object({
198
- enabled: Schema.const(true),
199
- username: Schema.string().default("admin"),
200
- password: Schema.string().role("secret").required(),
230
+ enabled: Schema.boolean().default(true),
201
231
  }),
202
- Schema.object({}),
203
- ]),
204
- ]);
205
-
206
- static Config: Schema<AuthService.Config> = Schema.intersect([
207
- Schema.object({
208
- admin: AuthService.Admin,
209
- }),
210
- Schema.object({
211
- authTokenExpire: Schema.natural()
212
- .role("ms")
213
- .default(Time.week)
214
- .min(Time.hour),
215
- loginTokenExpire: Schema.natural()
216
- .role("ms")
217
- .default(Time.minute * 10)
218
- .min(Time.minute),
219
- }),
220
- ]).i18n({
221
- "zh-CN": zhCN,
222
- });
232
+ Schema.union([
233
+ Schema.object({
234
+ enabled: Schema.const(true),
235
+ username: Schema.string().default("admin"),
236
+ password: Schema.string()
237
+ .role("secret")
238
+ .required(),
239
+ }),
240
+ Schema.object({}),
241
+ ]),
242
+ ]);
243
+
244
+ static Config: Schema<AuthService.Config> =
245
+ Schema.intersect([
246
+ Schema.object({
247
+ admin: AuthService.Admin,
248
+ }),
249
+ Schema.object({
250
+ authTokenExpire: Schema.natural()
251
+ .role("ms")
252
+ .default(Time.week)
253
+ .min(Time.hour),
254
+ loginTokenExpire: Schema.natural()
255
+ .role("ms")
256
+ .default(Time.minute * 10)
257
+ .min(Time.minute),
258
+ }),
259
+ ]).i18n({
260
+ "zh-CN": zhCN,
261
+ });
223
262
 
224
263
  // Service 基类已声明 config(T = any),此处覆盖为插件配置类型
225
264
  override config: AuthService.Config;
@@ -267,7 +306,8 @@ class AuthService extends Service {
267
306
 
268
307
  /** 启动时按配置确保管理员账户(id = 0)存在。 */
269
308
  override async start() {
270
- const { enabled, username, password } = this.config.admin;
309
+ const { enabled, username, password } =
310
+ this.config.admin;
271
311
  if (!enabled) return;
272
312
  this.ctx.logger.info("creating admin account");
273
313
  // enabled 分支的 Schema 已保证 username/password 存在(默认 admin / required),
@@ -291,24 +331,42 @@ class AuthService extends Service {
291
331
  * @param passive 为 true 时只写 client.auth,不推送数据也不刷新
292
332
  * (已登录状态下修改资料后仅同步本地时使用)
293
333
  */
294
- async setAuth(client: Client, auth = client.auth, passive = false) {
334
+ async setAuth(
335
+ client: Client,
336
+ auth = client.auth,
337
+ passive = false,
338
+ ) {
295
339
  client.auth = auth;
296
340
  if (passive) return;
297
341
  if (auth) {
298
- const bindings = await this.ctx.database.get("binding", { aid: auth.id });
342
+ const bindings = await this.ctx.database.get(
343
+ "binding",
344
+ { aid: auth.id },
345
+ );
299
346
  // 下发前剥离服务端字段;minato 模型字段必填,需按可删除的形状断言
300
- bindings.forEach((binding) => delete (binding as Partial<Binding>).aid);
301
- const tokens = await this.ctx.database.get("token", { id: auth.id });
347
+ bindings.forEach(
348
+ (binding) =>
349
+ delete (binding as Partial<Binding>).aid,
350
+ );
351
+ const tokens = await this.ctx.database.get("token", {
352
+ id: auth.id,
353
+ });
302
354
  tokens.reverse().forEach((login) => {
303
355
  delete (login as Partial<LoginToken>).id;
304
356
  delete (login as Partial<LoginToken>).token;
305
357
  });
306
358
  client.send({
307
359
  type: "data",
308
- body: { key: "user", value: { ...auth, bindings, tokens } },
360
+ body: {
361
+ key: "user",
362
+ value: { ...auth, bindings, tokens },
363
+ },
309
364
  });
310
365
  } else {
311
- client.send({ type: "data", body: { key: "user", value: null } });
366
+ client.send({
367
+ type: "data",
368
+ body: { key: "user", value: null },
369
+ });
312
370
  }
313
371
  client.ctx.emit("console/connection", client);
314
372
  client.refresh();
@@ -322,7 +380,10 @@ class AuthService extends Service {
322
380
  async createToken(
323
381
  client: Client,
324
382
  type: LoginType,
325
- user: Pick<User, "id" | "name" | "authority" | "config">,
383
+ user: Pick<
384
+ User,
385
+ "id" | "name" | "authority" | "config"
386
+ >,
326
387
  ) {
327
388
  // WebSocket 升级连接必带 HTTP 请求对象(见 console 服务端构造 Client 处)
328
389
  const { headers, socket } = client.request;
@@ -330,8 +391,10 @@ class AuthService extends Service {
330
391
  const lastUsedAt = new Date();
331
392
  const userAgent = headers["user-agent"]?.toString();
332
393
  const address =
333
- headers["x-forwarded-for"]?.toString() || socket.remoteAddress;
334
- const expiredAt = Date.now() + this.config.authTokenExpire;
394
+ headers["x-forwarded-for"]?.toString() ||
395
+ socket.remoteAddress;
396
+ const expiredAt =
397
+ Date.now() + this.config.authTokenExpire;
335
398
  const token = randomId();
336
399
  // 请求头字段可能缺失,undefined 由 minato 归一化为 NULL 落库,不做默认值替换
337
400
  await this.ctx.database.create("token", {
@@ -344,7 +407,11 @@ class AuthService extends Service {
344
407
  userAgent: userAgent as string,
345
408
  address: address as string,
346
409
  });
347
- await this.setAuth(client, { ...user, expiredAt, token });
410
+ await this.setAuth(client, {
411
+ ...user,
412
+ expiredAt,
413
+ token,
414
+ });
348
415
  }
349
416
 
350
417
  /** 注册全部登录 / 用户管理事件与权限拦截逻辑(构造时调用)。 */
@@ -353,46 +420,67 @@ class AuthService extends Service {
353
420
  const { ctx, config } = this;
354
421
  // 平台验证码登录的进行中状态:键为 `${platform}:${userId}`,
355
422
  // 值为 [验证码, 过期时间, 发起登录的客户端]
356
- const states: Record<string, [string, number, Client]> = {};
423
+ const states: Record<string, [string, number, Client]> =
424
+ {};
357
425
 
358
426
  // 用户密码登录:校验通过后签发新令牌;
359
427
  // 命中旧的无盐 SHA-256 存储时透明升级为 PBKDF2
360
- ctx.console.addListener("login/password", async function (name, password) {
361
- const [user] = await ctx.database.get("user", { name }, [
362
- "password",
363
- "name",
364
- "id",
365
- "authority",
366
- "config",
367
- ]);
368
- if (!user?.password || !verifyPassword(password, user.password))
369
- throw new Error("用户名或密码错误。");
370
- if (!user.password.startsWith("pbkdf2$")) {
371
- await ctx.database.set("user", user.id, {
372
- password: toHash(password),
373
- });
374
- }
375
- await self.createToken(this, "password", omit(user, ["password"]));
376
- });
428
+ ctx.console.addListener(
429
+ "login/password",
430
+ async function (name, password) {
431
+ const [user] = await ctx.database.get(
432
+ "user",
433
+ { name },
434
+ ["password", "name", "id", "authority", "config"],
435
+ );
436
+ if (
437
+ !user?.password ||
438
+ !verifyPassword(password, user.password)
439
+ )
440
+ throw new Error("用户名或密码错误。");
441
+ if (!user.password.startsWith("pbkdf2$")) {
442
+ await ctx.database.set("user", user.id, {
443
+ password: toHash(password),
444
+ });
445
+ }
446
+ await self.createToken(
447
+ this,
448
+ "password",
449
+ omit(user, ["password"]),
450
+ );
451
+ },
452
+ );
377
453
 
378
454
  // 已存令牌续期登录:本地记录的令牌未过期即恢复登录态,
379
455
  // 同时刷新该令牌的最后访问时间
380
- ctx.console.addListener("login/token", async function (aid, token) {
381
- const [data] = await ctx.database.get("token", { id: aid, token }, [
382
- "expiredAt",
383
- ]);
384
- if (!data || data.expiredAt <= Date.now())
385
- throw new Error("令牌已失效。");
386
- const [user] = await ctx.database.get("user", { id: aid }, [
387
- "id",
388
- "name",
389
- "authority",
390
- "config",
391
- ]);
392
- if (!user) throw new Error("用户不存在。");
393
- await ctx.database.set("token", { token }, { lastUsedAt: new Date() });
394
- await self.setAuth(this, { ...user, ...data, token });
395
- });
456
+ ctx.console.addListener(
457
+ "login/token",
458
+ async function (aid, token) {
459
+ const [data] = await ctx.database.get(
460
+ "token",
461
+ { id: aid, token },
462
+ ["expiredAt"],
463
+ );
464
+ if (!data || data.expiredAt <= Date.now())
465
+ throw new Error("令牌已失效。");
466
+ const [user] = await ctx.database.get(
467
+ "user",
468
+ { id: aid },
469
+ ["id", "name", "authority", "config"],
470
+ );
471
+ if (!user) throw new Error("用户不存在。");
472
+ await ctx.database.set(
473
+ "token",
474
+ { token },
475
+ { lastUsedAt: new Date() },
476
+ );
477
+ await self.setAuth(this, {
478
+ ...user,
479
+ ...data,
480
+ token,
481
+ });
482
+ },
483
+ );
396
484
 
397
485
  // 平台账户登录(第一步):校验平台账号存在后生成一次性验证码,
398
486
  // 用户把验证码发给任意机器人即可完成登录/绑定(见下方中间件)。
@@ -400,16 +488,19 @@ class AuthService extends Service {
400
488
  ctx.console.addListener(
401
489
  "login/platform",
402
490
  async function (platform, userId) {
403
- const user = await ctx.database.getUser(platform, userId, [
404
- "id",
405
- "name",
406
- ]);
491
+ const user = await ctx.database.getUser(
492
+ platform,
493
+ userId,
494
+ ["id", "name"],
495
+ );
407
496
  if (!user) throw new Error("找不到此账户。");
408
- if (this.auth?.id === user.id) throw new Error("你已经绑定了此账户。");
497
+ if (this.auth?.id === user.id)
498
+ throw new Error("你已经绑定了此账户。");
409
499
 
410
500
  const key = `${platform}:${userId}`;
411
501
  const token = Math.random().toString().slice(2, 8);
412
- const expiredAt = Date.now() + config.loginTokenExpire;
502
+ const expiredAt =
503
+ Date.now() + config.loginTokenExpire;
413
504
  states[key] = [token, expiredAt, this];
414
505
 
415
506
  // 客户端断开或验证码超时即作废本次登录状态
@@ -424,7 +515,12 @@ class AuthService extends Service {
424
515
  }, config.loginTokenExpire);
425
516
  this.socket.addEventListener("close", listener);
426
517
 
427
- return { id: user.id, name: user.name, token, expiredAt };
518
+ return {
519
+ id: user.id,
520
+ name: user.name,
521
+ token,
522
+ expiredAt,
523
+ };
428
524
  },
429
525
  );
430
526
 
@@ -459,65 +555,99 @@ class AuthService extends Service {
459
555
 
460
556
  // 拦截带 authority 要求的 console 事件:未登录、令牌过期或
461
557
  // 权限不足时拒绝(返回 true 表示拦截)
462
- ctx.on("console/intercept", async (client, listener) => {
463
- if (!listener.authority) return false;
464
- if (!client.auth) return true;
465
- if (client.auth.expiredAt <= Date.now()) return true;
466
- if (client.auth.authority < listener.authority) return true;
467
- return false;
468
- });
558
+ ctx.on(
559
+ "console/intercept",
560
+ async (client, listener) => {
561
+ if (!listener.authority) return false;
562
+ if (!client.auth) return true;
563
+ if (client.auth.expiredAt <= Date.now())
564
+ return true;
565
+ if (client.auth.authority < listener.authority)
566
+ return true;
567
+ return false;
568
+ },
569
+ );
469
570
 
470
571
  // 删除指定登录会话(登出其它设备)
471
- ctx.console.addListener("user/delete-token", async function (inc) {
472
- if (!this.auth) throw new Error("请先登录。");
473
- const [data] = await ctx.database.get("token", { id: this.auth.id, inc });
474
- if (!data) throw new Error("令牌不存在。");
475
- await ctx.database.remove("token", { inc });
476
- await self.setAuth(this);
477
- });
572
+ ctx.console.addListener(
573
+ "user/delete-token",
574
+ async function (inc) {
575
+ if (!this.auth) throw new Error("请先登录。");
576
+ const [data] = await ctx.database.get("token", {
577
+ id: this.auth.id,
578
+ inc,
579
+ });
580
+ if (!data) throw new Error("令牌不存在。");
581
+ await ctx.database.remove("token", { inc });
582
+ await self.setAuth(this);
583
+ },
584
+ );
478
585
 
479
586
  // 退出登录:删除当前令牌并清除登录态
480
- ctx.console.addListener("user/logout", async function () {
481
- if (this.auth) {
482
- await ctx.database.remove("token", { token: this.auth.token });
483
- }
484
- await self.setAuth(this, undefined);
485
- });
587
+ ctx.console.addListener(
588
+ "user/logout",
589
+ async function () {
590
+ if (this.auth) {
591
+ await ctx.database.remove("token", {
592
+ token: this.auth.token,
593
+ });
594
+ }
595
+ await self.setAuth(this, undefined);
596
+ },
597
+ );
486
598
 
487
599
  // 修改用户资料(用户名 / 密码 / 配置),密码先哈希再落库
488
- ctx.console.addListener("user/update", async function (data) {
489
- if (!this.auth) throw new Error("请先登录。");
490
- if (data.password) data.password = toHash(data.password);
491
- await ctx.database.set("user", { id: this.auth.id }, data);
492
- Object.assign(this.auth, data);
493
- await self.setAuth(this, undefined, true);
494
- });
600
+ ctx.console.addListener(
601
+ "user/update",
602
+ async function (data) {
603
+ if (!this.auth) throw new Error("请先登录。");
604
+ if (data.password)
605
+ data.password = toHash(data.password);
606
+ await ctx.database.set(
607
+ "user",
608
+ { id: this.auth.id },
609
+ data,
610
+ );
611
+ Object.assign(this.auth, data);
612
+ await self.setAuth(this, undefined, true);
613
+ },
614
+ );
495
615
 
496
616
  // 解绑平台账号:绑到别的用户时改指回其主账号;是自身主账号且
497
617
  // 仅剩一个自绑定时拒绝解绑(避免用户失去登录途径),否则删除记录
498
- ctx.console.addListener("user/unbind", async function (platform, pid) {
499
- if (!this.auth) throw new Error("请先登录。");
500
- const bindings = await ctx.database.get("binding", { aid: this.auth.id });
501
- // 客户端仅对已列出的绑定发起解绑,查找必命中,未命中视为异常状态
502
- const binding = bindings.find(
503
- (item) => item.platform === platform && item.pid === pid,
504
- );
505
- if (!binding) throw new Error("绑定不存在。");
506
- if (binding.aid !== binding.bid) {
507
- await ctx.database.set(
508
- "binding",
509
- { platform, pid },
510
- { aid: binding.bid },
618
+ ctx.console.addListener(
619
+ "user/unbind",
620
+ async function (platform, pid) {
621
+ if (!this.auth) throw new Error("请先登录。");
622
+ const bindings = await ctx.database.get("binding", {
623
+ aid: this.auth.id,
624
+ });
625
+ // 客户端仅对已列出的绑定发起解绑,查找必命中,未命中视为异常状态
626
+ const binding = bindings.find(
627
+ (item) =>
628
+ item.platform === platform && item.pid === pid,
511
629
  );
512
- } else if (
513
- bindings.filter((item) => item.aid === item.bid).length === 1
514
- ) {
515
- throw new Error("无法解除绑定。");
516
- } else {
517
- await ctx.database.remove("binding", { platform, pid });
518
- }
519
- await self.setAuth(this);
520
- });
630
+ if (!binding) throw new Error("绑定不存在。");
631
+ if (binding.aid !== binding.bid) {
632
+ await ctx.database.set(
633
+ "binding",
634
+ { platform, pid },
635
+ { aid: binding.bid },
636
+ );
637
+ } else if (
638
+ bindings.filter((item) => item.aid === item.bid)
639
+ .length === 1
640
+ ) {
641
+ throw new Error("无法解除绑定。");
642
+ } else {
643
+ await ctx.database.remove("binding", {
644
+ platform,
645
+ pid,
646
+ });
647
+ }
648
+ await self.setAuth(this);
649
+ },
650
+ );
521
651
  }
522
652
  }
523
653