사이트에서 로그인을 진행하였을 때, 로컬/세션 스토리지나 쿠키에 사용자의 데이터를 그대로 담는 것은 보안에 매우 취약하다.
그래서 서버에서는 로그인 요청이 들어올 때 토큰을 발급해 주고, 프론트에서 데이터를 요청할 때마다 토큰과 함께 요청해야 데이터를 넘겨줘야 한다.
우리 프로젝트에서는 다음과 같이
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
를 넣어 주는 것이 아니라 자동으로 가능하다고 하니 편한 방법을 사용하기 바란다.