mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +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');
|
||||
}
|
||||
});
|
||||
62
yarn.lock
62
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"
|
||||
|
||||
Reference in New Issue
Block a user