Просмотр исходного кода

fix(auth): initialize imported student credentials consistently

彭峰 1 час назад
Родитель
Сommit
1b612c7882

+ 8 - 1
scripts/backfill-legacy-users.mjs

@@ -142,6 +142,11 @@ function projection(row) {
   return { legacyUserId, username, body };
 }
 
+// Imported students follow the same initial credential policy as newly created members.
+function legacyImportPassword(groupId, createRandom) {
+  return Number(groupId) === 1 ? '12345678' : createRandom();
+}
+
 // Deleted identities are durable tombstones. Reading-generated numeric IDs are
 // not proof of ownership in the old Xiaoshu account namespace.
 function legacyAccountDecision(row) {
@@ -257,7 +262,9 @@ for (const row of source.rows) {
     }
   } else {
     requests.push({ method: 'POST', path: '/parse/users', body: {
-      ...item.body, username: item.username, password: `${randomBytes(24).toString('base64url')}Aa9!`,
+      ...item.body, username: item.username,
+      password: legacyImportPassword(item.body.legacyGroupId, () => `${randomBytes(24).toString('base64url')}Aa9!`),
+      ...(item.body.legacyGroupId === 1 ? { passwordResetRequired: false } : {}),
     } });
     creates += 1;
   }

+ 4 - 2
scripts/cloud/legacy-direct-supplement.js

@@ -27,9 +27,11 @@ async function directSupplement(p,co){
    const user=new Parse.User();
    for(const[k,v]of Object.entries(value.body))user.set(k,k==='company'?tenant:v?.__type==='Date'?new Date(v.iso):v);
    user.set('identityType',value.body.legacyGroupId===3?'coach':value.body.legacyGroupId===2?'store':'member');user.setUsername(username);
-   user.setPassword((await Psql.one('SELECT gen_random_uuid()::text||gen_random_uuid()::text AS password')).password);
+   const member=value.body.legacyGroupId===1;
+   user.setPassword(await legacyImportPassword(value.body.legacyGroupId,async()=>(await Psql.one('SELECT gen_random_uuid()::text||gen_random_uuid()::text AS password')).password));
+   if(member)user.set('passwordResetRequired',false);
    await user.save(null,{useMasterKey:true});
-   await Psql.one('SELECT xs_enable_initial_legacy_login($1,$2)',[co,user.id]);
+   if(!member)await Psql.one('SELECT xs_enable_initial_legacy_login($1,$2)',[co,user.id]);
    const check=await Psql.query('SELECT "objectId" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,id]);if(check.length!==1)throw Error('new_legacy_user_identity_verification_failed');
    result.accounts.created++;
    });

+ 3 - 1
scripts/deploy-admin-functions.mjs

@@ -2432,7 +2432,9 @@ function legacyUser(current, withToken = false) {
   if (withToken) value.sessionToken = current.getSessionToken(); return value;
 }
 function legacyUserAddon(current) {
-  const data = legacyData(current); const state = objectValue(current,'isDisabled') === true ? 0 : number(firstValue(data.State,1),1); const result = { vip:number(data.VIP), state, State:state, regTime:firstValue(data.RegTime,objectValue(current,'createdAt')), purse:number(data.Purse), silverCoin:number(data.SilverCoin), userExp:number(data.UserExp), userPoint:number(data.UserPoint), boffExp:number(data.boffExp) }; result.VIP = result.vip; result.Purse = result.purse; result.SilverCoin = result.silverCoin; result.UserExp = result.userExp; result.UserPoint = result.userPoint; return result;
+  const data = legacyData(current), disabled = objectValue(current,'isDisabled'), legacyState = number(firstValue(data.State,1),1);
+  const state = disabled === true ? 0 : disabled === false && isMemberAccount(current) && legacyState === 0 ? 1 : legacyState;
+  const result = { vip:number(data.VIP), state, State:state, regTime:firstValue(data.RegTime,objectValue(current,'createdAt')), purse:number(data.Purse), silverCoin:number(data.SilverCoin), userExp:number(data.UserExp), userPoint:number(data.UserPoint), boffExp:number(data.boffExp) }; result.VIP = result.vip; result.Purse = result.purse; result.SilverCoin = result.silverCoin; result.UserExp = result.userExp; result.UserPoint = result.userPoint; return result;
 }
 async function registerUser(input) {
   const username = String(input.name || input.username || '').trim(); const password = '12345678'; if (!username) fail(400,'账号不能为空'); const inviteCode = String(input.code || input.inviCode || '').trim(); let parentId = 0; if (inviteCode) { const rows = await Psql.query('SELECT COALESCE("legacyUserId",("legacyUserData"->>\'UserID\')::numeric) AS id FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[DEFAULT_COMPANY_ID,inviteCode]); parentId = number(rows[0] && rows[0].id); if (!parentId) fail(400,'邀请码不存在'); }

+ 8 - 0
scripts/tests/account-security.test.mjs

@@ -1,6 +1,14 @@
 import {test} from 'node:test';import assert from 'node:assert/strict';import vm from 'node:vm';import {readFileSync} from 'node:fs';import {PGlite} from '@electric-sql/pglite';
 const code=readFileSync(new URL('../cloud/account-security.js',import.meta.url),'utf8');
 const gatewaySource=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+test('登录资料以明确的新账号启停状态为准,旧资料待审核不能阻止已启用学员进入',()=>{
+ const c=vm.createContext({legacyData:u=>u.data,objectValue:(u,k)=>u[k],isMemberAccount:u=>u.identityType==='member',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,firstValue:(...values)=>values.find(v=>v!==undefined&&v!==null&&v!=='')});
+ vm.runInContext(gatewaySource.slice(gatewaySource.indexOf('function legacyUserAddon('),gatewaySource.indexOf('async function registerUser(')),c);
+ for(const [isDisabled,oldState,wanted]of [[false,0,1],[false,2,2],[true,2,0],[undefined,0,0],[undefined,2,2]]){
+  const result=c.legacyUserAddon({identityType:'member',isDisabled,data:{State:oldState,Purse:7}});assert.equal(result.State,wanted);assert.equal(result.state,wanted);assert.equal(result.Purse,7);
+ }
+ assert.equal(c.legacyUserAddon({identityType:'coach',isDisabled:false,data:{State:0}}).State,0);
+});
 test('老师原账号和绑定手机号指向同一老师,重复手机号拒绝且不影响原账号登录',async()=>{
  const db=new PGlite();try{
   await db.exec(`CREATE TABLE "_User"("objectId" text,"username" text,"company" text,"realName" text,"mobile" text,"phone" text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDeleted" boolean,"identityType" text);

+ 11 - 1
scripts/tests/direct-nightly-sync.test.mjs

@@ -154,15 +154,25 @@ test('新增账号与栏目幂等补齐,已有账户余额和目录名称保
  const previous=context.fetch;let passwords=0;
  context.Parse.Query=class{constructor(className){assert.equal(className,'Company');}async get(id){return{id,className:'Company'};}};
  context.Parse.Object={createWithoutData:()=>{throw Error('Production SDK does not support two-argument createWithoutData');}};
- context.Parse.User=class{constructor(){this.fields={};}set(k,v){this.fields[k]=v;}setUsername(v){this.fields.username=v;}setPassword(v){assert.ok(v.length>60);passwords++;}async save(){this.id='new-user';const f=this.fields;assert.equal(f.identityType,'member');assert.equal(f.adminRoleKey,undefined);assert.equal(f.password,undefined);await sql('INSERT INTO "_User" ("objectId",company,username,"legacyUserId","legacyUserData") VALUES($1,$2,$3,$4,$5::jsonb)',['new-user',f.company.id,f.username,f.legacyUserId,JSON.stringify(f.legacyUserData)]);}};
+ context.Parse.User=class{constructor(){this.fields={};}set(k,v){this.fields[k]=v;}setUsername(v){this.fields.username=v;}setPassword(v){assert.equal(v,'12345678');passwords++;}async save(){this.id='new-user';const f=this.fields;assert.equal(f.identityType,'member');assert.equal(f.passwordResetRequired,false);assert.equal(f.adminRoleKey,undefined);assert.equal(f.password,undefined);await sql('INSERT INTO "_User" ("objectId",company,username,"legacyUserId","legacyUserData","_hashed_password") VALUES($1,$2,$3,$4,$5::jsonb,$6)',['new-user',f.company.id,f.username,f.legacyUserId,JSON.stringify(f.legacyUserData),'sdk-hashed-initial-password']);}};
  context.fetch=async url=>{const action=new URL(url).searchParams.get('action');return {ok:true,json:async()=>({retcode:0,page:{pageCount:1},result:action==='user_list'?[{UserID:20,UserName:'existing',GroupID:1,Purse:1},{UserID:4000,UserName:'new-member',GroupID:1,Purse:10,Password:'ignored-hash'}]:[{NodeID:8,NodeName:'旧目录'},{NodeID:9,ParentID:8,NodeName:'新目录'}]})};};
  try{let r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,1);assert.equal(r.data.nodes.created,1);
+  assert.equal((await sql('SELECT "legacyPasswordMigrationAllowed" FROM "_User" WHERE "objectId"=\'new-user\''))[0].legacyPasswordMigrationAllowed,false);
+  await sql('UPDATE "_User" SET "_hashed_password"=$1 WHERE "objectId"=\'new-user\'',['admin-reset-password']);
   r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,0);assert.equal(r.data.nodes.created,0);assert.equal(passwords,1);
+  assert.equal((await sql('SELECT "_hashed_password" FROM "_User" WHERE "objectId"=\'new-user\''))[0]._hashed_password,'admin-reset-password');
   assert.equal((await sql('SELECT "legacyUserData" FROM "_User" WHERE "legacyUserId"=20'))[0].legacyUserData.Purse,99);
   assert.equal((await sql('SELECT "nodeName" FROM "Node" WHERE "nodeId"=8'))[0].nodeName,'新系统目录');
  }finally{context.fetch=previous;}
 });
 
+test('新导入的老师和门店仍使用各自的一次性旧密码迁移,不设置学生初始密码',async()=>{
+ const previous=context.fetch;const passwords=new Map();
+ context.Parse.User=class{constructor(){this.fields={};}set(k,v){this.fields[k]=v;}setUsername(v){this.fields.username=v;}setPassword(v){this.password=v;}async save(){const f=this.fields;this.id='role-'+f.legacyUserId;passwords.set(f.legacyUserId,this.password);assert.ok(this.password.length>60);assert.equal(f.identityType,f.legacyGroupId===3?'coach':'store');await sql('INSERT INTO "_User" ("objectId",company,username,"legacyUserId","legacyUserData","_hashed_password") VALUES($1,$2,$3,$4,$5::jsonb,$6)',[this.id,f.company.id,f.username,f.legacyUserId,JSON.stringify(f.legacyUserData),'sdk-'+f.legacyUserId]);}};
+ context.fetch=async url=>({ok:true,json:async()=>({retcode:0,page:{pageCount:1},result:new URL(url).searchParams.get('action')==='user_list'?[{UserID:4001,UserName:'new-coach',GroupID:3},{UserID:4002,UserName:'new-store',GroupID:2}]:[]})});
+ try{const r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,2);assert.notEqual(passwords.get(4001),passwords.get(4002));assert.ok((await sql('SELECT "legacyPasswordMigrationAllowed" allowed FROM "_User" WHERE "legacyUserId" IN (4001,4002)')).every(r=>r.allowed===true));}finally{context.fetch=previous;}
+});
+
 
 test('未取得服务器对账证据时不完成运行;已退出进程的目录锁可恢复',async()=>{
  const root=await mkdtemp(join(tmpdir(),'direct-sync-negative-')),realFetch=globalThis.fetch;let finished=false;

+ 46 - 0
scripts/tests/legacy-user-import-password.test.mjs

@@ -0,0 +1,46 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { randomBytes } from 'node:crypto';
+import vm from 'node:vm';
+
+const source = readFileSync(new URL('../backfill-legacy-users.mjs', import.meta.url), 'utf8');
+function plan(rows, existing = []) {
+  const context = vm.createContext({
+    company: { __type: 'Pointer', className: 'Company', objectId: 'co' },
+    source: { rows }, byLegacyId: new Map(existing.map(row => [row.legacyUserId, row])),
+    byUsername: new Map(existing.map(row => [row.username, row])), refreshExisting: false, randomBytes,
+  });
+  const helpers = source.slice(source.indexOf('function text(value)'), source.indexOf('function requestGroups('));
+  const planner = source.slice(source.indexOf('const requests = [];'), source.indexOf('const groups = requestGroups(requests);'));
+  vm.runInContext(helpers + planner + '\nglobalThis.planned = requests;', context);
+  return context.planned;
+}
+
+test('manual legacy import initializes new students consistently and leaves existing credentials alone', () => {
+  const requests = plan([
+    { UserID: 20, UserName: 'existing', GroupID: 1 },
+    { UserID: 21, UserName: 'new-student', GroupID: 1, State: 1, Password: 'ignored-source-secret' },
+    { UserID: 22, UserName: 'disabled-student', GroupID: 1, State: 0 },
+  ], [{ objectId: 'existing', username: 'existing', legacyUserId: 20, identityType: 'member' }]);
+  assert.equal(requests.length, 2);
+  for (const request of requests) {
+    assert.equal(request.method, 'POST');
+    assert.equal(request.body.password, '12345678');
+    assert.equal(request.body.passwordResetRequired, false);
+    assert.equal(request.body.legacyPasswordMigrationAllowed, undefined);
+    assert.equal(request.body.legacyUserData.Password, undefined);
+  }
+  assert.equal(requests[0].body.isDisabled, false);
+  assert.equal(requests[1].body.isDisabled, true);
+});
+
+test('manual import keeps distinct random credentials for nonstudent roles', () => {
+  const requests = plan([
+    { UserID: 31, UserName: 'store', GroupID: 2 },
+    { UserID: 32, UserName: 'coach', GroupID: 3 },
+  ]);
+  assert.equal(requests.length, 2);
+  assert.ok(requests.every(row => row.body.password.length > 30));
+  assert.notEqual(requests[0].body.password, requests[1].body.password);
+});