Ayden's journal

04. jwt를 사용한 인증/인가

인증과 인가의 차이나 jwt가 무엇인지는 여기서 다루지 않는다. 이 포스트에서는 그저 내가 어떤 방식으로 인증/인가를 구현하는지 그 워크플로우를 따라가볼 뿐이다.

 

시작하기 전에

jwt 비밀키는 노출되면 곤란해지기 때문에 환경 변수를 통해 그 값들을 가져오게 된다. 나는 dotenv 라이브러리를 사용해 환경 변수의 값에 접근하기로 했다. 또한, jwt 토큰을 만들고 검증하는 데에는 jsonwebtoken 라이브러리를 사용했다.

 

토큰 생성 함수 구현

jsonwebtoken가 제공하는 sign 함수를 사용해 토큰을 만들어줄 수 있다. 액세스 토큰과 리프레시 토큰만 생성할 예정이라 아래와 같이 하나의 함수로 통일하여 구현하였다.

export function generateToken(
  nickname: string,
  email: string,
  type: "access" | "refresh"
) {
  const hour = Date.now() + 1000 * 60 * 60
  const month = hour * 24 * 30
  const exp = type === "access" ? hour : month
  
  const secretKey = process.env[`JWT_SECRET${type === 'access' ? '' : '_REFRESH'}`];

  const token = sign(
    { nickname, email, exp },
    secretKey
  );

  return token;
}

 

회원가입

새로운 유저가 가입하면 이메일과 비밀번호, 그리고 닉네임을 받아서 저장해야 한다. 그런데 이 비밀번호를 아무 처리 없이 데이터베이스에 넣어두면 (나중에 데이터베이스가 털리기라도 하면) 큰 문제가 생기는 것이다. 따라서 비밀번호를 특정한 방법에 따라 처리해주어야 하는데 이것이 바로 hash이다.

물론 hash가 완벽한 것은 아니고, 'A를 넣었을 때 B가 나온다'는 hash의 특징을 이용해 무차별 대입으로 비밀번호를 뚫어내는 경우가 종종 있다. 따라서 추가적인 salt가 필요한데, bcryptjs 라이브러리를 사용하면 hash와 salt를 쉽게 사용할 수 있다.

// 들어온 값 확인
const { email, password, nickname } = create(req.body, SignUp);

// salt + hash
const salt = await genSalt();
const hashedPassword = await hash(password, salt);

// DB에는 해시된 값을 넣어둔다
await prisma.user.create({
    data: { email, password: hashedPassword, nickname },
});

 

로그인

반대로 로그인은 클라이언트로부터 비밀번호를 받아서 DB에 저장된 '해시된 비밀번호'와 일치하는지를 비교해야 한다. 이를 위해서는 bcryptjs 라이브러리에서 제공하는 compare 함수를 사용하면 된다.

// 들어온 값 확인
const { email, password } = create(req.body, SignIn);

// 로그인하려는 유저를 DB에서 찾아옴
const user = await prisma.user.findUnique({ where: { email } });

// 해당 유저가 존재하는지, 그리고
// 비밀번호와 해시된 비밀번호가 일치하는지를 확인한다
if (user && (await compare(password, user.password))) {}

만약 비밀번호가 일치한다면 앞서 만들었던 토큰 생성 함수를 사용해 클라이언트에게 액세스 토큰과 리프레시 토큰을 보내주면 된다.

 

인증

인증은 접근한 유저가 누구인지를 확인하는 과정이다. 나는 jwt를 요청 헤더에 넣어서 처리하는 방식을 선호한다. 아무래도 프론트엔드가 서버사이드 랜더링이 될 수록 쿠키에 심어두는 것보다 헤더에 넣는 편이 더 고려할 게 적어진다는 느낌을 받기 때문이다.

나는 인증을 위해 추가적인 함수 두 개를 더 만들어서 사용한다. 하나는 decode 함수이고, 다른 하나는 varify 함수이다. decode 함수는 비밀키를 사용해 jwt 토큰을 생성할 때 넣었던 값을 꺼내오며, varifiy 함수는 특정 토큰이 만료되었는지 여부를 boolean 값으로 리턴한다.

type Decoded = {
	email: string;
	nickname: string;
	exp: number;
};

export function verifyToken(decoded: Decoded): boolean {
	// exp가 현재 시간 이전인 경우(만료된 토큰)
	if (decoded.exp < Date.now()) return false;
	else return true;
}

export function decodeToken(token: string, secret: string) {
	// 임의의 문자열로 구성된 토큰을 Payload로 되돌림
	try {
		const decoded = verify(token, secret) as Decoded;
		return decoded;
	} catch (err) {
		throw new Error('Failed to decode token');
	}
}

이러한 함수를 기반으로 나는 ExpressJs의 미들웨어를 사용해 각 요청의 인증 여부를 확인하고 있다. 만약 인증된 유저라면 req.cookies에 이메일과 닉네임을 넣어주고, 인증되지 않은 유저라면 아무 일도 일어나지 않는다.

// app.ts
app.use(authValidate)

// authValidate.ts
export function authValidate(req: Request, res: Response, next: NextFunction) {
    // 헤더에 액세스 토큰이 있는지 확인
    // 문자열 bearer가 앞에 붙어있을 거라 split으로 분리해서 토큰만 가져오기
	const accessToken = req.headers['authorization']?.split(' ')[1];

    // 토큰이 있으면 아래의 로직 수행
	if (accessToken) {
		const decoded = decodeToken(accessToken, process.env.JWT_SECRET!);
		const isOK = verifyToken(decoded);

        // 엑세스 토큰이 만료되지 않았다면 아래의 로직 수행
		if (isOK) {
			req.cookies = {
				email: decoded.email,
				nickname: decoded.nickname,
			};
		} else {
            // 토큰이 만료되었다면 예외 던지기
			throw new Error(`token expired`);
		}
	}

    // 토큰 유무와 별개로 이후 코드 진행
	next();
}

 

인가

인증이 유저가 누구인지 확인하는 과정이라면, 인가는 그 유저에게 특정 권한이 있는지를 확인하는 과정이다. 만약 로그인한 사람만 확인이 가능한 게시물이 있다면, 로그인 여부를 확인하는 것이 곧 인가 과정이라고 할 수 있다.

인가를 편하게 하기 위해서 나는 인증 과정에서 req.cookies에 이메일과 닉네임을 심어두었다. 만약 유저들에게 레벨이나 그 밖에 인가 과정에 필요한 값들이 존재한다면 이것도 마찬가지로 심어둘 수 있겠다. 나는 컨트롤러 수준에서 인가 여부를 확인하고 있으며, 인가에 실패한 경우에는 예외를 던지도록 조치해두었다.

// authChecker.ts
export const authChecker_Email = (email: string | null) => {
	if (!email) {
		throw new CustomError('CE_Unauthorized', '로그인이 필요한 서비스입니다');
	}
};

export const authChecker_Level = (level: number | null) => {
	if (level < 4) {
		throw new CustomError('CE_Level_Not_Enough', '5레벨 이상만 접근할 수 있습니다');
	}
};

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기