refactor(server): auth (#7994)

This commit is contained in:
forehalo
2024-09-03 09:03:39 +00:00
parent 821de0a3bb
commit 8b0afd6eeb
39 changed files with 639 additions and 775 deletions

View File

@@ -0,0 +1,219 @@
import { randomBytes } from 'node:crypto';
import {
getCurrentMailMessageCount,
getTokenFromLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import { AuthService } from '../../src/core/auth/service';
import { MailService } from '../../src/fundamentals/mailer';
import {
changeEmail,
changePassword,
createTestingApp,
currentUser,
sendChangeEmail,
sendSetPasswordEmail,
sendVerifyChangeEmail,
signUp,
} from '../utils';
const test = ava as TestFn<{
app: INestApplication;
auth: AuthService;
mail: MailService;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp();
const auth = app.get(AuthService);
const mail = app.get(MailService);
t.context.app = app;
t.context.auth = auth;
t.context.mail = mail;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('change email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const u1 = await signUp(app, 'u1', u1Email, '1');
const primitiveMailCount = await getCurrentMailMessageCount();
await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro');
const afterSendChangeMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterSendChangeMailCount,
'failed to send change email'
);
const changeEmailToken = await getTokenFromLatestMailMessage();
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
await sendVerifyChangeEmail(
app,
u1.token.token,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const afterSendVerifyMailCount = await getCurrentMailMessageCount();
t.is(
afterSendChangeMailCount + 1,
afterSendVerifyMailCount,
'failed to send verify email'
);
const verifyEmailToken = await getTokenFromLatestMailMessage();
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
await changeEmail(app, u1.token.token, verifyEmailToken as string, u2Email);
const afterNotificationMailCount = await getCurrentMailMessageCount();
t.is(
afterSendVerifyMailCount + 1,
afterNotificationMailCount,
'failed to send notification email'
);
}
t.pass();
});
test('set and change password', async t => {
const { mail, app, auth } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const u1 = await signUp(app, 'u1', u1Email, '1');
const primitiveMailCount = await getCurrentMailMessageCount();
await sendSetPasswordEmail(app, u1.token.token, u1Email, 'affine.pro');
const afterSendSetMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterSendSetMailCount,
'failed to send set email'
);
const setPasswordToken = await getTokenFromLatestMailMessage();
t.not(
setPasswordToken,
null,
'fail to get set password token from email content'
);
const newPassword = randomBytes(16).toString('hex');
const success = await changePassword(
app,
u1.id,
setPasswordToken as string,
newPassword
);
t.true(success, 'failed to change password');
const ret = auth.signIn(u1Email, newPassword);
t.notThrowsAsync(ret, 'failed to check password');
t.is((await ret).id, u1.id, 'failed to check password');
}
t.pass();
});
test('should revoke token after change user identify', async t => {
const { mail, app, auth } = t.context;
if (mail.hasConfigured()) {
// change email
{
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const u1 = await signUp(app, 'u1', u1Email, '1');
{
const user = await currentUser(app, u1.token.token);
t.is(user?.email, u1Email, 'failed to get current user');
}
await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro');
const changeEmailToken = await getTokenFromLatestMailMessage();
await sendVerifyChangeEmail(
app,
u1.token.token,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const verifyEmailToken = await getTokenFromLatestMailMessage();
await changeEmail(
app,
u1.token.token,
verifyEmailToken as string,
u2Email
);
const user = await currentUser(app, u1.token.token);
t.is(user, null, 'token should be revoked');
const newUserSession = await auth.signIn(u2Email, '1');
t.is(newUserSession?.email, u2Email, 'failed to sign in with new email');
}
// change password
{
const u3Email = 'u3@affine.pro';
const u3 = await signUp(app, 'u1', u3Email, '1');
{
const user = await currentUser(app, u3.token.token);
t.is(user?.email, u3Email, 'failed to get current user');
}
await sendSetPasswordEmail(app, u3.token.token, u3Email, 'affine.pro');
const token = await getTokenFromLatestMailMessage();
const newPassword = randomBytes(16).toString('hex');
await changePassword(app, u3.id, token as string, newPassword);
const user = await currentUser(app, u3.token.token);
t.is(user, null, 'token should be revoked');
const newUserSession = await auth.signIn(u3Email, newPassword);
t.is(
newUserSession?.email,
u3Email,
'failed to sign in with new password'
);
}
}
t.pass();
});

View File

@@ -20,7 +20,7 @@ const test = ava as TestFn<{
app: INestApplication;
}>;
test.beforeEach(async t => {
test.before(async t => {
const { app } = await createTestingApp({
imports: [FeatureModule, UserModule, AuthModule],
tapModule: m => {
@@ -36,10 +36,14 @@ test.beforeEach(async t => {
t.context.mailer = app.get(MailService);
t.context.app = app;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
});
test.afterEach.always(async t => {
test.beforeEach(() => {
Sinon.reset();
});
test.after.always(async t => {
await t.context.app.close();
});

View File

@@ -1,15 +1,10 @@
import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import {
AuthGuard,
AuthModule,
CurrentUser,
Public,
} from '../../src/core/auth';
import { AuthModule, CurrentUser, Public, Session } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { createTestingApp } from '../utils';
@@ -25,115 +20,123 @@ class TestController {
private(@CurrentUser() user: CurrentUser) {
return { user };
}
@Get('/session')
session(@Session() session: Session) {
return session;
}
}
const test = ava as TestFn<{
app: INestApplication;
auth: Sinon.SinonStubbedInstance<AuthService>;
}>;
test.beforeEach(async t => {
let server!: any;
let auth!: AuthService;
let u1!: CurrentUser;
test.before(async t => {
const { app } = await createTestingApp({
imports: [AuthModule],
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
controllers: [TestController],
tapModule: m => {
m.overrideProvider(AuthService).useValue(
Sinon.createStubInstance(AuthService)
);
},
});
t.context.auth = app.get(AuthService);
auth = app.get(AuthService);
u1 = await auth.signUp('u1@affine.pro', '1');
const db = app.get(PrismaClient);
await db.session.create({
data: {
id: '1',
},
});
await auth.createUserSession(u1.id, '1');
server = app.getHttpServer();
t.context.app = app;
});
test.afterEach.always(async t => {
test.after.always(async t => {
await t.context.app.close();
});
test('should be able to visit public api if not signed in', async t => {
const { app } = t.context;
const res = await request(app.getHttpServer()).get('/public').expect(200);
const res = await request(server).get('/public').expect(200);
t.is(res.body.user, undefined);
});
test('should be able to visit public api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer())
const res = await request(server)
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
t.is(res.body.user.id, u1.id);
});
test('should not be able to visit private api if not signed in', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/private')
.expect(HttpStatus.UNAUTHORIZED)
.expect({
status: 401,
code: 'Unauthorized',
type: 'AUTHENTICATION_REQUIRED',
name: 'AUTHENTICATION_REQUIRED',
message: 'You must sign in first to access this resource.',
});
await request(server).get('/private').expect(HttpStatus.UNAUTHORIZED).expect({
status: 401,
code: 'Unauthorized',
type: 'AUTHENTICATION_REQUIRED',
name: 'AUTHENTICATION_REQUIRED',
message: 'You must sign in first to access this resource.',
});
t.assert(true);
});
test('should be able to visit private api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer())
const res = await request(server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
t.is(res.body.user.id, u1.id);
});
test('should be able to parse session cookie', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer())
const spy = Sinon.spy(auth, 'getUserSession');
await request(server)
.get('/public')
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(200);
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
t.deepEqual(spy.firstCall.args, ['1', undefined]);
spy.restore();
});
test('should be able to parse bearer token', async t => {
const { app, auth } = t.context;
const spy = Sinon.spy(auth, 'getUserSession');
// @ts-expect-error mock
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer())
await request(server)
.get('/public')
.auth('1', { type: 'bearer' })
.expect(200);
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
t.deepEqual(spy.firstCall.args, ['1', undefined]);
spy.restore();
});
test('should be able to refresh session if needed', async t => {
await t.context.app.get(PrismaClient).userSession.updateMany({
where: {
sessionId: '1',
},
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
},
});
const res = await request(server)
.get('/session')
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(200);
const cookie = res
.get('Set-Cookie')
?.find(c => c.startsWith(AuthService.sessionCookieName));
t.truthy(cookie);
});

View File

@@ -3,11 +3,11 @@ import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CurrentUser } from '../../src/core/auth';
import { AuthService, parseAuthUserSeqNum } from '../../src/core/auth/service';
import { AuthService } from '../../src/core/auth/service';
import { FeatureModule } from '../../src/core/features';
import { QuotaModule } from '../../src/core/quota';
import { UserModule, UserService } from '../../src/core/user';
import { createTestingModule } from '../utils';
import { createTestingModule, initTestingDB } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
@@ -17,7 +17,7 @@ const test = ava as TestFn<{
m: TestingModule;
}>;
test.beforeEach(async t => {
test.before(async t => {
const m = await createTestingModule({
imports: [QuotaModule, FeatureModule, UserModule],
providers: [AuthService],
@@ -27,50 +27,18 @@ test.beforeEach(async t => {
t.context.user = m.get(UserService);
t.context.db = m.get(PrismaClient);
t.context.m = m;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
test.beforeEach(async t => {
await initTestingDB(t.context.db);
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
});
test.after.always(async t => {
await t.context.m.close();
});
test('should be able to parse auth user seq num', t => {
t.deepEqual(
[
'1',
'2',
3,
-3,
'-4',
'1.1',
'str',
'1111111111111111111111111111111111111111111',
].map(parseAuthUserSeqNum),
[1, 2, 3, 0, 0, 0, 0, 0]
);
});
test('should be able to sign up', async t => {
const { auth } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
t.is(u2.email, 'u2@affine.pro');
const signedU2 = await auth.signIn(u2.email, '1');
t.is(u2.email, signedU2.email);
});
test('should throw if email duplicated', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), {
message: 'This email has already been registered.',
});
});
test('should be able to sign in', async t => {
test('should be able to sign in by password', async t => {
const { auth } = t.context;
const signedInUser = await auth.signIn('u1@affine.pro', '1');
@@ -114,7 +82,7 @@ test('should be able to change password', async t => {
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
t.is(signedInU1.email, u1.email);
await auth.changePassword(u1.id, '2');
await auth.changePassword(u1.id, 'hello world affine');
await t.throwsAsync(
() => auth.signIn('u1@affine.pro', '1' /* old password */),
@@ -123,7 +91,7 @@ test('should be able to change password', async t => {
}
);
signedInU1 = await auth.signIn('u1@affine.pro', '2');
signedInU1 = await auth.signIn('u1@affine.pro', 'hello world affine');
t.is(signedInU1.email, u1.email);
});
@@ -147,7 +115,7 @@ test('should be able to change email', async t => {
test('should be able to create user session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const session = await auth.createUserSession(u1.id);
t.is(session.userId, u1.id);
});
@@ -155,7 +123,7 @@ test('should be able to create user session', async t => {
test('should be able to get user from session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const session = await auth.createUserSession(u1.id);
const userSession = await auth.getUserSession(session.sessionId);
@@ -166,23 +134,50 @@ test('should be able to get user from session', async t => {
test('should be able to sign out session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const session = await auth.createUserSession(u1.id);
await auth.signOut(session.sessionId);
const userSession = await auth.getUserSession(session.sessionId);
const signedOutSession = await auth.signOut(session.sessionId);
t.is(userSession, null);
});
t.is(signedOutSession, null);
test('should not return expired session', async t => {
const { auth, u1, db } = t.context;
const session = await auth.createUserSession(u1.id);
await db.userSession.update({
where: { id: session.id },
data: {
expiresAt: new Date(Date.now() - 1000),
},
});
const userSession = await auth.getUserSession(session.sessionId);
t.is(userSession, null);
});
// Tests for Multi-Accounts Session
test('should be able to sign in different user in a same session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const u2 = await auth.signUp('u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
const session = await auth.createSession();
const [signedU1, signedU2] = await auth.getUserList(session.sessionId);
await auth.createUserSession(u1.id, session.id);
let userList = await auth.getUserList(session.id);
t.is(userList.length, 1);
t.is(userList[0]!.id, u1.id);
await auth.createUserSession(u2.id, session.id);
userList = await auth.getUserList(session.id);
t.is(userList.length, 2);
const [signedU1, signedU2] = userList;
t.not(signedU1, null);
t.not(signedU2, null);
@@ -193,29 +188,30 @@ test('should be able to sign in different user in a same session', async t => {
test('should be able to signout multi accounts session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const u2 = await auth.signUp('u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
const session = await auth.createSession();
// sign out user at seq(0)
let signedOutSession = await auth.signOut(session.sessionId);
await auth.createUserSession(u1.id, session.id);
await auth.createUserSession(u2.id, session.id);
t.not(signedOutSession, null);
await auth.signOut(session.id, u1.id);
const userSession1 = await auth.getUserSession(session.sessionId, 0);
const userSession2 = await auth.getUserSession(session.sessionId, 1);
let list = await auth.getUserList(session.id);
t.is(userSession2, null);
t.not(userSession1, null);
t.is(list.length, 1);
t.is(list[0]!.id, u2.id);
t.is(userSession1!.user.id, u2.id);
const u1Session = await auth.getUserSession(session.id, u1.id);
// sign out user at seq(0)
signedOutSession = await auth.signOut(session.sessionId);
t.is(u1Session, null);
t.is(signedOutSession, null);
await auth.signOut(session.id, u2.id);
list = await auth.getUserList(session.id);
const userSession3 = await auth.getUserSession(session.sessionId, 0);
t.is(userSession3, null);
t.is(list.length, 0);
const u2Session = await auth.getUserSession(session.id, u2.id);
t.is(u2Session, null);
});

View File

@@ -10,7 +10,7 @@ const test = ava as TestFn<{
m: TestingModule;
}>;
test.beforeEach(async t => {
test.before(async t => {
const m = await createTestingModule({
providers: [TokenService],
});
@@ -19,7 +19,7 @@ test.beforeEach(async t => {
t.context.m = m;
});
test.afterEach.always(async t => {
test.after.always(async t => {
await t.context.m.close();
});