Skip to content

Commit adc6098

Browse files
authored
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 타입 이슈 해결
1 parent c66f600 commit adc6098

File tree

13 files changed

+1968
-231
lines changed

13 files changed

+1968
-231
lines changed

.example.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ DB_NAME=postgres
88
DB_SCHEMA=public
99

1010
PORT=8000
11+
12+
# JWT
13+
ACCESS_TOKEN_SECRET=
14+
REFRESH_TOKEN_SECRET=

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,22 @@
2929
"@nestjs/config": "^4.0.2",
3030
"@nestjs/core": "^11.0.1",
3131
"@nestjs/graphql": "^13.0.3",
32+
"@nestjs/jwt": "^11.0.0",
3233
"@nestjs/mercurius": "^13.0.3",
34+
"@nestjs/passport": "^11.0.5",
3335
"@nestjs/platform-express": "^11.0.1",
3436
"@nestjs/platform-fastify": "11.0.12",
3537
"@prisma/client": "^6.5.0",
3638
"aws-lambda": "^1.0.7",
39+
"bcrypt": "^5.1.1",
3740
"class-transformer": "^0.5.1",
41+
"class-validator": "^0.14.1",
3842
"fastify": "5.2.1",
3943
"graphql": "^16.10.0",
4044
"mercurius": "^16.1.0",
45+
"passport": "^0.7.0",
46+
"passport-jwt": "^4.0.1",
47+
"passport-local": "^1.0.0",
4148
"prisma": "^6.5.0",
4249
"reflect-metadata": "^0.2.2",
4350
"rxjs": "^7.8.1"
@@ -51,9 +58,12 @@
5158
"@swc/cli": "^0.6.0",
5259
"@swc/core": "^1.10.7",
5360
"@types/aws-lambda": "^8.10.148",
61+
"@types/bcrypt": "^5.0.2",
5462
"@types/express": "^5.0.0",
5563
"@types/jest": "^29.5.14",
5664
"@types/node": "^22.10.7",
65+
"@types/passport-jwt": "^4.0.1",
66+
"@types/passport-local": "^1.0.38",
5767
"@types/supertest": "^6.0.2",
5868
"eslint": "^9.18.0",
5969
"eslint-config-prettier": "^10.0.1",
@@ -89,4 +99,4 @@
8999
"coverageDirectory": "../coverage",
90100
"testEnvironment": "node"
91101
}
92-
}
102+
}

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { GraphQLModule } from '@nestjs/graphql';
44
import { MercuriusDriver, MercuriusDriverConfig } from '@nestjs/mercurius';
55
import { UserModule } from './user/user.module';
66
import { ConfigModule, ConfigService } from '@nestjs/config';
7+
import { AuthModule } from './auth/auth.module';
78
import { errorFormatter } from './common/exception/exception.format';
89

910
@Module({
@@ -24,6 +25,7 @@ import { errorFormatter } from './common/exception/exception.format';
2425
},
2526
}),
2627
UserModule,
28+
AuthModule,
2729
],
2830
controllers: [],
2931
providers: [],

src/auth/auth.module.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Module } from '@nestjs/common';
2+
import { AuthService } from './auth.service';
3+
import { AuthResolver } from './auth.resolver';
4+
import { UserModule } from '../user/user.module';
5+
import { JwtModule } from '@nestjs/jwt';
6+
import { ConfigModule } from '@nestjs/config';
7+
import { PrismaModule } from '../prisma/prisma.module';
8+
import { LocalAuthGuard, LocalStrategy } from './strategies/local.strategy';
9+
import { JwtAuthGuard, JwtStrategy } from './strategies/jwt.strategy';
10+
11+
@Module({
12+
imports: [JwtModule.register({}), PrismaModule, ConfigModule, UserModule],
13+
providers: [
14+
AuthService,
15+
AuthResolver,
16+
LocalAuthGuard,
17+
JwtAuthGuard,
18+
LocalStrategy,
19+
JwtStrategy,
20+
],
21+
exports: [AuthService, JwtModule],
22+
})
23+
export class AuthModule {}

src/auth/auth.resolver.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Args, Mutation, Resolver } from '@nestjs/graphql';
2+
import { AuthService } from './auth.service';
3+
import { SignInInput, SignUpInput } from './inputs/auth.input';
4+
import { JwtWithUser } from './entities/auth.entity';
5+
6+
@Resolver()
7+
export class AuthResolver {
8+
constructor(private readonly authService: AuthService) {}
9+
10+
@Mutation(() => JwtWithUser)
11+
signIn(@Args('data') data: SignInInput) {
12+
return this.authService.signIn(data);
13+
}
14+
15+
@Mutation(() => JwtWithUser)
16+
signUp(@Args('data') data: SignUpInput) {
17+
return this.authService.signUp(data);
18+
}
19+
}

src/auth/auth.service.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Injectable, UnauthorizedException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { PrismaService } from '../prisma/prisma.service';
4+
import * as bcrypt from 'bcrypt';
5+
import { SignInInput, SignUpInput } from './inputs/auth.input';
6+
import { JwtService } from '@nestjs/jwt';
7+
import { User } from '../@generated/user/user.model';
8+
9+
@Injectable()
10+
export class AuthService {
11+
constructor(
12+
private readonly configService: ConfigService,
13+
private readonly prismaService: PrismaService,
14+
private readonly jwtService: JwtService,
15+
) {}
16+
17+
async authenticate(input: SignInInput) {
18+
const { email, password } = input;
19+
const user = await this.prismaService.user.findUnique({
20+
where: {
21+
email,
22+
},
23+
});
24+
if (!user) {
25+
return null;
26+
}
27+
28+
const passOk: boolean = await bcrypt.compare(password, user.password);
29+
if (!passOk) {
30+
return null;
31+
}
32+
return user;
33+
}
34+
35+
async signUp(input: SignUpInput) {
36+
const { email, password, nickname } = input;
37+
const hashedPassword = await this.hashPassword(password);
38+
const user = await this.prismaService.user.create({
39+
data: {
40+
email,
41+
nickname,
42+
password: hashedPassword,
43+
},
44+
});
45+
const accessToken = await this.issueToken(user, false);
46+
const refreshToken = await this.issueToken(user, true);
47+
return {
48+
accessToken,
49+
refreshToken,
50+
user,
51+
};
52+
}
53+
54+
async signIn(input: SignInInput) {
55+
const { email, password } = input;
56+
const user = await this.authenticate({ email, password });
57+
if (!user) {
58+
throw new UnauthorizedException('잘못된 로그인 정보입니다!');
59+
}
60+
const accessToken = await this.issueToken(user, false);
61+
const refreshToken = await this.issueToken(user, true);
62+
return {
63+
accessToken,
64+
refreshToken,
65+
user,
66+
};
67+
}
68+
69+
async hashPassword(password: string): Promise<string> {
70+
const salt = await bcrypt.genSalt(10);
71+
return bcrypt.hash(password, salt);
72+
}
73+
async issueToken(user: User, isRefreshToken: boolean) {
74+
const secret = this.configService
75+
.get<string>(
76+
isRefreshToken ? 'REFRESH_TOKEN_SECRET' : 'ACCESS_TOKEN_SECRET',
77+
)
78+
?.replace(/\\n/g, '\n');
79+
return this.jwtService.signAsync(
80+
{
81+
sub: user.id,
82+
role: user.role,
83+
type: isRefreshToken ? 'refresh' : 'access',
84+
},
85+
{
86+
secret,
87+
expiresIn: isRefreshToken ? '7d' : '15m',
88+
},
89+
);
90+
}
91+
}

src/auth/entities/auth.entity.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Field, ObjectType } from '@nestjs/graphql';
2+
import { User } from '../../@generated/user/user.model';
3+
4+
@ObjectType()
5+
export class JwtWithUser {
6+
@Field(() => String)
7+
accessToken: string;
8+
9+
@Field(() => String)
10+
refreshToken?: string;
11+
12+
@Field(() => User)
13+
user: User;
14+
}

src/auth/inputs/auth.input.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Field, InputType } from '@nestjs/graphql';
2+
3+
import { IsEmail, IsNotEmpty } from 'class-validator';
4+
5+
@InputType()
6+
export class SignInInput {
7+
@Field(() => String)
8+
@IsEmail()
9+
@IsNotEmpty()
10+
email: string;
11+
12+
@Field(() => String)
13+
@IsNotEmpty()
14+
password: string;
15+
}
16+
17+
@InputType()
18+
export class SignUpInput extends SignInInput {
19+
@Field(() => String)
20+
@IsNotEmpty()
21+
nickname: string;
22+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Injectable, UnauthorizedException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { AuthGuard, PassportStrategy } from '@nestjs/passport';
4+
import { ExtractJwt, Strategy, VerifiedCallback } from 'passport-jwt';
5+
import { UserService } from '../../user/user.service';
6+
7+
export class JwtAuthGuard extends AuthGuard('jwt') {}
8+
9+
@Injectable()
10+
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
11+
constructor(
12+
private readonly userService: UserService,
13+
private readonly configService: ConfigService,
14+
) {
15+
super({
16+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
17+
ignoreExpiration: false,
18+
secretOrKey: configService.getOrThrow<string>('ACCESS_TOKEN_SECRET'),
19+
});
20+
}
21+
22+
async validate(payload: { id: string }, done: VerifiedCallback) {
23+
try {
24+
const user = await this.userService.findOneById(payload.id);
25+
if (!user) {
26+
return done(null, false);
27+
}
28+
const { id, role } = user;
29+
done(null, { id, role });
30+
} catch (err) {
31+
throw new UnauthorizedException('Error', err);
32+
}
33+
}
34+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Injectable, UnauthorizedException } from '@nestjs/common';
2+
import { AuthGuard, PassportStrategy } from '@nestjs/passport';
3+
4+
import { Strategy } from 'passport-local';
5+
6+
import { AuthService } from '../auth.service';
7+
import { SignInInput } from '../inputs/auth.input';
8+
9+
export class LocalAuthGuard extends AuthGuard('local') {}
10+
11+
@Injectable()
12+
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
13+
constructor(private readonly authService: AuthService) {
14+
super({
15+
usernameField: 'email',
16+
passwordField: 'password',
17+
});
18+
}
19+
async validate(email: string, password: string): Promise<SignInInput> {
20+
const user = await this.authService.authenticate({ email, password });
21+
22+
if (!user) {
23+
throw new UnauthorizedException();
24+
}
25+
26+
return user;
27+
}
28+
}

0 commit comments

Comments
 (0)