mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
feat: init auth service (#2180)
This commit is contained in:
@@ -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",
|
||||
|
||||
19
apps/server/scripts/gen-auth-key.ts
Normal file
19
apps/server/scripts/gen-auth-key.ts
Normal 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);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -36,8 +36,7 @@ export class AuthResolver {
|
||||
|
||||
return {
|
||||
token: this.auth.sign(user),
|
||||
// TODO: impl
|
||||
refresh: '',
|
||||
refresh: this.auth.refresh(user),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
82
apps/server/src/tests/auth.spec.ts
Normal file
82
apps/server/src/tests/auth.spec.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user