feat: init auth service (#2180)

This commit is contained in:
Himself65
2023-04-27 22:49:44 -05:00
committed by GitHub
parent b4bb57b2a5
commit 3a5a66a5a3
11 changed files with 303 additions and 22 deletions

View File

@@ -21,6 +21,7 @@
"@nestjs/graphql": "^11.0.5", "@nestjs/graphql": "^11.0.5",
"@nestjs/platform-express": "^9.4.0", "@nestjs/platform-express": "^9.4.0",
"@prisma/client": "^4.13.0", "@prisma/client": "^4.13.0",
"bcrypt": "^5.1.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"graphql": "^16.6.0", "graphql": "^16.6.0",
@@ -33,6 +34,7 @@
}, },
"devDependencies": { "devDependencies": {
"@nestjs/testing": "^9.4.0", "@nestjs/testing": "^9.4.0",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.1", "@types/jsonwebtoken": "^9.0.1",
"@types/lodash-es": "^4.17.7", "@types/lodash-es": "^4.17.7",

View File

@@ -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);

View File

@@ -12,7 +12,7 @@ const root = fileURLToPath(new URL('..', import.meta.url));
const testDir = resolve(root, 'src', 'tests'); const testDir = resolve(root, 'src', 'tests');
const files = await readdir(testDir); const files = await readdir(testDir);
const args = [...pkg.nodemonConfig.nodeArgs, '--test']; const sharedArgs = [...pkg.nodemonConfig.nodeArgs, '--test'];
const env = { const env = {
PATH: process.env.PATH, PATH: process.env.PATH,
@@ -21,7 +21,7 @@ const env = {
}; };
if (process.argv[2] === 'all') { if (process.argv[2] === 'all') {
const cp = spawn('node', [...args, resolve(testDir, '*')], { const cp = spawn('node', [...sharedArgs, resolve(testDir, '*')], {
cwd: root, cwd: root,
env, env,
stdio: 'inherit', stdio: 'inherit',
@@ -44,12 +44,21 @@ if (process.argv[2] === 'all') {
const target = resolve(testDir, result.file); const target = resolve(testDir, result.file);
const cp = spawn('node', [...args, target], { const cp = spawn(
cwd: root, 'node',
env, [
stdio: 'inherit', ...sharedArgs,
shell: true, '--test-reporter=spec',
}); '--test-reporter-destination=stdout',
target,
],
{
cwd: root,
env,
stdio: 'inherit',
shell: true,
}
);
cp.on('exit', code => { cp.on('exit', code => {
process.exit(code ?? 0); process.exit(code ?? 0);
}); });

View File

@@ -70,9 +70,9 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
export interface AFFiNEConfig { export interface AFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>; ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
/** /**
* Application sign key secret * Server Identity
*/ */
readonly secret: string; readonly serverId: string;
/** /**
* System version * System version
*/ */
@@ -169,6 +169,28 @@ export interface AFFiNEConfig {
* authentication config * authentication config
*/ */
auth: { 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 * whether allow user to signup with email directly
*/ */

View File

@@ -1,8 +1,23 @@
/// <reference types="../global.d.ts" />
import pkg from '../../package.json' assert { type: 'json' }; import pkg from '../../package.json' assert { type: 'json' };
import type { AFFiNEConfig } from './def'; 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 = () => ({ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
secret: 'secret', serverId: 'affine-nestjs-server',
version: pkg.version, version: pkg.version,
ENV_MAP: {}, ENV_MAP: {},
env: process.env.NODE_ENV ?? 'development', env: process.env.NODE_ENV ?? 'development',
@@ -41,6 +56,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
debug: true, debug: true,
}, },
auth: { auth: {
salt: '$2b$10$x4VDo2nmlo74yB5jflNhlu',
accessTokenExpiresIn: '1h',
refreshTokenExpiresIn: '7d',
publicKey: examplePublicKey,
privateKey: examplePrivateKey,
enableSignup: true, enableSignup: true,
enableOauth: false, enableOauth: false,
oauthProviders: {}, oauthProviders: {},

View File

@@ -17,4 +17,9 @@ const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bodyParser: true, 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}`);

View File

@@ -36,8 +36,7 @@ export class AuthResolver {
return { return {
token: this.auth.sign(user), token: this.auth.sign(user),
// TODO: impl refresh: this.auth.refresh(user),
refresh: '',
}; };
} }

View File

@@ -4,6 +4,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { compare, hash } from 'bcrypt';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { Config } from '../../config'; import { Config } from '../../config';
@@ -16,30 +17,76 @@ export class AuthService {
constructor(private config: Config, private prisma: PrismaService) {} constructor(private config: Config, private prisma: PrismaService) {}
sign(user: UserClaim) { 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) { verify(token: string) {
try { try {
const claims = jwt.verify(token, this.config.secret) as UserClaim; return jwt.verify(token, this.config.auth.publicKey, {
return claims; algorithms: ['ES256'],
}) as UserClaim;
} catch (e) { } catch (e) {
throw new UnauthorizedException('Invalid token'); throw new UnauthorizedException('Invalid token');
} }
} }
async signIn(email: string, password: string) { async signIn(email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { where: {
email, email,
password,
}, },
}); });
if (!user) { 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; return user;
} }
async register(name: string, email: string, password: string): Promise<User> {
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,
},
});
}
} }

View File

@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, test } from 'node:test';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../app'; import { AppModule } from '../app';
@@ -12,10 +14,24 @@ const gql = '/graphql';
globalThis.AFFiNE = getDefaultAFFiNEConfig(); globalThis.AFFiNE = getDefaultAFFiNEConfig();
// please run `ts-node-esm ./scripts/init-db.ts` before running this test
describe('AppModule', () => { describe('AppModule', () => {
let app: INestApplication; 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 () => { beforeEach(async () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],

View File

@@ -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<void>(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');
}
});

View File

@@ -248,11 +248,13 @@ __metadata:
"@nestjs/platform-express": ^9.4.0 "@nestjs/platform-express": ^9.4.0
"@nestjs/testing": ^9.4.0 "@nestjs/testing": ^9.4.0
"@prisma/client": ^4.13.0 "@prisma/client": ^4.13.0
"@types/bcrypt": ^5.0.0
"@types/express": ^4.17.17 "@types/express": ^4.17.17
"@types/jsonwebtoken": ^9.0.1 "@types/jsonwebtoken": ^9.0.1
"@types/lodash-es": ^4.17.7 "@types/lodash-es": ^4.17.7
"@types/node": ^18.16.2 "@types/node": ^18.16.2
"@types/supertest": ^2.0.12 "@types/supertest": ^2.0.12
bcrypt: ^5.1.0
c8: ^7.13.0 c8: ^7.13.0
dotenv: ^16.0.3 dotenv: ^16.0.3
express: ^4.18.2 express: ^4.18.2
@@ -5162,6 +5164,25 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@mdx-js/react@npm:^2.1.5":
version: 2.3.0 version: 2.3.0
resolution: "@mdx-js/react@npm:2.3.0" resolution: "@mdx-js/react@npm:2.3.0"
@@ -8120,6 +8141,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/better-sqlite3@npm:^7.6.4":
version: 7.6.4 version: 7.6.4
resolution: "@types/better-sqlite3@npm:7.6.4" resolution: "@types/better-sqlite3@npm:7.6.4"
@@ -10376,6 +10406,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "better-opn@npm:^2.1.1":
version: 2.1.1 version: 2.1.1
resolution: "better-opn@npm:2.1.1" resolution: "better-opn@npm:2.1.1"
@@ -18332,7 +18372,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 3.1.0
resolution: "make-dir@npm:3.1.0" resolution: "make-dir@npm:3.1.0"
dependencies: dependencies:
@@ -19191,6 +19231,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "node-api-version@npm:^0.1.4":
version: 0.1.4 version: 0.1.4
resolution: "node-api-version@npm:0.1.4" resolution: "node-api-version@npm:0.1.4"
@@ -19336,6 +19385,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "nopt@npm:^6.0.0":
version: 6.0.0 version: 6.0.0
resolution: "nopt@npm:6.0.0" resolution: "nopt@npm:6.0.0"