diff --git a/apps/server/package.json b/apps/server/package.json index b9b80edf30..de11c4eb20 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -21,6 +21,7 @@ "@nestjs/graphql": "^11.0.5", "@nestjs/platform-express": "^9.4.0", "@prisma/client": "^4.13.0", + "bcrypt": "^5.1.0", "dotenv": "^16.0.3", "express": "^4.18.2", "graphql": "^16.6.0", @@ -33,6 +34,7 @@ }, "devDependencies": { "@nestjs/testing": "^9.4.0", + "@types/bcrypt": "^5.0.0", "@types/express": "^4.17.17", "@types/jsonwebtoken": "^9.0.1", "@types/lodash-es": "^4.17.7", diff --git a/apps/server/scripts/gen-auth-key.ts b/apps/server/scripts/gen-auth-key.ts new file mode 100644 index 0000000000..357dcc508f --- /dev/null +++ b/apps/server/scripts/gen-auth-key.ts @@ -0,0 +1,19 @@ +import crypto from 'node:crypto'; + +import { genSalt } from 'bcrypt'; + +const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, +}); + +console.log('Salt:\n', await genSalt(10)); +console.log('ECDSA Public Key:\n', publicKey); +console.log('ECDSA Private Key:\n', privateKey); diff --git a/apps/server/scripts/run-test.ts b/apps/server/scripts/run-test.ts index ec5aed1d02..403c886c01 100755 --- a/apps/server/scripts/run-test.ts +++ b/apps/server/scripts/run-test.ts @@ -12,7 +12,7 @@ const root = fileURLToPath(new URL('..', import.meta.url)); const testDir = resolve(root, 'src', 'tests'); const files = await readdir(testDir); -const args = [...pkg.nodemonConfig.nodeArgs, '--test']; +const sharedArgs = [...pkg.nodemonConfig.nodeArgs, '--test']; const env = { PATH: process.env.PATH, @@ -21,7 +21,7 @@ const env = { }; if (process.argv[2] === 'all') { - const cp = spawn('node', [...args, resolve(testDir, '*')], { + const cp = spawn('node', [...sharedArgs, resolve(testDir, '*')], { cwd: root, env, stdio: 'inherit', @@ -44,12 +44,21 @@ if (process.argv[2] === 'all') { const target = resolve(testDir, result.file); - const cp = spawn('node', [...args, target], { - cwd: root, - env, - stdio: 'inherit', - shell: true, - }); + const cp = spawn( + 'node', + [ + ...sharedArgs, + '--test-reporter=spec', + '--test-reporter-destination=stdout', + target, + ], + { + cwd: root, + env, + stdio: 'inherit', + shell: true, + } + ); cp.on('exit', code => { process.exit(code ?? 0); }); diff --git a/apps/server/src/config/def.ts b/apps/server/src/config/def.ts index 404a458b2f..a7575b6d03 100644 --- a/apps/server/src/config/def.ts +++ b/apps/server/src/config/def.ts @@ -70,9 +70,9 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) { export interface AFFiNEConfig { ENV_MAP: Record; /** - * Application sign key secret + * Server Identity */ - readonly secret: string; + readonly serverId: string; /** * System version */ @@ -169,6 +169,28 @@ export interface AFFiNEConfig { * authentication config */ auth: { + /** + * Application sign key secret + */ + readonly salt: string; + /** + * Application access token expiration time + */ + readonly accessTokenExpiresIn: string; + /** + * Application refresh token expiration time + */ + readonly refreshTokenExpiresIn: string; + /** + * Application public key + * + */ + readonly publicKey: string; + /** + * Application private key + * + */ + readonly privateKey: string; /** * whether allow user to signup with email directly */ diff --git a/apps/server/src/config/default.ts b/apps/server/src/config/default.ts index 9bdf1531bf..ca3ef02434 100644 --- a/apps/server/src/config/default.ts +++ b/apps/server/src/config/default.ts @@ -1,8 +1,23 @@ +/// + import pkg from '../../package.json' assert { type: 'json' }; import type { AFFiNEConfig } from './def'; +// Don't use this in production +export const examplePublicKey = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnxM+GhB6eNKPmTP6uH5Gpr+bmQ87 +hHGeOiCsay0w/aPwMqzAOKkZGqX+HZ9BNGy/yiXmnscey5b2vOTzxtRvxA== +-----END PUBLIC KEY-----`; + +// Don't use this in production +export const examplePrivateKey = `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgWOog5SFXs1Vjh/WP +QCYPQKgf/jsNmWsvD+jYSn6mi3yhRANCAASfEz4aEHp40o+ZM/q4fkamv5uZDzuE +cZ46IKxrLTD9o/AyrMA4qRkapf4dn0E0bL/KJeaexx7Llva85PPG1G/E +-----END PRIVATE KEY-----`; + export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({ - secret: 'secret', + serverId: 'affine-nestjs-server', version: pkg.version, ENV_MAP: {}, env: process.env.NODE_ENV ?? 'development', @@ -41,6 +56,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({ debug: true, }, auth: { + salt: '$2b$10$x4VDo2nmlo74yB5jflNhlu', + accessTokenExpiresIn: '1h', + refreshTokenExpiresIn: '7d', + publicKey: examplePublicKey, + privateKey: examplePrivateKey, enableSignup: true, enableOauth: false, oauthProviders: {}, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 1da448719e..3c3dfbf952 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -17,4 +17,9 @@ const app = await NestFactory.create(AppModule, { bodyParser: true, }); -await app.listen(process.env.PORT ?? 3010); +const host = process.env.HOST ?? 'localhost'; +const port = process.env.PORT ?? 3010; + +await app.listen(port, host); + +console.log(`Listening on http://${host}:${port}`); diff --git a/apps/server/src/modules/auth/resolver.ts b/apps/server/src/modules/auth/resolver.ts index 8c1e1aadad..66d4466e17 100644 --- a/apps/server/src/modules/auth/resolver.ts +++ b/apps/server/src/modules/auth/resolver.ts @@ -36,8 +36,7 @@ export class AuthResolver { return { token: this.auth.sign(user), - // TODO: impl - refresh: '', + refresh: this.auth.refresh(user), }; } diff --git a/apps/server/src/modules/auth/service.ts b/apps/server/src/modules/auth/service.ts index 24ec6de7b1..480ef3f733 100644 --- a/apps/server/src/modules/auth/service.ts +++ b/apps/server/src/modules/auth/service.ts @@ -4,6 +4,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { User } from '@prisma/client'; +import { compare, hash } from 'bcrypt'; import jwt from 'jsonwebtoken'; import { Config } from '../../config'; @@ -16,30 +17,76 @@ export class AuthService { constructor(private config: Config, private prisma: PrismaService) {} sign(user: UserClaim) { - return jwt.sign(user, this.config.secret); + return jwt.sign(user, this.config.auth.privateKey, { + algorithm: 'ES256', + subject: user.id, + issuer: this.config.serverId, + expiresIn: this.config.auth.accessTokenExpiresIn, + }); + } + + refresh(user: UserClaim) { + return jwt.sign(user, this.config.auth.privateKey, { + algorithm: 'ES256', + subject: user.id, + issuer: this.config.serverId, + expiresIn: this.config.auth.refreshTokenExpiresIn, + }); } verify(token: string) { try { - const claims = jwt.verify(token, this.config.secret) as UserClaim; - return claims; + return jwt.verify(token, this.config.auth.publicKey, { + algorithms: ['ES256'], + }) as UserClaim; } catch (e) { throw new UnauthorizedException('Invalid token'); } } - async signIn(email: string, password: string) { + async signIn(email: string, password: string): Promise { const user = await this.prisma.user.findFirst({ where: { email, - password, }, }); if (!user) { - throw new BadRequestException('Invalid email or password'); + throw new BadRequestException('Invalid email'); + } + + if (!user.password) { + throw new BadRequestException('User has no password'); + } + + const equal = await compare(password, user.password); + + if (!equal) { + throw new UnauthorizedException('Invalid password'); } return user; } + + async register(name: string, email: string, password: string): Promise { + const hashedPassword = await hash(password, this.config.auth.salt); + + const user = await this.prisma.user.findFirst({ + where: { + email, + }, + }); + + if (user) { + throw new BadRequestException('Email already exists'); + } + + return this.prisma.user.create({ + data: { + name, + email, + password: hashedPassword, + }, + }); + } } diff --git a/apps/server/src/tests/app.e2e.ts b/apps/server/src/tests/app.e2e.ts index b9e8485f8e..cb0fe3e78f 100644 --- a/apps/server/src/tests/app.e2e.ts +++ b/apps/server/src/tests/app.e2e.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, test } from 'node:test'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import { hash } from 'bcrypt'; import request from 'supertest'; import { AppModule } from '../app'; @@ -12,10 +14,24 @@ const gql = '/graphql'; globalThis.AFFiNE = getDefaultAFFiNEConfig(); -// please run `ts-node-esm ./scripts/init-db.ts` before running this test describe('AppModule', () => { let app: INestApplication; + // cleanup database before each test + beforeEach(async () => { + const client = new PrismaClient(); + await client.$connect(); + await client.user.deleteMany({}); + await client.user.create({ + data: { + id: '1', + name: 'Alex Yang', + email: 'alex.yang@example.org', + password: await hash('123456', globalThis.AFFiNE.auth.salt), + }, + }); + }); + beforeEach(async () => { const module = await Test.createTestingModule({ imports: [AppModule], diff --git a/apps/server/src/tests/auth.spec.ts b/apps/server/src/tests/auth.spec.ts new file mode 100644 index 0000000000..8561d1adbe --- /dev/null +++ b/apps/server/src/tests/auth.spec.ts @@ -0,0 +1,82 @@ +import { ok, throws } from 'node:assert'; +import { beforeEach, test } from 'node:test'; + +import { UnauthorizedException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; + +import { Config, ConfigModule } from '../config'; +import { getDefaultAFFiNEConfig } from '../config/default'; +import { GqlModule } from '../graphql.module'; +import { AuthModule } from '../modules/auth'; +import { AuthService } from '../modules/auth/service'; +import { PrismaModule } from '../prisma'; + +globalThis.AFFiNE = getDefaultAFFiNEConfig(); + +let auth: AuthService; +let config: Config; + +// cleanup database before each test +beforeEach(async () => { + const client = new PrismaClient(); + await client.$connect(); + await client.user.deleteMany({}); +}); + +beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + auth: { + accessTokenExpiresIn: '1s', + refreshTokenExpiresIn: '3s', + }, + }), + PrismaModule, + GqlModule, + AuthModule, + ], + }).compile(); + config = module.get(Config); + auth = module.get(AuthService); +}); + +async function sleep(ms: number) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +test('should be able to register and signIn', async () => { + await auth.register('Alex Yang', 'alexyang@example.org', '123456'); + await auth.signIn('alexyang@example.org', '123456'); +}); + +test('should be able to verify', async () => { + await auth.register('Alex Yang', 'alexyang@example.org', '123456'); + await auth.signIn('alexyang@example.org', '123456'); + const user = { + id: '1', + name: 'Alex Yang', + email: 'alexyang@example.org', + }; + { + const token = auth.sign(user); + const clain = auth.verify(token); + ok(clain.id === '1'); + ok(clain.name === 'Alex Yang'); + ok(clain.email === 'alexyang@example.org'); + await sleep(1050); + throws(() => auth.verify(token), UnauthorizedException, 'Invalid token'); + } + { + const token = auth.refresh(user); + const clain = auth.verify(token); + ok(clain.id === '1'); + ok(clain.name === 'Alex Yang'); + ok(clain.email === 'alexyang@example.org'); + await sleep(3050); + throws(() => auth.verify(token), UnauthorizedException, 'Invalid token'); + } +}); diff --git a/yarn.lock b/yarn.lock index 78abd649e7..1929861b29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -248,11 +248,13 @@ __metadata: "@nestjs/platform-express": ^9.4.0 "@nestjs/testing": ^9.4.0 "@prisma/client": ^4.13.0 + "@types/bcrypt": ^5.0.0 "@types/express": ^4.17.17 "@types/jsonwebtoken": ^9.0.1 "@types/lodash-es": ^4.17.7 "@types/node": ^18.16.2 "@types/supertest": ^2.0.12 + bcrypt: ^5.1.0 c8: ^7.13.0 dotenv: ^16.0.3 express: ^4.18.2 @@ -5162,6 +5164,25 @@ __metadata: languageName: node linkType: hard +"@mapbox/node-pre-gyp@npm:^1.0.10": + version: 1.0.10 + resolution: "@mapbox/node-pre-gyp@npm:1.0.10" + dependencies: + detect-libc: ^2.0.0 + https-proxy-agent: ^5.0.0 + make-dir: ^3.1.0 + node-fetch: ^2.6.7 + nopt: ^5.0.0 + npmlog: ^5.0.1 + rimraf: ^3.0.2 + semver: ^7.3.5 + tar: ^6.1.11 + bin: + node-pre-gyp: bin/node-pre-gyp + checksum: 1a98db05d955b74dad3814679593df293b9194853698f3f5f1ed00ecd93128cdd4b14fb8767fe44ac6981ef05c23effcfdc88710e7c1de99ccb6f647890597c8 + languageName: node + linkType: hard + "@mdx-js/react@npm:^2.1.5": version: 2.3.0 resolution: "@mdx-js/react@npm:2.3.0" @@ -8120,6 +8141,15 @@ __metadata: languageName: node linkType: hard +"@types/bcrypt@npm:^5.0.0": + version: 5.0.0 + resolution: "@types/bcrypt@npm:5.0.0" + dependencies: + "@types/node": "*" + checksum: 063c32c7a519d64768dfc0169a319b8244d6a6cb50a355c93992b3c5fee1dbc236526a1111f0e7bb25abc8b0473e5f40a5edfeb8b33cad2a6ea35aa2d7d7db14 + languageName: node + linkType: hard + "@types/better-sqlite3@npm:^7.6.4": version: 7.6.4 resolution: "@types/better-sqlite3@npm:7.6.4" @@ -10376,6 +10406,16 @@ __metadata: languageName: node linkType: hard +"bcrypt@npm:^5.1.0": + version: 5.1.0 + resolution: "bcrypt@npm:5.1.0" + dependencies: + "@mapbox/node-pre-gyp": ^1.0.10 + node-addon-api: ^5.0.0 + checksum: a590b65d276d75d861dc85acc3128508b8f78c87431719658ea3be7996368b34b397b6efefe6bca0a3d555bf41a9267307fd4ce04e956598fca3ba81199c6706 + languageName: node + linkType: hard + "better-opn@npm:^2.1.1": version: 2.1.1 resolution: "better-opn@npm:2.1.1" @@ -18332,7 +18372,7 @@ __metadata: languageName: node linkType: hard -"make-dir@npm:^3.0.0, make-dir@npm:^3.0.2": +"make-dir@npm:^3.0.0, make-dir@npm:^3.0.2, make-dir@npm:^3.1.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0" dependencies: @@ -19191,6 +19231,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^5.0.0": + version: 5.1.0 + resolution: "node-addon-api@npm:5.1.0" + dependencies: + node-gyp: latest + checksum: 2508bd2d2981945406243a7bd31362fc7af8b70b8b4d65f869c61731800058fb818cc2fd36c8eac714ddd0e568cc85becf5e165cebbdf7b5024d5151bbc75ea1 + languageName: node + linkType: hard + "node-api-version@npm:^0.1.4": version: 0.1.4 resolution: "node-api-version@npm:0.1.4" @@ -19336,6 +19385,17 @@ __metadata: languageName: node linkType: hard +"nopt@npm:^5.0.0": + version: 5.0.0 + resolution: "nopt@npm:5.0.0" + dependencies: + abbrev: 1 + bin: + nopt: bin/nopt.js + checksum: d35fdec187269503843924e0114c0c6533fb54bbf1620d0f28b4b60ba01712d6687f62565c55cc20a504eff0fbe5c63e22340c3fad549ad40469ffb611b04f2f + languageName: node + linkType: hard + "nopt@npm:^6.0.0": version: 6.0.0 resolution: "nopt@npm:6.0.0"