GrowMe

JWT를 SpringBoot 프로젝트에 적용하기(1) - Access 토큰 적용 본문

Security

JWT를 SpringBoot 프로젝트에 적용하기(1) - Access 토큰 적용

오늘도 타는중 2022. 8. 24. 06:01
JWT(JSON Web Token)
# JWT란
# JWT의 구조
# 인증 절차
# JWT 적용하기(인증 처리)
# JWT 적용하기(인가 처리)

*JWT란?

  • JSON을 기반으로, 인증에 필요한 정보들을 암호화 시킨 토큰이다.
  • 토큰이란? 마치 일정한 현금의 가치를 지닌 동전처럼, 어떠한 가치(ex : 인증된 유저)의 의미를 지닌 물건이라 할 수 있다.

 


*JWT의 구조

J(Json)WT이므로 Json 형태의 데이터를 base64 방식으로 인코딩하면 위 구조가 완성됩니다.

1. Header

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg : Signature 부분에서 어떤 알고리즘 방식으로 암호화하였는지 적혀있습니다.
  • typ : 어떤 종류의 토큰인지를 나타냅니다.

2. Payload

{
  "sub": "someInformation",
  "name": "phillip",
  "iat": 151623391
}
  • Payload에는 서버에서 활용할 수 있는 유저의 정보가 담겨 있습니다. (실제 JWT를 통해 알 수 있는 데이터)
  • key-value 형식으로 이루어진 한 쌍의 정보를 Claim이라고 칭합니다.

3. Signature

출처 : https://cbw1030.tistory.com/331

  • Signature의 구조는 (Header + Payload)와 서버가 가진 유일한 Key 값을 합친 후, 헤더에서 정한 알고리즘(alg)으로 암호화하여 저장됩니다.
  • Header와 Payload는 단순히 인코딩된 값이기 때문에 제 3자가 디버거를 사용해 약 1초만에 복호화 및 조작할 수 있지만, Signature는 서버 측에서 관리하는 비밀키가 유출되지 않는 이상 복호화할 수 없습니다.
    -> Signature는 토큰의 위변조 여부를 확인하는데 사용됩니다. (해당 유저가 맞는지 서버에서 검증 시 사용)
  • 위 이유로 인해 Payload에는 유저의 비밀번호와 같은 민감한 정보는 포함해서는 안됩니다.

*JWT를 이용한 인증 과정

출처 : https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-JWTjson-web-token-%EB%9E%80-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC#thankYou

  1. 사용자가 ID, PW를 입력하여 서버에 로그인 인증을 요청한다.
  2. 서버에서 클라이언트로부터 인증 요청을 받으면, Header, PayLoad, Signature를 정의한다.Hedaer, PayLoad, Signature를 각각 Base64로 한 번 더 암호화하여 JWT를 생성하고 이를 쿠키에 담아 클라이언트에게 발급한다.
  3. 클라이언트는 서버로부터 받은 JWT를 로컬 스토리지에 저장한다. (쿠키나 다른 곳에 저장할 수도 있음)API를 서버에 요청할때 Authorization headerAccess Token을 담아서 보낸다.
  4. 서버가 할 일은 클라이언트가 Header에 담아서 보낸 JWT가 내 서버에서 발행한 토큰인지 일치 여부를 확인하여 일치한다면 인증을 통과시켜주고 아니라면 통과시키지 않으면 된다.인증이 통과되었으므로 페이로드에 들어있는 유저의 정보들을 활용하여 클라이언트에 돌려준다.
  5. 클라이언트가 서버에 요청을 했는데, 만일 액세스 토큰의 시간이 만료되면 클라이언트는 리프래시 토큰을 이용해서
    서버로부터 새로운 엑세스 토큰을 발급 받는다.

*JWT를 Spring Security를 통해 적용해보기 (인증 처리)

1. 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.auth0:java-jwt:3.19.2'
  • SpringSecurity와 JWT를 사용할 것이기에, 위와 같이 의존성을 추가해준다.

2. User 엔티티 / UserRepository : 유저 정보를 담기위해 필요

@Builder
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    private String name;

    private String pwd;

    private String phone;

    @Enumerated(value = EnumType.STRING)
    @Builder.Default
    private UserStatus userStatus = UserStatus.USER_ACTIVE;

    @OneToMany(mappedBy = "teamBoard", targetEntity = User_TeamBoard.class)
//    @Builder.Default
    private List<TeamBoard> boards = new ArrayList<>();

    @Enumerated(EnumType.STRING)
    private UserRole role;

    public enum UserRole {
        ROLE_USER("일반 사용자"),
        ROLE_TEAMBOSS("팀장"),
        ROLE_ADMIN("관리자");

        @Getter
        private final String role;

        UserRole(String role) {
            this.role = role;
        }
    }

    public enum UserStatus {
        USER_ACTIVE("활동 중"),
        USER_SLEEP("휴면 상태");

        @Getter
        private final String status;

        UserStatus(String status) { this.status = status;}
    }

    public void update(String name, String pwd, String phone, UserStatus status) {
        this.name = name;
        this.pwd = pwd;
        this.phone = phone;
        this.userStatus = status;
    }
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    public User findByName(String name);
}

3. SecurityConfig 클래스 (시큐리티 설정 파일)

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserRepository userRepository;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf().disable();  // csrf 보호를 해제한다. csrf 보호 : get 요청을 제외한 Post, Patch, Delete 요청으로부터 보호한다.
        httpSecurity.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 인증방식이 아니기에 세션생성을 막아놓는다.(Stateless로 진행)
                .and()
                .httpBasic().disable()	// JWT 동작만 rest API로 테스트할 것이기에 불필요하게 나오는 기본폼을 없애기 위함.
                .apply(new CustomLogin())
                .and()
                .authorizeRequests()
                .antMatchers("/boss/**").access("hasRole('ROLE_TEAMBOSS') or hasRole('ROLE_ADMIN')")
                .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll()
                .and()
                .logout()
                .logoutSuccessUrl("/");
        return httpSecurity.build();
    }

    public class CustomLogin extends AbstractHttpConfigurer<CustomLogin, HttpSecurity> {
        @Override
        public void configure(HttpSecurity http) throws Exception {
            AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
            http
                    .addFilter(new JwtAuthenticationFilter(authenticationManager))
                    .addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository));
        }
    }
}

 

  • @EnableWebSecurity : Spring Security 설정들을 활성화시켜 줍니다.
  • @Configuration : SecurityFilterChain 객체를 빈으로 등록시켜 주어 시큐리티 설정을 진행 가능하게 합니다.
    (Spring Security 5.7.0-M2 부터 기존의 WebSecurityConfigurerAdapter를 상속 후, configure 메소드를 오버라이딩하는 방식에서 권장 방식이 변경됨)
  • Spring Security를 사용하기 위해서는 Spring Security Filter Chain사용한다는 것을 명시해줘야 하는데, 위 두 설정이 그것을 위함이라고 생각하면 이해하기 편할 것입니다.
  • apply() : 커스텀 로그인 방식을 사용합니다.
  • CustomLogin 클래스 : JWT 필터를 적용하기 위해 내부 클래스를 만들어, 클래스 내부에서 .addFilter() 메소드를 통해 필터를 추가하게 동작한 뒤, apply() 메서드를 통해 해당 필터를 적용시킵니다.
    여기서는 아래에서 직접 만들 인증 담당 JwtAuthenticationFilter 필터와 인가 담당 JwtAuthorizationFilter 필터를 추가하였습니다. 
  • 기타 기본적인 Security 설명은 아래 블로그 내용을 참고 바랍니다.
    https://grow-myself.tistory.com/38
 

[Spring] 스프링부트에서의 스프링 시큐리티 기본 세팅

Spring Security 기본 세팅 # Gradle 추가 및 SecurityConfig 파일 생성 # 예외처리 # 로그인 화면을 커스텀 페이지로 # id, pw 인증 처리 # 비밀번호 암호화 방식 커스텀 *Gradle 추가 / SecurityConfig - bu..

grow-myself.tistory.com

4. JwtAuthenticationFilter (인증 처리 클래스)

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

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("로그인 시도");

        try {
        //	로그인 요청으로부터 유저 정보를 받아 저장한다.
            ObjectMapper om = new ObjectMapper();
            User user = om.readValue(request.getInputStream(),User.class);
        // 	토큰 생성하여 UsernamePasswordAuthenticationToken 객체에 넣어준다.(인증은 아직되지않음)
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getName(), user.getPwd());
        //  UsernamePasswordAuthenticationToken에 있는 유저정보에 대한 인증을 시도한다.    
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
            return authentication;

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

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain, Authentication authResult) throws IOException, ServletException {
        System.out.println("인증 성공 유저 처리");
        // 인증이 성공한 유저의 정보를 저장한다.
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
		// JWT 토큰을 생성하여 Response 헤더에 추가한다.
        String jwtToken = JWT.create()
        		// JWT 토큰의 Payload 중 sub의 값 할당
                .withSubject("todo jwt token")
                // JWT 토큰의 유효기간 설정
                .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 60)))
                // JWT 토큰의 Payload에 유저정보 저장
                .withClaim("id", principalDetails.getUser().getUserId())
                .withClaim("username", principalDetails.getUsername())
                // JWT 토큰의 서명 값으로 비밀키를 암호화하여 저장
                .sign(Algorithm.HMAC512("todo jwt token"));
                // JWT 토큰을 응답 헤더에 추가
        response.addHeader("Authorization", "Bearer " + jwtToken);
    }
}

 

  • UsernamePasswordAuthenticationFilter 를 오버라이딩하여 유저의 인증 처리를 커스텀하였습니다.
  • authenticate() : 아래에서 만들 PrincipalDetailsService 클래스(UserDetailsService를 구현)와 PrincipalDetails 클래스(UserDetails를 구현) 를 활용하여 전달받은 Athentication 객체에 있는 유저정보를 검증합니다. (인증 실패 시 )
  • 인증되어 반환되는 Authentication 객체는 SecurityContext로 저장됩니다. 이후 전역적으로 SecurityContextHolder에서 사용할 수 있습니다.
  • 인증 성공 후, successfulAuthentication()를 오버라이딩하여 응답 헤더에 JWT 토큰을 넣어 전달합니다.

5. PrincipalDetailsService(실제 인증 처리 클래스)

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User userEntity = userRepository.findByName(username);
        return new PrincipalDetails(userEntity);
    }
}
  • UserDetailsService를 상속함으로써, UsernamePasswordAuthenticationFilter 내부 authenticate() 메서드 실행 시 이 클래스의 loadUserByUsername() 메서드가 동작하게된다. 본래 이 클래스 공간에서 유저의 유효성 검증(패스워드 체크 등)을 하게된다. 여기서는 유효성 검증 로직은 생략하고, 그냥 바로 유저의 이름을 통해 데이터베이스에 저장된 유저의 정보를 PrincipalDetails 클래스에 바로 담아 리턴하였다.

6. PrincipalDetails

@Data
public class PrincipalDetails implements UserDetails {
	// PrincipalDetailsService에서 인자로 전달받은 User 정보를 주입한다.
    private final User user;

	// 유저의 권한목록을 반환한다. 여기서는 권한종류를 하나로하였기에 getRole사용
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(() -> String.valueOf(user.getRole()));
        return authorities;
    }

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

    @Override
    public String getUsername() {
        return user.getName();
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • isAccountNonExpired() 등 기타 유효성 검증은 JWT 구현에 집중하기 위해 모두 true로 통과되게끔 하였습니다.

*현재까지 진행 결과

아래와 같이 로그인 성공 시, 응답 헤더에 Authorization의 value 값으로 토큰이 들어옴을 확인 가능합니다.


*JWT를 Spring Security를 통해 적용해보기 (인가 처리)

7. JwtAuthorizationFilter

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
    private UserRepository userRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository = userRepository;
    }

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

	// 헤더에 있는 JWT 토큰을 받아와 담습니다.
        String jwtHeader = request.getHeader("Authorization");
		// JWT 토큰이 Null 이거나 Bearer로 시작되지 않으면(JWT 인증방식의 규칙) 인가처리 X 후 그냥 넘겨버립니다.
        if(jwtHeader == null || !jwtHeader.startsWith("Bearer")){
            filterChain.doFilter(request, response);
            return;
        }
	// 규칙을 위해 추가했던 Bearer를 빼고나서 JWT 토큰 검증을 시도합니다.
        String jwtToken = jwtHeader.replace("Bearer ", "");
        String username = JWT.require(Algorithm.HMAC512("todo jwt token")).build().verify(jwtToken).getClaim("username").asString();

	// 검증이 성공했다면 인증정보와 권한정보가 담긴 Authentication 객체를 새로 생성하여 SecurityContextHolder에 담습니다.
        if(username != null) {
            User userEntity = userRepository.findByName(username);

            PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
            Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

	// 다음필터로 넘깁니다.
            filterChain.doFilter(request, response);
        }
        super.doFilterInternal(request,response,filterChain);
    }
}

 

  • 인증이나 권한이 필요한 주소가 요청되면, JWT 토큰이 유효한지 검증해야합니다.
  • JWT.require(Algorithm.HMAC512("todo jwt token")).build() : 토큰 해독 객체를 생성합니다. 이 때 require의 인자로 쓰인 Algorithm.HMAC512("todo jwt token")은 우리가 JwtAuthenticationFilter 클래스에서 생성했던 JWT토큰의 서명 값으로 사용했던 비밀 키값을 알고리즘화한 것과 동일한 것임을 알 수 있습니다. 즉, 가지고 있는 키값을 다시한번 암호화해 값을 verify()에서 비교합니다.
  • verify() : 인자로 전달받은 토큰을 다시한번 생성한 암호화 서명값과 비교합니다. 서명값이 다르거나, 토큰의 유효기간이 다되는 등, 토큰이 유효하지 않다면 SignatureVerificationException, TokenExpiredException 등의 에러를 발생시킵니다.
  • getClaim("username").asString() : 토큰의 값 중, username 키값의 value 값을 String으로 반환합니다.

8. LoginController

@RestController
@RequiredArgsConstructor
//@Controller

public class LoginController {

    private final UserService userService;

    // 회원가입
    @PostMapping("/join")
    public String join(@RequestBody UserDto.PostDto postDto) {
        userService.save(postDto);
        return "회원가입 완료";
    }
}

9. UserService

@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public User save(UserDto.PostDto postDto) {
        User user = User.builder()
                        .name(postDto.getName())
                        .pwd(bCryptPasswordEncoder.encode(postDto.getPwd()))
                        .phone(postDto.getPhone())
                        .userStatus(User.UserStatus.USER_ACTIVE)
                        .role(User.UserRole.ROLE_USER)
                        .build();
        return userRepository.save(user);
    }
}
  • 위처럼 회원가입 시, 기본적으로 Role_USER의 권한을 부여하고 있습니다.
  • 따라서, USER 권한이 아닌, User 엔티티에서 정의했던 다른 권한이 필요한 Url 접근은 차단되어야 할 것입니다.

*인가 처리 결과 테스트

1. USER 권한 URI 접근

  • Request 헤더에 토큰을 넣어 주었더니 정상적으로 접근하여, 응답이 가능한 것을 확인할 수 있습니다.

2. USER 권한 외 URI 접근(권한이 없는 상태)

  • 위처럼 토큰을 Request 헤더에 넣어주었음에도 403 에러가 결과로 나옴을 알 수 있다.
  • 403 에러는 사용자에 대한 액세스를 금지할 때 나오는 에러이므로 인가처리가 정상적으로 동작하고 있습니다.

*Refresh Token을 적용한 사례는 아래 포스팅을 참고해주세요~

https://grow-myself.tistory.com/49

Comments