Nest.js - Authentication(인증), Authorizatino(인가) 심화

0
post-thumbnail

NestJS 인증/인가 심화 과정 정리

1. 인증, 인가 개념

1-1. RefreshToken Entity와 User Entity 간의 OneToOne 관계 설정

RefreshToken Entity 생성 (src/auth/entity/refresh-token.entity.ts)

import { User } from 'src/user/entity/user.entity';
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  OneToOne,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity()
export class RefreshToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  token: string;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  @OneToOne(() => User, (user) => user.refreshToken)
  @JoinColumn({ name: 'user_id' })
  user: User;
}

User Entity에서 RefreshToken과의 관계 설정 (src/user/entity/user.entity.ts)

import { RefreshToken } from 'src/auth/entity/refresh-token.entity';
// ... 기타 import들

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  password: string;

  @Column({ type: 'enum', enum: Role })
  role: Role = Role.User;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  @OneToMany(() => Video, (video) => video.user)
  videos: Video[];

  @OneToOne(() => RefreshToken, (refreshToken) => refreshToken.user)
  refreshToken: RefreshToken;
}

user_id가 name으로 성립되는가?

@JoinColumn({ name: 'user_id' })에서 user_id는 데이터베이스 테이블에서 실제로 생성되는 외래키 컬럼명입니다.

  • TypeORM에서 @OneToOne 관계에서 @JoinColumn을 사용하면 해당 엔티티의 테이블에 외래키 컬럼이 생성됩니다.
  • RefreshToken 엔티티에 @JoinColumn({ name: 'user_id' })를 설정하면, refresh_token 테이블에 user_id 컬럼이 생성되어 user 테이블의 id를 참조합니다.
  • 만약 name을 지정하지 않으면 기본적으로 userID 형태로 생성됩니다.

1-2. Auth Module에서 TypeORM 설정

auth.module.ts에서 RefreshToken 등록

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RefreshToken } from './entity/refresh-token.entity';
// ... 기타 import들

@Module({
  imports: [
    UserModule,
    PassportModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        return {
          global: true,
          secret: configService.get('jwt.secret'),
          signOptions: { expiresIn: '1d' },
        };
      },
    }),
    TypeOrmModule.forFeature([RefreshToken]), // RefreshToken 엔티티 등록
  ],
  providers: [AuthService, JwtStrategy, /* ... */],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

왜 이렇게 설정하는가?

  • TypeOrmModule.forFeature([RefreshToken])은 해당 모듈에서 RefreshToken 엔티티를 사용할 수 있도록 등록합니다.
  • 이를 통해 @InjectRepository(RefreshToken)을 사용하여 Repository를 주입받을 수 있습니다.
  • 각 모듈에서 자신이 사용할 엔티티를 등록하는 것이 모듈화 원칙에 부합합니다.

2. 슬라이딩 세션과 리프레시 토큰

2-1. 슬라이딩 세션 개념과 리프레시 토큰을 사용하는 이유

슬라이딩 세션(Sliding Session)

  • 사용자가 활동할 때마다 세션 만료 시간을 연장하는 방식
  • 사용자가 지속적으로 활동하면 로그아웃되지 않음

리프레시 토큰을 사용하는 이유
1. 보안성: Access Token의 수명을 짧게 하여 탈취 위험 최소화
2. 사용자 경험: 자동으로 토큰을 갱신하여 재로그인 불필요
3. 토큰 무효화: 리프레시 토큰을 통해 특정 세션만 무효화 가능

2-2. AuthService에서 RefreshToken 생성 및 관리

auth.service.ts에서 토큰 생성 및 관리

@Injectable()
export class AuthService {
  constructor(
    private userService: UserService,
    private jwtService: JwtService,
    @InjectRepository(RefreshToken) private refreshTokenRepository: Repository<RefreshToken>,
  ) {}

  async signin(email: string, password: string) {
    const user = await this.userService.findOneByEmail(email);
    if (!user) throw new UnauthorizedException();

    const isMatch = password === user.password;
    if (!isMatch) throw new UnauthorizedException();

    const refreshToken = this.generateRefreshToken(user.id); // refresh 토큰 생성
    await this.createRefreshTokenUsingUser(user.id, refreshToken); // 영속성 유지

    return {
      accessToken: this.generateAccessToken(user.id),
      refreshToken,
    };
  }

  private generateAccessToken(userId: string) {
    const payload = { sub: userId, tokenType: 'access' };
    return this.jwtService.sign(payload, { expiresIn: '1d' });
  }

  private generateRefreshToken(userId: string) {
    const payload = { sub: userId, tokenType: 'refresh' };
    return this.jwtService.sign(payload, { expiresIn: '30d' });
  }

  private async createRefreshTokenUsingUser(userId: string, refreshToken: string) {
    const refreshTokenEntity = await this.refreshTokenRepository.findOneBy({ user: { id: userId } });

    if (refreshTokenEntity) {
      // 기존 토큰이 있으면 교체
      refreshTokenEntity.token = refreshToken;
      await this.refreshTokenRepository.save(refreshTokenEntity);
    } else {
      // 없으면 새로 생성
      await this.refreshTokenRepository.save({ user: { id: userId }, token: refreshToken });
    }
  }
}

왜 이렇게 구현하는가?

  1. 토큰 타입 구분: tokenType을 통해 access와 refresh 토큰을 구분
  2. 영속성 유지: 데이터베이스에 refresh 토큰을 저장하여 서버 재시작 시에도 유지
  3. 토큰 교체: 기존 토큰이 있으면 새로운 토큰으로 교체하여 중복 방지

2-3. AuthController에서 Refresh API 구현

auth.controller.ts에서 refresh 엔드포인트

@ApiTags('Auth')
@Controller('api/auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @ApiPostResponse(RefreshResDto)
  @ApiBearerAuth()
  @Post('refresh')
  async refresh(@Headers('authorization') authorization, @User() user: UserAfterAuth) {
    const token = /Bearer\s(.+)/.exec(authorization)[1];
    const { accessToken, refreshToken } = await this.authService.refresh(token, user.id);
    return { accessToken, refreshToken };
  }
}

AuthService의 refresh 메서드

async refresh(token: string, userId: string) {
  const refreshTokenEntity = await this.refreshTokenRepository.findOneBy({ token });
  if (!refreshTokenEntity) throw new BadRequestException('Invalid refresh token');

  const accessToken = this.generateAccessToken(userId);
  const refreshToken = this.generateRefreshToken(userId);

  refreshTokenEntity.token = refreshToken;
  await this.refreshTokenRepository.save(refreshTokenEntity);
  
  return {
    accessToken,
    refreshToken,
  };
}

왜 이렇게 구현하는가?

  1. 토큰 검증: 데이터베이스에서 refresh 토큰의 유효성 검증
  2. 새로운 토큰 발급: 새로운 access 토큰과 refresh 토큰 모두 발급
  3. 토큰 갱신: 기존 refresh 토큰을 새로운 토큰으로 교체

2-4. JWT Guard에서 토큰 타입 검증

jwt-auth.guard.ts에서 토큰 타입 및 URL 검증

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector, private jwtService: JwtService, private userService: UserService) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }

    const http = context.switchToHttp();
    const { url, headers } = http.getRequest<Request>();
    const token = /Bearer\s(.+)/.exec(headers['authorization'])[1];
    const decoded = this.jwtService.decode(token);

    // refresh API가 아닌데 refresh 토큰을 사용하면 차단
    if (url !== '/api/auth/refresh' && decoded['tokenType'] === 'refresh') {
      console.error('accessToken is required');
      throw new UnauthorizedException();
    }

    const requireRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (requireRoles) {
      const userId = decoded['sub'];
      return this.userService.checkUserIsAdmin(userId);
    }
    return super.canActivate(context);
  }
}

왜 이렇게 구현하는가?

  1. 토큰 타입 검증: refresh 토큰은 오직 /api/auth/refresh 엔드포인트에서만 사용 가능
  2. 보안 강화: 잘못된 토큰 타입 사용 시 접근 차단
  3. 컨텍스트 정보 활용: context.switchToHttp()를 통해 요청 URL과 헤더 정보 추출

2-5. @Public 데코레이터를 통한 인증 우회

public.decorator.ts

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

JWT Guard에서 Public 메타데이터 확인

canActivate(context: ExecutionContext) {
  const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);
  if (isPublic) {
    return true; // 인증 없이 접근 허용
  }
  // ... 나머지 로직
}

왜 이렇게 구현하는가?

  1. 선택적 인증: 로그인, 회원가입 등 인증이 필요 없는 엔드포인트 처리
  2. 메타데이터 활용: NestJS의 Reflector를 통해 메타데이터 기반 권한 제어
  3. 코드 재사용: 데코레이터를 통한 선언적 프로그래밍

2-6. User Role 기반 권한 관리

user.enum.ts에서 Role 정의

export enum Role {
  Admin = 'ADMIN',
  User = 'USER',
}

user.entity.ts에서 role 컬럼 추가

@Entity()
export class User {
  // ... 기타 컬럼들

  @Column({ type: 'enum', enum: Role })
  role: Role = Role.User;

  // ... 나머지 컬럼들
}

데이터베이스 마이그레이션 과정

  1. 처음에는 nullable: true로 설정하여 기존 데이터 호환성 확보
  2. npm run start:dev 실행 후 데이터베이스 스키마 확인
  3. 기존 사용자들의 role을 수동으로 설정
  4. nullable 속성 제거 후 재실행

PostgreSQL에서 확인 및 업데이트

-- 테이블 구조 확인
\d+ "user"

-- 데이터 확인
SELECT * FROM "user";

-- role 업데이트
UPDATE "user" SET role = 'ADMIN' WHERE email = 'nestjs@fastcampus.com';

2-7. @Roles 데코레이터를 통한 역할 기반 접근 제어

role.decorator.ts

import { SetMetadata } from '@nestjs/common';
import { Role } from 'src/user/enum/user.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

user.controller.ts에서 Admin 권한 요구

@ApiTags('User')
@Controller('api/users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @ApiBearerAuth()
  @ApiGetItemsResponse(FindUserResDto)
  @Roles(Role.Admin) // Admin 권한 필요
  @Get()
  findAll(@Query() { page, size }: PageReqDto, @User() user: UserAfterAuth) {
    console.log(user);
    return this.userService.findAll();
  }
}

JWT Guard에서 역할 검증

canActivate(context: ExecutionContext) {
  // ... 기타 검증 로직

  const requireRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);
  if (requireRoles) {
    const userId = decoded['sub'];
    return this.userService.checkUserIsAdmin(userId);
  }
  return super.canActivate(context);
}

user.service.ts에서 Admin 권한 검증

@Injectable()
export class UserService {
  constructor(@InjectRepository(User) private readonly userRepository: Repository<User>) {}

  async checkUserIsAdmin(userId: string) {
    const user = await this.userRepository.findOneBy({ id: userId });
    return user.role === Role.Admin;
  }
}

왜 이렇게 구현하는가?

  1. 역할 기반 접근 제어: 특정 역할을 가진 사용자만 접근 가능
  2. 선언적 권한 관리: 데코레이터를 통한 직관적인 권한 설정
  3. 확장성: 새로운 역할 추가 시 쉽게 확장 가능
  4. 보안: 데이터베이스에서 실시간으로 사용자 권한 검증

결론

이 인증/인가 시스템을 통해 다음과 같은 보안 기능을 구현했습니다:

  1. JWT 기반 인증: Access Token과 Refresh Token을 통한 안전한 인증
  2. 토큰 타입 검증: 각 토큰의 용도에 맞는 사용 제한
  3. 역할 기반 접근 제어: 사용자 역할에 따른 API 접근 제한
  4. 선택적 인증: Public 엔드포인트와 인증 필요 엔드포인트 구분
  5. 토큰 갱신: 사용자 경험을 해치지 않는 자동 토큰 갱신

이러한 구조를 통해 확장 가능하고 안전한 인증/인가 시스템을 구축할 수 있습니다.

profile
하루하루 기록하기!

1개의 댓글

테스트

답글 달기