-
6. Spring Boot + Spring Security + JWT + access tokenProject/React+Java 2022. 7. 22. 14:29
https://github.com/dchkang83/project-board
jwt 및 security를 최신 버전으로 설정하다 보니 deprecated 된 class 및 function 들이 많아서 설정하는데 애를 먹었다.!!
자세한 소스들은 깃을 참조하기 바란다.
1. boot starter security - 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test'
위와 같이 의존성만 추가하여도 시큐리티의 기본설정을 따르게 됨으로 아래 이미지와 같이 로그인 화면이 뜬다.
(로그인 폼 참의 URL도 기본설정을 따름으로 이후에 config 설정을 통해서 변경하면 된다.)
현재 상태에서 로그인 하려면 Username 은 user로 입력하고, Password는 아래 사진과 같이 어플리케이션 시작 로그에 표시되는 패스워드를 사용하면 된다.2. JWT - 의존성 추가
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
3. JWT key 설정
# application.yml jwt: secret: ThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExample # WeSecurityConfig.java @Value("${jwt.secret}") private String secret; @Bean public JwtTokenProvider jwtTokenProvider() { return new JwtTokenProvider(secret); } @Bean protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ... // login 주소가 호출되면 인증 및 토큰 발행 필터 추가 http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class); // jwt 토큰 검사 http.addFilterBefore(new JwtAuthorizationFilter(userRepository, jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class); ... }
4. 주요 소스 정리
- WebSecurityConfig
@Configuration @EnableWebSecurity @RequiredArgsConstructor @Slf4j public class WebSecurityConfig { private final CorsFilter corsFilter; private final UserRepository userRepository; private final AuthenticationConfiguration authenticationConfiguration; @Value("${jwt.secret}") private String secret; @Bean public AuthenticationManager authenticationManager() throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public JwtTokenProvider jwtTokenProvider() { return new JwtTokenProvider(secret); } @Bean protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf() .disable() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilter(corsFilter) // Controller 어노테이트 @CrossOrigin(인증 X), 시큐리티 필터에 등록 인증(O) .formLogin() // .loginProcessingUrl("/login") // /login 주소가 호출이 되면 시큐리티가 낚아채서 대신 로그인을 진행해준다. .and() .httpBasic().disable() .authorizeRequests() // .antMatchers("/signin").permitAll() // 컨트롤러에서 인증 하는 부분 테스트 .antMatchers("/api/v1/user/**") .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") .antMatchers("/api/v1/manager/**") .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") .antMatchers("/api/v1/admin/**") .access("hasRole('ROLE_ADMIN')") .anyRequest().permitAll(); // login 주소가 호출되면 인증 및 토큰 발행 필터 추가 http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class); // jwt 토큰 검사 http.addFilterBefore(new JwtAuthorizationFilter(userRepository, jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class); // TODO. 왜 추가 했는지 잘 기억이가 안남. 확인이 필요함 // http.authenticationProvider(authenticationProvider()); return http.build(); } @Bean public UserDetailsService userDetailsService() { return new PrincipalDetailsService(userRepository); } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService()); authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } }
- JwtAuthenticationFilter
@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; // @Autowired private final JwtTokenProvider jwtTokenProvider; // /login 요청을 하면 로그인 시도를 위해서 실행되는 함수 @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { log.info("{} - attemptAuthentication -> 로그인 시도중", this.getClass()); // request에 있는 username과 password를 파싱해서 자바 Object로 받기 ObjectMapper om = new ObjectMapper(); try { /* log.info(request.getInputStream().toString()); BufferedReader br = request.getReader(); String input = null; while((input = br.readLine()) != null) { log.info(input); } */ User user = om.readValue(request.getInputStream(), User.class); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()); // PrincipalDetailsService의 loadUserByUsername 함수가 실행된 후 정상이면 authentication이 리턴됨 // DB에 있는 username과 password가 일치한다. Authentication authentication = authenticationManager.authenticate(authenticationToken); // 로그인이 되었다는 뜻. PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); log.info(principalDetails.getUser().getUsername()); // 로그인 정상적으로 되었다는 뜻 // authentication 객체가 session 영역에 저장을 해야하고 그방법이 return 해주면 됨. // 리턴의 이유는 권한 관리를 security가 대신 해주기 때문에 편하려고 하는거임 // 굳이 JWT토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session에 넣어준다. return authentication; } catch (Exception e) { e.printStackTrace(); } return null; } // attemptAuthentication실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수가 실행됨 // JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 response 해주면 됨 @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { log.info("{} - successfulAuthentication -> 인증 완료", this.getClass()); String jwtToken = jwtTokenProvider.generateToken(authResult); log.info("jwtToken : {}", jwtToken); response.addHeader("Authorization", "Bearer " + jwtToken); } }
- JwtAuthorizationFilter
// 시큐리티가 filter 가지고 있는데 그 필터중에 BasicAuthenticationFilter 라는 것이 있음. // 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음. // 만약에 권한이 인증이 필요한 주소가 아니라면 이 필터를 안탐 @Slf4j @RequiredArgsConstructor public class JwtAuthorizationFilter extends OncePerRequestFilter { private final UserRepository userRepository; private final JwtTokenProvider jwtTokenProvider; // public JwtAuthorizationFilter(UserRepository userRepository, JwtTokenProvider jwtTokenProvider) { // this.userRepository = userRepository; // this.jwtTokenProvider = jwtTokenProvider; // } /** * 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 타게됨 */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { log.info("{} - successfulAuthentication -> 인증이나 권한이 필요한 주소 요청이 됨", this.getClass()); String bearerToken = request.getHeader("Authorization"); if (bearerToken == null || bearerToken.startsWith("Bearer") == false) { chain.doFilter(request, response); return; } String jwtToken = bearerToken.substring("Bearer ".length()); String username = jwtTokenProvider.getUsernameFromJWT(jwtToken); // 서명이 정상적으로 됨 if (username != null) { User userEntity = userRepository.findByUsername(username); PrincipalDetails principalDetails = new PrincipalDetails(userEntity); // Jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어 준다. Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities()); // 강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장. SecurityContextHolder.getContext().setAuthentication(authentication); chain.doFilter(request, response); } } }
- JwtTokenProvider
@Slf4j public class JwtTokenProvider { // 토큰 유효시간 // private final int JWT_EXPIRATION_MS = 604800000; private final int JWT_EXPIRATION_MS = 60000 * 1; // 만료 시간 세팅 : 60000 (1분) * 10 => 10분 private final Key key; public JwtTokenProvider(String secret) { byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret); this.key = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName()); } /** * jwt 토큰 생성 * * @param authentication * @return */ public String generateToken(Authentication authentication) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS); // Date expiryDate = new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS); // // 만료 시간 세팅 : 60000 (1분) * 10 => 10분 PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); /* * https://velopert.com/2389 * iss: 토큰 발급자 (issuer) * sub: 토큰 제목 (subject) * aud: 토큰 대상자 (audience) * exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370) * 언제나 현재 시간보다 이후로 설정되어있어야합니다. * nbf: Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념입니다. 여기에도 NumericDate 형식으로 날짜를 지정하며, * 이 날짜가 지나기 전까지는 토큰이 처리되지 않습니다. * iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다. * jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다. (id) */ // 표준 클레임 셋팅 JwtBuilder builder = Jwts.builder() .setId(String.valueOf(principalDetails.getUser().getUserNo())) // jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다. .setIssuedAt(now) // iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다. - 현재시간 기반으로 생성 .setSubject(principalDetails.getUser().getUsername()) // sub: 토큰 제목 (subject) - 사용자 .setIssuer("gundam.com") // iss: 토큰 발급자 (issuer) .signWith(key, SignatureAlgorithm.HS512) // 사용할 암호화 알고리즘, signature에 들어갈 secret 값 세팅 .setExpiration(expiryDate) // exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370), 언제나 현재 시간보다 이후로 설정되어있어야합니다. ; return builder.compact(); } /** * Jwt 토큰에서 유져이름 추출 * * @param jwtToken * @return */ public String getUsernameFromJWT(String jwtToken) { Jws<Claims> claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(jwtToken); return claims.getBody().getSubject(); } }
9. 이슈
9.1. WebSecurityConfigurerAdapter deprecated (Spring Security - How to Fix WebSecurityConfigurerAdapter Deprecated)
참조: https://www.codejava.net/frameworks/spring-boot/fix-websecurityconfigureradapter-deprecated
'Project > React+Java' 카테고리의 다른 글
8. React 프로젝트 생성 (0) 2022.08.10 7. Spring Boot + Spring Security + JWT + access token + refresh token + 토큰 갱신 (0) 2022.08.02 5. SpringBoot + Rancher + VsCode Mysql Client (0) 2022.07.22 4. SpringBoot + JPA + H2 database (0) 2022.07.21 3. SpringBoot + Logback 설정 (0) 2022.07.21