Spring-Boot/etc.

SpringBoot - JWT ( JSON WEB TOKEN ) - 코드편

PHM 2022. 5. 9. 15:03

HS512 알고리즘을 사용할 것이기 때문에 secret key는 512bit, 즉 64byte 이상을 사용해야 합니다.

터미널에서 secret key를 base64로 인코딩하여 secret 항목에 채워넣습니다.

$ echo ‘silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret’|base64

application.yml

jwt:
  header: Authorization
  secret: 4oCYc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXTigJkK
  token-validity-in-seconds: 86400

( 저는 그냥 하드 코딩했습니다. 하지만 yml 파일에서 관리하는게 좋습니다. )


Filter방식으로 JWT 토큰 활용하기

1. WebSecurityConfig

     - corsFilter는 cors방지용 필터

     - JwtAuthenticationFilter : 로그인 성공시 토큰발급해주는 필터

     - JwtAuthorizationFilter : 해당 토큰 보안여부 판단해주는 필터

                                      ( /joinMember와 /readBoard 이외에는 이 필터가 판단)

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	http
        	.csrf().disable()   // jwt토큰 방식을 쓰기에 필요한 설정

                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)        // 세션을 사용하지 않기에 stateless

                .and()
                .addFilter(corsFilter)      // @CrossOrigin(인증X), 시큐리티 필터에 등록 인증(O)
                .formLogin().disable()      // 폼로그인 사용X
                .httpBasic().disable()      // Bearer 방식을 쓸거기 때문
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))   // AuthenticationManager
                .addFilter(new JwtAuthorizationFilter(authenticationManager(),memberMapper))
                .authorizeRequests()
                .antMatchers("/joinMember","/readBoard").permitAll()                       //login페이지
                .anyRequest().authenticated();
 	}
 }

2. CorsConfig

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);       // 내 서버가 응답할 때 json을 JS에서 처리할 수 있게 설정
        config.addAllowedOrigin("*");           // 모든 ip에 응답을 허용
        config.addAllowedHeader("*");           // 모든 header에 응답 허용
        config.addAllowedMethod("*");           // 모든 post,get,put,delete,patch 요청허용
        source.registerCorsConfiguration("/**",config);
        return new CorsFilter(source);
    }

}

3. JwtAuthenticationFilter

// 스프링 시큐리티에서 UsernamePasswordAuthenticationFilter 가 있음
// /login 요청해서 username, password 전송하면 (post)
// UsernamePasswordAuthenticationFilter 동작함

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final AuthenticationManager authenticationManager;

    // /login요청을 하면 로그인 시도를 위해서 실행되는 함수
   @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {


       // 1. username,password 받아서
       try {

           // json데이터를 parsing해줌
           ObjectMapper om = new ObjectMapper();
           MemberVO mem = om.readValue(request.getInputStream(), MemberVO.class);
           System.out.println("request요청 값들 :"+mem);

           UsernamePasswordAuthenticationToken authenticationToken =
                   new UsernamePasswordAuthenticationToken(mem.getM_id(),mem.getM_pw());

           // PrincipalDetailsService의 loadUserByUsername() 함수가 실행된 후 정상이면 authentication이 리턴
           // DB에 있는 username과 password가 일치한다.
           // pw는 스프링이 알아서 처리 해줌
           Authentication authentication =
                   authenticationManager.authenticate(authenticationToken);

           // => 로그인이 되었다는 뜻
           PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            System.out.println("로그인 완료 여부: "+principalDetails.getUsername());
           // authentication 객체가 session영역에 저장을 해야하고 그 방법이 return 해주면 됨
           // 리턴의 이유는 권한 관리를 security가 대신 해주기 떄문에 편하려고 하는 거임
           // 굳이 JWT토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session넣어줌
            return  authentication;
       } catch (IOException e) {
           e.printStackTrace();
           System.out.println(111111);
       }
       // 2. 정상인지 로그인 시도를 해보는 것, authenticationManager로 로그인 시도를 하면!!
       //    PrincipalDetailsService가 호출 -> loadUserByUsername이 자동으로 실행

       // 3. PrincipalDetails를 세션에 담고 ( 권한 관리를 위해서 )

       // 4. JWT토큰을 만들어서 응답해주면 됨.
       return null;
    }
    
    // attemptAuthentication 실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수 실행
    // JWT 토큰을 만들어서 request요청한 사용자에게 JWT토큰을 response해주면 됨
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        System.out.println("successfulAuthentication 실행됨 : 인증완료되었다는 뜻");
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
        
        // RSA방식은 아니구 Hash암호방식
        String jwtToken = JWT.create()
                .withSubject("cos토큰")
                .withExpiresAt(new Date(System.currentTimeMillis()+(60000*10000)))
                .withClaim("username",principalDetails.getUsername())
                .withClaim("m_seq", Integer.toString(principalDetails.getMem().getM_seq()))
                .sign(Algorithm.HMAC512("cos"));

       response.addHeader("Authorization","Bearer "+jwtToken);
    }
}

- token의 규칙은 { "Authorization" : "Bearer 토큰명" } 이다. 

  ( 안지키면 token 판별을 못하더라구요.... )

- attemptAuthentication의 authenticationManager.authenticate가 실행 시 PrincipalDetailService의

  loadUserByUsername이 실행되므로 다음 파일로 보겠다.

 

4. PrincipalDetailsService

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberMapper memberMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("loadUserByUsername 로그인요청");
        MemberVO user = memberMapper.selectId(username);
        return new PrincipalDetails(user);
    }
}

- loadUserByUsername의 반환변수가 UserDetails이므로 PrincipalDetails 파일도 첨부

 

+ PrincipalDetails

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PrincipalDetails implements UserDetails {

    private MemberVO mem;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return  mem.getM_role();
            }
        });
        return authorities;
    }

    @Override
    public String getPassword() {
        return mem.getM_pw();
    }

    @Override
    public String getUsername() {
        return mem.getM_id();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

5. JwtAuthorizationFilter 

- 토큰 인증해주는 필터

// 시큐리티가 filter가지고 있는 그 필터중에 BasicAuthenticationFilter 라는 것이 있음
// 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음
// 만약에 권한이 인증이 필요한 주소가 아니라면 이 필터를 안탄다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    private MemberMapper memberMapper;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberMapper memberMapper) {
        super(authenticationManager);
        this.memberMapper = memberMapper;
    }

    // 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 타게 됨
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("인증이나 권한이 필요한 주소 요청이됨");

        String jwtHeader = request.getHeader("Authorization");
        System.out.println("jwtHeader : " + jwtHeader);

        // header가 있는 확인
        if (jwtHeader == null || jwtHeader.trim().isEmpty() || !jwtHeader.startsWith("Bearer")) {
            chain.doFilter(request, response);
        }else {

            // JWT 토큰을 검증을 해서 정상적인 사용자인지 확인
            String jwtToken = request.getHeader("Authorization").replace("Bearer ", "");

            String username =
                    JWT.require(Algorithm.HMAC512("cos")).build().verify(jwtToken).getClaim("username").asString();

            // 서명이 정상적으로 됨
            if (username != null) {
                MemberVO mem = memberMapper.selectId(username);

                PrincipalDetails principalDetails = new PrincipalDetails(mem);
                // Jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어준다.
                Authentication authentication =
                        new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());

                // 강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장.
                SecurityContextHolder.getContext().setAuthentication(authentication);

                chain.doFilter(request, response);
            }

        }
    }


}

 

+ Controller에서 토큰 복호화해서 쓰기

@PostMapping("/")
public void 토큰복화화(@RequestHeader("Authorization") String jwtToken){
	// 토큰 복호화 과정
        String token = jwtToken.substring(7);
        System.out.println("post에서 받은 토큰 : "+token);
        String m_seq =
                JWT.require(Algorithm.HMAC512("cos")).build().verify(token).getClaim("m_seq").asString();
        String username =
                JWT.require(Algorithm.HMAC512("cos")).build().verify(token).getClaim("username").asString();
        System.out.println("post user : "+m_seq+ " / " + username);
}

 


마무리

Spring-Security와 JWT토큰 적용시키기위해 4일동안 공부하고 코드쓰고... 쉽지 않았다...

@Override때문에 기존의 스프링 라이브러리 읽는 것도 연습해야겠고 자동적으로 실행하는 순서때문에 많이 애먹은거같다. 나중에는 편하다고 느껴지겠지만 지금은 이 자동적으로 실행하는거 때문에 많은 시간을 썼다.

정리하자면 

 

1. 로그인과정 + 토큰 발급

WebScurityConfig.java

-> JwtAuthenticationFilter.java( extends UsernamePasswordAuthenticationFilter ) 의

         attemptAuthentication ( @Override )에서 로그인을 처리해주는데 해당 함수에서

-> PrincipalDetailsService( implements UserDetailsService )의 loadUserByUsername( @Override )을 실행하고

-> PrincipalDetails( implements UserDetails )를 반환한다.

-> 로그인을 성공하면 attemptAuthentication( @Override ) 실행 후 바로

           successfulAuthentication( @Override )을 실행해서 토큰을 발급해준다.

2.  토큰 인증

WebScurityConfig.java

-> WebScurityConfig.java에서 권한이나 인증이 필요한 특정 주소가 요청이 온다면 

    JwtAuthorizaionFilter( extends BasicAuthenticationFilter )의

                               doFilterInternal( @Override )이 실행되고 토큰 인증을 하게 된다.

 

 

implements ,extends, @Override 의 중요성을 많이 깨닫고 간다...

다음은 JWT를 인터셉터로 한번해보거나 JWT토큰 코드를 좀 더 리펙토링해봐야겠다.

 

github : https://github.com/PHyeonMIN/boardDemo

 

( config만 보시면됩니다. config파일과 같은 라인에있는 jwt는 실험용이었습니다..ㅠ )