Win-9 / Focus

0 stars 0 forks source link

인증처리 #35

Open Win-9 opened 3 months ago

Win-9 commented 3 months ago
Win-9 commented 2 months ago

클러스터링 구성하기

현재 JWT 토큰이 아닌, 세션 방식을 이용하여 로그인을 구현하고 있다.
즉, 인증과 인가 및 만료 처리를 서버측에서 모든 부분을 구현해야 한다.

세션에 대한 정보를 얻기 위해서는 다양한 전략이 존재한다. 그중 Redis 세션 스토리지 관리를 할 것이다.

Redis 세션 스토리지

서버에 세션 저장이 아닌, 외부에 레디스 서버를 띄우고 서버에 모든 세션 정보를 저장하는 방식.

모든 WAS 서버들이 레디스에서 세션 정보를 읽어온다.

image

장점

Win-9 commented 1 month ago

Spring Redis Session 설정하기

request에서 getSession()을 하게되는 경우 세션을 가져오게된다. 그러나 이 정보를 메모리에 저장되기 때문에, 저장 된다고 한들 서버를 재시작하면 정보가 사라지게 된다.
즉, 세션 아이디를 클라이언트에 받아 와도 재시작하면 세션 정보가 모두 사라져서, 사용자 정보를 구분할 수 없다.

따라서 Spring-Session 의존성을 추가하고 약간의 설정을 거쳐야한다.

 implementation 'org.springframework.session:spring-session-data-redis

spring-session-redis의존성을 가져와 추가해준다.

redis 설정 정보를 다음과 같다.

#redis_host: 레디스 서버 주소
redis_host: host.docker.internal
redis_port: 레디스 포트
redis_password: ...

필자는 배포를 도커를 이용하여 진행하고 있기 때문에 도커에서 서버의 레디스로 바로 연결이 되도록 위와 같은 설정을 하였다.

세션 설정 정보는 다음과 같다.

spring:
  session:
    store-type: redis
    redis:
      namespace: spring:session

nameSpace는 레디스에 저장될 데이터의 key 정보이다.

본격적으로 redis 정보를 설정하자.

@Configuration
@EnableRedisRepositories
@EnableRedisHttpSession
public class RedisConfig {
    @Value("${redis_host}")
    private String redisHost;

    @Value("${redis_port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();

        // redis-cli 을 통한 데이터 조회 시 알아볼 수 있는 형태로 변환하기 위해 key-value Serializer 설정
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        return redisTemplate;
    }
}

들어온 세션 정보를 redis를 통해 들어오게 된다.

세션 정보 저장, 가져오기

클라이언트가 처음 접속하면 세션 정보가 없는 것은 자명하다.
따라서 login하는 로직을 다음과 같이 설정한다.

public void loginMember(LoginReqeust reqeust, HttpServletRequest httpServletRequest) {
        Member member = memberRepository.findByMemberId(reqeust.getId());
        if (!member.getPassword().equals(reqeust.getPassword())) {
            throw new MemberNotExistException(ErrorCode.MEMBER_NOT_EXIST);
        }

        httpServletRequest.getSession().invalidate();
        HttpSession session = httpServletRequest.getSession(true);
        session.setAttribute(SessionConst.LOGIN_USER, member.getId());
        session.setMaxInactiveInterval(1800);
    }

로그인 일치 여부와 함께, 세션 정보 설정과 세션 유지 시간을 설정해준다.

인증과 인가 처리를 사용자의 요청시 마다 계속 진행되어야 한다.
이를 컨트롤러 계층마다 모두 넣어서 if 분기처리, 에러 처리를 모두 진행한다면 컨트롤러의 역할이 가중된다.
따라서 Filter를 이용하여, 컨트롤러에 들어오기 전 클라이언트의 모든 요청에 대해서 이를 처리하도록 하였다.

@Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        String requestURI = request.getRequestURI();
        if (checkWhitelist(requestURI)) {
            HttpSession session = request.getSession();
            if (session == null) {
                responseError(response);
                return;
            }

            String sessionId = session.getId();
            if (!redisTemplate.hasKey(EncryptUtil.namespace + SessionConst.REDIS_SESSION_KEY + sessionId)) {
                responseError(response);
                return;
            }
        }
        filterChain.doFilter(request, servletResponse);
    }

사용자의 요청 requestURL를 뽑아와서, whiteList에 포함되지 않는 지를 체크한다.
whiteList란 개발자가 미리 정한 요청 URL만 허용을 하겠다는 보안적 요소이다.
비슷한 기법으로 blackList가 있지만, 보안 측면으로는 whiteList가 더 유리한 방안이다.

기본적으로 회원가입, 로그인은 세션이 필요 없기 때문에 whiteList로 등록해주었다.
또 비즈니스 로직에 따라 whiteList를 추가시켜줘, 나머지 요청에서는 세션 정보가 유효해야 api 요청이 가능하도록 하였다.

null 처리 이외의 유효정 정보는 아래와 같다.

            if (!redisTemplate.hasKey(EncryptUtil.namespace + SessionConst.REDIS_SESSION_KEY + sessionId)) {
                responseError(response);
                return;
            }

namespace는 위의 yml에서 설정한 namespace 정보이다.
@Value("${spring.session.redis.namespace}")를 용하여 가져와 사용한 상수이다.
레디스 key 값을 이용하여 value값을 가져올 수 있도록 하여 존재 여부를 파악한다.

세션 유효시간 늘리기

이번엔 세션 유효 시간을 늘릴 것이다.
하나의 필터에서 처리해도 좋지만, 구분만을 위해 사용되어질 필터이므로 필터를 하나 더 생성하여 다음과 같은 로직을 작성하였다.

@Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String requestURI = request.getRequestURI();

        if (checkWhitelist(requestURI) && request.getSession(false) != null) {
            request.getSession().setMaxInactiveInterval(1800);
        }
    }

세션 만료 시간을 다시 최대로 늘리도록 하였다.
따라서 요청을 할 때 마다 유효시간을 연장이 가능해졌다.

Argument Resolver 설정하기

거의 모든 서비스층에서는 사용자 정보를 기반으로 로직을 구성해야 한다.
그렇다면 요청에 대해서 사용자 정보를 파싱하는 로직을 모두 넣어버리면 역할 분리가 명확히 되지 않을 것이다.
따라서 Arugment Resolver를 이용하여 어노테이션 사용으로 간단히 처리할 것이다.

먼저 어노테이션을 만든다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

Login이라는 어노테이션을 설정하고 실행시, 파라미터로 존재하는 어노테이션이라는 뜻이다.

본격적으로 LoginResolver를 만든다.

@Component
@RequiredArgsConstructor
public class LoginResolver implements HandlerMethodArgumentResolver {

    private final MemberService memberService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(Login.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        HttpSession session = request.getSession();
        long memberId = (long) session.getAttribute(SessionConst.LOGIN_USER);
        return memberService.getMemberById(memberId);
    }
}

Login어노테이션의 존재 여부를 확인하고, 세션 정보를 가져와 사용자 id값을 이용하여 요청하는 사용자의 Member객체를 가져온다.

@Configuration
@RequiredArgsConstructor
public class ArgumentResolverConfig implements WebMvcConfigurer {

    private final MemberService memberService;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginResolver(memberService));
    }
}

이 ArgumentResolver를 등록해주면 비로소 request에 따른 사용자를 어노테이션을 이용하여 바로 가져올 수 있다. 즉, 역할 분리를 함으로 Controller 레이어에서 세션 정보를 추출하는 로직을 모두 제외할 수 있어졌다!

@GetMapping("/book/{bookId}")
    @Transactional(readOnly = true)
    public ResponseEntity<BookResponseDto> getBook(@Login Member member, @PathVariable String bookId) {
        ...
    }