Sign in verified accounts automatically and require eight-character passwords
This commit is contained in:
@@ -75,6 +75,43 @@ try {
|
||||
`const base='http://127.0.0.1:${port}';const r=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error('session failed');const session=await r.json(),cookie=r.headers.get('set-cookie').split(';')[0];if(!r.headers.get('set-cookie').includes('Secure'))throw Error('insecure cookie');const renamed=await fetch(base+'/api/device/me',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({name:'Docker 저장 검증'})});if(!renamed.ok)throw Error('rename failed');console.log(JSON.stringify({cookie,id:session.device.id}));`,
|
||||
),
|
||||
);
|
||||
let verifiedAccount;
|
||||
if (process.env.HOP_CHECK_AUTH === '1') {
|
||||
verifiedAccount = JSON.parse(
|
||||
node(`
|
||||
import Database from 'better-sqlite3';
|
||||
import {drizzle} from 'drizzle-orm/better-sqlite3';
|
||||
import argon2 from 'argon2';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {users, accountTokens} from './apps/server/dist/db/schema.js';
|
||||
import {hash, randomToken} from './apps/server/dist/security.js';
|
||||
const base='http://127.0.0.1:${port}', guestCookie=${JSON.stringify(state.cookie)};
|
||||
const passwordHash=await argon2.hash('12345678'), token=randomToken(), now=Date.now();
|
||||
const database=new Database('/data/database/hop.sqlite'), db=drizzle(database);
|
||||
const oldId=randomUUID(), nextId=randomUUID();
|
||||
db.insert(users).values({id:oldId,email:'previous@docker.hop.test',displayName:'previous',passwordHash,status:'active',verifiedAt:now,createdAt:now}).run();
|
||||
db.insert(users).values({id:nextId,email:'verified@docker.hop.test',displayName:'verified',passwordHash,status:'pending',createdAt:now}).run();
|
||||
db.insert(accountTokens).values({id:randomUUID(),userId:nextId,purpose:'verify',tokenHash:hash(token),createdAt:now,expiresAt:now+60000}).run();
|
||||
database.close();
|
||||
const sessionResponse=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:guestCookie,'Content-Type':'application/json'},body:'{}'});
|
||||
const session=await sessionResponse.json();
|
||||
const headers={Origin:'https://hop.test',Cookie:guestCookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'};
|
||||
const login=await fetch(base+'/api/auth/login',{method:'POST',headers,body:JSON.stringify({email:'previous@docker.hop.test',password:'12345678'})});
|
||||
if(!login.ok)throw Error(await login.text());
|
||||
const oldCookie=guestCookie+'; '+login.headers.get('set-cookie').split(';')[0];
|
||||
const verification=await fetch(base+'/api/auth/verify-email',{method:'POST',headers:{...headers,Cookie:oldCookie},body:JSON.stringify({token})});
|
||||
if(!verification.ok)throw Error(await verification.text());
|
||||
const verified=await verification.json(), cookie=verification.headers.get('set-cookie');
|
||||
if(verified.account.id!==nextId||!verified.account.verified||verified.token)throw Error('wrong verified account');
|
||||
if(!cookie.includes('Secure')||!cookie.includes('HttpOnly')||!cookie.includes('SameSite=Lax'))throw Error('unprotected verification cookie');
|
||||
const old=await fetch(base+'/api/auth/me',{headers:{Cookie:oldCookie}});
|
||||
if((await old.json()).account!==null)throw Error('previous session still valid');
|
||||
const replay=await fetch(base+'/api/auth/verify-email',{method:'POST',headers:{...headers,Cookie:guestCookie+'; '+cookie.split(';')[0]},body:JSON.stringify({token})});
|
||||
if(replay.status!==400||replay.headers.get('set-cookie'))throw Error('token replay allowed');
|
||||
console.log(JSON.stringify({cookie:guestCookie+'; '+cookie.split(';')[0],userId:nextId}));
|
||||
`),
|
||||
);
|
||||
}
|
||||
const fileState = JSON.parse(
|
||||
node(
|
||||
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
|
||||
@@ -89,6 +126,16 @@ try {
|
||||
);
|
||||
assert.equal(result.device.name, 'Docker 저장 검증');
|
||||
assert.equal(result.device.id, state.id);
|
||||
if (verifiedAccount) {
|
||||
const restored = JSON.parse(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/auth/me',{headers:{Cookie:${JSON.stringify(verifiedAccount.cookie)}}});console.log(JSON.stringify(await r.json()));`,
|
||||
),
|
||||
);
|
||||
assert.equal(restored.passwordMinLength, 8);
|
||||
assert.equal(restored.account.id, verifiedAccount.userId);
|
||||
assert.equal(restored.account.verified, true);
|
||||
}
|
||||
const preservedText = JSON.parse(
|
||||
node(
|
||||
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('text list failed');const item=(await r.json()).items.find(i=>i.accessId===${JSON.stringify(fileState.text.accessId)});if(!item)throw Error('text not preserved');console.log(JSON.stringify(item.text));`,
|
||||
@@ -142,7 +189,7 @@ try {
|
||||
}
|
||||
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
||||
console.log(
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}`,
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token and session persistence after restart.' : ''}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
||||
|
||||
@@ -68,18 +68,35 @@ try {
|
||||
const auth = page.locator('.account-auth');
|
||||
await auth.getByLabel('이메일', { exact: true }).fill(email);
|
||||
await auth.getByLabel('표시 이름', { exact: true }).fill(name);
|
||||
await auth.getByLabel('비밀번호', { exact: true }).fill('1234');
|
||||
await auth.getByLabel('비밀번호 확인', { exact: true }).fill('1234');
|
||||
assert.equal(await auth.getByLabel('비밀번호', { exact: true }).getAttribute('minlength'), '8');
|
||||
assert.equal(
|
||||
await auth.getByLabel('비밀번호', { exact: true }).getAttribute('placeholder'),
|
||||
'8자 이상',
|
||||
);
|
||||
await auth.getByLabel('비밀번호', { exact: true }).fill('1234567');
|
||||
await auth.getByLabel('비밀번호 확인', { exact: true }).fill('1234567');
|
||||
const beforeShortPassword = mails.length;
|
||||
await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click();
|
||||
assert.equal(
|
||||
await auth.getByLabel('비밀번호', { exact: true }).evaluate((el) => el.validity.tooShort),
|
||||
true,
|
||||
);
|
||||
assert.equal(mails.length, beforeShortPassword);
|
||||
await auth.getByLabel('비밀번호', { exact: true }).fill('12345678');
|
||||
await auth.getByLabel('비밀번호 확인', { exact: true }).fill('12345678');
|
||||
await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click();
|
||||
await auth.locator('.auth-message').waitFor();
|
||||
const token = mails.find((m) => m.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1];
|
||||
await page.goto(`${url}/#verify=${token}`);
|
||||
await page.getByRole('button', { name: '이메일 인증 완료하기' }).click();
|
||||
await auth.getByRole('button', { name: '로그인', exact: true }).waitFor();
|
||||
await auth.getByLabel('이메일', { exact: true }).fill(email);
|
||||
await auth.getByLabel('비밀번호', { exact: true }).fill('1234');
|
||||
await auth.getByRole('button', { name: '로그인', exact: true }).click();
|
||||
await page.locator('.account-workspace').waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(async () => (await (await fetch('/api/auth/me')).json()).account.email),
|
||||
email,
|
||||
'인증한 계정으로 자동 로그인',
|
||||
);
|
||||
assert.equal(await auth.count(), 0);
|
||||
assert(!(await page.locator('body').innerText()).includes('로그인해 주세요'));
|
||||
assert.deepEqual(
|
||||
(await page.getByRole('tab').allTextContents()).map((text) => text.trim()),
|
||||
['받은 항목', '임시 저장'],
|
||||
@@ -377,9 +394,82 @@ try {
|
||||
await receiver.getByRole('button', { name: '로그아웃', exact: true }).click();
|
||||
await receiver.locator('.account-auth').waitFor();
|
||||
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
|
||||
const signup = receiver.locator('.account-auth');
|
||||
await signup.getByRole('button', { name: '이메일로 가입', exact: true }).click();
|
||||
await signup.getByLabel('이메일', { exact: true }).fill('carol@example.com');
|
||||
await signup.getByLabel('표시 이름', { exact: true }).fill('Carol');
|
||||
await signup.getByLabel('비밀번호', { exact: true }).fill('12345678');
|
||||
await signup.getByLabel('비밀번호 확인', { exact: true }).fill('12345678');
|
||||
await signup.getByRole('button', { name: '가입하고 인증 메일 받기' }).click();
|
||||
await signup.locator('.auth-message').waitFor();
|
||||
const carolToken = mails
|
||||
.find((mail) => mail.to === 'carol@example.com')
|
||||
.text.match(/#verify=([A-Za-z0-9_-]+)/)[1];
|
||||
const oldAccountCookie = (await a.cookies(url)).find(
|
||||
(cookie) => cookie.name === 'hop_account',
|
||||
).value;
|
||||
const verificationPage = await a.newPage();
|
||||
verificationPage.on('pageerror', (error) => errors.push(error.message));
|
||||
await verificationPage.goto(`${url}/#verify=${carolToken}`);
|
||||
await verificationPage.getByRole('button', { name: '이메일 인증 완료하기' }).waitFor();
|
||||
await verificationPage.locator('.app-shell[data-online="true"]').waitFor();
|
||||
await verificationPage.waitForFunction(
|
||||
() => document.querySelector('.account-auth button[type="submit"]')?.disabled === false,
|
||||
);
|
||||
assert.equal(
|
||||
await verificationPage.evaluate(
|
||||
async () => (await (await fetch('/api/auth/me')).json()).account.email,
|
||||
),
|
||||
'alice@example.com',
|
||||
);
|
||||
let delayedAuthLookup = false;
|
||||
await verificationPage.route('**/api/auth/me', async (route) => {
|
||||
const response = await route.fetch();
|
||||
if (!delayedAuthLookup) {
|
||||
delayedAuthLookup = true;
|
||||
const stale = { ...(await response.json()), account: null };
|
||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||
await route.fulfill({ response, json: stale });
|
||||
} else await route.fulfill({ response });
|
||||
});
|
||||
await verificationPage.getByRole('button', { name: '이메일 인증 완료하기' }).click();
|
||||
await verificationPage.locator('.account-workspace').waitFor();
|
||||
await verificationPage.unroute('**/api/auth/me');
|
||||
assert.equal(
|
||||
await verificationPage.evaluate(
|
||||
async () => (await (await fetch('/api/auth/me')).json()).account.email,
|
||||
),
|
||||
'carol@example.com',
|
||||
);
|
||||
assert.equal(
|
||||
await verificationPage
|
||||
.getByRole('tab', { name: '받은 항목', exact: true })
|
||||
.getAttribute('aria-selected'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(await verificationPage.locator('.item-card,.account-auth').count(), 0);
|
||||
assert(
|
||||
!(await verificationPage.locator('body').innerText()).includes('https://example.com/saved'),
|
||||
);
|
||||
const revokedAccount = await a.request.get(`${url}/api/auth/me`, {
|
||||
headers: { Cookie: `hop_account=${oldAccountCookie}` },
|
||||
});
|
||||
assert.equal((await revokedAccount.json()).account, null);
|
||||
const inaccessibleVault = await a.request.get(`${url}/api/vault`, {
|
||||
headers: { Cookie: `hop_account=${oldAccountCookie}` },
|
||||
});
|
||||
assert.equal(inaccessibleVault.status(), 401);
|
||||
assert.equal(verificationPage.url(), `${url}/`, '인증 토큰을 주소창에 남기지 않음');
|
||||
await verificationPage.getByRole('button', { name: '계정 설정', exact: true }).click();
|
||||
await verificationPage
|
||||
.getByRole('dialog', { name: '계정 설정', exact: true })
|
||||
.getByText('carol@example.com', { exact: true })
|
||||
.waitFor();
|
||||
await verificationPage.screenshot({ path: 'tests/e2e/verification-switch.png' });
|
||||
await verificationPage.close();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: 받은 항목 기본 화면·보관 안내, PC/모바일 계정 설정·입력·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장·메모·수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료·삭제/복원·로그아웃 권한 격리, 원본 수동 삭제 시 다운로드 전 안내·양쪽 목록/용량 정리·열린 전송 선택 해제. 실제 Resend 발송은 포함하지 않음.',
|
||||
'PASS: PC/모바일 가입 8자 규칙과 인증 자동 로그인, 다른 계정의 인증 링크에서 세션 교체·이전 쿠키 무효화·보관함 격리·지연된 인증 조회 경합, 받은 항목 기본 화면·계정 설정·날짜별 기록·저장/메모/수락 후 열기/다운로드·7일 보관·삭제/복원·원본 수동 삭제 대응·토스트. 실제 Resend 발송은 포함하지 않음.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
Reference in New Issue
Block a user