refactor(server): auth (#5895)

Remove `next-auth` and implement our own Authorization/Authentication system from scratch.

## Server

- [x] tokens
  - [x] function
  - [x] encryption

- [x] AuthController
  - [x] /api/auth/sign-in
  - [x] /api/auth/sign-out
  - [x] /api/auth/session
  - [x] /api/auth/session (WE SUPPORT MULTI-ACCOUNT!)

- [x] OAuthPlugin
  - [x] OAuthController
  - [x] /oauth/login
  - [x] /oauth/callback
  - [x] Providers
    - [x] Google
    - [x] GitHub

## Client

- [x] useSession
- [x] cloudSignIn
- [x] cloudSignOut

## NOTE:

Tests will be adding in the future
This commit is contained in:
liuyi
2024-03-12 10:00:09 +00:00
parent af49e8cc41
commit fb3a0e7b8f
148 changed files with 3407 additions and 2851 deletions

View File

@@ -1,16 +1,8 @@
import { ok } from 'node:assert';
import { randomUUID } from 'node:crypto';
import { Transformer } from '@napi-rs/image';
import type { INestApplication } from '@nestjs/common';
import { hashSync } from '@node-rs/argon2';
import { PrismaClient, type User } from '@prisma/client';
import ava, { type TestFn } from 'ava';
import type { Express } from 'express';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { FeatureManagementService } from '../src/core/features';
import { createTestingApp } from './utils';
const gql = '/graphql';
@@ -19,43 +11,9 @@ const test = ava as TestFn<{
app: INestApplication;
}>;
class FakePrisma {
fakeUser: User = {
id: randomUUID(),
name: 'Alex Yang',
avatarUrl: '',
email: 'alex.yang@example.org',
password: hashSync('123456'),
emailVerified: new Date(),
createdAt: new Date(),
};
get user() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const prisma = this;
return {
async findFirst() {
return prisma.fakeUser;
},
async findUnique() {
return this.findFirst();
},
async update() {
return this.findFirst();
},
};
}
}
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [AppModule],
tapModule(builder) {
builder
.overrideProvider(PrismaClient)
.useClass(FakePrisma)
.overrideProvider(FeatureManagementService)
.useValue({ canEarlyAccess: () => true });
},
});
t.context.app = app;
@@ -66,7 +24,6 @@ test.afterEach.always(async t => {
});
test('should init app', async t => {
t.is(typeof t.context.app, 'object');
await request(t.context.app.getHttpServer())
.post(gql)
.send({
@@ -78,130 +35,22 @@ test('should init app', async t => {
})
.expect(400);
const { token } = await createToken(t.context.app);
await request(t.context.app.getHttpServer())
const response = await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
__typename
}
`,
})
.expect(200)
.expect(res => {
t.is(res.body.data.__typename, 'Query');
});
});
test('should find default user', async t => {
const { token } = await createToken(t.context.app);
await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
user(email: "alex.yang@example.org") {
... on UserType {
email
}
... on LimitedUserType {
email
}
query: `query {
serverConfig {
name
version
type
features
}
}
`,
}`,
})
.expect(200)
.expect(res => {
t.is(res.body.data.user.email, 'alex.yang@example.org');
});
.expect(200);
const config = response.body.data.serverConfig;
t.is(config.type, 'Affine');
t.true(Array.isArray(config.features));
});
test('should be able to upload avatar and remove it', async t => {
const { token, id } = await createToken(t.context.app);
const png = await Transformer.fromRgbaPixels(
Buffer.alloc(400 * 400 * 4).fill(255),
400,
400
).png();
await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.field(
'operations',
JSON.stringify({
name: 'uploadAvatar',
query: `mutation uploadAvatar($avatar: Upload!) {
uploadAvatar(avatar: $avatar) {
id
name
avatarUrl
email
}
}
`,
variables: { id, avatar: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
.attach('0', png, 'avatar.png')
.expect(200)
.expect(res => {
t.is(res.body.data.uploadAvatar.id, id);
});
await request(t.context.app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation removeAvatar {
removeAvatar {
success
}
}
`,
})
.expect(200)
.expect(res => {
t.is(res.body.data.removeAvatar.success, true);
});
});
async function createToken(app: INestApplication<Express>): Promise<{
id: string;
token: string;
}> {
let token;
let id;
await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
signIn(email: "alex.yang@example.org", password: "123456") {
id
token {
token
}
}
}
`,
})
.expect(200)
.expect(res => {
id = res.body.data.signIn.id;
ok(
typeof res.body.data.signIn.token.token === 'string',
'res.body.data.signIn.token.token is not a string'
);
token = res.body.data.signIn.token.token;
});
return { token: token!, id: id! };
}

View File

@@ -39,7 +39,7 @@ test('change email', async t => {
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const tokenRegex = /token=3D([^"&\s]+)/;
const tokenRegex = /token=3D([^"&]+)/;
const u1 = await signUp(app, 'u1', u1Email, '1');
@@ -57,7 +57,7 @@ test('change email', async t => {
const changeTokenMatch = changeEmailContent.Content.Body.match(tokenRegex);
const changeEmailToken = changeTokenMatch
? decodeURIComponent(changeTokenMatch[1].replace(/=3D/g, '='))
? decodeURIComponent(changeTokenMatch[1].replace(/=\r\n/, ''))
: null;
t.not(
@@ -85,7 +85,7 @@ test('change email', async t => {
const verifyTokenMatch = verifyEmailContent.Content.Body.match(tokenRegex);
const verifyEmailToken = verifyTokenMatch
? decodeURIComponent(verifyTokenMatch[1].replace(/=3D/g, '='))
? decodeURIComponent(verifyTokenMatch[1].replace(/=\r\n/, ''))
: null;
t.not(
@@ -94,7 +94,7 @@ test('change email', async t => {
'fail to get verify change email token from email content'
);
await changeEmail(app, u1.token.token, verifyEmailToken as string);
await changeEmail(app, u1.token.token, verifyEmailToken as string, u2Email);
const afterNotificationMailCount = await getCurrentMailMessageCount();

View File

@@ -1,172 +0,0 @@
/// <reference types="../src/global.d.ts" />
import { TestingModule } from '@nestjs/testing';
import test from 'ava';
import { AuthResolver } from '../src/core/auth/resolver';
import { AuthService } from '../src/core/auth/service';
import { ConfigModule } from '../src/fundamentals/config';
import {
mintChallengeResponse,
verifyChallengeResponse,
} from '../src/fundamentals/storage';
import { createTestingModule } from './utils';
let authService: AuthService;
let authResolver: AuthResolver;
let module: TestingModule;
test.beforeEach(async () => {
module = await createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: 1,
refreshTokenExpiresIn: 1,
leeway: 1,
},
host: 'example.org',
https: true,
}),
],
});
authService = module.get(AuthService);
authResolver = module.get(AuthResolver);
});
test.afterEach.always(async () => {
await module.close();
});
test('should be able to register and signIn', async t => {
await authService.signUp('Alex Yang', 'alexyang@example.org', '123456');
await authService.signIn('alexyang@example.org', '123456');
t.pass();
});
test('should be able to verify', async t => {
await authService.signUp('Alex Yang', 'alexyang@example.org', '123456');
await authService.signIn('alexyang@example.org', '123456');
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
{
const token = await authService.sign(user);
const claim = await authService.verify(token);
t.is(claim.id, '1');
t.is(claim.name, 'Alex Yang');
t.is(claim.email, 'alexyang@example.org');
t.is(claim.emailVerified?.toISOString(), date.toISOString());
t.is(claim.createdAt.toISOString(), date.toISOString());
}
{
const token = await authService.refresh(user);
const claim = await authService.verify(token);
t.is(claim.id, '1');
t.is(claim.name, 'Alex Yang');
t.is(claim.email, 'alexyang@example.org');
t.is(claim.emailVerified?.toISOString(), date.toISOString());
t.is(claim.createdAt.toISOString(), date.toISOString());
}
});
test('should not be able to return token if user is invalid', async t => {
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
const anotherUser = {
id: '2',
name: 'Alex Yang 2',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
await t.throwsAsync(
authResolver.token(
{
req: {
headers: {
referer: 'https://example.org',
host: 'example.org',
},
} as any,
},
user,
anotherUser
),
{
message: 'Invalid user',
}
);
});
test('should not return sessionToken if request headers is invalid', async t => {
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
const result = await authResolver.token(
{
req: {
headers: {},
} as any,
},
user,
user
);
t.is(result.sessionToken, undefined);
});
test('should return valid sessionToken if request headers valid', async t => {
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
const result = await authResolver.token(
{
req: {
headers: {
referer: 'https://example.org/open-app/test',
host: 'example.org',
},
cookies: {
'next-auth.session-token': '123456',
},
} as any,
},
user,
user
);
t.is(result.sessionToken, '123456');
});
test('verify challenge', async t => {
const resource = 'xp8D3rcXV9bMhWrb6abxl';
const response = await mintChallengeResponse(resource, 20);
const success = await verifyChallengeResponse(response, 20, resource);
t.true(success);
});

View File

@@ -11,7 +11,7 @@ import {
FeatureService,
FeatureType,
} from '../src/core/features';
import { UserType } from '../src/core/users/types';
import { UserType } from '../src/core/user/types';
import { WorkspaceResolver } from '../src/core/workspaces/resolvers';
import { Permission } from '../src/core/workspaces/types';
import { ConfigModule } from '../src/fundamentals/config';
@@ -54,11 +54,6 @@ test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: 1,
refreshTokenExpiresIn: 1,
leeway: 1,
},
host: 'example.org',
https: true,
featureFlags: {

View File

@@ -21,15 +21,7 @@ const test = ava as TestFn<{
test.beforeEach(async t => {
t.context.module = await createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: 1,
refreshTokenExpiresIn: 1,
leeway: 1,
},
}),
],
imports: [ConfigModule.forRoot({})],
});
t.context.auth = t.context.module.get(AuthService);
});

View File

@@ -1,40 +0,0 @@
/// <reference types="../src/global.d.ts" />
import { TestingModule } from '@nestjs/testing';
import ava, { type TestFn } from 'ava';
import { CacheModule } from '../src/fundamentals/cache';
import { SessionModule, SessionService } from '../src/fundamentals/session';
import { createTestingModule } from './utils';
const test = ava as TestFn<{
session: SessionService;
module: TestingModule;
}>;
test.beforeEach(async t => {
const module = await createTestingModule({
imports: [CacheModule, SessionModule],
});
const session = module.get(SessionService);
t.context.module = module;
t.context.session = session;
});
test.afterEach.always(async t => {
await t.context.module.close();
});
test('should be able to set session', async t => {
const { session } = t.context;
await session.set('test', 'value');
t.is(await session.get('test'), 'value');
});
test('should be expired by ttl', async t => {
const { session } = t.context;
await session.set('test', 'value', 100);
t.is(await session.get('test'), 'value');
await new Promise(resolve => setTimeout(resolve, 500));
t.is(await session.get('test'), undefined);
});

View File

@@ -1,16 +1,18 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import type { TokenType } from '../../src/core/auth';
import type { UserType } from '../../src/core/users';
import type { ClientTokenType } from '../../src/core/auth';
import type { UserType } from '../../src/core/user';
import { gql } from './common';
export async function signUp(
app: INestApplication,
name: string,
email: string,
password: string
): Promise<UserType & { token: TokenType }> {
password: string,
autoVerifyEmail = true
): Promise<UserType & { token: ClientTokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
@@ -24,9 +26,23 @@ export async function signUp(
`,
})
.expect(200);
if (autoVerifyEmail) {
await setEmailVerified(app, email);
}
return res.body.data.signUp;
}
async function setEmailVerified(app: INestApplication, email: string) {
await app.get(PrismaClient).user.update({
where: { email },
data: {
emailVerifiedAt: new Date(),
},
});
}
export async function currentUser(app: INestApplication, token: string) {
const res = await request(app.getHttpServer())
.post(gql)
@@ -36,7 +52,7 @@ export async function currentUser(app: INestApplication, token: string) {
query: `
query {
currentUser {
id, name, email, emailVerified, avatarUrl, createdAt, hasPassword,
id, name, email, emailVerified, avatarUrl, hasPassword,
token { token }
}
}
@@ -94,8 +110,9 @@ export async function sendVerifyChangeEmail(
export async function changeEmail(
app: INestApplication,
userToken: string,
token: string
): Promise<UserType & { token: TokenType }> {
token: string,
email: string
): Promise<UserType & { token: ClientTokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
@@ -103,7 +120,7 @@ export async function changeEmail(
.send({
query: `
mutation {
changeEmail(token: "${token}") {
changeEmail(token: "${token}", email: "${email}") {
id
name
avatarUrl

View File

@@ -1,11 +1,13 @@
import { INestApplication, ModuleMetadata } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { Query, Resolver } from '@nestjs/graphql';
import { Test, TestingModuleBuilder } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule, FunctionalityModules } from '../../src/app.module';
import { AuthModule } from '../../src/core/auth';
import { AuthGuard, AuthModule } from '../../src/core/auth';
import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init';
import { GqlModule } from '../../src/fundamentals/graphql';
@@ -78,7 +80,14 @@ export async function createTestingModule(
const builder = Test.createTestingModule({
imports,
providers: [MockResolver, ...(moduleDef.providers ?? [])],
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
MockResolver,
...(moduleDef.providers ?? []),
],
controllers: moduleDef.controllers,
});
@@ -113,6 +122,8 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
})
);
app.use(cookieParser());
if (moduleDef.tapApp) {
moduleDef.tapApp(app);
}