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;
}
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
는 데이터베이스 테이블에서 실제로 생성되는 외래키 컬럼명입니다.
@OneToOne
관계에서 @JoinColumn
을 사용하면 해당 엔티티의 테이블에 외래키 컬럼이 생성됩니다.RefreshToken
엔티티에 @JoinColumn({ name: 'user_id' })
를 설정하면, refresh_token
테이블에 user_id
컬럼이 생성되어 user
테이블의 id
를 참조합니다.name
을 지정하지 않으면 기본적으로 userID
형태로 생성됩니다.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를 주입받을 수 있습니다.슬라이딩 세션(Sliding Session)
리프레시 토큰을 사용하는 이유
1. 보안성: Access Token의 수명을 짧게 하여 탈취 위험 최소화
2. 사용자 경험: 자동으로 토큰을 갱신하여 재로그인 불필요
3. 토큰 무효화: 리프레시 토큰을 통해 특정 세션만 무효화 가능
@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 });
}
}
}
왜 이렇게 구현하는가?
tokenType
을 통해 access와 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 };
}
}
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,
};
}
왜 이렇게 구현하는가?
@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);
}
}
왜 이렇게 구현하는가?
/api/auth/refresh
엔드포인트에서만 사용 가능context.switchToHttp()
를 통해 요청 URL과 헤더 정보 추출import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true; // 인증 없이 접근 허용
}
// ... 나머지 로직
}
왜 이렇게 구현하는가?
export enum Role {
Admin = 'ADMIN',
User = 'USER',
}
@Entity()
export class User {
// ... 기타 컬럼들
@Column({ type: 'enum', enum: Role })
role: Role = Role.User;
// ... 나머지 컬럼들
}
데이터베이스 마이그레이션 과정
nullable: true
로 설정하여 기존 데이터 호환성 확보npm run start:dev
실행 후 데이터베이스 스키마 확인nullable
속성 제거 후 재실행PostgreSQL에서 확인 및 업데이트
-- 테이블 구조 확인
\d+ "user"
-- 데이터 확인
SELECT * FROM "user";
-- role 업데이트
UPDATE "user" SET role = 'ADMIN' WHERE email = 'nestjs@fastcampus.com';
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);
@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();
}
}
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);
}
@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;
}
}
왜 이렇게 구현하는가?
이 인증/인가 시스템을 통해 다음과 같은 보안 기능을 구현했습니다:
이러한 구조를 통해 확장 가능하고 안전한 인증/인가 시스템을 구축할 수 있습니다.
테스트