졸업작품

[Spring Boot] Spring Security + JWT (2)

ankisile 2023. 3. 19. 03:51

Spring Security + JWT 1편

https://hse06028.tistory.com/214

 

프로젝트 생성

build.gradle

dependencies{
	...
    implementation 'org.springframework.boot:spring-boot-starter-security'
    
    //jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
}

application.properties

...
jwt.auth = Authorization
jwt.secret = 
jwt.token-validity-in-seconds = 3600
...
  • secret: jwt를 암호화할 때 사용하는 시크릿 키로 사용된다.  JWT를 생성하는데 매우 중요한 정보이기 때문에 해당 정보가 public하게 노출되면 안된다.
  • token-validity-in-seconds: token의 유효 시간을 기재한다.

 

User

유저 엔티티. 유저의 정보들과 유저가 로그인 할 때 사용하는 email과 password로 이루어져 있다.

User.java

@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;


    @Builder
    public User (String name, String email, String password, Role role) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.role = role;
    }
}

Role.java

@Getter
@RequiredArgsConstructor
public enum Role {
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String value;

회원이 일반 사용자만 있도록 만들어 주었다.

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

email을 활용하여 조회하는 메소드만 추가하였다.

UserService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserMainResponseDto signup(UserSaveRequestDto requestDto) {
        if (userRepository.findByEmail(requestDto.getEmail()).orElse(null) != null) {
            throw new RuntimeException("이미 가입된 유저입니다.");
        }

        User user = User.builder()
                .email(requestDto.getEmail())
                .password(passwordEncoder.encode(requestDto.getPassword()))
                .name(requestDto.getName())
                .role(Role.ROLE_USER)
                .build();

        userRepository.save(user);

        return new UserMainResponseDto(user);
    }

    public UserMainResponseDto findById(Long id) {
        return userRepository.findById(id)
                .map(user -> new UserMainResponseDto(user))
                .orElseThrow(InvalidateUserException::new);
    }

    public UserMainResponseDto findUserInfo() {
        String email = findCurrentUserEmail();

        return userRepository.findByEmail(email)
                .map(user -> new UserMainResponseDto(user))
                .orElseThrow(InvalidateUserException::new);
    }

	private String findCurrentUserEmail(){
        String email = SecurityUtil.getCurrentEmail()
                .orElseThrow(EmptyAuthenticationException::new);
        return email;
    }

}

User에 대한 메소드들을 작성해줬다.

  • signup: login 시도한 email과 password가 담긴 UserSaveRequestDto를 매개변수로 받는다. 전달받은 email을 조회하여 가입되지 않은 유저이면 해당 정보를 기반으로 가입을 진행한다.
  • findById: 전달받은 user id를 활용하여 조회한다. 조회되는 user가 없다면 예외를 던진다.
  • findUserInfo: 후에 작성할 SecurityUtil.getCurrentEmail()에서 SecurityContext안에 담긴 user id 정보를 가져온다. 해당 id를 기반으로 조회하여 반환한다. 여기서 user id는 위에서 작성한 User Entity의 email에 해당한다.

UserController.java

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("signup")
    public ResponseEntity<? extends BasicResponse> signup(@Valid @RequestBody UserSaveRequestDto requestDto) {
        return ResponseEntity.ok(new CommonResponse<>(userService.signup(requestDto)));
    }
}
  • signup: 입력받은 requestDto를 검증하여 service에 전달한다.

 

JWT 설정

JwtTokenProvider.java

@Slf4j
@Component
public class JwtTokenProvider implements InitializingBean {
    private static final String AUTHORITIES_KEY = "auth";

    private final String secret;
    private final long tokenValidityInMilliseconds;

    private Key key;

    public JwtTokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }

    // 빈이 생성이 되고 의존성 주입이 되고 난 후에 주입받은 secret 값을 Base64 Decode 해서 key 변수에 할당
    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // Authentication 객체의 권한정보를 이용해서 토큰을 생성하는 createToken 메소드 추가
    public String createToken(Authentication authentication) {
        // 권한들
        String authorities = authentication.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    // token에 담겨있는 정보를 이용해 Authentication 객체를 리턴하는 메소드 생성
    public Authentication getAuthentication(String token) {
        // token을 활용하여 Claims 생성
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        List<SimpleGrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        // claims과 authorities 정보를 활용해 User (org.springframework.security.core.userdetails.User) 객체 생성
        User principal = new User(claims.getSubject(), "", authorities);

        // Authentication 객체를 리턴
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    // 토큰의 유효성 검증을 수행하는 validateToken 메소드 추가
    public boolean validateToken(String token) {
        try {
            // 토큰을 파싱해보고 발생하는 exception들을 캐치, 문제가 생기면 false, 정상이면 true를 return
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}
  • @Component: 스프링 빈으로 등록한다.
  • JwtTokenProvider(@Value("${jwt.secret}") String secret, ... ): application.properties에 명시한 정보를 주입받아 생성하는 생성자이다.
  • void afterPropertiesSet(): InitializingBean을 구현하여 override하였다. 빈이 생성이 되고 의존성 주입이 되고 난 후에 주입받은 secret 값을 Base64 Decode 해서 key 변수에 할당하기 위해 사용하였다.
  • String createToken(Authentication authentication): Authentication 객체의 권한정보를 이용해서 토큰을 생성하여 반환해준다. 단순히 AccessToken만 반환한다.
  • Authentication getAuthentication(String token): token에 담겨있는 정보를 이용해 Authentication 객체를 반환한다.
  • boolean validateToken(String token): 유효한 token인지 검증하는 메소드이다.

JwtFilter.java

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public final JwtTokenProvider jwtTokenProvider;

    // 실제 필터링 로직은 doFilter 내부에 작성 jwt 토큰의 인증 정보를 SecurityContext에 저장하는 역할
    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) request;

        // request에서 jwt 토큰 정보 추출
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        // token 유효성 검증에 통과하면
        if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(jwt); // 정상 토큰이면 SecurityContext 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
        } else {
            log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        chain.doFilter(request, response);

    }

    // request header에서 토큰 정보를 꺼내오는 메소드
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

}

Filter 인터페이스를 구현한 JwtFilter이다.

  • void doFilter(...): HttpServletRequest에서 JWT 정보를 추출하여 앞서 생성한 검증 메소드를 통해 검증한다. 통과하면 SecurityContext에 해당 정보를 가진 Autentication 객체를 저장한다.
  • String resolveToken(...): request header에서 토큰 정보를 꺼내온다.

JwtSecurityConfig.java

@Component
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final JwtFilter jwtFilter;

    // JwtFilter를 Security로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        // Security 로직에 필터를 등록
        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }

}

SecurityConfigurer<DefualtSecurityFilterChain, HttpSecurity> 인터페이스를 구현한 JwtSecurityConfig이다. JwtFilter를 Security 로직에 등록한다.

Spring Security는 각각 역할에 맞는 필터들이 체인 형태로 구성되어 순서에 맞게 실행된다. 특정한 필터를 생성하여 등록하기 위해서는 addFilterBefore을 활용하여 사용한다.

 

JwtAuthenticationEntryPoint.java

// 유효한 자격증명을 제공하지 않고 접근하려 할 때 401 Unauthorized 에러를 리턴
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할 때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

JwtAccessDeniedHandler.java

// 필요한 권한이 존재하지 않는 경우 403 Forbidden 에러를 리턴
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할 때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

 

Security 설정

SecurityConfig.java

@RequiredArgsConstructor
@EnableGlobalMethodSecurity(prePostEnabled = true) //@PreAuthorize 어노테이션을 메소드 단위로 추가하기 위해 사용
@EnableWebSecurity // 스프링 시큐리티가 스프링 체인 필터에 등록. 기본적인 web 보안 활성화
public class SecurityConfig{

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtSecurityConfig jwtSecurityConfig;
    private final JwtFilter jwtFilter;


    private static final String [] PERMIT_URL_ARRAY = {
            "/v3/api-docs/**",
            "/swagger-ui/**"
    };

    // password encoder
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return
                http
                        .cors().disable()
                        .formLogin().disable()
                        .headers().frameOptions().disable()
                        .and()
                        .csrf().disable()
                        .sessionManagement()
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                        .and()
                        .authorizeRequests() //HttpServeletRequest를 사용하는 요청들에대한 접근제한을 설정
                        .antMatchers(PERMIT_URL_ARRAY).permitAll()
                        .antMatchers("/api/signup").permitAll()
                        .antMatchers("/api/signin").permitAll()
                        .anyRequest().authenticated()
                        .and()
                        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) //OAUTH2 사용한다면 변경 필요
                        // Exception을 핸들링할 때 직접 만든 클래스를 추가
                        .exceptionHandling()
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                        .and().build();
    }

}

WebSecurityConfigurerAdapter 인터페이스를 구현한 구현체이다. Spring Security에 필요한 설정들을 적절히 추가한다.

  • @EnableWebSecurity: 웹 보안을 활성화 한다.
  • @EnableGlobalMethodSecurity: @PreAuthorize, @PostAuthorize 애노테이션을 사용하기 위해 추가
  • passwordEncoder: password encoder

SecurityUtil.java

@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SecurityUtil {

    public static Optional<String> getCurrentEmail() {
        // Security Context에 Authentication 객체가 저장되는 시점은
        // JwtFilter의 doFilter메소드에서 Request가 들어올 때 SecurityContext에 Authentication 객체를 저장해서 사용
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null) {
            log.debug("Security Context에 인증 정보가 없습니다.");
            return Optional.empty();
        }

        String username = null;
        if (authentication.getPrincipal() instanceof UserDetails) {
            UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
            username = springSecurityUser.getUsername();
        } else if (authentication.getPrincipal() instanceof String) {
            username = (String) authentication.getPrincipal();
        }

        return Optional.ofNullable(username);
    }
}

SecurityContext에 Authentication 객체가 저장되는 시점은 JwtFilter의 doFilter메소드가 실행될 때이다. 

SecurityContext에서 Authentication 객체를 get 한다.

그렇게 얻은 authentication 객체가 정상적인 값이 들어 있다면 username을 꺼내와 반환한다.

 

Login

AuthController.java

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {
    private final AuthService authService;

    @PostMapping("/signin")
    public ResponseEntity<TokenResponseDto> authorize(@Valid @RequestBody LoginDto loginDto) {

        TokenResponseDto tokenResponseDto = authService.login(loginDto);

        // 1. Response Header에 token 값을 넣어준다.
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + tokenResponseDto.getToken());

        // 2. Response Body에 token 값을 넣어준다.
        return new ResponseEntity<>(tokenResponseDto, httpHeaders, HttpStatus.OK);
    }

}

 

AuthService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthService {

    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    public TokenResponseDto login(UserLoginDto loginDto) {

        // username, password를 파라미터로 받고 이를 이용해 UsernamePasswordAuthenticationToken을 생성
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        // authenticationToken을 이용해서 Authenticaiton 객체를 생성하려고 authenticate 메소드가 실행될 때
        // CustomUserDetailsService에서 override한 loadUserByUsername 메소드가 실행된다.
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // authentication 을 기준으로 jwt token 생성
        String jwt = tokenProvider.createToken(authentication);

        return new TokenResponseDto(jwt);
    }
}

사용자가 입력한 login 정보를 기반으로 UsernamePasswordAuthenticationToken 객체를 생성한다. 아직 인증이 완료되지 않은 객체이기 때문에 authenticationManagerBuilder를 활용하여 AuthenticationManager의 구현체인 ProviderManger의 authenticate 메소드를 실행하여 검증 후 Authenication 객체를 받는다.

해당 정보를 기준으로 JWT를 생성하여 반환해준다.

 

CustomUserDetailsService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    // DB에서 유저정보를 가져온다.
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .map(user -> createUserDetails(user))
                .orElseThrow(() -> new UsernameNotFoundException(username + " 존재하지 않는 username 입니다."));
    }

    // DB에서 조회한 user 정보를 기반으로 UserDetails의 구현체인
    // User (org.springframework.security.core.userdetails.User) 를 생성하여 return 한다.
    private UserDetails createUserDetails(User user) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getRole().toString());

        return new org.springframework.security.core.userdetails.User(
                user.getEmail(),
                user.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
}

UserDetailsService 인터페이스를 구현한 클래스이다. 위에서 언급한 authenticate 메소드가 실행될 때 loadUserByUsername을 통하여 DB정보를 가져온 후 사용자의 PW가 일치하는지 검증한다. UserDetails 객체를 반환하기 위해 createUserDetails 메소드에서 User entity의 정보를 기반으로 org.springframework.security.core.userdetails.User 생성하여 반환한다.

 

 

* UsernamePasswordAuthenticationToken

UsernamePasswordAuthenticationToken은 Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스로, User의 ID가 Principal 역할을 하고, Password가 Credential의 역할을 한다. UsernamePasswordAuthenticationToken의 첫 번째 생성자는 인증 전의 객체를 생성하고, 두번째는 인증이 완료된 객체를 생성한다.

 

 

참고자료