스프링으로 JWT 구현

개발하는 구황작물·2022년 7월 29일
1

jwt

목록 보기
2/2

JWT 구현

  1. 세팅

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	implementation 'com.auth0:java-jwt:3.19.2'
}

application.yml

spring:
  h2:
    console:
      enabled: true
      path: /h2
  datasource:
    url: jdbc:h2:mem:test
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true

controller

@RequiredArgsConstructor
@RestController
public class RestApiController {
    
    @GetMapping("/")
    public String root() {
        return "root";
    }
}
  1. Entity(Model) 설정

Member.class

@Data
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long memberId;

    private String username;

    private String password;

    private String role;

    public List<String> roles() {
        if(role.length() > 0) {
            return Arrays.asList(role.split(","));
        }
        return new ArrayList<>();
    }

}
  1. Config 설정
    SecurityConfig.class
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http
                .formLogin().disable()
                .httpBasic().disable();
        http.authorizeRequests()
                .antMatchers("/").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().authenticated();
        
        return http.build();
                
    }

JWT토큰 구현을 위한 기본적인 filterChain 설정

  • .csrf().disable(); : 현재 프로젝트는 stateless 이고 쿠키나 세션을 사용하지 않아 disable로 설정

  • http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); : JWT는 Stateless상태를 유지해야 하므로 설정

  • .formLogin().disable() : form login 사용x

  • .httpBasic().disable(); : http통신시 header에 Authorization값을 id, password를 입력하는 방식
    -> 더 이상 브라우저에서 id, pwd를 헤더에 들고오지 않고 로그인시 발급된 토큰을 들고 오게 함.

    HTTP Basic Authentication
    username, password를 base64로 인코딩 하는 방법이다
    보안에 매우 취약해서 HTTPS와 같이 사용되어야 한다.

CorsConfig.class

@Configuration
public class CorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/api/**", config);

        return new CorsFilter(source);
    }
}
  • CorsFilter : CORS pre-flight request를 다루기 위한 필터이다.

  • setAllowCredentials() : 서버 응답시 JSON을 자바스크립트에서 처리 가능하도록 설정

  • addAllowedOrigin("*") : 모든 ip에 응답 허용

  • addAllowedHeader("*") : 모든 Header에 응답 허용

  • addAllowedMethod("*") : 모든 HttpMethod에 응답 허용(Get, Post, Put, ... ), Cors pre-flight request 시 OPTIONS 메서드를 접근을 막는 경우 방지.

SecurityConfig.class에 추가

	@Autowired
	private CorsFilter corsFilter;
	...
    public class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) throws Exception {
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            builder
                    .addFilter(corsFilter);

        }
    }
  • WebSecurityConfigureAdapter가 deprecated되어 내부에 클래스를 만들어주거나 @Bean을 등록해서 Filter를 추가해야 함
  1. UserDetails, UserDetailsService 설정
    PrincipalDetails.java
@Data
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

	    private Member member;
    
    public PrincipalDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        member.roles().forEach(n -> authorities.add(() ->n));
        return authorities;
    }

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

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

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

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

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

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • UserDetails 인터페이스를 implements

MemberRepository

public interface MemberRepository extends JpaRepository<Member,Long> {
    
    Member findByUsername(String username);

}

PrincipalDetailsService.class

@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsername(username);
        return new PrincipalDetails(member);
    }
}
  1. Jwt 관련 필터 설정
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { //로그인 처리
    //아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터이다.

    private final AuthenticationManager authenticationManager;
    //인자로 받은 Authentication이 유효한 인증인지 확인하고, UserDetails를 통해 "Authentication" 객체를 리턴


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            ObjectMapper om = new ObjectMapper();
            Member member = om.readValue(request.getInputStream(), Member.class);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getUsername(), member.getPassword());

            Authentication authentication = authenticationManager.authenticate(authenticationToken);

            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            //loadUserByName()이 실행된 후 정상 작동이 되면 authentication 리턴

            return authentication;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        //로그인 인증 성공 이후 과정

        System.out.println("successfulAuthentication");
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        String jwToken = JWT.create() //jwt토큰 생성
                .withSubject("jwt token")//토큰 이름
                .withExpiresAt(new Date(System.currentTimeMillis() + 60 * 1000 * 10)) //유효시간 10분
                .withClaim("id", principalDetails.getMember().getMemberId()) //payLoad부분(토큰에 담길 정보)
                .withClaim("username", principalDetails.getMember().getUsername()) //payLoad부분(토근에 담길 정보)
                .sign(Algorithm.HMAC512("jwt"));//Signature 부분,  알고리즘 종류와 secretKey를 넣는다.
        response.addHeader("Authorization", "Bearer " + jwToken); //header부분
    }
}
  • 회원의 로그인 처리를 담당하는 필터

  • attemptAuthentication() 메서드는 UsernamePasswordAuthenticationFilter의 메서드로 외부 인증시 UsernamePasswordAuthenticationFilter기능을 대신하는 커스텀 필터이다.

  • PrincipalDetailService의 loadUserByUsername() 메서드가 실행된 후 정상 작동된다면 authentication이 return된다.(AuthenticationManager의 authenticate()가 인증 처리 이후 Authentication객체로 저장, 리턴)

  • 이후 successfulAuthentication() 메서드로 JWT 토큰을 생성한다.

  1. JwtAuthorizationFilter 생성
    JwtAuthorizationFilter
public class JwtAuthrizationFilter extends BasicAuthenticationFilter {
    // 권한 및 인증이 필요한 주소를 요청하면 BasicAuthenticationFilter를 진행하게 되는데 여기선 JWT로 Authorization을 하므로 BasicAuthenticationFilter 오버라이딩한다.

    private MemberRepository memberRepository;

    public JwtAuthrizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository) {
        super(authenticationManager);
        this.memberRepository = memberRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("인증이나 권한이 필요한 주소 요청됨");

        String jwtHeader = request.getHeader("Authorization");
        //헤더를 가져와 토큰을 가지고 있는지 체크함

        if(jwtHeader==null || !jwtHeader.startsWith("Bearer")) { //시작이 Bearer가 아니거나 null일 경우
            chain.doFilter(request,response); //다음 필터로 넘김
            return;
        }

        String jwtToken = jwtHeader.replace("Bearer ","");
        String username = JWT.require(Algorithm.HMAC512("jwt")).build().verify(jwtToken).getClaim("username").asString();
        //토큰의 username 복호화해서 확인, 서비스에 등록한 유저인지 확인한다.

        if(username != null) { //만약 유저네임이 존재한다면 유저 인증
            Member member = memberRepository.findByUsername(username);

            PrincipalDetails principalDetails= new PrincipalDetails(member);
            Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication); //저장함

            chain.doFilter(request,response); //다음 필터로 넘어가기
        }
        super.doFilterInternal(request, response, chain);
        //dofilterInternal()는 인증이나 권한이 필요한 주소 요청이 있을을 때마다 해당 필터를 통하게 되어있음
    }
}
  • 로그인 이후 사용자가 권한 및 인증이 필요한 주소 요청시 토큰을 검사하는 필터이다.
  • request.getHeader("Authorization");로 토큰을 불러온다.
  • .verify() 메서드는 우리 서버가 만든 토큰이 맞는지 여부와 만료 여부를 검증한다.
  1. SecurityConfig에 필터 등록
...
    public class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) throws Exception {
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            builder
                    .addFilter(corsFilter)
                    .addFilter(new JwtAuthenticationFilter(authenticationManager))
                    .addFilter(new JwtAuthrizationFilter(authenticationManager, memberRepository));

        }
    }
  1. controller 추가
@RequiredArgsConstructor
@RestController
public class RestApiController {

    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @GetMapping("/")
    public String root() {
        return "root";
    }
    @PostMapping("/join")
    public String join(@RequestBody Member member) {
        member.setPassword(bCryptPasswordEncoder.encode(member.getPassword()));
        memberRepository.save(member);
        return "회원가입완료";
    }
    @GetMapping("/api/v1/user")
    public String user() {
        return "user";
    }
    @GetMapping("/api/v1/admin")
    public String admin() {
        return "admin";
    }
}

결과

  • 회원가입
    http://localhost:8080/join
  • 로그인
    http://localhost:8080/login
    로그인 성공과 함께 응답 헤더에 암호화된 JWT 토큰이 있는 것을 확인할 수 있다.

  • http://localhost:8080/api/v1/user/의 요청 헤더에 login의 응답 헤더의 JWT 입력(Authorization, Bearer ey~)후 Get요청시 /user로 접근 할 수 있다.(/admin/** 은 403 forbidden이 뜬다)

profile
어쩌다보니 개발하게 된 구황작물

0개의 댓글