반응형
구현 배경
- 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을 넣어줌으로써 쿠키의 조작을 할 수 없다는 장점이 있음
구현
- 전에 구현했었던 [Spring Boot] Spring Security를 사용한 Jwt 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
- [Spring Boot] Spring Security를 사용한 Jwt Token 로그인 구현 여기서 작성한 SecurityConfig에 인증, 인가 실패 시 에러 페이지로 redirect 시키는 부분만 추가
- [Spring Boot] Spring Security 인증, 인가 실패 처리 - authenticationEntryPoint, accessDeniedHandler 참고
@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
- [Spring Boot] Spring Security를 사용한 Jwt Token 로그인 구현 여기서 작성한 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이 달라진 것을 확인할 수 있음)
반응형