Spring Security (2) 적용

박영준·2023년 2월 3일
0

Spring

목록 보기
9/58

1. JwtFilter

Request Header 에서 가져온 토큰을 필터링하는 과정을 담당

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

  private final TokenProvider tokenProvider;

  /**
   * 실제 필터링 로직은 doFilter 내부에 작성 jwt 토큰의 인증 정보를 SecurityContext에 저장하는 역할.
   */
  @Override
  public void doFilter(ServletRequest request,ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
      
    // 1~7. Request 객체에서 담겨져 온 토큰을 조회
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;

    String jwt = resolveToken(httpServletRequest);

    String requestURI = httpServletRequest.getRequestURI();

	// # 2. TokenProvider 참고
    if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
    
      // 8. Provider에 의해 인증 절차를 진행. 인증이 완료되면 Authentication 객체를 리턴
      Authentication authentication = tokenProvider.getAuthentication(jwt); 
      
 	  // 9. Authentication 객체를 SecurityContextHolder에 저장
      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;
  }
}

2. TokenProvider

토큰 생성 및 유효성 검사, Authentication 객체 생성을 담당

@Slf4j
@Component
public class TokenProvider implements InitializingBean {

  private final String secret;
  private final long tokenValidityInMilliseconds;

  private Key key;

  public TokenProvider(
      @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 userName) {

    String authorities = authentication.getAuthorities()
        .stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining(","));

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

    Claims claims = Jwts.claims()
        .setSubject(authentication.getName())
        .setExpiration(validity);

    claims.put(AUTHORITIES_KEY, authorities);
    claims.put(NAME_KEY, userName);

    return Jwts.builder()
        .setClaims(claims)
        .signWith(key, SignatureAlgorithm.HS512)
        .compact();
  }

  /**
   * token에 담겨있는 정보를 이용해 Authentication 객체를 리턴하는 메소드 생성.
   */
  public Authentication getAuthentication(String token) {

    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());

    User principal = new User(claims.getSubject(), "", authorities);

    return new UsernamePasswordAuthenticationToken(principal, "", authorities);
  }

  /**
   * 토큰의 유효성 검증을 수행하는 validateToken 메소드 추가.
   */
  public boolean validateToken(String token) {
    try {
      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;
  }

3. CustomUserDetailsService

번호 6. 참고

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

  private final UserRepository userRepository;

  // DB 에 저장된 사용자 정보와 일치하는지 여부를 판단
  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

    User user = userRepository.findById(Integer.parseInt(username))
        .orElseThrow(() -> new UsernameNotFoundException(username + " 존재하지 않는 username 입니다."));

    return createUserDetails(user);
  }

  private UserDetails createUserDetails(User user) {

    return new org.springframework.security.core.userdetails.User(
        String.valueOf(user.getId()),
        user.getPassword(),
        List.of(new SimpleGrantedAuthority(user.getRole().toString()))
    );
  }
}

위 구현 클래스 외에도, Spring Security를 사용하기 위한 Config 클래스도 필요함

++ 추가 예시
UserDetailsServiceImpl

@Service    // 빈으로 사용할 것이다
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    //UserDetailsServiceImpl 은 DB 에서 user 를 조회하고, 인증한 다음, UserDetails 를 반환하고, UserDetails 를 사용해서 인증 객체를 만든다
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        System.out.println("UserDetailsServiceImpl.loadUserByUsername : " + username);

        //user 를 DB 에서 조회
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        //DB 에서 조회를 해온 user, username, password 를 User 객체에 담아주면서 UserDetailsImpl 를 반환
        return new UserDetailsImpl(user, user.getUsername(), user.getPassword());
    }
}

UserDetailsImpl

public class UserDetailsImpl implements UserDetails {

    //인증이 완료된 사용자 추가----------------------------------------------------
    private final User user;    // 인증완료된 User 객체 --> user 를 담는다
    private final String username;      // 인증완료된 User의 ID
    private final String password;      // 인증완료된 User의 PWD

    //생성자
    public UserDetailsImpl(User user, String username, String password) {
        this.user = user;
        this.username = username;
        this.password = password;
    }

    // 인증완료된 User 를 가져오는 Getter
    //getUser: user 를 가져온다
    public User getUser() {
        return user;
    }
    //----------------------------------------------------------------------

    //사용자의 권한 GrantedAuthority 로 추상화 및 반환---------------------------------------------------
    //권한을 가지고 오는 부분
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        UserRoleEnum role = user.getRole();     //1. 유저의 권한을 가져와서(user.getRole())
        String authority = role.getAuthority();     //2. 그것(role)을 String 값으로 만들고

        //3. 추상화해서 사용
        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(simpleGrantedAuthority);

        return authorities;
    }
    //----------------------------------------------------------------------------------------------

    //사용자의 ID, PWD Getter--------------------------------------------------------------------------
    //기본적으로 이렇게 반환값이 default 로 설정되는데, username 과 password 를 사용해야하므로(this.username = username; 이런 부분) 다르게 설정해줌
    @Override
    public String getUsername() {   //username 가져온다.
        return this.username;   //반환값은 기본적으로 null
    }

    @Override
    public String getPassword() {   //password 가져온다
        return this.password;
    }
    //-----------------------------------------------------------------------------------------------

    @Override
    public boolean isAccountNonExpired() {
        return false;       //기본적으로 이렇게 반환값이 default 로 설정되는데, username 과 password 를 사용해야하므로 다르게 설정해줌
    }

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

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

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

4. JwtSecurityConfig

JwtFilter를 SecurityConfig에 적용할 때 사용

@Configuration
@EnableWebSecurity
@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);
  }
}

5. SecurityConfig

Spring Security 관련 설정 파일

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

  /**
   * 암호화 방식 선택
   */
  @Bean
  public PasswordEncoder passwordEncoder() {

    return new BCryptPasswordEncoder();
  }

  /**
   * 어플리케이션 자체에 넘어오는 요청에 대한 인증, 인가 관련 설정에 대한 메소드.
   * 이 위치에서 제외된 API들은 Spring Security의 검증 대상 자체에서 제외됩니다.
   */
  @Override
  public void configure(WebSecurity web) throws Exception {   
    web
        .ignoring()
        .antMatchers("/chat/health/check")
        .antMatchers("/ws/send/message")
        .antMatchers("/ws/connect");
  }

  /**
   * API 접근에 대한 인증 처리 관련 설정.
   * 선택적으로 Spring Security에 의한 인증, 인가 절차 대상 및 방법을 설정할 수 있습니다.
   */
  @Override
  protected void configure(HttpSecurity http) throws Exception {
      http
          .csrf().disable()

          .exceptionHandling()
          .authenticationEntryPoint(jwtAuthenticationEntryPoint)
          .accessDeniedHandler(jwtAccessDeniedHandler)

          .and()
          .sessionManagement()
          .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

          // 인가 절차를 생략할 API를 지정
          .and()
          .authorizeRequests()
          .antMatchers("/user/signup", "/user/login").permitAll()

          // 그 외 API는 인증 절차 수행
          .anyRequest().authenticated()

          // JwtSecurityConfig 클래스 적용
          .and()
          .apply(jwtSecurityConfig);
    }
}

++ 추가 예시 : Spring Security 관련 설정 + 권한 설정
WebSecurityConfig (config 패키지로 분류했음)

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)  // TestController 에서의 @Secured 어노테이션 활성화
public class WebSecurityConfig {

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomAccessDeniedHandle customAccessDeniedHandler;
    private final UserDetailsServiceImpl userDetailsService;

    // 비밀번호 암호화 기능 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();     //BCryptPasswordEncoder: BCrypt 형식의 Password --> 적응형 단방향이 자동 적용됨
    }

    @Bean
    //WebSecurityCustomizer 은 SecurityFilterChain 보다 우선적으로 걸리는 설정
    public WebSecurityCustomizer webSecurityCustomizer() {
        // h2-console 사용 및 resources 접근 허용 설정
        //ignoring(): 이러한 경로도 들어온 것들은 인증 처리하는 것을 무시하겠다
        return (web) -> web.ignoring()
                //아래의 것들(아래의 ("/h2-console/**") 이런 URL 들)을 한번에 설정해줄 수 있음
                .requestMatchers(PathRequest.toH2Console())
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Bean
    //Security 는 모든 요청을 다 인증하기 때문에, ("/h2-console/**") 이런 것 들을 일일이 다 인증할 수 가 없다
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF 설정
        http.csrf().disable();

        http.authorizeRequests().antMatchers("/api/user/**").permitAll()
//                .antMatchers("/h2-console/**").permitAll()    //그래서, permitAll() 를 사용해서 ("/h2-console/**") 이런 URL 들을 인증하지 않고 실행 할 수 있게 함
//                .antMatchers("/css/**").permitAll()
//                .antMatchers("/js/**").permitAll()
//                .antMatchers("/images/**").permitAll()
//                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()

                //추가) 이런 설정도 가능함
                //.antMatchers(HttpMethod.GET, "/api/user").hasRole()

                //그 이외의 URL 요청들을 전부 다 authentication(인증 처리)하겠다
                .anyRequest().authenticated();

        // Custom 로그인 페이지 사용
        // Security 에서 제공하는 default Form Login 을 사용하겠다
        // loginPage("/api/user/login-page").permitAll(): Custom 로그인 페이지 사용
        //http.formLogin().loginPage("/api/user/login-page"): Form Login 방식에서 인증이 되지 않는 요청을 로그인 페이지로 보낼 때, 기존의 로그인 페이지가 아닌, 우리가 custom 한 로그인 페이지를 반환하는 URL 로 요청 되어짐
        //permitAll(): 이 요청은 다 허가해주겠다
        http.formLogin().loginPage("/api/user/login-page").permitAll();

        // Custom Filter 등록하기
        //addFilterBefore: 어떤 Filter 이전에 추가하겠다 --> 우리가 만든 CustomSecurityFilter 를 UsernamePasswordAuthenticationFilter 이전에 실행할 수 있도록
        //CustomSecurityFilter = 우리가 custom 한 SecurityFilter 를 사용하기 때문에, JWT 토큰을 검증하는 추가적인 Filter 가 필요
        //1. CustomSecurityFilter 를 통해 인증 객체를 만들고 --> 2. context 에 추가 --> 3.인증 완료 --> UsernamePasswordAuthenticationFilter 수행 --> 인증됐으므로 다음 Filter 로 이동 --> Controller 까지도 이동
        http.addFilterBefore(new CustomSecurityFilter(userDetailsService, passwordEncoder()), UsernamePasswordAuthenticationFilter.class);

        // "거부"가 났을 때, 403 Forbidden 페이지(접근 제한 페이지) 이동 설정  --> 왜 주석 처리? 이걸 하면, 이 코드가 우선적으로 잡혀서 밑에 403 쪽 처리가 되지 않아서
        // http.exceptionHandling().accessDeniedPage("/api/user/forbidden");

        // 401 Error 처리, Authorization 즉, 인증과정에서 실패할 시 처리
        http.exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint);

        // 403 Error 처리, 인증과는 별개로, 추가적인 권한이 충족되지 않는 경우
        http.exceptionHandling().accessDeniedHandler(customAccessDeniedHandler);

        return http.build();
    }
}

TestController

@Controller
@RequestMapping("/api")
public class TestController {

    @Secured(value = UserRoleEnum.Authority.ADMIN)	// WebSecurityConfig에서 @EnableGlobalMethodSecurity(securedEnabled = true)으로 인해 사용 가능
    @PostMapping("/test-secured")
    public String securedTest(@AuthenticationPrincipal UserDetails userDetails) {
        System.out.println("*********************************************************");
        System.out.println("UserController.securedTest");
        System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
        System.out.println("*********************************************************");

        return "redirect:/api/user/login-page";
    }
}

UserController

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

    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;

    // ADMIN_TOKEN
    //ADMIN 인지 user 인지 확인하기 위함(빠른 서비스를 위해, 저번 프로젝트와는 달리 controller 에서 처리하도록 함)
    private static final String ADMIN_TOKEN = "AAABnvxRVklrnYxKZ0aHgTBcXukeZygoC";

    @GetMapping("/signup")
    public ModelAndView signupPage() {
        return new ModelAndView("signup");
    }

    @GetMapping("/login-page")
    public ModelAndView loginPage() {
        return new ModelAndView("login");
    }

    @PostMapping("/signup")
    public String signup(SignupRequestDto signupRequestDto) {

        String username = signupRequestDto.getUsername();
        String password = passwordEncoder.encode(signupRequestDto.getPassword());

        // 회원 중복 확인
        Optional<User> found = userRepository.findByUsername(username);
        if (found.isPresent()) {
            throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
        }

        // 사용자 ROLE 확인
        UserRoleEnum role = UserRoleEnum.USER;
        if (signupRequestDto.isAdmin()) {
            if (!signupRequestDto.getAdminToken().equals(ADMIN_TOKEN)) {
                throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
            }
            role = UserRoleEnum.ADMIN;
        }

        User user = new User(username, password, role);
        userRepository.save(user);

        return "redirect:/api/user/login-page";
    }

    @PostMapping("/login")
    //@AuthenticationPrincipal: 인증 객체(Authentication)의 Principal 부분의 값을 가져온다
    //UserDetails userDetails: Filter(CustomSecurityFilter)에서 인증 객체를 만들 때, Principal 부분에 userDetails 를 넣었기 때문에, userDetails 를 파라미터로 받아올 수 있었음
    //userDetails 안에는 user, password 데이터가 들어가있는 상태
    public String login(@AuthenticationPrincipal UserDetails userDetails) {
        System.out.println("*********************************************************");
        System.out.println("UserController.login");
        System.out.println("userDetails.getUsername() = " + userDetails.getUsername());
        System.out.println("*********************************************************");

        return "redirect:/api/user/login-page";
    }

    //"거부"가 났을 때, 403 Forbidden 페이지 적용
    @PostMapping("/forbidden")
    public ModelAndView forbidden() {
        return new ModelAndView("forbidden");
    }
}

UserRoleEnum

public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    //"권한 (Authority)" 설정(권한 1개 이상 설정 가능)
    public static class Authority {
        public static final String USER = "ROLE_USER";  //"USER" 권한 부여 ("권한 이름" 규칙: "ROLE_" 로 시작하게 만들겠다)
        public static final String ADMIN = "ROLE_ADMIN";    //"ADMIN" 권한 부여
    }
}

++ 추가 예시 : 인증과정에서 실패할 시 처리
CustomAuthenticationEntryPoint (WebSecurityConfig 참고)

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private static final SecurityExceptionDto exceptionDto =
            new SecurityExceptionDto(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());

    @Override
    //401, 403 에러가 발생하면, commence() 함수 실행 --> 만드는 값들이 Client 쪽으로 반환됨
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authenticationException) throws IOException {

        //response 에 ContentType, Status 를 넣음
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());

        //ObjectMapper 를 사용해서, String 값으로 변환 --> Client 쪽으로 반환됨
        try (OutputStream os = response.getOutputStream()) {
            ObjectMapper objectMapper = new ObjectMapper();
            objectMapper.writeValue(os, exceptionDto);
            os.flush();
        }
    }
}

Trouble Shooting

  1. style.css 를 넣을 경로(폴더)를 만들지 않아서 css가 적용되지 않았음

  2. could not resolve all files for configuration ':compileclasspath'.
    ...
    could not find org.thymeleaf.extras:thymeleaf-extras-springsecurity6
    ...
    이런 에러 발생
    --> 해결법
    spring boot 버전을 3.0.0 에서 2.7.6 으로 낮춰서 생성


참고: [프로젝트 2] Spring Security를 활용한 인증, 인가 처리 로직 구현

profile
개발자로 거듭나기!

0개의 댓글