시작한 이유 #
express-sequelize-ts에서 인증은 Passport의 local, jwt, jwt-refresh strategy를 사용합니다. 처음에는 인증 방식별 callback을 하나의 객체에 모으고, 공통 함수에서 필요한 callback을 선택하는 구조였습니다.
동작에는 문제가 없었지만 controller를 routing-controllers 기반으로 바꾼 뒤에도 인증 middleware만 함수와 callback 묶음으로 남아 있었습니다. 이 글에서는 저장소 전체가 아니라, 이 인증 middleware를 class 단위로 나눈 과정만 다룹니다.
기존 구조에서 불편했던 점 #
기존 auth.middleware.ts는 인증 방식 선택, Passport 실행, 사용자 할당과 오류 응답을 한 흐름 안에서 처리했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
type TAuthType = 'local' | 'jwt' | 'jwt-refresh';
const verifyCallback = {
local:
(req: Request, resolve: any, reject: any) =>
(err: any, user: Model<User>, info: any): void => {
if (err || !user) {
return reject(new ApiError(httpStatus.UNAUTHORIZED, info?.message || err.message));
}
req.user = user;
resolve();
},
jwt: (req: Request, resolve: any, reject: any) => (err: any, user: Model<User>, info: any) => {
// ...
}
};
const authMiddlewareFn = (req: Request, res: Response, next: NextFunction, authType: TAuthType) =>
new Promise((resolve, reject) => {
passport.authenticate(authType, { session: false }, verifyCallback[authType](req, resolve, reject))(req, res, next);
});
|
인증 방식이 추가될수록 verifyCallback 객체와 분기 로직도 함께 커집니다. 오류를 HTTP response로 바꾸는 코드까지 이 함수에 들어 있어, middleware의 역할과 오류 처리 경계도 분명하지 않았습니다.
class middleware로 나누기 #
routing-controllers에서는 ExpressMiddlewareInterface를 구현한 class를 middleware로 사용할 수 있습니다. Express middleware의 진입점은 use()로 고정됩니다.
프로젝트에서는 여기에 인증에 필요한 속성을 더한 IAuthMiddleware를 정의했습니다. 아래에서는 callback의 세부 type을 생략하고 공통 형태만 남겼습니다.
1
2
3
4
5
6
7
8
|
export type TAuthType = 'local' | 'jwt' | 'jwt-refresh';
export interface IAuthMiddleware<TUser> extends ExpressMiddlewareInterface {
authType: TAuthType;
verifyCallback: (...args: any[]) => (...args: any[]) => void;
authenticate: (...args: any[]) => any;
use(req: Request, res: Response, next: NextFunction): void;
}
|
이 interface는 모든 인증 class가 authType, Passport callback과 use()를 같은 형태로 갖도록 만드는 역할을 합니다. 실제 저장소에는 LocalAuthMiddleware와 JWTRefreshAuthMiddleware가 이 구조로 구현되어 있습니다.
아래 코드는 JWTRefreshAuthMiddleware의 핵심 흐름만 정리한 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@Service()
export class JWTRefreshAuthMiddleware implements IAuthMiddleware<Model<User>> {
public readonly authType: TAuthType = 'jwt-refresh';
public readonly verifyCallback =
(req: Request, res: Response, next: NextFunction) =>
(err: any, user: Model<User>, info: any): void => {
if (err || !user) {
return next(new HttpError(httpStatus.UNAUTHORIZED, info?.message || err.message));
}
req.user = user;
next();
};
public readonly authenticate = (callback: any) =>
passport.authenticate(this.authType, { session: false }, callback);
public use(req: Request, res: Response, next: NextFunction): void {
this.authenticate(this.verifyCallback(req, res, next))(req, res, next);
}
}
|
이제 use()는 현재 class의 authType으로 Passport를 실행합니다. 인증 실패는 response를 middleware 안에서 직접 만들지 않고 HttpError를 next()로 전달합니다. 프로젝트의 HttpErrorHandler가 이 오류를 최종 HTTP response로 변환합니다.
실패 분기에서는 반드시 return next(error)처럼 실행을 끝내야 합니다. 그렇지 않으면 같은 요청에서 next()가 다시 호출될 수 있습니다.
controller에서 사용하기 #
class로 만든 middleware는 필요한 action에 @UseBefore로 연결했습니다.
1
2
3
4
5
6
7
8
9
10
11
|
@Post('/sign-in')
@UseBefore(LocalAuthMiddleware)
public async signIn(@Body() userBody: SignInUserDto, @Res() res: Response) {
// ...
}
@Get('/refresh-token')
@UseBefore(JWTRefreshAuthMiddleware)
public async refreshToken(@Req() req: Request, @Res() res: Response) {
// ...
}
|
로그인과 token refresh가 어느 인증 방식을 사용하는지 controller에서 바로 확인할 수 있습니다. 일반 JWT 보호 route는 같은 class를 억지로 재사용하지 않고, 프로젝트의 authorizationChecker와 @Authorized() 흐름으로 분리했습니다.
정리 #
이번 리팩터링에서 가장 달라진 점은 코드 양보다 경계였습니다. 인증 방식별 Passport 실행은 각 middleware class가 맡고, controller는 적용 위치를 선언하며, 오류 response는 error middleware가 처리하도록 나눴습니다.
인증 방식마다 class가 하나씩 생기는 비용은 있습니다. 그래도 callback 객체 하나를 계속 키우는 것보다 변경할 위치를 찾기 쉬운 구조라고 판단했습니다.
다음 글: routing-controllers로 리팩터링하기: 2. Interceptor
참고 #