[Spring Security] 실습

in_ho_·2023년 10월 24일
0

Spring Security

목록 보기
2/2
post-thumbnail

1. 의존성 추가

// spring-boot-starter-security
implementation 'org.springframework.boot:spring-boot-starter-security'     
/* jjwt */
implementation 'io.jsonwebtoken:jjwt:0.9.1'
  • 스프림 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행
  • 참고로 이 필터에서는 인증이 실패하면 로그인 폼이 포함된 화면을 전달하게 됩니다.

    REST API에는 이러한 화면이 없으므로 UsernamePasswordAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경하는 작업 방식으로 구현하겠습니다.

2. UserDetails 구현

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
@Entity
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false, unique = true)
    private String uid;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String name;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }


    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }


    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }


    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }


    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • getAuthorities()

    • 계정이 가지고 있는 권한 목록을 리턴합니다.
  • getPassword()

    • 계정의 비밀번호를 리턴합니다.
  • getUsername()

    • 계정의 아이디를 리턴합니다.
  • isAccountNonExpired()

    • 계정이 만료됐는지 리턴합니다.(true : 만료안됌)
  • isAccountNonLocked()

    • 계정이 잠겨있는지 리턴합니다.(true : 잠겨 있지 않음)
  • isCredentialNonExpired()

    • 비밀번호가 만료됐는지 리턴합니다.(true : 만료안됌)
  • isEnabled()

    • 계정이 활성화돼 있는지 리턴합니다.(true : 활성화)
  • UserDetails는 스프링 시큐리티에서 제공하는 개념입니다. UserDetails의 username은 각 사용자를 구분할 수 있는 IDfmf dmlalgkqslek.

3. UserRepository 구현

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User getByUid(String uid);
}
  • User 엔티티의 PK는 인덱스 값이기 때문에 사용자 아이디를 토큰 생성 정보로 사용하기 위해 getByUid 메서드를 사용합니다.

4. UserDetailsServiceImple 구현

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    private final UserRepository userRepository;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.getByUid(username);
    }
}

5. JwtTokenProvider 구현

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private final Logger log = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;

    @Value("${springboot.jwt.secret}")
    private String secretKey;
    private final long tokenValidMillisecond = 1000L * 60 * 60;

    @PostConstruct
    protected void init() {
        log.info("[JwtTokenProvider] secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        log.info("[JwtTokenProvider] secretKey 초기화 완료");
    }

    public String createToken(String userUid, List<String> roles) {
        log.info("[JwtTokenProvider/createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);

        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        log.info("[JwtTokenProvider/createToken] 토큰 생성 완료");
        return token;
    }

    public Authentication getAuthentication(String token) {
        log.info("[JwtTokenProvider/getAuthentication] 토큰 조회 시작");
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
        log.info("[JwtTokenProvider/getAuthentication] 토큰 조회 완료 Username : {}", userDetails.getUsername());

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUsername(String token) {
        log.info("[JwtTokenProvider/getUsername] 토큰에서 회원 정보 추출 시작");
        String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJwt(token).getBody().getSubject();
        
        log.info("[JwtTokenProvider/getUsername] 토큰에서 회원 정보 추출 완료 info : {}", info);

        return info;
    }

    public String resolveToken(HttpServletRequest request) {
        log.info("[JwtTokenProvider/resolverToken] HTTP 헤더에서 Token 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }

    public boolean validateToken(String token) {
        log.info("[JwtTokenProvider/validateToken] 토큰 유효성 체크 시작");
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);

            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            log.error("[JwtTokenProvider/validateToken] 토큰 유효성 체크 실패");
            return false;
        }
    }
}
  • application.yaml에 아래의 값을 추가합니다.
springboot:
  jwt:
    secret: ~~~

해당 값은 임의로 지정한 값입니다.

6. JwtAuthenticationFilter 구현

  • JwtAuthenticationFilter는 JWT 토큰으로 인증하고, SecurityContextHolder에 추가하는 필터를 설정하는 클래스입니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);
        log.info("[JwtAuthenticationFilter/doFilterInternal] token 추출 완료 : {}", token);


        log.info("[JwtAuthenticationFilter/doFilterInternal] token 유효성 체크 시작");
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            
            log.info("[JwtAuthenticationFilter/doFilterInternal] token 유효성 체크 완료");
        }
        
        filterChain.doFilter(request, response);
    }
}
  • 스프링 부트에서 필터를 여러 방법으로 구현할 수 있지만, 가장 편한 방법은 필터를 상속받아 사용하는 것입니다.
  • 위의 코드는 doFilter()를 기준으로 서블릿이 실행되기 전의 코드와 서블릿이 실행된 후에 실행되는 코드로 나뉩니다.

7. SecurityConfiguration 구현

@RequiredArgsConstructor
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;
    
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable()
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/sign-api/sign-in", "/sign-api/sign-up", "/sign-api/exception").permitAll()
                .antMatchers(HttpMethod.GET, "/product**").permitAll()
                .antMatchers("**exception**").permitAll()
                .anyRequest().hasRole("ADMIN")
                .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity webSecurity) {
        webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**", "/swagger-ui.html", 
                "/webjars/**", "/swagger/**", "/sign-api/exception");
    }
}

7-1. configure(HttpSecurity)

  • 스프링 시큐리티의 대부분 설정을 여기서 함.
  1. 리소스 접근 권한 설정
  2. 인증 실패 시 발생하는 예외 처리
  3. 인증 로직 커스터마이징
  4. csrf, cors 등의 스프링 시큐리티 설정
  • httpBasic().disable()
    • UI를 사용하는 것을 기본값으로 가진 시큐리티 설정 비활성화
  • csrf().disable()
    • REST API에서는 csrf 보안이 필요 없기 때문에 비활성화
  • sessinManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    • 세션을 사용하지 않음.
  • authorizeRequest()
    • 애플리케이션에 들어오는 요청에 대한 사용 권한을 체크
    • antMatchers()는 antPattern을 통해 권한을 설정할 수 있습니다.
      1. /sign-api/sign-in, /sign-api/sign-up, /sing-api/exception 경로는 모두에게 허용합니다.
      2. /product로 시작하는 GET 요청은 모두 허용합니다.
      3. exception이라는 단어가 들어간 경로는 모두 허용합니다.
      4. 다른 요청은 인증된 권한을 가진 사용자에게 허용합니다.
  • exceptionHandling().accessDeniedHandler()
    • 권한을 확인하는 과정에서 통과하지 못할 경우 예외를 전달
  • exceptionHandling().authenticationEntryPoint()
    • 인증 과정에서 예외가 발생할 경우 예외를 전달.
  • addFilterBefore()
    • 어느 필터 앞에 추가할 지 지정할 수 있음.
    • 지금의 코드는 UsernamePasswordAuthenticationFilter 앞에 필터를 추가하겠다는 의미입니다.

7-2. configure(WebSecurity)

  • WebSecurity를 핸들링하는 configure는 HttpSecurity를 핸들링 하는 configure 메서드 보다 먼저 동작합니다.
  • 그렇기 때문에 인증, 인가가 필요하지 않는 리소스 접근에 대해서만 주로 허용합니다.

8. CustomAccessDeniedHandler, CustomAuthenticationEntryPoint 구현

8-1. CustomAccessDeniedHandler

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    private final Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.info("[CustomAccessDeniedHandler/handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}
  • AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 때 발생하는 예외입니다.

8-2. CustomAuthenticationEntryPoint

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private Logger log = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
    
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ObjectMapper objectMapper = new ObjectMapper();
        log.info("[CustomAuthenticationEntryPoint/commence] 인증 실패로 error 발생");

        EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
        
        entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
        response.setStatus(401);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
    }
}
  • EntryPointErrorResponse
@Data
public class EntryPointErrorResponse {
    private String msg;
}

9. 회원가입과 로그인 구현

  • SignService를 구현한 후 signUp, signIn 메서드를 생성하겠습니다.
public interface SignService {
    SignUpResultDTO signUp(String id, String password, String name, String role);

    SignInResultDTO signIn(String id, String password) throws RuntimeException;
}

9-1. SignUpResultDTO

@AllArgsConstructor
@Data
public class SignUpResultDTO {
    private boolean isSuccess;
    private int code;
    private String msg;
}

9-2. SignInResultDTO

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignInResultDTO {
    private String token;
    private boolean isSuccess;
    private String msg;
    private int code;
}

9-3. 회원가입 로직 구현

@Service
@RequiredArgsConstructor
public class SignServiceImpl implements SignService {

    private final UserRepository userRepository;

    private final JwtTokenProvider jwtTokenProvider;

    private final PasswordEncoder passwordEncoder;

    private final Logger log = LoggerFactory.getLogger(SignServiceImpl.class);

    @Override
    public SignUpResultDTO signUp(String id, String password, String name, String role) {
        log.info("[SignServiceImpl/signUp] 회원 가입 로직 시작");
        User user = null;
        
        if (userRepository.getByUid(id) != null) {
            throw new DuplicateUserIdException("동일한 아이디의 사용자가 있습니다.");
        }


        /* Step 1. 권한별 엔티티 객체 생성 */
        if ("admin".equalsIgnoreCase(role)) {
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList("ROLE_ADMIN"))
                    .build();
        } else {
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList("ROLE_USER"))
                    .build();
        }

        /* Step 2. DB에 저장 */
        User savedUser = userRepository.save(user);
        SignUpResultDTO signUpResultDTO = null;

        /* Step 3. 저장이 맞게 되었는지 검증 */
        if (savedUser.getName().isEmpty()) {
            log.error("[SignServiceImpl/signUp] 회원가입 실패");

            signUpResultDTO = SignUpResultDTO.builder()
                    .isSuccess(Status.FAIL.value())
                    .code(HttpStatus.BAD_REQUEST.value())
                    .msg("회원가입을 성공했습니다.")
                    .build();
        } else {
            log.info("[SignServiceImpl/signUp] 회원가입 성공");

            signUpResultDTO = SignUpResultDTO.builder()
                    .isSuccess(Status.SUCCESS.value())
                    .code(HttpStatus.OK.value())
                    .msg("회원가입을 성공했습니다.")
                    .build();

        }

        return signUpResultDTO;
    }
    
    ...
}

임의로 Status라는 enum을 만들어 처리했다.

9-4. PasswordEncoder 빈 정의

  • 패스워드는 암호화해서 저장해야 하기 때문에 PasswordEncoder를 활용해 저장합니다.
@Configuration
public class PasswordEncoderConfiguration {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

9-5. 로그인 로직 구현

  • 로그인 로직은 아래와 같이 동작합니다.

    1. Client가 입력한 id를 통해 DB에서 User를 조회합니다.
      • 없을 경우 로그인 실패
      • 있을 경우 다음 스텝
    2. 조회된 User 엔티티의 password와 입력된 password를 비교합니다. 단, 이때 비밀번호가 암호화 되어 있기 때문에 PasswordEncoder를 이용해 일치 여부를 확인합니다.
      • 패스워드 불일치 시 로그인 실패
      • 패스워드 일치 시 다음 스텝
    3. JwtTokenProvider를 통해 id와 role로 토큰을 생성한 후 전달합니다.
        @Override
      public SignInResultDTO signIn(String id, String password) throws RuntimeException {
          log.info("[SignServiceImpl/signIn] 로그인 시도");
    
          String loginFailMsg = "입력한 정보가 일치하지 않습니다.";
          User user = userRepository.getByUid(id);
    
          /* id와 맞는 User가 있는지 조회 */
          if (user == null) {
              throw new LoginFailedException(loginFailMsg);
          }
    
          /* Step 2. 비밀번호 일치여부 */
          if (!passwordEncoder.matches(password, user.getPassword())) {
              throw new LoginFailedException(loginFailMsg);
          }
    
          /* Step 3. 토큰 생성 및 전달 */
          String token = jwtTokenProvider.createToken(user.getUid(), user.getRoles());
          
          return SignInResultDTO.builder()
                  .token(token)
                  .build();
      }
    • RuntimeException을 발생시켜 처리할 수 있지만 CustomException을 사용하여 에러를 핸들링하는 방식을 선호해서 커스텀 익셉션을 사용하였습니다.

10. 회원가입, 로그인 Controller

@RestController
@RequestMapping("/sign-api")
@RequiredArgsConstructor
public class SignController {
   private final Logger log = LoggerFactory.getLogger(SignController.class);

   private final SignService signService;

   /**
    * 로그인
    * @return SignInResultDTO
    */
   @PostMapping("/sign-in")
   public SignInResultDTO signIn(@Valid SignInRequestDTO signInRequestDTO) {
       return signService.signIn(signInRequestDTO.getId(), signInRequestDTO.getPassword());
   }

   /**
    * 회원가입
    * @return SignUpResultDTO
    */
   @PostMapping("/sign-up")
   public SignUpResultDTO signUp(@Valid SignUpRequestDTO signUpRequestDTO) {
       return signService.signUp(signUpRequestDTO.getId(), signUpRequestDTO.getPassword(), signUpRequestDTO.getName(), signUpRequestDTO.getRole());
   }
}
  • 우리는 이전에 SignService에서 LoginFailedException, DuplicateUserIdException을 정의했습니다. 또한 여기서 @Valid를 사용하여 spring validation 라이브러리를 통해 유효성 검사를 하고 있습니다.
  • Global하게 유효성 검사를 하기 위해 CustomExceptionHandler를 정의합니다.

10-1. CustomExceptionHandler 구현

@RestControllerAdvice
public class CustomExceptionHandler {

    private final Logger log = LoggerFactory.getLogger(CustomExceptionHandler.class);

    @ExceptionHandler({BindException.class})
    public ResponseEntity<ErrorResponseDTO> handleBindException(BindException e) {
        String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponseDTO.builder()
                        .msg(errorMessage)
                        .build());
    }

    @ExceptionHandler({DuplicateUserIdException.class, LoginFailedException.class})
    public ResponseEntity<ErrorResponseDTO> handleCustomException(RuntimeException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(ErrorResponseDTO.builder()
                        .msg(e.getMessage())
                        .build());
    }
}

11. 정리

  • Spring Security의 간단한 사용법을 알아봤다. WebSecurityConfigurerAdapter가 Deprecated가 되었지만 이 방법을 통해 간단한 원리를 알아가면 좋을 것 같다.
  • 다음에는 WebSecurityConfigurerAdapter를 어떻게 변경해야 하는지에 대해서 알아보겠습니다. :)

0개의 댓글