[북스터디]스프링 부트 핵심 가이드 ch13을 공부해 보았다.(10편)ch13 서비스의 인증과 권한 부여

Wang_Seok_Hyeon·2023년 4월 25일
0
post-thumbnail

서비스 인증과 권한 부여

정리하다 보니 기네요. 그래도, 부수적으로 많이 나눠 놨습니다 :)

보안인증 개발을 하는 사람이건 아니건,
해당 부분은 굉장히 흥미롭고 눈길이 가는 단어입니다.
하지만 해당 부분을 조금 자세히 들여다 보기만 해도,
이걸 개발해준 개발자에게 감사하고, 쉽게 이해하지 못하니
철저한 보안이 되는 거야! 라고 생각하고 넘어가게 될 겁니다.
우선 무슨 말인지 코드 하나 보고 가시죠.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJldUxqVlJKSzhrcTV2OXJQdnowbjNRLzRRM08xMTZZTkdrbkpsckdpZlJrPSIsImp0aSI6IlJGaStTTmh0anRVb3ZXbk9ubzJ5QUE9PSIsInJvbGVzIjoiVVNFUiIsImlhdCI6MTY4MjMzOTE0OSwiZXhwIjoxNjgyNDI1NTQ5fQ.ktUKRi8Zx5dmFH8gxB_ZSYtoyTD1chdlcb6RIJVZdnY

기...길져? 해당 코드를 가지고
테스트를 할 때, 특히 스크래치 파일 .http 로
intellij에서 검증을 하려고 하면 너무 ... 힘듭니다. ㅠ.ㅠ

우선 위의 코드가 무엇인가 하면!
Jwt 토큰이라고 하며,로그인하면 해당 로그인의 응답값으로 위와 같은 인증 코드가 발급됩니다.
위의 보안 코드를 활용해 로그인한 유저를 확인하고 검증할 수 있습니다.
단순 로그인 Id로 검증을 하게 되면 누구나 접근을 하는 문제가 발생할 수 있습니다.

일반적으로 primaryKey를 가지는 id의 경우 Long으로 처리를 하는데
264 / 2 - 1, 만큼의 데이터를 저장할 수 있어서 충분하지만, 문제는 단순한 숫자기 때문에 해당 숫자로 로그인 로그아웃 처리를 관리하면 누구나 조금만 조작을 하면 쉽게 접근이 가능합니다.
이러한 보안이 낮은 형태의 값을 일반적인 바법으로 접근할 수 없는
방식으로 만들어 주는 방식 중 하나가 Jwt 토큰이고, 이외에도 Spring Security입니다.

주저리는 이 정도만 해두고 책의 내용을 조금 보고 가시죠!
개발자라면 해당 내용에 관심은 없더라도 과정의 이해와 대화를 할 수 있어야 하니까요!

보안 용어


프로그래밍을 공부하면서 늘 생각하는 거지만 일상어들이 정말 낯설게 느껴질 때가 많다는 겁니다.
가령, 의존성 주입 이런 단어는 뭐 그렇다 하더라도, 보안 쪽은 더

인증(authentication)

인증(authentication)은 사용자가 누구인지 확인하는 단계입니다. 
대표적인 예로는 제가 위에 말한 '로그인'이 있고 이를 전달하는 토큰이
제가 위에 말한 Jwt 토큰 입니다 :)

인가(authorization)

'인증'을 통해 검증된 사용자가 내부 리소스에 접근 시 
권리가 있는지를 확인하는 과정으로 권한이 있는지 '유효성'을 검증하는 과정입니다.
적절하게 게시글의 수정, 삭제 등의 접근할 권한이 있는지를 확인해서
승인 또는 거절을 하는 것이죠. :)

접근 주체(principal)

애플리케이션의 기능을 사용하는 주체를 의미한다고 하네요.
접근 주체는 사용자, 디바이스, 시스템 등이 될 수 있습니다.
애플리케이션은 위의 과정들을 통해 접근 주체가 신뢰할 수 있는지
확인하고, 인가 과정을 통해 `접근 주체`에게 부여된 권한을 확인합니다.

뭐, 솔직히 말은 어려운데, 위의 내용들은 하나의 연속적인 과정에서 나타납니다. 그걸 저렇게 나눠 놨으니 어렵고 복잡해 보이지만
위와 같은 내용으로 서비스를 나누고 코드를 작성해야 하는 순간 또는
과정의 이해가 필요할 수 있으니 알아두고 가는 거죠 :)

다음으로 스프링 시큐리티를 알아 보겠습니다.

스프링 시큐리티

애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나인데 위의 과정을 직접 코딩 하지 않아도 된다니 참 좋은 것 같습니다.

구조에 관한 설명이 길게 되어 있는데 해당 내용의 이해를 간단하게만 소개하고자 합니다. 필터체인이라는 것을 활용해 이를 활용합니다.
관련해서 WebSecurityConfigurerAdapter 클래스를 상속해야 합니다.
이 부분을 주목해야 합니다. 특히 인텔리제이에서 상속받을 때
WebSecurityConfigure로 잘못 자동완성될 수 있기 때문에
꼭 이 부분을 확인하셔야 합니다!
필터의 실행순서에 관한 내용이 다른 곳에 잘 안 나오는 유의미한 자료라 정리해 보았습니다.
일단 왜 정리를 잘 안하나,
그리고 처음 말씀드렸던 사람들이 도망간다는 내용을 짧게 보고 가시죠!

짱...복잡하죠...ㅎ
자 이제 스압이 올 겁니다. 아래가 필터의 실행 순서입니다.

- ChannelProcessingFilter
- WebAsyncManagerIntegerationFilter
- SecurityContextPersitenceFilter
- HeaderWriterFilter
- CorsFilter
- CsrfFilter
- LogoutFilter
- OAuth2AuthorizationRequestRedirectFilter
- Saml2WebSsoAuthenticationRequestFilter
- X509AuthenticationFilter
- AbstractPreAuthenticatedProcessingFilter
- CasAuthenticationFilter
- OAuth2LoginAuthenticationFilter //이거 책에는 오타.
- Saml2WebSsoAuthenticationFilter
- UsernamePasswordAuthenticationFilter
- OpenIDAuthenticationFilter
- DefaultLoginPageGeneratingFilter
- DefaultLogoutPageGeneratingFilter
- ConcurrentSessionFilter
- DigestAuthenticationFilter
- BearerTokenAuthenticationFilter
- BasicAuthenticationFilter
- SecurityContextHolderAwareRequestFilter
- JaasApiIntergrationFilter
- RememberMeAuthenticationFilter
- AnonymousAuthenticationFilter
- OAuth2AuthorizationCodeGrantFilter
- SessionManagementFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
- SwitchUserFilter

왜 정리를 안 하는지는 위의 내용의 어마어마한 양만 봐도 알 수 있습니다.
엄청난 인사이트가 있는 것도 아니지만, 추후에 해당 내용이 자연스럽게 몸에 익는다고 생각하면 될 거 같습니다. 연산자가 몸에 자연스럽게 익듯이요.
그럼 이제 JWT를 알아 보겠습니다. 해당 내용이 주요합니다 :)

JWT(JsonWebToken)


JWT는 UIRL로 이용할 수 있는 문자열로만 구성돼 있습니다.
디지털 서명이 적용돼 있어 신뢰할 수 있구요.
JWT는 제가 가장 먼저 올리고 위에 쭈욱 이야기 해온 내용의 핵심입니다.
로그인을 통해 아이디와 비밀번호가 매칭되면 해당 데이터가
일회용으로 사용할 수 있는 JWT 토큰이 발급되고,
해당 토큰을 기반으로 다른 페이지의 접근 및 권한을 가집니다 :)
JWT의 구조는 점('.')으로 구분이 됩니다.
점에 따라

  • 헤더(Header)
  • 내용(Payload) //솔직히 얘는 좀 단어가 생소했습니다. 저만 그렇게 느끼나...
  • 서명(Signature)
    라는 이름을 갖습니다.

    다시 한 번 토큰을 살펴 보고 해당 내용을 확인해 보시죠
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJldUxqVlJKSzhrcTV2OXJQdnowbjNRLzRRM08xMTZZTkdrbkpsckdpZlJrPSIsImp0aSI6IlJGaStTTmh0anRVb3ZXbk9ubzJ5QUE9PSIsInJvbGVzIjoiVVNFUiIsImlhdCI6MTY4MjMzOTE0OSwiZXhwIjoxNjgyNDI1NTQ5fQ.ktUKRi8Zx5dmFH8gxB_ZSYtoyTD1chdlcb6RIJVZdnY

간단하게 내용을 정리해 보면
헤더(Header)
헤더의 경우, SHA256 또는 RSA를 사용해 암호화 되고, 완성은 Base64Url 형식으로 인코딩 됩니다 :)

내용(Payload)
이 곳에 포함된 속성들은 클레임(Claim)이라 한다고 합니다.(이것도 익숙하지 않게 느껴지더라구요.)

- 등록된 클레임(Registered Claims)
- 공개 클레임(Public Claims)
- 비공개 클레임(Private Claims)

위와 같은 속성을 가집니다. :) 필수는 아니지만 관련해 상태를 확인할 수 있는 정보 입니다.

서명(Signature)
인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성됩니다. 즉, 앞의 값들을 참고해서 만들어지는 것이죠 :)

JWT 사용하기

  1. 의존성 주입.
    gradle

    implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
    

    maven

    <dependency>
       <groupId>io.jsonwebtoken</groupId>
       <artifactId>jjwt</artifactId>
       <version>0.9.1</version>
    </dependency>

    참고 사이트
    https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt/0.9.1

    해당 페이지가 기본적인 확인 페이지여서 위를 사용할 줄 아는게 주요해서 :) 공유합니다. :) 버전을 누르고 아래로 가면 됩니다 :)

    Spring Security를 사용하기 위한 의존성도 함께 알아 보죠 :)
    gradle

    implementation group: 'org.springframework.security', name: 'spring-security-web'
    

    maven

    <dependency>
     <groupId>org.springframework.security</groupId>
     <artifactId>spring-security-web</artifactId>
    </dependency>
    

참고 사이트 https://mvnrepository.com/artifact/org.springframework.security/spring-security-web/6.0.3
gradle의 경우 형태가 조금 다를 수 있습니다.
인텔리제이로 만들면요 :)

User엔티티 생성

  @Entity
  @Getter
  @Setter
  @NoArgsConstructor
  @AllArgsConstructor
  @Builder
  @Table
  public class User implements UserDetails {

  private static final long serialVersionUID = 6014984039564979072L;

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

  @Column(nullable = false, unique = true)
  private String uid; // 회원 ID (JWT 토큰 내 정보)

  @JsonProperty(access = Access.WRITE_ONLY) // Json 결과로 출력하지 않을 데이터에 대해 해당 어노테이션 설정 값 추가
  @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());
  }

  /**
   * security 에서 사용하는 회원 구분 id
   * @return uid
   */
  @JsonProperty(access = Access.WRITE_ONLY)
  @Override
  public String getUsername() {
      return this.uid;
  }

  /**
   * 계정이 만료되었는지 체크하는 로직
   */
  @JsonProperty(access = Access.WRITE_ONLY)
  @Override
  public boolean isAccountNonExpired() {
      return true;
  }

  /**
   * 계정이 잠겼는지 체크하는 로직
   */
  @JsonProperty(access = Access.WRITE_ONLY)
  @Override
  public boolean isAccountNonLocked() {
      return true;
  }

  /**
   * 계정의 패스워드가 만료되었는지 체크하는 로직
   */
  @JsonProperty(access = Access.WRITE_ONLY)
  @Override
  public boolean isCredentialsNonExpired() {
      return true;
  }

  /**
   * 계정이 사용가능한지 체크하는 로직
   */
  @JsonProperty(access = Access.WRITE_ONLY)
  @Override
  public boolean isEnabled() {
      return true;
  }
 }

UserRepository는 아래와 같이 Uid를 가져올 수 있는 형태로 구현합니다.

import com.springboot.security.data.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;


public interface UserRepository extends JpaRepository<User, Long> {

 User getByUid(String uid);

}

UserDetailsServiceImpl의 구현
해당 서비스의 구현은 레포지토리를 통해 User 엔티티의 id를 가져오는 서비스를 생성합니다. :)

import com.springboot.security.data.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

 private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

 private final UserRepository userRepository;

 @Override
 public UserDetails loadUserByUsername(String username) {
     LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
     return userRepository.getByUid(username);
 }

}

위까지가 SpringSecurity를 사용하기 위한 설정입니다 :)
다음으로 JwtToken을 가져오는 걸 생성해 보죠 :)

JwtTokenProvider

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

/**
 * JWT 토큰을 생성하고 유효성을 검증하는 컴포넌트 클래스 
 * JWT 는 여러 암호화 알고리즘을 제공하고 알고리즘과 
 * 비밀키를 가지고 토큰을 생성
 * claim 정보에는 토큰에 부가적으로 정보를 추가할 수 있음 
 * claim 정보에 회원을 구분할 수 있는 값을 세팅하였다가 
 * 토큰이 들어오면 해당 값으로 회원을 구분하여 리소스 제공
 * JWT 토큰에 expire time을 설정할 수 있음
 */

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService; // Spring Security 에서 제공하는 서비스 레이어

    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    private final long tokenValidMillisecond = 1000L * 60 * 60; // 1시간 토큰 유효

    /**
     * SecretKey 에 대해 인코딩 수행
     */
    @PostConstruct
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        System.out.println(secretKey);
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        System.out.println(secretKey);
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }

    // JWT 토큰 생성
    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[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) // 암호화 알고리즘, secret 값 세팅
            .compact();

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

    // JWT 토큰으로 인증 정보 조회
    public Authentication getAuthentication(String token) {
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}",
            userDetails.getUsername());
        return new UsernamePasswordAuthenticationToken(userDetails, "",
            userDetails.getAuthorities());
    }

    // JWT 토큰에서 회원 구별 정보 추출
    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody()
            .getSubject();
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
        return info;
    }

    /**
     * HTTP Request Header 에 설정된 토큰 값을 가져옴
     */
    public String resolveToken(HttpServletRequest request) {
        LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }

    // 예제 13.16
    // JWT 토큰의 유효성 + 만료일 체크
    public boolean validateToken(String token) {
        LOGGER.info("[validateToken] 토큰 유효 체크 시작");
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            LOGGER.info("[validateToken] 토큰 유효 체크 완료");
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }
}

어우 뭐가 많죠... 그런데 저런 형태가 공식적인 형태 중 하나입니다.

위에서는 LOGGER를 통해 확인하고 있는데요.
학습을 하면서 해당 내용까지 원활하게 이해하시게 되셨으면 좋겠습니다.
뭐 저도 원활하게 이해가 된다기 보다는 익숙해져서
아, logger로 해서 구현했구나. 이런 게 보이네요 :)
예외 처리는 try catch로만 했네요 :)

이제 필터 체인 위에 엄청 스압을 주기 시작했던 Filter 관련한 클래스를 구현해 보겠습니다.

JwtAuthenticationFilter


JwtAuthenticationFilter

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest,
        HttpServletResponse servletResponse,
        FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(servletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);

        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }
}

위는
OncePerRequestFilter 를 상속 받아 구현했습니다.

GenericFilterBean를 상속받아 구현한 경우

public class JwtAuthenticationFilter extends GenericFilterBean{
    
    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider){
        this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest, HttpServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출 완료, token: {}", token);
        
        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if(token != null && jwtTokenProvider.validateToken(token)){
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }
        
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

2개의 차이가 조금 있습니다.

GenericFilterBean은 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상 클래스 입니다.

다만 서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장하고 동일한 클라이언트의 요청을 받으면 재활용하는 구조
GenericFilterBean->OncePerRequestFilter 이렇게
GenericFilterBean이 부모인 관계입니다.
더 상위의 개념인 셈이죠. 하지만GenericFilterBean은 RequestDispatcher에 의해 다른 서블릿으로 디스패치 되면서 필터가 두번 실행될 수 있습니다.
-> 이를 해결하기 위해 등장한 것이 OncePerRequestFilter 이고
한 번만 실행되는 걸 구현한 셈입니다.
해당 서적에서는 OncePerRequestFilter 를 통한 구현만을 다루고 있습니다.

그 전까지는 스프링 시큐리티를 적용하기 위한 컴포넌트 구현하였음

스프링 시큐리티 관련 설정을 진행하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것

SecurityConfiguration


다음으로 SecurityConfiguration를 구현해 보겠습니다.

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    
    private final JwtTokenProvider jwtTokenProvider;
    
    @Autowired
    public SecurityConfiguration(JwtTokenProvider jwtTokenProvider){
        this.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");
    }
    
}

지금까지 스프링 시큐리티를 적용하기 위한 컴포넌트를 구현했습니다.

스프링 시큐리티 관련 설정을 진행하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration(어노테이션) 클래스 구현 위에서 가장 먼저 보이는 어노테이션이죠 :)
관련한 설정의 대부분은 HttpSecurity를 통해 진행하며 대표적 기능은 아래 4가지가 있습니다.

  • 리소스 접근 권한 설정
  • 인증 실패 시 발생하는 예외처리
  • 인증 로직 커스터마이징
  • csrf,cors 등의 스프링 시큐리티 설정

이번엔 메서드에 관해 살표 보죠 :) 모든 설정은 전달 받은 HttpSecurity에서 설정합니다.

httpBasic().disable()

UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화

csrf().disable()

REST API에서는 CSRF(사이트 간 요청 위조) 보안이 필요 없기 때문에 비활성화 하는 로직
CSRF는 Cross-Site Request Forgery 줄임말로 '사이트 간 요청 위조' 의미
'사이트 간 요청 위조'란 웹 애플리케이션의 취약점 중 하나.
사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로
공격자가 의도한 행동을 함으로써 
특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등 작업을 하는 공격 방법.
스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급
클라이언트로부터 요청을 받을 때 마다 토큰을 검증하는 방식으로 동작
브라우저 사용환경이 아니면 비활성화 해도 크게 문제는 없습니다.

얘만 왤케 긴 거죠...
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

RESTAPI 기반 애플리케이션의 동작 방식 설정 
현재는 JWT 토큰으로 인증을 처리
세션을 사용하지 않기에 STATELESS로 진행

authorizeRequest()

애플리케이션에 들어오는 요청에 대한 사용 권한 체크
이어서 사용한 antMatchers() 메서드는 antPattern을 통해 권한 설정

- "/sign-api/sign-in", 
  "/sign-api/sign-up",
  "/sign-api/exception" 경로에 대해 모두에게 허용	
- /product로 시작하는 경로의 GET요청은 모두 허용
- exception 단어가 들어간 경로는 모든 허용
- 기타 요청은 인증된 권한을 가진 사용자에게 허용

exceptionHandling().accessDeniedHandler()

권한을 확인하는 과정에서 통과하지 못하는 예외가 있을 경우 예외 전달

exceptionHandling().authenticationEntryPoint()

인증 과정에서 예외가 발생하는 경우 예외 전달

각 메서드는 CustomAccessDeniedHandlerCustomAuthenticationEntryPoint로 예외를 전달
스프링 시큐리티는 각각의 역할을 수행하는 필터들이 체인 형태로 구성돼 순서대로 동작

addFilterBefore() 메서드를 통해 어느 필터 앞에 추가할지 설정
위 코드에서는 스프링 시큐리티에서 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter 앞에 앞에서 생성한 JwtAuthenticationFilter를 추가하겠다는 의미

WebSecurity를 사용하는 configure() 메서드

WebSecurity는 HttpSecurity 앞단에 적용
전체적으로 스프링 시큐리티 영향 밖
즉 인증과 인가가 모두 적용되기 전에 동작하는 설정
인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용

위 코드는 Swagger 관련된 경로에 대한 예외 처리를 수행 합니다. 즉 인증과 인가를 무시하는 경로를 설정하는 것이죠 :)

커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

앞선 과정에서 인증과 인가 과정의 예외 상황에서AccessDeniedHandler,
AuthenticationEntryPoint로 예외를 전달 중이었습니다.
이를 구체적으로 구현해 보겠습니다. :)
기본적인 예외가 아닌, 직접 만든 예외인데,
한 번 다룬 적이 있는 내용인데 자세히 있어서
한 번 더 공유합니다.

AccessDeniedHandler interface 구현체 클래스

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    
    private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
    
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}

AccessDeniedException

엑세스 권한이 없는 리소스에 접근 시 발생하는 예외
이를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용
SecurityConfiguration에도
exceptionHandling() 메서드를 통해 추가

handle() 메서드를 오버라이딩 했습니다.

response에서 리다이렉트하는
sendRedirect()메서드를 활용하는 방식으로 구현했습니다.

접근이 막혔을 경우
경로 리다이렉트 출력 후 다른 스레드에서 동작

인증이 실패한 상황을 처리하는 AuthenticationEntryPoint interface를 CustomAuthenticationEntryPoint 구현한 클래스

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class)
    
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ObjectMapper objectMapper = new ObjectMapper();
        LOGGER.info("[commence] 인증 실패로 response.sendError 발생");
        
        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
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
    
    private String msg;
}

구조가 앞선 AccessDeniedHandler와 크게 다르지 않으며, commence() 을 오버라이딩.
commence() 메서드는 HttpServletReques, HttpServletResponse, AuthenticationException을 매개변수로 받으며,
책에서는 리다이렉트가 아니라, 예외 처리를 위해 리다이렉트가 아니라
직접 Response를 생성해서 클라이언트에게 응답하는 방식으로 구현

컨트롤러에서 응답을 위한 설정
자동으로 구현해서 별도의 작업을 하지 않았지만
여기서 응답값을 설정해 주어야 할 필요가 있습니다.

메세지 담기 위해 EntryPointErrorResponse 객체 사용해 메세지 설정
response에 상태 코드(status)
콘텐츠 타입(Content-type)을 설정 후
ObjectMapper를 사용
EntryPointErrorResponse 객체를 바디 값으로 파싱합니다.

메시지를 설정할 필요가 없다면 commence() 메서드 내부에 아래와 같이 한 줄만 작성하면 됩니다.

@Override
public void commence(HttpServletRequest request, HttpServletResponse response
, AuthenticationException ex) throws IOExcpetion{
	reponse.sendError(HttpServletResponse.SC_UNAUTHORIZED);
} 

회원가입과 로그인 구현


자 이제 Spring Security의 구현을 마무리 짓는
과정을 하겠습니다 :)

아래의 내용은 정말 많이 활용하니 저장해서 활용하시면 좋습니다 :)

서비스 레이어 부분의 구현입니다. Sign 부분입니다.

public interface SignService {
    
    SignUpResultDto signUp(String id, String password, String name, String role);
    
    SignInResultDto signIn(String id, String password) throws  RuntimeException;
}

SignService, interface를 구현하는 클래스.

@Service
public class SignServiceImpl implements SignService {

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

    public UserRepository userRepository;
    public JwtTokenProvider jwtTokenProvider;
    public PasswordEncoder passwordEncoder;

    @Autowired
    public SignServiceImpl(UserRepository userRepository, JwtTokenProvider jwtTokenProvider,
                           PasswordEncoder passwordEncoder){
        this.userRepository = userRepository;
        this.jwtTokenProvider = jwtTokenProvider;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public SignUpResultDto signUp(String id, String password, String name, String role) {
        LOGGER.info("[getSignUpResult] 회원 가입 정보 전달");
        User user;
        if(role.equalsIgnoreCase("admin")){
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList(password))
                    .build();
        } else{
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList("ROLE_USER"))
                    .build();
        }

        User savedUser = userRepository.save(user);
        SignUpResultDto signUpResultDto = new SignInResultDto();

        LOGGER.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과 주입");
        if(!savedUser.getName().isEmpty()){
            LOGGER.info("[getSignUpResult] 정상 처리 완료");
            setSuccessResult(signUpResultDto);
        } else{
            LOGGER.info("[getSignUpResult] 실패 처리 완료");
            setFailResult(signUpResultDto);
        }
        return signUpResultDto;
    }

    @Override
    public SignInResultDto signIn(String id, String password) throws RuntimeException {
        LOGGER.info("[getSignInResult] signDataHandler로 회원 정보 요청");
        User user = userRepository.getByUid(id);
        LOGGER.info("[getSignInResult] Id : {}", id);

        LOGGER.info("[getSignInResult] 패스워드 비교 수행");
        if(!passwordEncoder.matches(password, user.getPassword())){
            throw new RuntimeException();
        }

        LOGGER.info("[getSignInResult] 패스워드 일치");

        LOGGER.info("[getSignInResult] SignInResultDto 객체 생성");
        SignInResultDto signInResultDto = SignInResultDto.builder()
                .token(jwtTokenProvider.createToken(String.valueOf(user.getUid()),
                        user.getRoles()))
                .build();

        LOGGER.info("[getSignInResult] SignInResultDto 객체에 값 주입");
        setSuccessResult(signInResultDto);

        return signInResultDto;
    }

    private void setSuccessResult(SignUpResultDto result){
        result.setSuccess(true);
        result.setCode(CommonResponse.SUCCESS.getCode());
        result.setMsg(CommonResponse.SUCCESS.getMsg());
    }

    private void setFailResult(SignUpResultDto result){
        result.setSuccess(false);
        result.setCode(CommonResponse.FAIL.getCode());
        result.setMsg(CommonResponse.FAIL.getMsg());
    }
}

회원가입과 로그인을 구현하기 위해 세가지 객체에 대한 의존성 주입 받습니다.

signUp(회원가입 구현)

ADMIN및 USER 권한으로 구분된 role 객체를 확인
User 엔티티의 roles 변수에 추가해서 엔티티 생성

패스워드는 암호화해서 저장하는 스킬은 PasswordEncoder를 활용! 비밀번호가 그대로 드러난 상태로 유출되면 안 되니까요!

PassWordEncoder는 별도의 @Configuration 클래스를 생성 @Bean 객체로 등록 구현

@Configuration
public class PasswordEncoderConfiguration {
    
    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

위 코드는 빈 객체를 등록하기 위해서 생성된 클래스입니다.
SecurityConfiguration 클래스처럼 이미 생성된 @Configuration 클래스 내부에
passwordEncoder() 메서드만 정의해도 충분합니다 :)

이렇게 생성된 엔티티를 UserRepository에 저장

이후 SignIn 메서드에서 로그인 기능 구현
로그인 : 미리 저장돼 있는 계정 정보
요청을 통해 전달된 계정 정보가 일치하는지 확인

SignIn 내부 로직
1. id를 기반으로 UserRepository에서 User엔티티를 가져옴
2. PassWordEncoder를 사용해 DB에 저장돼 있던 패스워드와 입력받은 패스워드가 일치하는지 확인
위에선 RuntimeException을 사용했지만 별도의 커스텀 예외로 처리하는 경우가 실무에서 잘 사용하는 방식입니다.
3. 패스워드 일치해 인증을 통과하면 JwtTokenProvider를 통해 id와 role 전달해
토큰 생성 후 Response에 담아 전달.

아래는 코드 마지막 부분에 사용된 CommonResponse enum (열거체) class 입니다.

public enum CommonResponse {
    
    SUCCESS(0, "Success"), FAIL(-1, "Fail");
    
    int code;
    String msg;
    
    CommonResponse(int code, String msg){
        this.code = code;
        this.msg = msg;
    }
    
    public int getCode(){
        return code;
    }
    
    public String getMsg(){
        return msg;
    }
}

이제 회원가입로그인을 API로 노출하는 컨트롤러 생성해야 하는데,
사실상 서비스 레이어로 요청을 전달하고 응답하는 역할만 수행해서 책에서는 코드만 소개합니다. :)

SignController

@RestController
@RequestMapping("/sign-api")
public class SignController {

    private final Logger LOGGER = LoggerFactory.getLogger(SignController.class);
    private final SignService signService;

    @Autowired
    public SignController(SignService signService) {
        this.signService = signService;
    }

    @PostMapping(value = "/sign-in")
    public SignInResultDto signIn(
        @ApiParam(value = "ID", required = true) @RequestParam String id,
        @ApiParam(value = "Password", required = true) @RequestParam String password)
        throws RuntimeException {
        LOGGER.info("[signIn] 로그인을 시도하고 있습니다. id : {}, pw : ****", id);
        SignInResultDto signInResultDto = signService.signIn(id, password);

        if (signInResultDto.getCode() == 0) {
            LOGGER.info("[signIn] 정상적으로 로그인되었습니다. id : {}, token : {}", id,
                signInResultDto.getToken());
        }
        return signInResultDto;
    }

    @PostMapping(value = "/sign-up")
    public SignUpResultDto signUp(
        @ApiParam(value = "ID", required = true) @RequestParam String id,
        @ApiParam(value = "비밀번호", required = true) @RequestParam String password,
        @ApiParam(value = "이름", required = true) @RequestParam String name,
        @ApiParam(value = "권한", required = true) @RequestParam String role) {
        LOGGER.info("[signUp] 회원가입을 수행합니다. id : {}, password : ****, name : {}, role : {}", id,
            name, role);
        SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);

        LOGGER.info("[signUp] 회원가입을 완료했습니다. id : {}", id);
        return signUpResultDto;
    }

    @GetMapping(value = "/exception")
    public void exceptionTest() throws RuntimeException {
        throw new RuntimeException("접근이 금지되었습니다.");
    }

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> ExceptionHandler(RuntimeException e) {
        HttpHeaders responseHeaders = new HttpHeaders();
        //responseHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("ExceptionHandler 호출, {}, {}", e.getCause(), e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", "에러 발생");

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }
}

클라이언트는 위와 같이 계정을 생성하고 로그인 과정을 거쳐 토큰값을 전달받음으로써 이 애플리케이션에서 제공하는 API 서비스를 사용할 준비를 마칩니다.
Response로 전달되는 SignUpResultDto 클래스는
아래와 같습니다.
SignUpResultDto

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto {

    private String token;

    @Builder
    public SignInResultDto(boolean success, int code, String msg, String token) {
        super(success, code, msg);
        this.token = token;
    }
}

SignInResultDto 는 아래와 같습니다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {

    private boolean success;

    private int code;

    private String msg;
}

여기까지가!
스프링 시큐리티가 동작하는
애플리케이션 환경의 완성입니다.
어후 기네요... 그리고 여기서 끝내겠습니다.
남은 걸로는
테스트 관련해서는 Swagger를 활용해서
결과를 정리한 걸 책에서 소개하는데
요게 크게 의미가 있진 않아서 패스합니다...

이걸로 스프링부트 핵심 가이드는 끝내겠습니다.
Clean Code
스프링 퀵 스타트
Effective Java
라는 개인적으로 가지고 있고,
또 부트캠프에서 진행하는 책들을 정리해 보겠습니다 :)

profile
하루 하루 즐겁게

0개의 댓글