
사이트에서 로그인을 진행하였을 때, 로컬/세션 스토리지나 쿠키에 사용자의 데이터를 그대로 담는 것은 보안에 매우 취약하다.
그래서 서버에서는 로그인 요청이 들어올 때 토큰을 발급해 주고, 프론트에서 데이터를 요청할 때마다 토큰과 함께 요청해야 데이터를 넘겨줘야 한다.
우리 프로젝트에서는 다음과 같이
config패키지가 구성되어 있다.
여기서SwaggerConfig와WebConfig를 제외한 모든 클래스를 만들 것이다.
(WebConfig에 대한 내용은 https://velog.io/@phraqe/Sekkison8 를 참고 바란다.)
다음 라이브러리를
build.gradle에 추가해 준다.build.gradle
//security implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'io.jsonwebtoken:jjwt:0.9.1'
먼저, JwtToken을 생성/발급해주는
JwtTokenProvider를 만들자.JwtTokenProvider.class
@RequiredArgsConstructor @Component public class JwtTokenProvider { private String secretKey = "webfirewood"; private long tokenValidTime = 30 * 60 * 1000L; // 유효시간 30분 private final UserDetailsService userDetailsService; // 객체 초기화, secretKey를 Base64로 인코딩 @PostConstruct protected void init() { secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes()); } // 토큰 생성 public String createToken(String userPk) { // userPK = username Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위 Date now = new Date(); return Jwts.builder() .setClaims(claims) // 정보 저장 .setIssuedAt(now) // 토큰 발행 시간 정보 .setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 유효시각 설정 .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘과, secret 값 .compact(); } // 인증 정보 조회 public Authentication getAuthentication(String token) { UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token)); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } // 토큰에서 회원 정보 추출 public String getUserPk(String token) { return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); } // 토큰 유효성, 만료일자 확인 public boolean validateToken(String jwtToken) { try { Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken); return !claims.getBody().getExpiration().before(new Date()); } catch (Exception e) { return false; } } public String resolveToken(HttpServletRequest request) { return request.getHeader("X-AUTH-TOKEN"); } }
그리고 유효한 토큰이라면 토큰으로부터 사용자 정보를 받아
SecurityContext에 저장하는JwtAuthenticationFilter를 작성하자.JwtAuthenticationFilter.class
@RequiredArgsConstructor public class JwtAuthenticationFilter extends GenericFilterBean { private final JwtTokenProvider jwtTokenProvider; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 헤더에서 토큰 받아오기 String token = jwtTokenProvider.resolveToken((HttpServletRequest) request); // 토큰이 유효하다면 if (token != null && jwtTokenProvider.validateToken(token)) { // 토큰으로부터 유저 정보를 받아 Authentication authentication = jwtTokenProvider.getAuthentication(token); // SecurityContext 에 객체 저장 SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } }
Spring Security에서loadUserByUsername함수를 통해 유저를 가져올 수 있도록CustomUserDetailService를 작성하자.@RequiredArgsConstructor @Service public class CustomUserDetailService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username); if (user == null) throw new UsernameNotFoundException("사용자를 찾을 수 없습니다."); return user; } }
WebSecurityConfig를 다음과 같이 작성한다.@RequiredArgsConstructor @EnableWebSecurity //Spring Security 설정 활성화 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final JwtTokenProvider jwtTokenProvider; //암호화에 필요한 PasswordEncoder Bean 등록 @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } //authenticationManager Bean 등록 @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/resources/**"); } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable().headers().frameOptions().disable() .and() //세션 사용 안함 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() //URL 관리 .authorizeRequests() .antMatchers("/api/users/**", "/api/memos/**").authenticated() .anyRequest().permitAll() .and() // JwtAuthenticationFilter를 먼저 적용 .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); } }
BCryptPasswordEncoder에 대해서는 https://velog.io/@phraqe/Sekkison07 에 정리해 놓았다.
thymeleaf파일에서js나css를 불러오는 것도api를 사용해 호출하는 형식이기에web.ignoring().antMatchers("/resources/**")를 통해resource패키지를WebSecurity에서 제외시켰다.페이지 요청이 아닌 api 데이터 요청인
/api/users/**,/api/memos/**에만WebSecurity를 적용시켰다.
addFilterBefore를 통해 데이터 요청이 왔을 때JwtToken을 먼저 확인하고 서버 함수를 실행한다.
이렇게 작성만 되어 있다면, 이제 서버에서 api 데이터 요청을 위해서는 토큰이 필요하게 된다. 다음과 같이 서버 로그인 로직에서 토큰 발급을, 프론트 데이터 요청에서 토큰을 담아 요청하도록 하자.
UserController.class
@PostMapping("/login") public TokenResponse login(@RequestBody @Validated LoginRequest request) { User user = request.loginUser(); return TokenResponse.of(userService.loginUser(user)); }UserService.class
public String loginUser(User user) { User loginUser = userRepository.findByUsername(user.getUsername()); if (loginUser == null) throw new MyMemoryException(404, "일치하는 계정이 없습니다"); if (!bCryptPasswordEncoder.matches(user.getPassword(), loginUser.getPassword())) { throw new MyMemoryException(404, "일치하는 계정이 없습니다"); } // 로그인에 성공하면 username, roles 로 토큰 생성 후 반환 return jwtTokenProvider.createToken(loginUser.getUsername()); }login.js
function login() { $.ajax({ contentType: 'application/json', url: `/api/login`, type:"POST", dataType: 'json', data: JSON.stringify({ "username": id, "password": pw }), success : function(data) { if (data.status == 200) localStorage.setItem("token" , data.token); else alert(data.message); } }) }header.js (데이터 요청 예시)
$.ajax({ contentType: 'application/json', url:`/api/users`, type:"GET", dataType: 'json', headers: { 'X-AUTH-TOKEN': localStorage.getItem("token") }, success : function(data) { if (data.status == 200) alert(`${data.name}님 환영합니다`); else alert(data.message); } });
로그인 로직에서의 토큰 발급과 데이터 요청에 토큰을 사용하는 방법을 알아보았다.
이번 프로젝트에서는 토큰을 로컬스토리지에 담아 사용하였지만, 쿠키에 담는 방법도 있고 각자 장단점이 존재한다고 한다.
만약 쿠키에 토큰을 담아 사용한다면
ajax요청에 일일이header를 넣어 주는 것이 아니라 자동으로 가능하다고 하니 편한 방법을 사용하기 바란다.