반응형

구현 배경

  • Spring Boot로 Jwt Login 구현 시 항상 API로만 구현했었음
    • 로그인에 성공하면 Jwt Token을 발행해주는데, 이 Token을 클라이언트 측에서 가지고 있다가 다른 요청을 할 때 Header의 "Authorization"에 "Bearer " + Jwt Token 을 넣어서 전송해야 함
    • 이 작업은 Front-End에서는 쉽게 구현이 가능하고 Front-End에서 하는 작업이지만 Front-End 없이 구현할 방법을 생각해 봄 => Jwt Token을 쿠키를 사용해 전달하여 구현 성공

구현 방법

  • 유저가 로그인 성공 시 쿠키에 "jwtToken"라는 Key 값에 발급받은 JwtToken값을 넣어서 전송
  • 유저는 다음 요청 시 해당 쿠키를 같이 전송
  • 서버 측에서는 쿠키에 "jwtToken" 값이 없으면 로그인 하지 않은 것으로 간주
  • 쿠키에 "jwtToken" 값이 있으면 추출 후 앞에 "Bearer "을 붙여주고 이 값으로 Jwt 인증, 인가 진행
  • 이 방법은 세션을 사용하지 않기 때문에 Token 로그인 방식의 장점인 서버 측의 부하를 줄일 수 있고, 쿠키에 유저 정보가 아닌 Token을 넣어줌으로써 쿠키의 조작을 할 수 없다는 장점이 있음

구현

JwtTokenFilter 수정

  • 기존의 JwtTokenFilter에서 Jwt Token을 추출하는 부분만 수정하면 됨
  • 기존의 코드에서는 아래와 같이 Request의 Header에서 "Authorization" 값을 추출하였고 만약 null이라면 로그인 하지 않은 것으로 처리했었음
// 기존 코드
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

// Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음 => 로그인 하지 않음
if(authorizationHeader == null) {
    filterChain.doFilter(request, response);
    return;
}
  • 여기서 Request의 Header에서 "Authorization" 값이 null인 경우에 바로 로그인 하지 않은 것으로 처리하지 않고, Cookie의 "jwtToken"값이 있는지 한 번 더 확인 후 만약 값이 있으면 이 값을 추출해서 인증 진행, 없으면 로그인 하지 않은 것으로 처리하는 방식으로 수정
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

  // Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음
  if(authorizationHeader == null) {

      // 화면 로그인 시 쿠키의 "jwtToken"로 Jwt Token을 전송
      // 쿠키에도 Jwt Token이 없다면 로그인 하지 않은 것으로 간주
      if(request.getCookies() == null) {
          filterChain.doFilter(request, response);
          return;
      }

      // 쿠키에서 "jwtToken"을 Key로 가진 쿠키를 찾아서 가져오고 없으면 null return
      Cookie jwtTokenCookie = Arrays.stream(request.getCookies())
              .filter(cookie -> cookie.getName().equals("jwtToken"))
              .findFirst()
              .orElse(null);

      if(jwtTokenCookie == null) {
          filterChain.doFilter(request, response);
          return;
      }

      // 쿠키 Jwt Token이 있다면 이 토큰으로 인증, 인가 진행
      String jwtToken = jwtTokenCookie.getValue();
      authorizationHeader = "Bearer " + jwtToken;
  }

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserService userService;
    private static String secretKey = "my-secret-key-123123";

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests()
                .antMatchers("/jwt-login/info").authenticated()
                .antMatchers("/jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name())
                .anyRequest().permitAll()
                .and()
                .exceptionHandling()
                // 인증 실패
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException, IOException {
                        // jwt-api-login(api)에서 인증에 실패하면 error을 그대로 출력
                        // jwt-login(화면)에서 인증에 실패하면 에러 페이지로 redirect
                        if (!request.getRequestURI().contains("api")) {
                            response.sendRedirect("/jwt-login/authentication-fail");
                        }
                    }
                })
                // 인가 실패
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        if (!request.getRequestURI().contains("api")) {
                            response.sendRedirect("/jwt-login/authorization-fail");
                        }
                    }
                })
                .and().build();
    }
}

JwtTokenUtil

public class JwtTokenUtil {

    // JWT Token 발급
    public static String createToken(String loginId, String key, long expireTimeMs) {
        // Claim = Jwt Token에 들어갈 정보
        // Claim에 loginId를 넣어 줌으로써 나중에 loginId를 꺼낼 수 있음
        Claims claims = Jwts.claims();
        claims.put("loginId", loginId);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }

    // Claims에서 loginId 꺼내기
    public static String getLoginId(String token, String secretKey) {
        return extractClaims(token, secretKey).get("loginId").toString();
    }

    // 밝급된 Token이 만료 시간이 지났는지 체크
    public static boolean isExpired(String token, String secretKey) {
        Date expiredDate = extractClaims(token, secretKey).getExpiration();
        // Token의 만료 날짜가 지금보다 이전인지 check
        return expiredDate.before(new Date());
    }

    // SecretKey를 사용해 Token Parsing
    private static Claims extractClaims(String token, String secretKey) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    }
}

JwtLoginController 구현

  • Entity, DTO, Repository, Service, 화면은 미리 만들어 놓음
    • [Spring Boot] 로그인 구현 방법 정리 참고
    • 공통 화면 사용을 위해 모든 요청에 아래 코드 추가
      • model.addAttribute("loginType", "jwt-login");
      • model.addAttribute("pageName", "Jwt Token 화면 로그인");
  • 인증, 인가는 SecurityFilterChain에서 해주기 때문에 로그인 성공 시 쿠키를 생성하고 클라이언트에 전송하는 부분과 로그아웃 시 쿠키를 파기하는 부분만 보면 될 것 같음

로그인 성공 시 쿠키 생성 및 전송 코드

// 발급한 Jwt Token을 Cookie를 통해 전송
Cookie cookie = new Cookie("jwtToken", jwtToken);
cookie.setMaxAge(60 * 60);  // 쿠키 유효 시간 : 1시간
response.addCookie(cookie);

로그아웃 시 쿠키 파기 코드

// 쿠키 파기
Cookie cookie = new Cookie("jwtToken", null);
cookie.setMaxAge(0);
response.addCookie(cookie);

JwtLoginController 전체 코드

@Controller
@RequiredArgsConstructor
@RequestMapping("/jwt-login")
public class JwtLoginController {

    private final UserService userService;

    @GetMapping(value = {"", "/"})
    public String home(Model model, Authentication auth) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        if(auth != null) {
            User loginUser = userService.getLoginUserByLoginId(auth.getName());
            if (loginUser != null) {
                model.addAttribute("nickname", loginUser.getNickname());
            }
        }

        return "home";
    }

    @GetMapping("/join")
    public String joinPage(Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        model.addAttribute("joinRequest", new JoinRequest());
        return "join";
    }

    @PostMapping("/join")
    public String join(@Valid @ModelAttribute JoinRequest joinRequest, BindingResult bindingResult, Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        // loginId 중복 체크
        if(userService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
            bindingResult.addError(new FieldError("joinRequest", "loginId", "로그인 아이디가 중복됩니다."));
        }
        // 닉네임 중복 체크
        if(userService.checkNicknameDuplicate(joinRequest.getNickname())) {
            bindingResult.addError(new FieldError("joinRequest", "nickname", "닉네임이 중복됩니다."));
        }
        // password와 passwordCheck가 같은지 체크
        if(!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
            bindingResult.addError(new FieldError("joinRequest", "passwordCheck", "바밀번호가 일치하지 않습니다."));
        }

        if(bindingResult.hasErrors()) {
            return "join";
        }

        userService.join2(joinRequest);
        return "redirect:/jwt-login";
    }

    @GetMapping("/login")
    public String loginPage(Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        model.addAttribute("loginRequest", new LoginRequest());
        return "login";
    }

    @PostMapping("/login")
    public String login(@ModelAttribute LoginRequest loginRequest, BindingResult bindingResult,
                        HttpServletResponse response, Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        User user = userService.login(loginRequest);

        // 로그인 아이디나 비밀번호가 틀린 경우 global error return
        if(user == null) {
            bindingResult.reject("loginFail", "로그인 아이디 또는 비밀번호가 틀렸습니다.");
        }

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

        // 로그인 성공 => Jwt Token 발급
        String secretKey = "my-secret-key-123123";
        long expireTimeMs = 1000 * 60 * 60;     // Token 유효 시간 = 60분

        String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs);

        // 발급한 Jwt Token을 Cookie를 통해 전송
        // 클라이언트는 다음 요청부터 Jwt Token이 담긴 쿠키 전송 => 이 값을 통해 인증, 인가 진행
        Cookie cookie = new Cookie("jwtToken", jwtToken);
        cookie.setMaxAge(60 * 60);  // 쿠키 유효 시간 : 1시간
        response.addCookie(cookie);

        return "redirect:/jwt-login";
    }

    @GetMapping("/logout")
    public String logout(HttpServletResponse response, Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        // 쿠키 파기
        Cookie cookie = new Cookie("jwtToken", null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);

        return "redirect:/jwt-login";
    }

    @GetMapping("/info")
    public String userInfo(Model model, Authentication auth) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        User loginUser = userService.getLoginUserByLoginId(auth.getName());
        model.addAttribute("user", loginUser);

        return "info";
    }

    @GetMapping("/admin")
    public String adminPage(Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        return "admin";
    }

    @GetMapping("/authentication-fail")
    public String authenticationFail(Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        return "errorPage/authenticationFail";
    }

    @GetMapping("/authorization-fail")
    public String authorizationFail(Model model) {
        model.addAttribute("loginType", "jwt-login");
        model.addAttribute("pageName", "Jwt Token 화면 로그인");

        return "errorPage/authorizationFail";
    }
}

결과

  • 메인 페이지 (로그인 하지 않은 상황)

  • 유저 정보 페이지 (로그인 하지 않은 상황)

  • 메인 페이지 (로그인 한 상황 => "jwtToken" Key를 가진 Cookie 확인 가능)

  • 유저 정보 페이지 (로그인 한 상황 => 위에서 발급받은 Cookie를 전송함으로써 인증 성공, JwtToken 값이 같은 것을 확인할 수 있음)

  • 관리자 페이지 (권한이 ADMIN이 아닌 유저가 접근한 상황 => Jwt Token은 있지만 접근은 불가능)

  • 관리자 페이지 (관리자로 다시 로그인 => 접근 성공 => Jwt Token이 달라진 것을 확인할 수 있음)

반응형

↓ 클릭시 이동

복사했습니다!