[Spring Security] 11. JWT 구현

개발자·2022년 7월 27일
0

Spring Security

목록 보기
11/11
post-thumbnail

JWT 구현

이번 시간에는 JWT를 구현해보도록 하겠습니다.

의존성 추가

먼저 bulid.gradle에 다음과 같이 의존성을 추가합니다.

  • Lombok
  • MySQL Driver
  • Spring Boot DevTools
  • Spring Data JPA
  • Spring Security
  • Spring Web
implementation group: 'com.auth0', name: 'java-jwt', version: '3.10.3'

application.yml 설정

server:
  port: 8080
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
    username: cos
    password: cos1234

  jpa:
    hibernate:
      ddl-auto: create #create update none
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    show-sql: true

Controller 생성

@RestController
public class RestApiController {

    @Autowired
    private JwtUserRepository jwtUserRepository;

    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    @GetMapping("home")
    public String home(){
        return "<h1>home</h1>";
    }

    @PostMapping("token")
    public String token(){
        return "<h1>token</h1>";
    }

    @PostMapping("join")
    public String join(@RequestBody JwtUser jwtUser){
        jwtUser.setPassword(bCryptPasswordEncoder.encode(jwtUser.getPassword()));
        jwtUser.setRoles("ROLE_USER");
        jwtUserRepository.save(jwtUser);
        return "회원가입완료";
    }
}

Model 생성

@Data
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private String username;
    private String password;
    private String roles;
    
    // Role의 Getter를 생성한다.
    // Server에서 Role을 2가지 이상 갖는 경우에만 필요
    public List<String> getRoleList(){
        if(this.roles.length() > 0){
            return Arrays.asList(this.roles.split(","));
        }else{
            return new ArrayList<>();
        }
    }
}

Config 생성

SecurityConfig.java

@Configuration
@EnableWebSecurity // 활성화 , 스프링 시큐리티 필터가 스프링 필터 체인에 등록
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) // secured 어노테이션 활성화, preAuthorize/postAuthorize 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private PrincipalOauth2UserService principalOauth2UserService;

    private final CorsFilter corsFilter;

    // 해당 메서드의 리턴되는 오브젝트를 IoC로 등록해준다.
    @Bean
    public BCryptPasswordEncoder encoedePwd(){
        return new BCryptPasswordEncoder();
    }

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.addFilterBefore(new MyFilter3(), SecurityContextPersistenceFilter.class);
            http.csrf().disable(); // 비활성화
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용x
            .and()
                    .addFilter(corsFilter) // @CrossOrigin(인증x), 시큐리티 필터에 등록을 해야 인증있을때 사용
                    .formLogin().disable()
                    .httpBasic().disable() // ID + PWD 를 들고가는 방식인 httpBasic이 아닌 Token을 들고가는 Bearer 방식을 사용하기위해
                    .addFilter(new JwtAuthenticationFilter(authenticationManager())) // AutenticationManger를 던져야함
                    .authorizeRequests()
                    .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(); // formLonin 사용안한다.
        }
}

먼저 SecurityConfig를 생성해주도록 하겠습니다.
SecurityConfig는 WebSecurityConfigurerAdapter를 상속받는데
WebSecurityConfigurerAdapter 클래스는 스프링 시큐리티의 웹 보안 기능의 초기화 및 설정들을 담당하는 내용이 담겨있으며 내부 적으로 getHttp()메서드가 실행될 때 HTTPSecurity 클래스를 생성하게 됩니다.
이때 우리가 인증/인가의 설정을 바꾸고자 한다면 WebSecurityConfigurerAdapter클래스를 상속한 SecurityConfig클래스를 생성하여 configure(HttpSecurity http)메서드를 override하며 설정해야합니다.

제가 만든 SecurityConfig 에서는 다음과 같이 설정하였습니다.

  • FormLogin 사용 x
  • 기존 Http 로그인 방식 x
  • Session 생성 x (Stateless)
  • 권한에 따른 홈페이지 접속 (추가기능)

CorsConfig

@Configuration
public class CorsConfig {

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

@Configuration은 설정파일을 만들기 위한 / Bean을 등록하기 위한 Annotation입니다.
CorsConfig 클래스에서는 CorsFilter를 생성 할 것입니다.
각 코드에 대한 설명은 주석으로 설명하겠습니다.

로그인 진행

PrincipalDetails 생성

@Data
public class PrincipalDetails implements UserDetails, OAuth2User {

    private User user;
    private Map<String,Object> attributes;

    // 일반 로그인 생성자
    public PrincipalDetails(User user){
        this.user = user;
    }


    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    // 해당 User의 권한을 리턴하는곳.
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        user.getRoleList().forEach(r->{
            authorities.add(()->r);
        });
        return authorities;
    }


    // User 의 password 리턴
    @Override
    public String getPassword() {
        return user.getPassword();
    }

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

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

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

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

    @Override
    public boolean isEnabled() {

        // 사이트 내에서 1년동안 로그인을 안하면 휴먼계정을 전환을 하도록 하겠다.
        // -> loginDate 타입을 모아놨다가 이 값을 false로 return 해버리면 된다.
        return true;
    }

    @Override
    public String getName() {
        return null;
    }

PrincipalDetailsService 생성

// Security 설정에서 loginProcessUrl("/login")으로 걸어뒀기 때문에 /login 요청이오면 자동으로 타입이 IoC 되어있는
// loadUserByUsername 함수가 수행 => "약속"
// 함수 종료시 @AuthenticationPrncipal 어노테이션이 만들어진다.
@Service
public class PrincipalDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    // 시큐리티 session(내부 Authentication(내부 UserDetails))
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User userEntity = userRepository.findByUsername(username);
        if(userEntity != null){
            return new PrincipalDetails(userEntity);
        }
        return null;
}

Filter 생성

JwtAutenticationFilter

// Spring Security에 있는 필터를 (/login 요청시 Post로 ID,PWD 전송시 동작)
@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 {
            ObjectMapper om = new ObjectMapper();
            JwtUser jwtUser = om.readValue(request.getInputStream(),JwtUser.class);
            System.out.println(jwtUser);

            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(jwtUser.getUsername(),jwtUser.getPassword());
            // PrincipalDetailsService의 loadUserByUsername() 함수가 실행됨
            Authentication authentication = authenticationManager.authenticate(authenticationToken);

            // authentication 객체가 session 영역에 저장됨 => 로그인이 되었다는 뜻.
            //PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
            //System.out.println("로그인 완료됨 : " + principalDetails.getUser().getUsername());

            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("인증 완료");
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        // HASH 암호방식으로 jwt토큰을 생성한다.
        String jwtToken = JWT.create()
                .withSubject("cos토큰")
                .withExpiresAt(new Date(System.currentTimeMillis() + (60000 * 10)))
                .withClaim("id",principalDetails.getUser().getId()) // 비공개 claim (내가 넣고 싶은)
                .withClaim("username",principalDetails.getUser().getUsername())
                .sign(Algorithm.HMAC512("cos")); // 서버만 알고있는 secret key


        response.addHeader("Authorization","Bearer " + jwtToken);
        super.successfulAuthentication(request, response, chain, authResult);
    }
}

UsernamePassWordAuthentication Filter를 상속받는 JwtAuthenticationFilter를 생성한다.
생성 후에는 SecurityFilter에 등록을 해줘야합니다.

.addFilter(new JwtAuthenticationFilter(authenticationManager())) // AutenticationManger를 던져야함

@RequiredArgsConstructor 어노테이션을 통해서 authenticationManager를 받고, 이를 통해서 로그인 시도를 합니다.
로그인 시도를 하기 위해 attemptAuthentication 메서드를 오버라이드해서 실행합니다.
이곳에서 ID,PWD를 받아서 정상인지 로그인 시도를 해봅니다.

다음과 같은 순서로 시작이 됩니다.
AuthenticationManager로 로그인 시도 -> PrincipalDetailsService 호출 -> loadUserByUsername() 함수 실행

ObjectMapper 는 JSON Data 를 Parsing 해주는 역할을 합니다.

다음과 같은 코드로 JWT 토큰을 생성합니다.
이제 토큰을 이용하여 민감한 정보를 접근하는 필터를 생성해야 합니다.

일반적으로 저희는 유저네임, 패스워드로 로그인을 하고 정상이라면
서버는 세션ID를 생성하고 클라이언트는 쿠키, 세션ID를 응답합니다.
요청할 때마다 쿠키값, 세션ID를 항상 들고 서버쪽으로 요청하기 때문에 서버는 세션ID가 유효한지 판단해서 유효하면 인증이 필요한 페이지로 접근하게 해야한다.

하지만 JWT를 이용하면 토큰을 생성하고 클라이언트쪽으로 JWT토큰을 응답합니다.
요청할 때마다 JWT토큰을 가지고 요청하고 서버가 JWT토큰이 유효한지를 판단하기 위한 필터를 생성해야 합니다.

JwtAuthorizationFilter

// Security Filter 중에서 BasicAuthenticationFilter 라는 것이 있다.
// 권한이나 인증이 필요한 특정 주소를 요청했을 경우 위의 필터를 무조건 타게 되어있다.
// 만약 권한이나 인증이 필요하지 않다면 위의 필터를 타지 않는다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private JwtUserRepository jwtUserRepository;
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtUserRepository jwtUserRepository) {
        super(authenticationManager);
        this.jwtUserRepository = jwtUserRepository;
    }

    // 인증이나 권한이 필요한 주소요청이 있을 경우 해당 필터를 타게 된다.
    @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.startsWith("Bearer")){
            chain.doFilter(request,response);
            return;
        }

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

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

        // 정상적으로 들어옴
        if(username != null){
            JwtUser userEntity = jwtUserRepository.findByUsername(username);

            PrincipalDetails principalDetails = new PrincipalDetails(userEntity);

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

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

            // 체인을 타게 한다.
            chain.doFilter(request,response);
        }
    }
}

이렇게 JWT를 활용하여 로그인을 해봤습니다.

코드를 더욱더 깔끔하게 하고 싶다면 JwtProperites파일을 만들어 토큰에 대한 정보를 저장하는것이 좋습니다

0개의 댓글