Home [soldout] 로그인 검증 기능 구현
Post
Cancel

[soldout] 로그인 검증 기능 구현

# 문제점


회원가입 및 로그인, 로그아웃 기능을 구현한 다음, 요청을 보낸 클라이언트가 현재 로그인한 상태인지 아닌지에 대한 검증을 거치고 로그인된 클라이언트의 요청일 경우 이를 처리해줄 수 있도록 해줘야 할 필요가 생겼습니다.

# 해결방안


1) AOP

이를 구현하기 위해 가장 먼저 적용해본 개념은 AOP(Aspect Oriented Programming)입니다.

AOP는 관점 지향 프로그래밍의 줄임말로 코드의 구성을 핵심 기능부가 기능으로 나눠 정의해 관심분야를 구분하는 방법을 의미입니다.

이러한 AOP를 적용하기 위해 생겨난 디자인 패턴들도 있지만 스프링에선 @Aspect 어노테이션을 통해 부가기능에 대한 선언 및 관리를 쉽게 분리해서 정의할 수 있습니다.

로그인 검증 과정의 경우 API 입장에선 핵심기능과는 거리가 멀다고 볼 수 있습니다.

또한 인가에 대한 처리는 구현된 서비스에서 제공되는 기능들 중 꽤 많은 기능들에겐 필요한 부분이기에 이를 각각의 메소드에 모두 추가해준다면 중복코드가 발생할 수 밖에 없습니다.

이러한 부가 관심 사항을 흩어진 관심사(Crosscutting Concerns) 라고 하며 하나의 모듈로써 묶어 관리하도록 해주는 것이 @Aspect 어노테이션의 역할이라고 볼 수 있습니다.

CheckSignIn 어노테이션

1
2
3
4
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckSignIn {
}

일단 API별로 로그인 검증 절차를 필요하는 API를 구분하기 위해 메소드 단위에 적용할 수 있는 어노테이션을 선언했습니다.

이후 @CheckSignIn 어노테이션이 적용된 API의 경우 로그인 검증 절차를 위한 로직이 수행되도록 구성합니다.

SignInAspect.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Aspect
@Component
@RequiredArgsConstructor
public class SignInAspect {

  private final SessionSecurityService securityService;


  @Before("@annotation(api.soldout.io.soldout.annotation.CheckSignIn)")
  public void checkSignIn() {

    RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();

    HttpSession session = ((ServletRequestAttributes) requestAttributes).getRequest().getSession();

    String sessionId = (String) session.getAttribute(SESSION_ID);

    if (!securityService.isAlreadySignInBrowser(sessionId)) {

      throw new NotSignInBrowserException("로그인한 상태가 아닙니다.");

    }

  }

스프링에서 @Aspect가 선언된 클래스의 경우, 메소드 별로 어드바이스를 선언할 수 있도록 도와줍니다.

  • @Before
    • 포인트 컷을 설정할 수 있는 어노테이션 중 하나로 매개변수값에 표현식으로 해당 메소드(어드바이스)의 적용 범위를 설정할 수 있습니다.
    • 위 코드에선 미리 정의한 @CheckStignIn 어노테이션이 붙은 메소드가 실행하기 전에 어드바이스가 먼저 실행 되도록 설정했습니다.
  • checkSignIn()
    • 어드바이스로 실행되는 메소드입니다.
    • @CheckSignIn 어노테이션이 선언되어 있는 경우에 checkSignIn() 메소드가 실행되며 로그인 검증 과정을 거치게 됩니다.
    • 로그인 검증 과정 내에선 해당 API로 넘어온 요청 객체에서 Session을 찾아 sessionId에 대한 존재 여부를 판단함으로써 로그인 검증 절차를 진행합니다.
  • RequestContextHolder
    • RequestContextHolder는 Spring에서 전역으로 Request에 대한 정보를 가져오고자 할 때 사용하는 유틸성 클래스입니다.
    • 이를 통해 Request 객체를 매개변수로 넘겨받지 않아도 Session 정보를 가져올 수 있습니다.

@Aspect 단점

원하는 로그인 검증을 @Aspect를 통해 구현할 수는 있지만 단점이 존재했습니다.

바로 로그인 검증이 필요한 모든 API에 요청이 올 때, 해당 어드바이스가 호출되게 되고 그 과정에서 Request 객체를 반복적으로 찾는 로직이 수행하게 되는 점입니다.

이를 보안하기 위해 찾게된 새로운 개념은 인터셉터(Interceptor)입니다.

2) 인터셉터(Interceptor)

Spring에서 인터셉터(Interceptor)는 클라이언트의 requestdispatcherServlet이 받아 이를 처리해줄 Hadler를 찾아 관련 로직을 구현하기 전에 Request에 대한 데이터를 가로채는 역할을 합니다.

이러한 인터셉를 활용한다면 @Aspect로 로그인 검증 기능 구현시 문제가 되었던 Request 객체에 대한 탐색 로직을 굳이 작성하지 않아도 Request에 대한 데이터를 가져와 로그인 검증을 할 수 있을 것이라 생각했습니다.

Session 인증 방식 인터셉터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
 @Slf4j
public class SessionSignInHandlerInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
      throws Exception {

    HandlerMethod handlerMethod = (HandlerMethod) handler;

    CheckSignIn checkSignIn = handlerMethod.getMethodAnnotation(CheckSignIn.class);

    if (checkSignIn == null) {

      return true;

    }

    HttpSession session = request.getSession();

    if (session == null || session.getAttribute(SESSION_ID) == null) {

      throw new NotSignInBrowserException("로그인한 상태가 아닙니다.");

    }

    return true;

  }

}

Session 인증 방식을 사용할 경우에 로그인 검증을 해주기 위한 인터셉터를 구현한 코드입니다.

  • HadlerInterceptor
    • Spring에서 인터셉터를 구현하기 위해선 HadlerInterceptor 인터페이스를 구현한 형태로 생성해야 합니다.
  • preHaldle()
    • 해당 인터셉터가 적용되는 hadler(controller)가 실행되기 전에 수행할 메소드를 의미합니다.

또한 @Aspect를 활용할 경우와는 다르게 매개변수로 HttpServletReqeust 객체를 받아올 수 있어, 특별한 메소드 없이 request에서 session을 확인할 수 있습니다.

추가) Jwt 토큰 인증 방식 인터셉터

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
 @Slf4j
public class JwtSignInHandlerInterceptor implements HandlerInterceptor {

  private String secretKey;

  public JwtSignInHandlerInterceptor(String secretKey) {

    this.secretKey = secretKey;

  }

  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
          throws Exception {

    HandlerMethod handlerMethod = (HandlerMethod) handler;

    CheckSignIn checkSignIn = handlerMethod.getMethodAnnotation(CheckSignIn.class);

    if (checkSignIn == null) {

      return true;

    }

    String token = request.getHeader(TOKEN_ID);

    if (token == null) {

      throw new NotSignInBrowserException("로그인한 상태가 아닙니다.");

    }

    checkTokenValid(token);

    return true;

  }

  private boolean checkTokenValid(String token) {

    byte[] secretKeyBytes = DatatypeConverter.parseBase64Binary(secretKey);

    Key signingKey = new SecretKeySpec(secretKeyBytes, SignatureAlgorithm.HS256.getJcaName());

    try {

      Jwts.parserBuilder()
              .setSigningKey(signingKey)
              .build()
              .parseClaimsJws(token)
              .getBody();

      return true;

    } catch (ExpiredJwtException e) {

      throw new NotValidTokenException("토큰 유효기간이 만료되었습니다.");

    } catch (JwtException e) {

      throw new NotValidTokenException("유효한 토큰이 아닙니다.");

    } catch (RuntimeException e) {

      throw new NotValidTokenException("예상치 못한 토큰 검증 에러");

    }

  }
}

같은 역할을 하는 인터셉터지만 Jwt 토큰 인증 방식으로 로그인 방식을 변경한 경우를 대비해 Jwt 토큰 방식에 대한 로그인 검증을 수행할 인터셉터도 구현했습니다.

#### SecurityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
 @Configuration
@RequiredArgsConstructor
public class SecurityConfig implements WebMvcConfigurer {

  @Value("${jwt.secretKey}")
  private String secretKey;

  /**
   * 세션 인증 방식 사용시 로그인 검증을 담당하는 인터셉터 객체.
   */

  @Bean
  public SessionSignInHandlerInterceptor sessionSignInHandlerInterceptor() {

    return new SessionSignInHandlerInterceptor();

  }

  /**
   * Jwt 인증 방식 사용시 로그인 검증을 담당하는 인터셉터 객체.
   * 객체 빈 등록 단계에서 secretKey 를 주입받는다.
  */

  @Bean
  public JwtSignInHandlerInterceptor jwtSignInHandlerInterceptor() {

    return new JwtSignInHandlerInterceptor(secretKey);

  }

  /**
   * 로그인 인증 방법에 따라 다른 인터셉터를 사용한다.
   */

  @Override
  public void addInterceptors(InterceptorRegistry registry) {

    registry.addInterceptor(sessionSignInHandlerInterceptor());
    // registry.addInterceptor(jwtSignInHandlerInterceptor());

  }
}

인터셉터를 생성한 이후에 config 클래스에서 인터셉터를 빈으로 등록해주고 추가했습니다.

config 클래스는 WebMvcConfigurer 클래스를 구현하도록 하고, addInterceptors()를 재정의해 생성한 인터셉터를 추가할 수 있습니다.

기본 인증 방식을 세션 방식으로 채택하고 있기에 JWt 토큰 방식을 위한 인터셉터는 주석처리 했습니다.

# 마치며


로그인 검증 과정에서 반복적으로 생성되는 코드나 동작하는 로직을 최소화하기 위해 Spring의 동작 원리를 더 자세히 학습할 수 있었고 성능적인 부분에서도 최적화를 할 수 있는 경험이였습니다.

# 참고자료


  • AOP : https://engkimbs.tistory.com/746
  • 인터셉터 : https://victorydntmd.tistory.com/176
This post is licensed under CC BY 4.0 by the author.

[soldout] 예외 처리에 대한 책임 할당의 고민

[soldout] MyBatis 연결을 위한 유연한 구조 변경