본문 바로가기
프로젝트/AI명종원

Session 기반 로그인

by HWK 2024. 5. 8.

회의 결과 이번 프로젝트에서는 Spring Security를 쓰지 않기로 했다.
이유는 로그인이 주된 기능이 아니기 때문에, 로그인 기능의 비중을 줄이기 위해서이다.

 

지금까지 써보지 않은 Session 식별자를 쿠키에 저장하는 방식을 쓸 것이다.

 

Session(세션)과 Token(토큰)의 차이는?

우선 HTTP의 프로토콜 상태에 알아보자. HTTP 는 stateless 한 특성 때문에 각 통신의 상태는 저장되지 않는다. 하지만 서비스에서는 어떤 유저가 기능을 사용하는지 특정할 수 있어야하는데 이를 위

velog.io

 

Session 기반 로그인을 위해 추가한 클래스들은 아래와 같다.

1. WebConfig

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");

        registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")//아래 경로 빼고 모든 경로 체크
                .excludePathPatterns("/", "/api/signup", "/api/login", "/api/logout", "/api/user-info/**",
                        // 실험용으로 빼놓는 자리
                        "/css/**", "/*.ico", "/error");
    }
}

위 클래스는 해당 프로젝트의 전반적인 설정을 담당한다.

  • addArgumetResolvers(): HandlerMethodArgumentResolver를 통해 로그인한 사용자 정보를 매개변수로 받는 기능을 등록, 프로젝트 전체에서 일관된 방식으로 로그인 정보를 처리할 수 있도록 해줌.
  • addInterceptors(): 요청을 가로채는 인터셉터를 추가해줌. LogInterceptor은 모든 요청에 대해 로깅을 처리,
    LoginCheckInterceptor은 모든 요청에 대해 로그인을 체크해줌. 둘 다 excludePathPatterns에 포함된 경로는 체크하지 않는다.

2. LoginMemberArgumentResolver

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");

        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = User.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        log.info("resolveArgument 실행");

        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}

위 클래스는 supportsParameter메서드를 통해 매개변수가 특정 조건을 만족하는지 확인, resolveArgument를 통해 실제 매개변수를 해결해준다.

  • supportsParameter() : @Login 어노테이션이 붙은 매개변수이면서, 해당 매개변수의 타입이 User 클래스나 User 클래스를 상속받은 클래스인지를 확인한다.
  • resolveArgument 메서드: HttpServletRequest를 통해 현재 요청에 대한 정보를 가져온다.
    그다음, 세션에서 로그인한 사용자 정보(SessionConst.LOGIN_MEMBER)를 가져온다.
    만약 세션이 없거나 로그인한 사용자 정보가 없다면 null을 반환한다.

이렇게 함으로써, 컨트롤러 메서드에서 @Login 어노테이션이 붙은 매개변수로 사용하면 로그인한 사용자 정보를 간편하게 받아올 수 있다.

 

3. @Login

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

위에서 말한 매개변수를 나타냄, 즉 로그인한 사용자를 뜻함

 

4. LogInterceptor

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    public static final String LOG_ID = "logId";

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

        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        request.setAttribute(LOG_ID, uuid);

        //@RequestMapping: HandlerMethod
        //정적 리소스: ResourceHttpRequestHandler
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;//호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
        }

        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        String logId = (String) request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}][{}]", logId, requestURI, handler);
        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }

    }
}

복잡해 보이지만 단지 로그를 남기는 클래스이다. 로깅 처리를 위해 HandlerInterceptor를 구현한다.

preHandle(), postHandle(), afterCompletion()모두 요청을 처리하기 전에 로그를 남기는 역할을 한다.

 

5. LoginCheckInterceptor

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {

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

        String requestURI = request.getRequestURI();

        log.info("인증 체크 인터셉터 실행 {}", requestURI);

        HttpSession session = request.getSession();

        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 요청");
            //로그인으로 redirect
            response.sendRedirect("/api/login?redirectURL=" + requestURI);
            return false;
        }

        return true;
    }
}

HandlerInterceptor를 구현하여 로그인을 체크한다.

  • preHandle(): 요청이 들어오면 로그인 상태를 체크하고, 로그인되지 않은 경우 로그인 페이지로 리다이렉트한다.

6. SessionConst

public class SessionConst {
    public static final String LOGIN_MEMBER = "loginMember";
}

세션에서 사용되는 상수를 정의한다. 여기서는 로그인한 사용자를 나타내는 상수를 정의한다.

그 결과 여러 클래스에서 일관된 방식으로 세션을 사용할 수 있다.

 

7. LoginController

@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping(value = "/api")
public class LoginController {
    private final LoginService loginService;

    /*
    BindingResult bindingResult: 유효성 검사 후 발생한 바인딩 오류를 포함하는 객체.
    @RequestParam(defaultValue = "/") String redirectURL: 로그인 후 리다이렉트할 URL을 받음. 이 값이 없을 경우 기본적으로 "/"로 리다이렉트.
    HttpServletRequest request: 현재 HTTP 요청에 대한 정보를 담고 있는 객체. 세션을 생성하거나 사용자 정보를 저장하기 위해 사용됨.
    @RequestBody @Valid LoginRequestDto loginRequestDto: 프론트에서 보내줘야함.
     */
    @PostMapping("/login")
    public String login(@RequestBody @Valid LoginRequestDto loginRequestDto, BindingResult bindingResult,
                        HttpServletRequest request) {

        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        User loginUser = loginService.login(loginRequestDto.getUserName(), loginRequestDto.getPassword());

        if (loginUser == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }

        //로그인 성공 처리
        //세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
        HttpSession session = request.getSession();
        //세션에 로그인 회원 정보 보관
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginUser);

        return "redirect:/";

    }

    @PostMapping("/logout")
    public String logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        return "redirect:/";
    }
}

위에서 만든 세션 기능을 통해 만든 로그인, 로그아웃 컨트롤러 코드이다.

  • login(): 서비스코드를 통과해 만일 유효한 로그인 요청이라면, 세션에 로그인 정보가 보관된다.
  • logout(): 세션이 이미 존재하는 경우 세션을 무효화하며 해당 세션 정보를 삭제한다.

이후 로그인한 정보를 사용하고 싶다면, HttpSession session 을 가져와서 이용하면 된다.

예시로 컨트롤러 메서드 하나를 보이면 아래와 같다.

@GetMapping("/recipes")
public List<GetAllRecipesResponseDto> getRecipes(HttpSession session) {
    return recipeService.getAllRecipes(session);
}

아래는 세션에서 사용자 정보를 가져오는 방법이다.

private User getUserFromSession(HttpSession session) {
    // 세션에서 사용자 정보 가져오기
    User loginUser = (User) session.getAttribute(SessionConst.LOGIN_MEMBER);

    // 만약 세션에 사용자 정보가 없다면 로그인하지 않은 상태이므로 적절히 처리
    if (loginUser == null) {
        throw new IllegalArgumentException("로그인이 필요합니다.");
    }

    return loginUser;
}

 

이렇게 로그인 기능이 완성되었다. 확실히 Security에 비해 보안 취약점이 있지만, 복잡성은 감소한 방식이다.