관리 메뉴

bright jazz music

blog32 : 데이터베이스를 통한 토큰 검증 본문

Projects/blog

blog32 : 데이터베이스를 통한 토큰 검증

bright jazz music 2023. 2. 16. 07:58

이 포스팅에서는 UUID를 통해서 세션 토큰을 발급받고 인증을 요청하는 부분을 진행한다.

 

로그인 - 토큰 발급 - DB를 통해서 인증을 확인한 뒤 - 인증 관련 페이지로 라우팅

 

- 로그인하면 응답에 토큰이 날아옴.

- 이 토큰을 향후 게시글 쓰기 같은 인증이 필요한 페이지에 진입하기 위해,

- header에 Authorization값으로 넣어준다.

- 인증이 필요한 해당 요청했을 때 인증이 되고 해당 회원의 primary id를 출력해 준다.

- 아마 숫자 1, 또는 2가 결괏값으로 나올 것이다.

 

0.현황

 

발급 받은 세션을 보내면 서버에서 검증하는 코드 작성.

 

현재는 WebMvcConfigurer에 등록한 AuthResolver(implements HandlerMethodArgumentResolver)를 사용하여 인증 처리를 하고 있다. 이 클래스는 컨트롤러 메서드 파라미터 타입으로 특정 객체(ex. UserSession)가 존재하는 경우 무조건 인증을 거치게 한다.

//AuthResolver.java

package com.endofma.blog.config;

import com.endofma.blog.config.data.UserSession;
import com.endofma.blog.exception.Unauthorized;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

public class AuthResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        //컨트롤러에서 사용할 어노테이션이나 DTO가 사용자가 사용하려는 것이 맞는지, 지원하는지 체크한다.
        return parameter.getParameterType().equals(UserSession.class); //UserSession 클래스를 사용하는 것이 맞는지 확인. 맞으면 컨트롤러에 값 할당
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        //실제로 해당 DTO에 값을 세팅해준다.

        //인증 작업 추가
//        String accessToken = webRequest.getParameter("accessToken");
        String accessToken = webRequest.getHeader("Authorization"); //헤더의 값으로 확인해준다.
        if (accessToken == null || accessToken.equals("")) {
            throw new Unauthorized();
        }

        //데이터베이스 사용자 확인작업
        //.. 추후 추가
        return new UserSession(1L);

    }
}

 

예를 들면 PostController의 @GetMapping("/foo")와 @GetMapping("/bar")는 파라미터로 UserSession 객체를 가지고 있다. 따라서 해당 경로의 API를 사용하려면 반드시 ArgumentResolver를 통한 인증을 거쳐야 한다.

 

@GetMapping("/foo")
public Long foo(UserSession userSession){
    log.info(">>> {}", userSession.id);
    return userSession.id;
}

@GetMapping("/bar")
public String bar(UserSession userSession){
    log.info(">>> {}", userSession.id);
    return "인증이 필요한 페이지";
}

 

지난 포스팅에서 로그인 시 토큰을 발급 받도록 하였으니 이번 포스팅에서는 요청을 받아 보도록 하자.

 

1. 테스트 코드 작성

//AuthControllerTest.java
    @Test
    @DisplayName("로그인 후 권한이 필요한 페이지에 접속한다. /foo")
    void test4() throws Exception {
        //given
//        //users 테이블에 사용자 저장
//        User user = userRepository.save(User.builder()
//                .name("catnails")
//                .email("catnails@gmail.com")
//                .password("1234")
//                .build());
//
//        Login login = Login.builder()
//                .email("catnails@gmail.com")
//                .password("1234")
//                .build();
//
//        String json = objectMapper.writeValueAsString(login);

        //expected
        mockMvc.perform(get("/foo")
                .contentType(APPLICATION_JSON))
                .andExpect(status().isUnauthorized())
                .andDo(print());
    }

토큰 발급 받는 부분을 주석처리 하였다.

 

결과

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /foo
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8"]
             Body = null
    Session Attrs = {}

Handler:
             Type = com.endofma.blog.controller.PostController
           Method = com.endofma.blog.controller.PostController#foo(UserSession)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = com.endofma.blog.exception.Unauthorized

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 401
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":"401","message":"인증이 필요합니다.","validation":{}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
2023-02-16 08:32:00.312  INFO 11688 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-02-16 08:32:00.314  INFO 11688 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
2023-02-16 08:32:00.317  WARN 11688 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 90121, SQLState: 90121
2023-02-16 08:32:00.318 ERROR 11688 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-214]
2023-02-16 08:32:00.318  WARN 11688 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 90121, SQLState: 90121
2023-02-16 08:32:00.318 ERROR 11688 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-214]
2023-02-16 08:32:00.319  WARN 11688 --- [ionShutdownHook] o.s.b.f.support.DisposableBeanAdapter    : Invocation of destroy method failed on bean with name 'entityManagerFactory': org.hibernate.exception.JDBCConnectionException: Unable to release JDBC Connection used for DDL execution
2023-02-16 08:32:00.319  INFO 11688 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2023-02-16 08:32:00.323  INFO 11688 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
BUILD SUCCESSFUL in 13s
5 actionable tasks: 2 executed, 3 up-to-date
AM 8:32:00: Execution finished ':test --tests "com.endofma.blog.controller.AuthControllerTest.test4"'.

로그인 하지 않아 토큰을 발급받지 못해 접근하지 못하는 결과를 확인하였다(성공)

만약 토큰을 발급받는 테스트를 하고 싶다면 AuthService의 signin() 메서드를 테스트 코드 내에 삽입할 수도 있다. 그러나 이 경우 signin()의 내용이 변경될 경우 sidepack이 생겨날 수 있다.

 

따라서 서비스를 거치지 않고 테스트 내에서 직접 사용자 엔티티에 세션 값까지 넣어서 repository에 save하는 네이티브 한 코드를 만들어 보자.

 

 

 

 

1. AuthResolver 수정

DB에서 accessToken 값을 조회하여 있는 경우 Session 객체에 담고, 없으면 Unauthorized 에러 발생시키는 코드 추가.

존재하는 경우 session.getUser().getId() 해준다. DB에서의  Long id값을 가져오는 것이다.

//AuthResolver.java

package com.endofma.blog.config;

import com.endofma.blog.config.data.UserSession;
import com.endofma.blog.domain.Session;
import com.endofma.blog.exception.Unauthorized;
import com.endofma.blog.repository.SessionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import java.util.Optional;

@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {

    //주입
    private final SessionRepository sessionRepository;
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        //컨트롤러에서 사용할 어노테이션이나 DTO가 사용자가 사용하려는 것이 맞는지, 지원하는지 체크한다.
        return parameter.getParameterType().equals(UserSession.class); //UserSession 클래스를 사용하는 것이 맞는지 확인. 맞으면 컨트롤러에 값 할당
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        //실제로 해당 DTO에 값을 세팅해준다.

        //인증 작업 추가
//        String accessToken = webRequest.getParameter("accessToken");
        String accessToken = webRequest.getHeader("Authorization"); //헤더의 값으로 확인해준다.
        if (accessToken == null || accessToken.equals("")) {
            throw new Unauthorized();
        }

        //데이터베이스 사용자 확인작업
//        Optional<Session> session = sessionRepository.findByAccessToken(accessToken);
        Session session = sessionRepository.findByAccessToken(accessToken)
                .orElseThrow(Unauthorized::new);
    

//        return new UserSession(1L);
        return new UserSession(session.getUser().getId()); //컨트롤러로 넘겨준다.

    }
}

 

값을 컨트롤러에 반환한다.

//postController.java
@GetMapping("/foo")
public Long foo(UserSession userSession){
    log.info(">>> {}", userSession.id);
    return userSession.id;
}

 

2. SessionRepository 수정

원래는 아무런 메소드도 보유하고 있지 않았다. findByAccessToken 메서드를 추가한다.

package com.endofma.blog.repository;

import com.endofma.blog.domain.Session;
import org.springframework.data.repository.CrudRepository;

import java.util.Optional;

public interface SessionRepository extends CrudRepository<Session, Long> {
    
    //추가
    Optional<Session> findByAccessToken(String accessToken);
}

 

3. WebMvcConfig 수정

원래 파라미터가 없는 생성자만 등록한 상태였다. @RequiredArgs와 SessionRepository를 추가하고 파라미터를 넣어준다.

//WebMvcConfig.java

package com.endofma.blog.config;

import com.endofma.blog.repository.SessionRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

    //인터셉터 추가
//    @Override
//    public void addInterceptors(InterceptorRegistry registry) {
//        registry.addInterceptor(new AuthInterceptor()) //생성한 인터셉터 등록
////                .addPathPatterns() //인증 적용할 패턴
//                .excludePathPatterns("/error", "/favicon.ico"); //인증 제외할 패턴
//    }

    private final SessionRepository sessionRepository; //blog32: 추가

    @Override //인터셉터를 추가해줬듯이 아규먼트 리졸버를 추가해준다.
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthResolver(sessionRepository));
    }

}

 

4. 테스트 코드 수정

//AuthControllerTest.java
    @Test
    @DisplayName("로그인 후 권한이 필요한 페이지에 접속한다. /foo")
    void test4() throws Exception {
        //given
        //User 엔티티 생성
        User user = User.builder()
                .name("catnails")
                .email("catnails@gmail.com")
                .password("1234")
                .build();

        //User 엔티티에 세션 추가
        //accessToken값이 uuid가 지정된 세션 객체가 반환된다. User객체는 이렇게 반환된 세션을 세션 리스트에 저장하여 보유한다.
        Session session = user.addSession();

        //이렇게 세션까지 보유하게 된 user를 저장한다.
        userRepository.save(user);

        //expected
        mockMvc.perform(get("/foo")
                        .header("Authorization", session.getAccessToken())
                .contentType(APPLICATION_JSON))
//                .andExpect(status().isUnauthorized())
                .andExpect(status().isOk())
                .andDo(print());
    }

 

성공 : Body에 id인 2가 반환된다.

.   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.6)

2023-02-16 12:17:26.456  INFO 22780 --- [    Test worker] c.e.blog.controller.AuthControllerTest   : Starting AuthControllerTest using Java 11.0.12 on DESKTOP-Q7HBM41 with PID 22780 (started by user in D:\personal\blog)
2023-02-16 12:17:26.459  INFO 22780 --- [    Test worker] c.e.blog.controller.AuthControllerTest   : No active profile set, falling back to 1 default profile: "default"
2023-02-16 12:17:27.587  INFO 22780 --- [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2023-02-16 12:17:27.701  INFO 22780 --- [    Test worker] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 100 ms. Found 3 JPA repository interfaces.
2023-02-16 12:17:28.425  INFO 22780 --- [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2023-02-16 12:17:28.766  INFO 22780 --- [    Test worker] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2023-02-16 12:17:28.908  INFO 22780 --- [    Test worker] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]
2023-02-16 12:17:28.986  INFO 22780 --- [    Test worker] org.hibernate.Version                    : HHH000412: Hibernate ORM core version 5.6.14.Final
2023-02-16 12:17:29.292  INFO 22780 --- [    Test worker] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {5.1.2.Final}
2023-02-16 12:17:29.525  INFO 22780 --- [    Test worker] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
2023-02-16 12:17:30.699  INFO 22780 --- [    Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
2023-02-16 12:17:30.710  INFO 22780 --- [    Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2023-02-16 12:17:31.742  WARN 22780 --- [    Test worker] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2023-02-16 12:17:32.186  INFO 22780 --- [    Test worker] o.s.b.a.h2.H2ConsoleAutoConfiguration    : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:blog'
2023-02-16 12:17:32.667  INFO 22780 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2023-02-16 12:17:32.668  INFO 22780 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2023-02-16 12:17:32.670  INFO 22780 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 2 ms
2023-02-16 12:17:32.697  INFO 22780 --- [    Test worker] c.e.blog.controller.AuthControllerTest   : Started AuthControllerTest in 6.779 seconds (JVM running for 9.963)
2023-02-16 12:17:33.298  INFO 22780 --- [    Test worker] c.e.blog.controller.PostController       : >>> 2

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /foo
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Authorization:"4950b232-7c95-48f8-8bea-8d931102feb6"]
             Body = null
    Session Attrs = {}

Handler:
             Type = com.endofma.blog.controller.PostController
           Method = com.endofma.blog.controller.PostController#foo(UserSession)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = 2
    Forwarded URL = null
   Redirected URL = null
          Cookies = []
2023-02-16 12:17:33.392  INFO 22780 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2023-02-16 12:17:33.393  INFO 22780 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
2023-02-16 12:17:33.396  WARN 22780 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 90121, SQLState: 90121
2023-02-16 12:17:33.396 ERROR 22780 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-214]
2023-02-16 12:17:33.397  WARN 22780 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 90121, SQLState: 90121
2023-02-16 12:17:33.397 ERROR 22780 --- [ionShutdownHook] o.h.engine.jdbc.spi.SqlExceptionHelper   : Database is already closed (to disable automatic closing at VM shutdown, add ";DB_CLOSE_ON_EXIT=FALSE" to the db URL) [90121-214]
2023-02-16 12:17:33.397  WARN 22780 --- [ionShutdownHook] o.s.b.f.support.DisposableBeanAdapter    : Invocation of destroy method failed on bean with name 'entityManagerFactory': org.hibernate.exception.JDBCConnectionException: Unable to release JDBC Connection used for DDL execution
2023-02-16 12:17:33.397  INFO 22780 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2023-02-16 12:17:33.403  INFO 22780 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
BUILD SUCCESSFUL in 14s
5 actionable tasks: 3 executed, 2 up-to-date
PM 12:17:33: Execution finished ':test --tests "com.endofma.blog.controller.AuthControllerTest.test4"'.

 

5. 테스트 코드 추가

그러나 정말로 검증된 토큰만 접근이 가능한지 확인하기 위해 테스트 코드를 하나 더 만든다.

즉, 발급되지 않은 토큰을 보내서 인증 절차를 통과하는지 확인한다.

  //AuthControllerTest.java
    @Test
    @DisplayName("로그인 후 검증되지 않은 세션값으로 권한이 필요한 페이지에 접속할 수 없다.")
    void test5() throws Exception {
        //given
        //User 엔티티 생성
        User user = User.builder()
                .name("catnails")
                .email("catnails@gmail.com")
                .password("1234")
                .build();

        //User 엔티티에 세션 추가
        //accessToken값이 uuid가 지정된 세션 객체가 반환된다. User객체는 이렇게 반환된 세션을 세션 리스트에 저장하여 보유한다.
        Session session = user.addSession();

        //이렇게 세션까지 보유하게 된 user를 저장한다.
        userRepository.save(user);

        //expected
        mockMvc.perform(get("/foo")
                        .header("Authorization", session.getAccessToken() + "-other") //검증되지 않은 세션값을 만들기 위해 스트링을 추가한다.
                        .contentType(APPLICATION_JSON))
//                .andExpect(status().isUnauthorized())
                .andExpect(status().isOk())
                .andDo(print());
    }

 

 

정말로 오류가 나는지 확인하기 위해 isOk()로 test5 수행

정말 오류가 났다.

isOk를 주석처리하고 isUnauthorized의 주석을 해제하였다.

                .andExpect(status().isUnauthorized())
//                .andExpect(status().isOk())

테스트 통과

 

추후에는 /post 와 같은 핸들러에도 UserSession 객체를 파라미터로 넣어서 인가된 사용자인지를 확인해야 할 것이다. write() 메소드에 사용자의 request뿐만 아니라 userSession.id 처럼 추가 정보 역시 넘겨줄 수 있도록 만들어야 할 것이다.

 

Comments