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/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",

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

View File

@@ -70,9 +70,9 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
export interface AFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
/**
* 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
*/

View File

@@ -1,8 +1,23 @@
/// <reference types="../global.d.ts" />
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: {},

View File

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

View File

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

View File

@@ -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<User> {
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<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 { 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],

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