passport.js

인증 시러ㅓㅓㅓㅓㅓㅓㅓㅓㅓㅓㅓ

Posted by surin01 on November 13, 2021 · 25 mins read

인증

웹에서 인증은 필수적이다. 내가 내 돈을 쓰기 위해서도 인증을 해야하고, 내가 난데!를 증명하기 위해서도 인증을 해야한다.

그렇기 때문에 인증을 위한 정말 다양한 방법이 존재하고, 여러 기술을 거쳐 지금의 방법(OAuth와 JWT등…)으로 발전하였다.

개발하는 프로젝트에서 사용자를 인증하고 자신이 계정에 대한 접근과, 게시판 이용 권한을 부여하기 위해서 인증 모듈을 사용해야 하는 경우가 생겼고, 써보고 여기저기서 삽질한 경험을 끼적거려 보려고 한다.

Passport.js?

passport.js는 Node.js에서 인증을 위해 사용되는 미들웨어다.

그럼과 동시에, passport.js 또한 하나의 프레임워크로서 생각하는 것이 좋다. 개발자가 구성해야 하는 함수들이 있고, 올바르게 구성했을 때 이 미들웨어는 예측했던 함수들을 호출하고 그 반환값을 바탕삼아 내부에서 구성되기 때문이다.

그렇기 때문에 기존에 짜여진 틀 내에서 작성되어야 하고, 더욱이 공식문서를 잘 읽어봐야 할 것이다.

공식 홈페이지링크로 가면 로컬 전략(strategy)뿐만 아니라, OAuth를 지원하는 여러 사이트들(facebook, google)을 지원한다. 이 중에서 우리는 Local Strategy와 JWT Strategy에 대해서 중점적으로 살펴볼 것이다.

글을 쓰는데 참고한 문서는 공식 홈페이지 도큐링크, Nest.js 공식 도큐링크를 참고하였음을 밝힌다.

LocalStrategy

가장 기본적으로 사용되는 LocalStrategy는 아이디/패스워드 인증 매커니즘으로, 대부분의 사이트들에서 사용되는 인증 전략일 것이다.

코드를 작성하기 앞서, 필요한 모듈들을 설치해야 한다.

npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local

필요한 모듈들을 설치했다면, 이제 인증 전략을 구성하고 서비스에 적용해보자.

Implementing LocalStrategy

기본적으로, LocalStrategy는 이와 비슷하게 구성된다

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

위의 코드를 살펴보면, LocalStrategy는 PssportStrategy(Strategy)의 반환값으로 나온 클래스를 계승하고 있고, 내부적으로는 validate라는 함수가 있고 이 함수는 username과 password라는 매개변수들을 가진다.

위 매개변수들은 request.body에서 자동으로 username과 password 값을 찾아와 넣어주기 때문에, 프론트쪽에서 아이디를

<input type="text" name="id">

와 같은 형식으로 개발되고 있다면 아래와 같이 변경해줄 수 있다.

// local.strategy.ts
constructor() {
    super({
        // 여기서 옵션 오브젝트로 바꿀 수 있다!
        usernameField: "id"
    });
  }

볼만큼 봤으니, 우리가 구성한 코드에서 이 전략이 어떻게 사용되는지 보자

<form method="post" action="/auth" class="signin-form">
    <div class="form-group mt-3">
    	<input name="username" type="text" class="form-control" required />
    	<label class="form-control-placeholder" for="username">Username</label>
    </div>
    <div class="form-group">
    	<input name="password" type="password" class="form-control" required />
    	<label class="form-control-placeholder" for="password">Password</label>
    	<span
    		toggle="#password-field"
    		class="fa fa-fw fa-eye field-icon toggle-password"
    	></span>
    </div>
    <div class="form-group">
    	<button type="submit" class="form-control btn btn-primary rounded submit px-3">
    		Sign In
    	</button>
    </div>
</form>

로그인 페이지 코드이다. 아이디와 패스워드를 인풋으로 가져오고, 각 input의 name을 username과 password로 구성해놓았고, post메소드를 통해서 /auth 로 보내지게 된다.

// auth.controller.ts
import { Get, Controller, Post, Render, UseGuards, Request, Res, Redirect } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { AuthService } from "../service/auth.service";

@Controller("auth")
export class AuthController {
	constructor(private authService: AuthService) {}

    ...(생략)
    @UseGuards(AuthGuard("local"))
	@Redirect("/", 302)
	@Post()
	async login(): Promise<any> {

		return "success!"; 
	}
    ...(생략)
}

위의 페이지에서 전송되는 데이터를 제어하기 위한 컨트롤러 모듈이다. 다른 코드들은 제외하고, login 만을 위한 부분을 추렸다.

// auth.service.ts
import { Injectable } from "@nestjs/common";
import { Mapper } from "../mapper/mapper";
import { queryString } from "../common/query";
import { executionResult } from "src/dto/user.dto";

@Injectable()
export class AuthService {
	constructor(
		private readonly mapper: Mapper,
	) {}

    ...(생략)
	async validate(userid: string, userpw: string): Promise<any> {
		const findOne: executionResult = await this.mapper.mapper(
			queryString.findOne,
			[userid],
		);

		if (
			findOne.data.length !== 0 &&
			findOne.data[0]["password"] === userpw
		) {
			return findOne;
		} else {
			return false;
		}
	}
    ...(생략)

}

컨트롤러에서 사용되는 AuthService 부분의 validate부분이다. userid와 userpw를 매개변수로 받고, db에서 해당 매개변수들로 해당하는 값이 있는지 찾고 실행 결과에 따른 값을 리턴해준다.

위의 코드에서는 패스워드를 평문으로 다루고 있지만, 실제 모듈에서는 bycrypt와 같은 모듈을 사용하여 패스워드를 단방향 해싱하여 저장하고 비교할때는 받아온 데이터를 해싱 후 비교하는 방식으로 진행하여야 한다.

// local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

최종적으로(실제로는 가장 맨 처음 실행되는 코드) 사용되는 코드다. 사용자가 보내는 데이터가 post 메소드로 http://example.com/auth로 보내지게 된다. 이 데이터는 @UseGuard(AuthGuard(“local”)) 어노테이션을 통해서 localStrategy로 검증하게 된다. AuthService에 속한 validate 메소드를 통해서 받아온 값을 바탕으로 인증 성공 유무를 판단하고, 인증 실패시 UnauthorizedException을 반환한다. 그렇지 않을 경우 user의 값을 반환하고, 이 값은 req의 user에 저장된다.

이 값으로 뭔가 하고 싶다면, 이제 컨트롤러에서

@Post()
async login(@Request() req): Promise<any> {
    console.log(req.user);
	return "success!"; 
}

와 같은 방식으로 접근해서 사용하면 된다.

사용자가 보내주는 데이터도 받아서 사용할 수 있게 되고, 데이터베이스에서 조회해서 검증할 수 있고 그 데이터를 사용까지 할 수 있게 되었다.

근데 우리는 인터넷에서 메일 전체를 읽어올때 한번 로그인하고, 그 중에 하나를 읽어올때 마다 로그인을 다시 하지 않는다. 이러한 작업이 가능한건 서버와 클라이언트에서 세션을 생성하고 유지하기 때문인데, stateless한 http의 한계를 넘어설수 있는 방법으로 언급될 때 항상 나오는 키워드들인 쿠키와 세션 그리고 토큰까지 알아보도로고 하자.

Cookie, Session

가끔 쿠키와 세션에 대해서 반대되는 개념이고, 완전히 동떨어진 개념이라고 설명하는 글도 있었는데 정리를 위해 다시 찾아보니 그러한 오개념들을 많이 사라진 것 같다.

결론적으로 쿠키와 세션은 다른 개념이기는 하지만, 완전히 동떨어졌다기 보단 사용되는 영역이 각각 클라이언트와 서버로 차이가 있고 서로가 서로에게 연관이 있다라고 생각하는것이 좋지 않나 싶다.

세션이라는 것은, 사용자가 아이디와 패스워드로 로그인을 시도했을때 로그인이 성공한다면 이 유저를 식별할 수 있도록 고유한 ID를 생성하고, 이와 연결되는 세션 ID를 발행한다. 그 다음 사용자에게 응답을 보낼때 이 세션 ID를 함께 보내고, 사용자는 세션 ID를 쿠키에 저장한 후 인증이 필요한 요청마다 쿠키를 헤더에 실어서 보내게 된다. 이제부터 서버는 이 쿠키가 만료되지 않는 한(서버의 세선 저장소와 사용자의 클라이언트 둘 다에서), 해당 사용자가 보내는 인증을 누가 보냈는지 파악알 수 있게 되었다.

이렇게 세션과 쿠키는 분리된 개념이 아니라 같이 사용하는 개념인 것이다.

Token

그럼 토큰은 대체 뭐냐? 싶은 사람들도 있을 것이다.

일단 토큰은 작동되는 방식을 보면 세션과 유사하게 생각 할 수도 있다. 인증에 필요한 정보들이 쿠키에 첨부되서 보내지게 되고, 서버단에서 특정한 방법으로 이를 검증한다.

하지만 가장 큰 부분인 검증하는 쪽에서 차이점이 발생하게 된다. 앞서 말했듯, 세션은 세선 저장소라는 공간이 추가적으로 필요하고, 이 저장소에서 클라이언트가 보내준 세션 id를 대조해서 확인하는 반면에 토큰을 사용하여 요청을 하면 서버는 토큰이 유효한지 확인만 하게 된다. http의 stateless한 성격과 더욱 들어맞고, 서버측에서 사용되는 리소스를 절약할 수 있기 때문에 대용량의 데이터를 다루는 서버일수록 더욱 효율이 좋아진다고 한 글링크에서 설명한다.

그러면 토큰은(대표격인 JWT는) 모든 경우에서 유효한 만능의 수단인가? 라고 말한다면 이것또한 아니라고 할 수 있다.

우선 토큰은 한번 탈취되게 되면 유효기간이 만료되기 전까지 탈취한 해커가 무제한으로 사용할 수 있는 만능키가 되고, 더욱 유의해서 관리해야 할 필요가 있다. 또 Stateless하다는 것은 서버에서 사용자가 가지고 있는 토큰과 연결되어 있지 않다는 것이고, 기존 세션의 경우 사용자가 로그아웃 한다면 단순하게 세션 저장소에서 그 세션ID를 날리면 모든것이 해결되지만 토큰의 경우 이런 방법으로 사용할 수 없고, 만료된 토큰을 저장하는 저장소를 추가적으로 만들던가, 토큰의 생명 주기를 굉장히 짧게(5분 정도) 설정하고 시간이 지나면 새롭게 토큰을 만들던가, 서버 로그아웃 기능을 제거하고 클라이언트에 토큰 삭제를 위임하는 방법이 있다.

하지만 대부분의 시스템들이 앞의 두 방법, 생명주기를 짧게 하거나 만료된 토큰을 저장하는 형식으로 구현이 되어있다. 이러한 방법들은 결국 JWT의 특징이였던 Stateless와 이를 통해서 발생하는 장점들이 사라지게 되고, 또 JWT의 크기는 상대적으로 크기 때문에 쿠키에 JWT를 담을 경우 오버헤드가 발생할 수 있다.

그래서 쓰라는 거야 말라는거야 라고 물으신다면? 한번쯤 써 보자 지금 아니면 언제 써보겠냐 라고 답해드리고 싶다. 이런 개인 개발 프로젝트에서나 써보지 아니면 어떻게 쓰겠어요 ㅎㅎ;

JWT 또한, passport.js의 JWT Strategy를 사용해서 개발하고, 이전의 코드들에서 몇가지가 더 추가된 형태로 개발된다.

Using JWT Strategy

실 코드에서 어떠한 방향으로 콬드가 추가 되었는지 살펴보자

// jwt.strategy.ts
import { Strategy } from "passport-jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";

const fromAuthCookie = function () {
	return function (request) {
		let token: string = null;
		if (request && request.headers.cookie) {
			token = request.headers.cookie.split("=")[1];
			console.log(token);
		}
		return token;
	};
};

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
	constructor() {
		super({
			jwtFromRequest: fromAuthCookie(),
			ignoreExpiration: false,
			secretOrKey: process.env.JWTSECRET,
		});
	}

	async validate(payload: any) {
		return { userId: payload.sub, username: payload.username };
	}
}


우선 jwt.strategy.ts파일이다. 이 부분에서 요청에서 쿠키를 분리해와서 jwtFromRequest로 넣어주는 fromAuthCookie라는 함수를 만들어주었고, 이를 통해 넣어진 데이터는 자동으로 디코딩되고 validate의 payload로 보내지게 된다. 그 후 검증의 과정을 거치고 userId와 username을 payload에서 분리해서 리턴시켜준다.

// auth.controller.ts
import { Get, Controller, Post, Render, UseGuards, Request, Res, Redirect } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { AuthService } from "../service/auth.service";

@Controller("auth")
export class AuthController {
	constructor(private authService: AuthService) {}

	@UseGuards(AuthGuard("local"))
	@Redirect("/", 302)
	@Post()
	async login(@Request() req, @Res({ passthrough: true }) response): Promise<any> {
		const tokens = await this.authService.getAccessToken({
			useranme: req.user.username,
			sub: req.user.userpw,
		});

		response.cookie("Authorization", tokens.access_token, {
			httpOnly: true,
		});
		response.cookie("Refresh", tokens.refresh_token, {
			httpOnly: true,
		});
		return;
	}

}

처음 작성하였던 local strategy를 활용한 인증이 정상적으로 성공하면 AuthService의 getAccessToken으로 토큰을 생성하고 response 객체에 cookie를 주입해서 전송시켜준다.

import { Injectable } from "@nestjs/common";
import { Mapper } from "../mapper/mapper";
import { queryString } from "../common/query";
import { JwtService } from "@nestjs/jwt";
import { executionResult } from "src/dto/user.dto";

@Injectable()
export class AuthService {
	constructor(
		private readonly mapper: Mapper,
		private readonly jwtService: JwtService,
	) {}

	async validate(userid: string, userpw: string): Promise<any> {
		const findOne: executionResult = await this.mapper.mapper(
			queryString.findOne,
			[userid],
		);

		if (
			findOne.data.length !== 0 &&
			findOne.data[0]["password"] === userpw
		) {
			return findOne;
		} else {
			return false;
		}
	}

	async getAccessToken(user: any) {
		const payload = { useranme: user.username, sub: user.userId };

		return {
			access_token: this.jwtService.sign(payload, {
				secret: process.env.JWTSECRET,
				expiresIn: "5m",
			}),
			refresh_token: this.jwtService.sign(payload, {
				secret: process.env.JWTSECRET,
				expiresIn: "7d",
			}),
		};
	}
}

getAccessToken 함수의 형태를 보면, jwtService의 sign함수를 통해서 토큰을 생성시켜주고, 프론트에서 필요하는 accessToken과 refreshToken 2개를 만들어서 클라이언트측에 전달해준다.

import { Module } from "@nestjs/common";
import { AuthController } from "../controller/auth.controller";
import { AuthService } from "../service/auth.service";
import { Mapper } from "../mapper/mapper";
import { LocalStrategy } from "../strategy/local.strategy";
import { PassportModule } from "@nestjs/passport";
import { UserServcie } from "src/service/user.service";
import { JwtModule } from "@nestjs/jwt";
import { JwtStrategy } from "src/strategy/jwt.strategy";

@Module({
	imports: [
		PassportModule,
		JwtModule.register({
			secret: process.env.JWTSECRET,
			signOptions: { expiresIn: "5m" },
		}),
	],
	controllers: [AuthController],
	providers: [AuthService, UserServcie, Mapper, LocalStrategy, JwtStrategy],
})
export class AuthModule {}


jwt를 사용하기 위해 모듈에 등록해주는 작업이다. 여기서 사인하는데 필요한 secret 키는 .env파일에서 불러와서 사용한다. 이 값은 외부로 유출되거나, 깃 등에 올려서는 안되는 파일이다.

유의!!!

import { Get, Controller, Post, Body, Param, Patch, UseGuards, UseFilters } from "@nestjs/common";
import { UserServcie } from "../service/user.service";
import { executionResult } from "../dto/user.dto";
import { createUserDTO } from "../dto/createUser.dto";
import { AuthGuard } from "@nestjs/passport";
import { ViewAuthFilter } from "src/filter/ViewAuth.Filter";

@Controller("user")
export class UserController {
	constructor(private userService: UserServcie) {}

	@UseGuards(AuthGuard("jwt"))
	@UseFilters(ViewAuthFilter)
	@Get("/:id")
	async getUser(@Param("id") id: string): Promise<executionResult> {
		console.log(await this.userService.getUser(id));
		return await this.userService.getUser(id);
	}

	@Post()
	async createUser(@Body() user: createUserDTO): Promise<executionResult> {
		return await this.userService.createUser(user);
	}

	@UseGuards(AuthGuard("jwt"))
	@UseFilters(ViewAuthFilter)
	@Patch("/:id")
	async patchUser(@Param("id") user: createUserDTO): Promise<executionResult> {
		await this.userService.deleteUser(user.id);

		return await this.userService.createUser(user);
	}
}

실제로 jwt인증이 사용되는 코드 부분이다. jwt 토큰으로 인증된 경우에만 이하의 코드들이 작동하게 된다.

결론

jwt는 상당히 풀어져있고 느슨한 형태의 표준이라는 생각이 든다. 개발자가 필요한 정보를 payload안에 넣을 수 있고, 개발하기 편리한 형태로 발전할 수 있다는 생각도 든다.

하지만 그러한 점은 장점으로 다가오기도 하지만, 욕심이 과해지면 토큰이 무거워지고 세션과 쿠키로 사용자의 정보를 저장하는 방식보다 오히려 퍼포먼스나 사용자 편리성을 해칠 수 있겠다는 생각또한 들게 된다.

또 가장 큰 문제점으로 꼽는 것 중 하나가, 세션으로도 충분한 규모의 사이트인데 괜한 와! 새로운 기술! 이라는 환상으로 차선으로 충분한 jwt가 최선이 되는 경우도 있다는 것이다. 필요한 경우에만, 잘 활용하자