feat(server): auth server (#2773)

This commit is contained in:
LongYinan
2023-06-21 14:08:32 +08:00
committed by GitHub
parent 2698e7fd0d
commit 9b3fa43b81
36 changed files with 4089 additions and 2013 deletions
@@ -0,0 +1,77 @@
/*
Warnings:
- You are about to drop the column `avatar_url` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `fulfilled` on the `users` table. All the data in the column will be lost.
- You are about to drop the column `token_nonce` on the `users` table. All the data in the column will be lost.
- You are about to drop the `connected_accounts` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "connected_accounts" DROP CONSTRAINT "connected_accounts_user_id_fkey";
-- AlterTable
ALTER TABLE "users" DROP COLUMN "avatar_url",
DROP COLUMN "fulfilled",
DROP COLUMN "token_nonce",
ADD COLUMN "email_verified" TIMESTAMP(3),
ADD COLUMN "image" VARCHAR,
ALTER COLUMN "name" SET DATA TYPE TEXT,
ALTER COLUMN "email" DROP NOT NULL,
ALTER COLUMN "email" SET DATA TYPE TEXT;
-- DropTable
DROP TABLE "connected_accounts";
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"provider_account_id" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"session_token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verificationtokens" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token");
-- CreateIndex
CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17 -11
View File
@@ -15,25 +15,32 @@
},
"dependencies": {
"@apollo/server": "^4.7.4",
"@nestjs/apollo": "^11.0.6",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/graphql": "^11.0.6",
"@nestjs/platform-express": "^10.0.0",
"@node-rs/bcrypt": "^1.7.1",
"@auth/prisma-adapter": "^1.0.0",
"@aws-sdk/client-s3": "^3.354.0",
"@nestjs/apollo": "^12.0.1",
"@nestjs/common": "^10.0.2",
"@nestjs/core": "^10.0.2",
"@nestjs/graphql": "^12.0.1",
"@nestjs/platform-express": "^10.0.2",
"@node-rs/argon2": "^1.5.0",
"@node-rs/crc32": "^1.7.0",
"@node-rs/jsonwebtoken": "^0.2.0",
"@prisma/client": "^4.15.0",
"dotenv": "^16.1.4",
"express": "^4.18.2",
"graphql": "^16.6.0",
"graphql-type-json": "^0.3.2",
"jsonwebtoken": "^9.0.0",
"graphql-upload": "^16.0.2",
"lodash-es": "^4.17.21",
"prisma": "^4.15.0",
"next-auth": "^4.22.1",
"parse-duration": "^1.1.0",
"prisma": "^4.16.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/testing": "^10.0.0",
"@napi-rs/image": "^1.6.1",
"@nestjs/testing": "^10.0.2",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2",
"@types/lodash-es": "^4.17.7",
@@ -43,8 +50,7 @@
"nodemon": "^2.0.22",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"typescript": "^5.1.3",
"vitest": "^0.32.0"
"typescript": "^5.1.3"
},
"nodemonConfig": {
"exec": "node",
+60 -35
View File
@@ -1,29 +1,10 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid()) @db.VarChar
name String @db.VarChar
email String @unique @db.VarChar
tokenNonce Int @default(0) @map("token_nonce") @db.SmallInt
avatarUrl String? @map("avatar_url") @db.VarChar
/// Available if user signed up through OAuth providers
password String? @db.VarChar
/// User may created by email collobration invitation before signup.
/// We will precreate a user entity in such senarios but leave fulfilled as false until they signed up
/// This implementation is convenient for handing unregistered user permissoin
fulfilled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
connectedAccounts ConnectedAccount[]
workspaces UserWorkspacePermission[]
@@map("users")
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Workspace {
@@ -35,18 +16,6 @@ model Workspace {
@@map("workspaces")
}
model ConnectedAccount {
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id")
/// the general provider name, e.g. google, github, facebook
provider String @db.VarChar
/// the user id provided by OAuth providers, or other user identitive credential like `username` provided by GitHub
providerUserId String @unique @map("provider_user_id") @db.VarChar
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("connected_accounts")
}
model UserWorkspacePermission {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
@@ -56,8 +25,64 @@ model UserWorkspacePermission {
/// Whether the permission invitation is accepted by the user
accepted Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@map("user_workspace_permissions")
}
model User {
id String @id @default(uuid()) @db.VarChar
name String
email String? @unique
emailVerified DateTime? @map("email_verified")
// image field is for the next-auth
avatarUrl String? @map("image") @db.VarChar
accounts Account[]
sessions Session[]
workspaces UserWorkspacePermission[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
/// Not available if user signed up through OAuth providers
password String? @db.VarChar
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verificationtokens")
}
-3
View File
@@ -1,7 +1,5 @@
import crypto from 'node:crypto';
import { genSalt } from '@node-rs/bcrypt';
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
publicKeyEncoding: {
@@ -14,6 +12,5 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
},
});
console.log('Salt:\n', await genSalt(10));
console.log('ECDSA Public Key:\n', publicKey);
console.log('ECDSA Private Key:\n', privateKey);
+17 -2
View File
@@ -163,6 +163,12 @@ export interface AFFiNEConfig {
* }
*/
config: Record<string, string>;
/**
* Only used when `enable` is `false`
*/
fs: {
path: string;
};
};
/**
@@ -172,11 +178,16 @@ export interface AFFiNEConfig {
/**
* Application access token expiration time
*/
readonly accessTokenExpiresIn: string;
readonly accessTokenExpiresIn: number;
/**
* Application refresh token expiration time
*/
readonly refreshTokenExpiresIn: string;
readonly refreshTokenExpiresIn: number;
/**
* Add some leeway (in seconds) to the exp and nbf validation to account for clock skew.
* Defaults to 60 if omitted.
*/
readonly leeway: number;
/**
* Application public key
*
@@ -195,6 +206,10 @@ export interface AFFiNEConfig {
* whether allow user to signup by oauth providers
*/
enableOauth: boolean;
/**
* NEXTAUTH_SECRET
*/
nextAuthSecret: string;
/**
* all available oauth providers
*/
+14 -2
View File
@@ -1,5 +1,10 @@
/// <reference types="../global.d.ts" />
import { homedir } from 'node:os';
import { join } from 'node:path';
import parse from 'parse-duration';
import pkg from '../../package.json' assert { type: 'json' };
import type { AFFiNEConfig } from './def';
@@ -56,16 +61,23 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
debug: true,
},
auth: {
accessTokenExpiresIn: '1h',
refreshTokenExpiresIn: '7d',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
accessTokenExpiresIn: parse('1h')! / 1000,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
refreshTokenExpiresIn: parse('7d')! / 1000,
leeway: 60,
publicKey: examplePublicKey,
privateKey: examplePrivateKey,
enableSignup: true,
enableOauth: false,
nextAuthSecret: '',
oauthProviders: {},
},
objectStorage: {
enable: false,
config: {},
fs: {
path: join(homedir(), '.affine-storage'),
},
},
});
+3
View File
@@ -16,6 +16,9 @@ import { Config } from './config';
return {
...config.graphql,
path: `${config.path}/graphql`,
csrfPrevention: {
requestHeaders: ['content-type'],
},
autoSchemaFile: join(
fileURLToPath(import.meta.url),
'..',
+18 -1
View File
@@ -2,8 +2,12 @@ import './prelude';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { static as staticMiddleware } from 'express';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from './app';
import { Config } from './config';
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: {
@@ -12,14 +16,27 @@ const app = await NestFactory.create<NestExpressApplication>(AppModule, {
? ['https://affine-preview.vercel.app']
: ['http://localhost:8080'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: '*',
allowedHeaders: ['x-operation-name', 'x-definition-name'],
},
bodyParser: true,
});
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ?? 3010;
const config = app.get(Config);
if (!config.objectStorage.enable) {
app.use('/assets', staticMiddleware(config.objectStorage.fs.path));
}
await app.listen(port, host);
console.log(`Listening on http://${host}:${port}`);
+10 -3
View File
@@ -49,10 +49,17 @@ class AuthGuard implements CanActivate {
if (!token) {
return false;
}
const [type, jwt] = token.split(' ') ?? [];
const claims = this.auth.verify(token);
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
return !!req.user;
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
return !!req.user;
}
return false;
}
}
+2
View File
@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { NextAuthController } from './next-auth.controller';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
@@ -7,6 +8,7 @@ import { AuthService } from './service';
@Module({
providers: [AuthService, AuthResolver],
exports: [AuthService],
controllers: [NextAuthController],
})
export class AuthModule {}
export * from './guard';
@@ -0,0 +1,158 @@
import { randomUUID } from 'node:crypto';
import { PrismaAdapter } from '@auth/prisma-adapter';
import {
BadRequestException,
Controller,
Get,
Next,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import type { NextFunction, Request, Response } from 'express';
import type { AuthAction, AuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import { Config } from '../../config';
import { PrismaService } from '../../prisma/service';
import { getUtcTimestamp, type UserClaim } from './service';
const BASE_URL = '/api/auth/';
@Controller(BASE_URL)
export class NextAuthController {
private readonly nextAuthOptions: AuthOptions;
constructor(readonly config: Config, readonly prisma: PrismaService) {
this.nextAuthOptions = {
providers: [],
// @ts-expect-error Third part library type mismatch
adapter: PrismaAdapter(prisma),
};
if (config.auth.oauthProviders.github) {
this.nextAuthOptions.providers.push(
Github({
clientId: config.auth.oauthProviders.github.clientId,
clientSecret: config.auth.oauthProviders.github.clientSecret,
})
);
}
if (config.auth.oauthProviders.google) {
this.nextAuthOptions.providers.push(
Google({
clientId: config.auth.oauthProviders.google.clientId,
clientSecret: config.auth.oauthProviders.google.clientSecret,
})
);
}
this.nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await this.prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: this.config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
},
decode: async ({ token }) => {
if (!token) {
return null;
}
const { name, email, id } = (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
leeway: this.config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as UserClaim;
return {
name,
email,
sub: id,
};
},
};
this.nextAuthOptions.secret ??= config.auth.nextAuthSecret;
}
@Get()
@Post()
async auth(
@Req() req: Request,
@Res() res: Response,
@Query() query: Record<string, any>,
@Next() next: NextFunction
) {
const nextauth = req.url // start with request url
.slice(BASE_URL.length) // make relative to baseUrl
.replace(/\?.*/, '') // remove query part, use only path part
.split('/') as AuthAction[]; // as array of strings;
const { status, headers, body, redirect, cookies } = await AuthHandler({
req: {
body: req.body,
query: query,
method: req.method,
action: nextauth[0],
providerId: nextauth[1],
error: query.error ?? nextauth[1],
cookies: req.cookies,
},
options: this.nextAuthOptions,
});
if (status) {
res.status(status);
}
if (headers) {
for (const { key, value } of headers) {
res.setHeader(key, value);
}
}
if (cookies) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
if (redirect) {
res.redirect(redirect);
} else if (typeof body === 'string') {
res.send(body);
} else if (body && typeof body === 'object') {
res.json(body);
} else {
next();
}
}
}
+12
View File
@@ -50,4 +50,16 @@ export class AuthResolver {
ctx.req.user = user;
return user;
}
@Mutation(() => UserType)
async signUp(
@Context() ctx: { req: Request },
@Args('email') email: string,
@Args('password') password: string,
@Args('name') name: string
) {
const user = await this.auth.register(name, email, password);
ctx.req.user = user;
return user;
}
}
+72 -24
View File
@@ -1,44 +1,88 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { compare, hash } from '@node-rs/bcrypt';
import { hash, verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import type { User } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
@Injectable()
export class AuthService {
constructor(private config: Config, private prisma: PrismaService) {}
sign(user: UserClaim) {
return jwt.sign(user, this.config.auth.privateKey, {
algorithm: 'ES256',
subject: user.id,
issuer: this.config.serverId,
expiresIn: this.config.auth.accessTokenExpiresIn,
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
},
iat: now,
exp: now + this.config.auth.accessTokenExpiresIn,
iss: this.config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
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,
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
},
exp: now + this.config.auth.refreshTokenExpiresIn,
iat: now,
iss: this.config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
verify(token: string) {
async verify(token: string) {
try {
return jwt.verify(token, this.config.auth.publicKey, {
algorithms: ['ES256'],
}) as UserClaim;
return (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
leeway: this.config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as UserClaim;
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
@@ -58,9 +102,13 @@ export class AuthService {
if (!user.password) {
throw new BadRequestException('User has no password');
}
const equal = await compare(password, user.password);
let equal = false;
try {
equal = await verify(user.password, password);
} catch (e) {
console.error(e);
throw new InternalServerErrorException(e, 'Verify password failed');
}
if (!equal) {
throw new UnauthorizedException('Invalid password');
}
@@ -69,8 +117,6 @@ export class AuthService {
}
async register(name: string, email: string, password: string): Promise<User> {
const hashedPassword = await hash(password);
const user = await this.prisma.user.findFirst({
where: {
email,
@@ -81,6 +127,8 @@ export class AuthService {
throw new BadRequestException('Email already exists');
}
const hashedPassword = await hash(password);
return this.prisma.user.create({
data: {
name,
+23
View File
@@ -0,0 +1,23 @@
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { Injectable } from '@nestjs/common';
import { Config } from '../../config';
import { FileUpload } from '../../types';
@Injectable()
export class FSService {
constructor(private readonly config: Config) {}
async writeFile(key: string, file: FileUpload) {
const dest = this.config.objectStorage.fs.path;
await mkdir(dest, { recursive: true });
const destFile = join(dest, key);
await pipeline(file.createReadStream(), createWriteStream(destFile));
return `/assets/${destFile}`;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FSService } from './fs';
import { S3 } from './s3';
import { StorageService } from './storage.service';
@Module({
providers: [S3, StorageService, FSService],
exports: [StorageService],
})
export class StorageModule {}
+15
View File
@@ -0,0 +1,15 @@
import { S3Client } from '@aws-sdk/client-s3';
import { FactoryProvider } from '@nestjs/common';
import { Config } from '../../config';
export const S3_SERVICE = Symbol('S3_SERVICE');
export const S3: FactoryProvider<S3Client> = {
provide: S3_SERVICE,
useFactory: (config: Config) => {
const s3 = new S3Client(config.objectStorage.config);
return s3;
},
inject: [Config],
};
@@ -0,0 +1,31 @@
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Inject, Injectable } from '@nestjs/common';
import { Config } from '../../config';
import { FileUpload } from '../../types';
import { FSService } from './fs';
import { S3_SERVICE } from './s3';
@Injectable()
export class StorageService {
constructor(
@Inject(S3_SERVICE) private readonly s3: S3Client,
private readonly fs: FSService,
private readonly config: Config
) {}
async uploadFile(key: string, file: FileUpload) {
if (this.config.objectStorage.enable) {
await this.s3.send(
new PutObjectCommand({
Body: file.createReadStream(),
Bucket: this.config.objectStorage.config.bucket,
Key: key,
})
);
return `https://avatar.affineassets.com/${key}`;
} else {
return this.fs.writeFile(key, file);
}
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { StorageModule } from '../storage';
import { UserResolver } from './resolver';
@Module({
imports: [StorageModule],
providers: [UserResolver],
})
export class UsersModule {}
+40 -2
View File
@@ -1,7 +1,21 @@
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
import { BadRequestException } from '@nestjs/common';
import {
Args,
Field,
ID,
Mutation,
ObjectType,
Query,
Resolver,
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PrismaService } from '../../prisma/service';
import type { FileUpload } from '../../types';
import { Auth } from '../auth/guard';
import { StorageService } from '../storage/storage.service';
@ObjectType()
export class UserType implements Partial<User> {
@@ -21,9 +35,13 @@ export class UserType implements Partial<User> {
createdAt!: Date;
}
@Auth()
@Resolver(() => UserType)
export class UserResolver {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService
) {}
@Query(() => UserType, {
name: 'user',
@@ -34,4 +52,24 @@ export class UserResolver {
where: { email },
});
}
@Mutation(() => UserType, {
name: 'uploadAvatar',
description: 'Upload user avatar',
})
async uploadAvatar(
@Args('id') id: string,
@Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload
) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new BadRequestException(`User ${id} not found`);
}
const url = await this.storage.uploadFile(`${id}-avatar`, avatar);
return this.prisma.user.update({
where: { id },
data: { avatarUrl: url },
});
}
}
+87 -26
View File
@@ -1,10 +1,14 @@
import { equal, ok } from 'node:assert';
import { afterEach, beforeEach, describe, test } from 'node:test';
import { Transformer } from '@napi-rs/image';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { hash } from '@node-rs/bcrypt';
import { hash } from '@node-rs/argon2';
import { PrismaClient } from '@prisma/client';
import { Express } from 'express';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
@@ -24,7 +28,6 @@ describe('AppModule', () => {
await client.user.deleteMany({});
await client.user.create({
data: {
id: '1',
name: 'Alex Yang',
email: 'alex.yang@example.org',
password: await hash('123456'),
@@ -36,7 +39,16 @@ describe('AppModule', () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app = module.createNestApplication({
cors: true,
bodyParser: true,
});
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
@@ -57,32 +69,11 @@ describe('AppModule', () => {
})
.expect(400);
let token;
await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
signIn(email: "alex.yang@example.org", password: "123456") {
token {
token
}
}
}
`,
})
.expect(200)
.expect(res => {
ok(
typeof res.body.data.signIn.token.token === 'string',
'res.body.data.signIn.token.token is not a string'
);
token = res.body.data.signIn.token.token;
});
const { token } = await createToken(app);
await request(app.getHttpServer())
.post(gql)
.set({ Authorization: token })
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
@@ -116,8 +107,10 @@ describe('AppModule', () => {
});
test('should find default user', async () => {
const { token } = await createToken(app);
await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
@@ -133,4 +126,72 @@ describe('AppModule', () => {
equal(res.body.data.user.email, 'alex.yang@example.org');
});
});
test('should be able to upload avatar', async () => {
const { token, id } = await createToken(app);
const png = await Transformer.fromRgbaPixels(
Buffer.alloc(400 * 400 * 4).fill(255),
400,
400
).png();
await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.field(
'operations',
JSON.stringify({
name: 'uploadAvatar',
query: `mutation uploadAvatar($id: String!, $avatar: Upload!) {
uploadAvatar(id: $id, avatar: $avatar) {
id
name
avatarUrl
email
}
}
`,
variables: { id, avatar: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
.attach('0', png, 'avatar.png')
.expect(200)
.expect(res => {
equal(res.body.data.uploadAvatar.id, id);
});
});
});
async function createToken(app: INestApplication<Express>): Promise<{
id: string;
token: string;
}> {
let token;
let id;
await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
signIn(email: "alex.yang@example.org", password: "123456") {
id
token {
token
}
}
}
`,
})
.expect(200)
.expect(res => {
id = res.body.data.signIn.id;
ok(
typeof res.body.data.signIn.token.token === 'string',
'res.body.data.signIn.token.token is not a string'
);
token = res.body.data.signIn.token.token;
});
return { token: token!, id: id! };
}
+15 -24
View File
@@ -1,7 +1,6 @@
import { ok, throws } from 'node:assert';
import { ok } from 'node:assert';
import { beforeEach, test } from 'node:test';
import { UnauthorizedException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
@@ -28,8 +27,9 @@ beforeEach(async () => {
imports: [
ConfigModule.forRoot({
auth: {
accessTokenExpiresIn: '1s',
refreshTokenExpiresIn: '3s',
accessTokenExpiresIn: 1,
refreshTokenExpiresIn: 1,
leeway: 1,
},
}),
PrismaModule,
@@ -40,12 +40,6 @@ beforeEach(async () => {
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');
@@ -58,23 +52,20 @@ test('should be able to verify', async () => {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
createdAt: new Date(),
};
{
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 = await auth.sign(user);
const claim = await auth.verify(token);
ok(claim.id === '1');
ok(claim.name === 'Alex Yang');
ok(claim.email === 'alexyang@example.org');
}
{
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');
const token = await auth.refresh(user);
const claim = await auth.verify(token);
ok(claim.id === '1');
ok(claim.name === 'Alex Yang');
ok(claim.email === 'alexyang@example.org');
}
});
+8
View File
@@ -0,0 +1,8 @@
import { Readable } from 'node:stream';
export interface FileUpload {
filename: string;
mimetype: string;
encoding: string;
createReadStream: () => Readable;
}
+3 -2
View File
@@ -6,12 +6,13 @@
"module": "ESNext",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"moduleResolution": "Bundler",
"isolatedModules": false,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "dist",
"noEmit": false
"noEmit": false,
"verbatimModuleSyntax": false
},
"include": ["src", "package.json"],
"exclude": ["dist", "node_modules"],