본문 바로가기
Back-end/nest.js

Nest.js : Nest.js로 Redis와 연동하여 세션 로그인 구현하기 (1)

by hsloth 2023. 2. 19.

 

왜 구글에 nest.js 레디스 세션 로그인을 치면 레디스를 캐시모듈로만 활용하는 글들만 나올까...

나의 구글링 실력의 부족일지도 모르겠다. (뭐 결국 삽질하다보면 나오긴 하는데...)

(물론, 캐시 모듈을 사용해서 세션을 직접 구현해도... 될거 같긴 하지만... 나는 말하는 감자니까 쉬운 길을 택해보자)

 

 

그래서 혹시 나의 글이 다른 분들에게 도움이 될까 싶어 글을 적어본다.

 

일단, JWT에 대한 글은 천지삐까리로 널려있다. 세션 글도 좀 써주세요 제발....

오죽하면, nestjs 세션을 검색해도 jwt글들만 나올까 ㅋㅋㅋ

 

짧게 말하자면, 결론은 express-session을 사용해서 저장소만 redis로 연결해주면 끝이다.

너무 간단하다. 나는 바보였다.

처음에는 그냥 redis를 캐시 저장소로 사용해서 거기다가 userId를 박아서 확인해 주는 식으로 활용할 생각이었다.

그런데, 세션에 userId를 그대로 때려박는건 아무리 생각해도 미친짓 같았다... 그래서 express-session을 사용해서 구현이 가능하나? 알아보았고, 가능했다.

 

(최대한 ports and adapters 구조를 없애서 코드를 작성해보겠습니다)

 

1. 필요한 모듈 설치

passport 관련 모듈

npm install passport passport-local @nestjs/passport

 

 

세션과 레디스 연결시 필요한 모듈

npm install express-session connect-redis ioredis
npm i --save-dev @types/express-session @types/connect-redis @types/ioredis

다른 글들은 좀 오래된 글들이 많아서 ioredis 말고 redis를 사용하던데, RedisStoreclient와 타입이 맞지 않아 지금은 사용이 불가능했다.

그래서 express-session npm문서를 보고, ioredis를 사용하기로 했다.

 

2. main.ts에서 session 설정

일단, main.ts에 session 설정을 그냥 때려박지말고 코드를 깔끔하게 짜보자.

// setting/session/init.session.ts
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as session from 'express-session';
import * as connectRedis from 'connect-redis';
import { Redis } from 'ioredis';
import * as passport from 'passport';

export function setUpSession(app: INestApplication): void {
  const configService = app.get<ConfigService>(ConfigService);

  const port = configService.get('REDIS_PORT');
  const host = configService.get('REDIS_HOST');

  // 세션과 Redis를 연결해줄 객체
  const RedisStore = connectRedis(session);

  // 레디스 설정
  const client = new Redis({
    host,
    port,
  });

    app.use(
        session({
          secret: configService.get('SESSION_SECRET'),  // 세션에 사용될 시크릿 값. 감춰두자.
          saveUninitialized: false,
          resave: false,
          store: new RedisStore({  // 세션 스토어 설정. 여기서 RedisStore를 설정해서 client에 위에서 설정한 레디스를 입력하자.
            client: client,
            ttl: 30,  // time to live
          }),
          cookie: {
            httpOnly: true,
            secure: true,
            maxAge: 30000,  //세션이 redis에 저장되는 기간은 maxAge로 조절한다.(ms)
          },
        }),
      );
      app.use(passport.initialize());
      app.use(passport.session());
}

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { setUpSession } from './settings/session/init.session';
import { setUpSwagger } from './settings/swagger/init.swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  ...

  // 세션 초기설정
  setUpSession(app);

  ...

  await app.listen(port);
  console.log(`Server is open on port ${port}`);
}
bootstrap();
  • 일단, init.session.ts 파일을 만든다.
    • app을 인자로 받아서 세션 설정을 한다. (INestApplication 타입이라고 명시해주자)
    • 그리고, ConfigService를 받아와서 환경변수를 사용하여 Redis의 port와 host를 가져와주자. (이거 잘 모르겠으면 그냥 dotenv를 사용하거나, 그냥 쌩으로 박으세요!)
    • ioredis에서 Redis를 불러오고, port와 host를 인자로 입력한 다음, RedisStore의 client 속성에 입력하면 된다.
  • 그 후, main.ts에 해당 함수를 import해서 app을 인자로 넣고 실행시키면 된다.
    • 참고로, app.use(passport.initialize())app.use(passport.session()) 같은 함수를 사용할 필요는 없는 것 같다.
    • 네... 써야됩니다. 써야돼요. req.isAuthenticated() 쓰려면 작성해야합니다.
  • 귀찮은 분들은 그냥 때려박면 된다...!

 

3. LocalAuthGuard와 LocalStrategy, LocalSerializer 작성

이 부분은 검색하면 널리고 널렸으니... 간단한 설명과 함께 코드만 올려놓겠습니다.

// local-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
  
    const can = await super.canActivate(context);
    
    if (can) {
      const request = context.switchToHttp().getRequest();
      await super.logIn(request);
    }
    return true;
  }
}
  • 아래의 순서는 알아두면 좋습니다...! (직접 console.log 찍으면서 확인한거라 부정확할 수 있습니다)
  • 여기서 await super.canActivate(context) 함수를 통해서 LocalStrategy의 validate 함수로 들어간다.
  • LocalStrategy에서 빠져나온 후, request를 받아서 super.logIn(request) 함수를 실행시키는데, 이 함수를 통해 LocalSerializer의 serializeUser 함수로 들어간다.

 

// local.strategy.ts
import { Strategy } from 'passport-local';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import {
  AuthControllerInboundPort,
  AUTH_CONTROLLER_INBOUND_PORT,
} from 'src/inbound-ports/auth/auth-controller.inbound-port';
import { AuthService } from 'src/service/auth.service.ts';


@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
  constructor(
    private readonly authService: AuthService,
  ) {
    super({
      usernameField: 'email',
      passwordField: 'password',
    });
  }

  async validate(
    email: string,
    password: string,
    done: CallableFunction,
  ): Promise<any> {
    const user = await this.authService.validateUser({
      email: email,
      password: password,
    });

    if (!user) {
      throw new UnauthorizedException();
    }

    return done(null, user);
  }
}


// local.serializer.ts
import { Inject, Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import {
  UserRepositoryOutboundPort,
  USER_REPOSITORY_OUTBOUND_PORT,
} from 'src/outbound-ports/user/user-repository.outbound-port';
import { UserRepository } from 'src/repositories/user.repository.ts';

@Injectable()
export class LocalSerializer extends PassportSerializer {
  constructor(
    @InjectRepository(UserRepository)
    private readonly userRepository: UserRepository,
  ) {
    super();
  }

  serializeUser(user: any, done: Function) {
    console.log(user);
    //user객체는 무거우니, userId만 뽑아서 세션에 저장한다.
    done(null, user.id);
  }

  async deserializeUser(payload: any, done: Function) {
    return await this.userRepository
      .findOneOrFail({
        where: { id: params.userId },
      })
      .then((user) => {
        console.log('user', user);
        done(null, user);
      })
      .catch((err) => done(err));
  }
}

 

4. AuthModule과 연결

LocalSerializer와 LocalStrategy를 꼭 providers에 넣어주자.

// auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LocalStrategy } from 'src/auth/strategies/local.strategy';
import { REDIS_REPOSITORY_OUTBOUND_PORT } from 'src/cache/redis/redis-repository.outbound-port';
import { RedisModule } from 'src/cache/redis/redis.module';
import { RedisRepository } from 'src/cache/redis/redis.repository';
import { AuthController } from 'src/controllers/auth.controller';
import { UserEntity } from 'src/entities/user/user.entity';
import { AUTH_CONTROLLER_INBOUND_PORT } from 'src/inbound-ports/auth/auth-controller.inbound-port';
import { USER_REPOSITORY_OUTBOUND_PORT } from 'src/outbound-ports/user/user-repository.outbound-port';
import { UserRepository } from 'src/repositories/user.repository';
import { AuthService } from 'src/services/auth.service';

@Module({
  imports: [
    PassportModule.register({ session: true }),
    TypeOrmModule.forFeature([UserEntity]),
  ],
  controllers: [AuthController],
  providers: [
    ...
    LocalStrategy,
    LocalSerializer,
    AuthService,
  ],
  exports: [AuthService],
})
export class AuthModule {}

 

5. AuthController에 로그인 로직 추가

여기서 authControllerInboundPort는 AuthService라고 생각하면 된다.

(하나 쯤은 ports and adapters 구조를 보려고 하시는 분들을 위해서 코드수정을 안해도 되겠다고 생각해서 남겨놨습니다)

// auth.controller.ts
import { Body, Controller, Inject, Post, Req, UseGuards } from '@nestjs/common';
import {
  ApiBody,
  ApiCreatedResponse,
  ApiOkResponse,
  ApiOperation,
  ApiTags,
  ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { LocalAuthGuard } from 'src/auth/guard/local-auth.guard';
import { Users } from 'src/decorators/user.decorator';
import { LogInUserDto } from 'src/dtos/auth/login.user.dto';
import { RegisterUserDto } from 'src/dtos/register.user.dto';
import {
  AuthControllerInboundPort,
  AUTH_CONTROLLER_INBOUND_PORT,
} from 'src/inbound-ports/auth/auth-controller.inbound-port';

@ApiTags('유저 인증 API')
@Controller('api/auths')
export class AuthController {
  constructor(
    @Inject(AUTH_CONTROLLER_INBOUND_PORT)
    private readonly authControllerInboundPort: AuthControllerInboundPort,
  ) {}

  @ApiOperation({
    summary: 'Local 로그인 인증 api',
    description:
      '유저의 이메일과 비밀번호가 데이터베이스와 동일하면 인증에 성공하며, 세션에 user${id}: current_time 형태로 등록한다.',
  })
  @ApiBody({
    type: LogInUserDto,
  })
  @ApiOkResponse({
    description: '성공 : userId를 제공한다.',
    type: Number,
  })
  @ApiUnauthorizedResponse({
    description:
      '인증 실패 : 에러 발생 일시, 에러 메시지, 에러가 발생된 Path, status code를 반환한다.',
  })
  @UseGuards(LocalAuthGuard)
  @Post('login')
  async logIn(@Users() user) {
    return user;
  }

  @ApiOperation({
    summary: 'Local 회원가입 api',
    description: '유저의 이메일과 일치하는 메일이 없으면 회원가입에 성공한다.',
  })
  @ApiBody({
    type: RegisterUserDto,
  })
  @ApiCreatedResponse({
    description: '성공 : DB에 유저를 등록한다.',
  })
  @Post('register')
  async register(@Body() user: RegisterUserDto) {
    await this.authControllerInboundPort.register({
      email: user.email,
      password: user.password,
    });
  }
}

 

 

그리고, 더 찾아보니까 nestjs-sesson 이라는 모듈이 있던데... 여유가 되면 이 모듈을 사용해서 구현해보는 것도 좋을 것 같다.

이게 더 편해보이니까 여러분들도 한번 찾아보세요!

https://www.npmjs.com/package/nestjs-session

 

주의사항

Error [AuthGuard] undefined

라는 에러가 LocalStrategy의 valiedate 함수의 done(null, user) 부분에서 발생하는 거 같은데... 왜 뜨는지 잘 모르겠습니다.

그런데, 또 로그인은 잘돼요. 하지만 좀 찜찜하니...

혹시 해결방법을 아시는 분 있으시면 댓글 부탁드립니다!