Skip to content

Commit 24a8f0b

Browse files
Ho-slabyrinth30
andauthored
Feat/field permission: 접근할 수 있는 필드를 권한에 따라 조정 (#15)
* feat: graphql context에서 user 정보 받도록 * feat: graphql 에서 유저가 선택한 필드 읽을 수 있는 권한을 검증 * chore: prisma-nestjs-graphql 의존성 삭제 -> 직접 작성하는 방식으로 변경 - custom decorator를 사용하기 위함 * chore: change to enum * feat: user, post 의 graphql용 response dto를 설정 * fix: fix typo * Update README.md * Feat/guard (#9) * feat: passport 사용한 guard 작업을 위한 라이브러리 설치 - @nestjs/jwt, @nestjs/passport, passport, passport-jwt, passport-local 설치 - @types/passport-jwt, @types/passport-local 설치 * feat: auth module 생성 * feat: bcrypt, class-validator 설치 * feat: auth module에 jwt module 추가 및 user module과 연결 - .example.env에 jwt 관련 환경변수 추가 * feat: LocalAuthGuard 구현 * feat: 로그인 후 반환되는 ObjectType JwtWithUser 구현 * feat: current-user.decorator.ts 구현 - gql 속 현재 user 정보를 가져오는 커스텀 데코레이터 구현 * feat: LocalAuthGuard에 사용되는 authenticate 메서드 구현 * chore: @nestjs/passport 버전 변경 - passport-jwt와의 호환성 문제 해결 * feat: JwtAuthGuard 구현 * feat: AuthModule에 AuthGuard들 추가 * feat: RS256에서 HS256으로 변경 * chore: AuthModule에 Strategy들 추가 * feat: signIn, signUp 구현 * chore: 400에러를 401에러로 구체화 * chore: @nestjs/passport 버전 복구 - ^10.0.3으로 다운그레이드 -> ^11.0.5로 복구 * chore: JwtStrategy 타입 이슈 해결 --------- Co-authored-by: Younha Lee <younha0088@gmail.com>
1 parent adc6098 commit 24a8f0b

File tree

16 files changed

+1448
-2258
lines changed

16 files changed

+1448
-2258
lines changed

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@
7171
"globals": "^16.0.0",
7272
"jest": "^29.7.0",
7373
"prettier": "^3.4.2",
74-
"prisma-nestjs-graphql": "^21.1.1",
7574
"serverless-dotenv-plugin": "^6.0.0",
7675
"source-map-support": "^0.5.21",
7776
"supertest": "^7.0.0",

src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { UserModule } from './user/user.module';
66
import { ConfigModule, ConfigService } from '@nestjs/config';
77
import { AuthModule } from './auth/auth.module';
88
import { errorFormatter } from './common/exception/exception.format';
9+
import { graphQLContext } from './common/config/graphql.context';
10+
import { FieldAccessModule } from './common/field-access/field-access.module';
911

1012
@Module({
1113
imports: [
@@ -21,11 +23,13 @@ import { errorFormatter } from './common/exception/exception.format';
2123
graphiql: true,
2224
autoSchemaFile: (isDev ? `./src/` : '/tmp/') + SCHEMA_FILE_NAME,
2325
errorFormatter,
26+
context: graphQLContext,
2427
};
2528
},
2629
}),
2730
UserModule,
2831
AuthModule,
32+
FieldAccessModule,
2933
],
3034
controllers: [],
3135
providers: [],

src/auth/auth.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
44
import * as bcrypt from 'bcrypt';
55
import { SignInInput, SignUpInput } from './inputs/auth.input';
66
import { JwtService } from '@nestjs/jwt';
7-
import { User } from '../@generated/user/user.model';
7+
import { User } from '@prisma/client';
88

99
@Injectable()
1010
export class AuthService {

src/auth/entities/auth.entity.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Field, ObjectType } from '@nestjs/graphql';
2-
import { User } from '../../@generated/user/user.model';
2+
import { User } from '@prisma/client';
3+
import { UserObject } from 'src/user/dto/user.object';
34

45
@ObjectType()
56
export class JwtWithUser {
@@ -9,6 +10,6 @@ export class JwtWithUser {
910
@Field(() => String)
1011
refreshToken?: string;
1112

12-
@Field(() => User)
13+
@Field(() => UserObject)
1314
user: User;
1415
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Role, User } from '@prisma/client';
2+
import { FastifyReply, FastifyRequest } from 'fastify';
3+
4+
export interface GraphQLContext {
5+
req: FastifyRequest;
6+
reply: FastifyReply;
7+
user?: User;
8+
}
9+
10+
export const graphQLContext = (
11+
req: FastifyRequest,
12+
reply: FastifyReply,
13+
): GraphQLContext => {
14+
return {
15+
req,
16+
reply,
17+
/**
18+
* @todo
19+
* Guard에서 주입받도록
20+
*/
21+
user: {
22+
role: Role.USER,
23+
} as User,
24+
};
25+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import 'reflect-metadata';
2+
import { User } from '@prisma/client';
3+
4+
export const FIELD_ROLE = Symbol('FIELD_ROLE');
5+
6+
export const FieldAccess = (...roles: User['role'][]): PropertyDecorator => {
7+
return (target: object, propertyKey: string | symbol) => {
8+
if (!roles.length) roles = ['USER'];
9+
Reflect.defineMetadata(FIELD_ROLE, roles, target, propertyKey);
10+
};
11+
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
Injectable,
3+
NestInterceptor,
4+
ExecutionContext,
5+
CallHandler,
6+
ForbiddenException,
7+
} from '@nestjs/common';
8+
import { GqlExecutionContext, TypeMetadataStorage } from '@nestjs/graphql';
9+
import { Observable, map } from 'rxjs';
10+
11+
import { Role, User } from '@prisma/client';
12+
import {
13+
FieldNode,
14+
getNamedType,
15+
GraphQLResolveInfo,
16+
GraphQLType,
17+
Kind,
18+
} from 'graphql';
19+
import { GraphQLContext } from '../config/graphql.context';
20+
import { FIELD_ROLE } from './field-access.decorator';
21+
22+
@Injectable()
23+
export class FieldAccessInterceptor<T extends Record<string, unknown>>
24+
implements NestInterceptor
25+
{
26+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
27+
const gqlContext = GqlExecutionContext.create(context);
28+
29+
const { returnType, fieldNodes } = gqlContext.getInfo<GraphQLResolveInfo>();
30+
31+
const targetClass = this.extractTargetClass(returnType);
32+
33+
if (!targetClass) return next.handle();
34+
35+
const { user } = gqlContext.getContext<GraphQLContext>();
36+
37+
const selectField = this.extractSelectFields<T>([...fieldNodes]);
38+
39+
return next.handle().pipe(
40+
map((data: T | T[]) => {
41+
if (Array.isArray(data)) {
42+
return data.map((v) =>
43+
this.filterField(v, targetClass, selectField, user?.role),
44+
);
45+
}
46+
47+
return this.filterField(data, targetClass, selectField, user?.role);
48+
}),
49+
);
50+
}
51+
52+
private filterField<T extends Record<string, unknown>>(
53+
obj: T,
54+
targetClass: { prototype: object },
55+
selectField: (keyof T)[],
56+
userRole?: User['role'],
57+
) {
58+
if (!obj || typeof obj !== 'object') return obj;
59+
60+
return Object.keys(obj)
61+
.filter((k) => selectField.includes(k))
62+
.reduce((acc, cur: keyof T) => {
63+
const requiredRoles = Reflect.getMetadata(
64+
FIELD_ROLE,
65+
targetClass.prototype,
66+
cur as string,
67+
) as User['role'][] | undefined;
68+
69+
const isPermitted = this.hasAccess(requiredRoles, userRole);
70+
71+
if (!isPermitted) throw new ForbiddenException();
72+
73+
acc[cur] = obj[cur];
74+
75+
return acc;
76+
}, {} as T);
77+
}
78+
79+
private hasAccess<T extends User['role']>(
80+
requiredRole: T[] | undefined,
81+
userRole?: T,
82+
) {
83+
if (userRole === Role.ADMIN) return true;
84+
85+
return requiredRole?.some((role) => role === userRole) ?? true;
86+
}
87+
88+
private extractSelectFields<T>(fieldNodes: FieldNode[]): (keyof T)[] {
89+
return fieldNodes.reduce(
90+
(acc, { selectionSet }) => {
91+
if (!selectionSet) return acc;
92+
93+
selectionSet.selections.forEach((selection) => {
94+
if (selection.kind !== Kind.FIELD) return;
95+
96+
acc.push(selection.name.value as keyof T);
97+
});
98+
99+
return acc;
100+
},
101+
[] as (keyof T)[],
102+
);
103+
}
104+
105+
private extractTargetClass(returnType: GraphQLType) {
106+
const gqlType = getNamedType(returnType);
107+
108+
const typeName = gqlType.name;
109+
110+
const typeMetadata = TypeMetadataStorage.getObjectTypesMetadata().find(
111+
(metadata) => metadata.name === typeName,
112+
);
113+
114+
return typeMetadata?.target as { prototype: object };
115+
}
116+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { APP_INTERCEPTOR } from '@nestjs/core';
3+
import { FieldAccessInterceptor } from './field-access.interceptor';
4+
import { GraphQLSchemaHost } from '@nestjs/graphql';
5+
6+
@Module({
7+
imports: [],
8+
providers: [
9+
GraphQLSchemaHost,
10+
{ provide: APP_INTERCEPTOR, useClass: FieldAccessInterceptor },
11+
],
12+
})
13+
export class FieldAccessModule {}

src/post/dto/post.object.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Field, ID, ObjectType } from '@nestjs/graphql';
2+
import { Post, User } from '@prisma/client';
3+
import { UserObject } from 'src/user/dto/user.object';
4+
5+
@ObjectType()
6+
export class PostObject implements Omit<Post, 'author' | 'authorId'> {
7+
@Field(() => ID, { nullable: false })
8+
id: string;
9+
10+
@Field(() => Date, { nullable: false })
11+
createdAt: Date;
12+
13+
@Field(() => Date, { nullable: false })
14+
updatedAt: Date;
15+
16+
@Field(() => String, { nullable: false })
17+
title: string;
18+
19+
@Field(() => String, { nullable: true })
20+
content: string | null;
21+
22+
@Field(() => UserObject, { nullable: false })
23+
author: User;
24+
25+
@Field(() => String)
26+
authorId: string;
27+
}

src/prisma/schema.prisma

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,6 @@ generator client {
33
provider = "prisma-client-js"
44
}
55

6-
generator nestgraphql {
7-
provider = "node node_modules/prisma-nestjs-graphql"
8-
output = "../@generated"
9-
}
10-
116
datasource db {
127
provider = "postgresql"
138
url = env("DB_URL")

0 commit comments

Comments
 (0)