Today
-
Yesterday
-
Total
-
  • 스프링 부트 핵심 가이드 8주차: 13
    책 공부/스프링 부트 핵심 가이드 2023. 12. 8. 00:01

    📖 책 정보

    모바일 가이드

    서비스의 인증과 권한 부여

    1. 보안 용어 이해
    2. 스프링 시큐리티
    3. 스프링 시큐리티 동작 구조
    4. JWT
    5. 스프링 시큐리티와 JWT 적용






    1. 보안 용어 이해

    스프링 시큐리티를 활용하기 위해

    보안과 관련된 용어를 먼저 학습해야 한다.

    1. 인증  Authentication 

    사용자가 누구인지 확인하는 단계.

    인증 authentication 의 대표적인 예로 로그인 이 있다.


    로그인은

    데이터베이스에 등록된 아이디와 비밀번호를

    사용자가 입력한 아이디와 비밀번호와 비교해서

    일치 여부를 확인하는 과정이다.


    로그인에 성공하면

    애플리케이션 서버는

    응답으로 사용자에게 토큰을 전달한다.


    로그인에 실패한 사용자는

    토큰을 전달받지 못해 원하는 리소스에 접근할 수 없게 된다.


    2. 인가  Authorization 

    앞에서 설명한

    인증 authentication 을 통해 검증된 사용자가

    애플리케이션 내부의 리소스에 접근할 때

    해당 리소스에 접근할 권리가 있는지를 확인하는 과정을 의미한다.


    예를 들어,

    로그인한 사용자가

    특정 게시판에 접근해서 글을 보려고 하는 경우

    게시판 접근 등급을 확인해

    접근을 허가하거나

    거부하는 것이

    대표적인 인가 authorization 사례이다.


    일반적으로

    사용자가 인증 authentication 단계에서 발급받은 토큰은

    인가 authorization 내용을 포함하고 있으며

    사용자가 리소스에 접근하면서

    토큰을 함께 전달하면

    애플리케이션 서버는

    토큰을 통해 권한 유무 등을 확인해

    인가 authorization 을 수행 한다.


    3. 접근 주체  principal 

    애플리케이션의 기능을 사용하는 주체를 의미한다.

    접근 주체는 사용자가 될 수도 있고

    디바이스, 시스템 등이 될 수도 있다.


    애플리케이션은

    앞서 소개한


    인증 authentication 과정을 통해

    접근 주체 principal 이 신뢰할 수 있는지 확인하고,


    인가 authorization 과정을 통해

    접근 주체 principal 에게 부여된 권한을 확인하는 과정 등을 거친다.



        🐢🎈


    2. 스프링 시큐리티

    애플리케이션의

    인증 authentication ,

    인가 authorization 등의

    보안 기능을 제공하는

    스프링 하위 프로젝트 중 하나이다.


    보안과 관련된 많은 기능을 제공하기 때문에

    스프링 시큐리티를 활용하면

    더욱 편리하게 원하는 기능을 설계할 수 있다.


    3. 스프링 시큐리티 동작 구조

    스프링 시큐리티는

    서블릿 필터를 기반으로 동작하며,

    다음 그림과 같이 DispatcherServlet 앞에 필터가 배치돼 있다.

    위 그림의 필터체인은

    서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다.


    클라이언트에서 애플리케이션으로 요청을 보내면

    서블릿 컨테이너는 URI를 확인해서

    필터와 서블릿을 매핑한다.


    스프링 시큐리티는

    사용하고자 하는 필터체인을

    서블릿 컨테이너의 필터 사이에서 동작시키기 위해

    다음과 같이 DelegatingFilterProxy 를 사용한다.

    출처: 공식문서

    DelegatingFilterProxy

    서블릿 컨테이너의 생명주기와

    스프링 애플리케이션 컨텍스트 사이에서

    다리 역할을 수행하는 필터 구현체이다.


    표준 서블릿 필터를 구현하고 있으며,

    역할을 위임할

    필터 체인 프록시 FilterChainProxy 를 내부에 가지고 있다.


    필터 체인 프록시는

    스프링 부트의 자동 설정에 의해

    자동 생성된다.


    필터 체인 프록시

    스프링 시큐리티에서 제공하는 필터로서

    보안 필터 체인 SecurityFilterChain 을 통해

    많은 보안 필터를 사용할 수 있다.


    필터 체인 프록시에서 사용할 수 있는 보안 필터 체인은

    List 형식으로 담을 수 있게 설정돼 있어

    URI 패턴에 따라

    특정 보안 필터 체인을 선택해서 사용하게 된다.


    보안 필터 체인에서 사용하는 필터는 여러 종류가 있으며,

    각 필터마다 실행되는 순서가 다르다.


    공식 문서에서 소개하는 필터의 실행 순서는 다음과 같다.

    출처: 공식문서 ForceEagerSessionCreationFilter

    ChannelProcessingFilter

    WebAsyncManagerIntegrationFilter

    SecurityContextPersistenceFilter

    HeaderWriterFilter

    CorsFilter

    CsrfFilter

    LogoutFilter

    OAuth2AuthorizationRequestRedirectFilter

    Saml2WebSsoAuthenticationRequestFilter

    X509AuthenticationFilter

    AbstractPreAuthenticatedProcessingFilter

    CasAuthenticationFilter

    OAuth2LoginAuthenticationFilter

    Saml2WebSsoAuthenticationFilter

    UsernamePasswordAuthenticationFilter

    DefaultLoginPageGeneratingFilter

    DefaultLogoutPageGeneratingFilter

    ConcurrentSessionFilter

    DigestAuthenticationFilter

    BearerTokenAuthenticationFilter

    BasicAuthenticationFilter

    RequestCacheAwareFilter

    SecurityContextHolderAwareRequestFilter

    JaasApiIntegrationFilter

    RememberMeAuthenticationFilter

    AnonymousAuthenticationFilter

    OAuth2AuthorizationCodeGrantFilter

    SessionManagementFilter

    ExceptionTranslationFilter

    AuthorizationFilter

    SwitchUserFilter


    보안 필터 체인은

    WebSecurityConfigurerAdapter 클래스를 상속받아 설정할 수 있다.


    앞에서 이야기한 것처럼

    필터 체인 프록시는 여러 보안 필터 체인을 가질 수 있는데


    여러 보안 필터 체인을 만들기 위해서는

    WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러개 생성하면 된다.


    이때 WebSecurityConfigurerAdapter 클래스에는

    @Order 어노테이션을 통해 우선순위가 지정돼 있는데,


    2개 이상의 클래스를 생성했을 때

    똑같은 설정으로 우선순위가 100이 설정돼 있으면

    예외가 발생하기 때문에


    상속받은 클래스에서

    @Order 어노테이션을 지정해

    순서를 정의하는 것이 중요하다.


    별도의 설정이 없다면


    스프링 시큐리티에서는

    다음 그림과 같이

    SecurityFilterChain에서 사용하는 필터 중

    UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.


    인증 수행 과정을 설명하면 다음과 같다.

    1. 클라이언트로 부터 인증 요청

    클라이언트로 부터 인증 요청을 받으면

    서블릿 필터에서 SecurityChain으로 작업이 위임되고

    그 중

    UsernamePasswordAuthenticationFilter ( 그림에선 AuthenticationFilter에 해당 )에서

    인증을 처리한다.


    2. 토큰 생성

    AuthenticationFilter 는

    요청 객체 HttpServletRequest 에서

    username 과 password를 추출해서

    토큰을 생성한다.


    3. AuthenticationManager 로 토큰 전달

    AuthenticationManager 로 토큰을 전달하면 AuthenticationManager 은 인터페이스의 형태이기 때문에,

    AuthenticationManager 의 구현체인

    ProviderManager 가 토큰을 받는다.


    4. AuthenticationProvider 로 토큰 전달

    인증을 위해

    ProviderManager 는 ( ProviderManager = AuthenticationManager의 구현체 )

    AuthenticationProvider 로 토큰을 전달한다.


    5. 토큰의 정보를 UserDetailsService에 전달

    토큰을 받은 AuthenticationProvider는

    토큰의 정보

    UserDetailService로 전달한다.


    6. UserDetails 객체 생성

    UserDetailsService는

    전달받은 정보를 통해

    데이터베이스에서 일치하는 사용자를 찾아

    UserDetails 객체를 생성한다.


    7. AuthenticationProvider 에서 인증 작업

    7-1. UserDetails 객체를 AuthenticationProvider 로 전달

    UserDetailsService에서 생성한 UserDetails 객체를 AuthenticationProvider 로 전달한다.

    7-2. AuthenticationProvider에서 인증 수행

    authenticationProvider 에서 인증을 수행한 후 인증에 성공하게 되면 ProviderManager로 토큰에 권한을 담아 전달한다.


    8. 검증된 토큰을 AuthenticationFilter로 전달

    ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.


    9. SecurityContext에 저장

    AuthenticationFilter검증된 토큰

    SecurityContextHolder에 있는

    SecurityContext에 저장한다.

    그리고 나서 UsernamePasswordAuthenticationFilter

    접근 권한을 확인하고 인증이 실패할 경우, 로그인 폼 이라는 화면을 보내는 역할을 수행한다.



        🐢🎈


    4. JWT

    JWT JSON Web Token 는

    당사자 간에 정보를

    JSON 형태로

    안전하게 전송하기 위한 토큰 이다.


    JWT는

    URL로 이용할 수 있는 문자열로만 구성되어 있으며,

    디지털 서명이 적용돼 있어

    신뢰할 수 있다.


    JWT는 주로 서버와의 통신에서

    인가를 위해 사용된다.


    URL에서 사용할 수 있는 문자열로만 구성돼 있기 때문에

    HTTP 구성요소 어디든 위치할 수 있다.


    1. JWT 구조

    . 으로 구분된 아래의 세 부분으로 구성된다.

    헤더
    Header
    내용
    Payload
    서명
    Signature

    따라서 JWT는 일반적으로 다음과 같은 형식을 띈다.

    xxxxx . yyyyy . zzzzz
    헤더    내용    서명

    1-1. 헤더

    JWT의 헤더는

    검증 과 관련된 내용을 담고 있다.


    헤더는 두 가지 정보를 포함하고 있는데,

    algtyp 속성에 각각의 정보를 담는다.

    {
    	"alg": "HS256", // 해싱 알고리즘  algorithm
      	"typ": "JWT" 	// 토큰의 타입    type
    }

    alg 속성에서는

    해싱 알고리즘을 지정한다.


    해싱 알고리즘은 보통 SHA256 또는 RSA를 사용하며,

    토큰을 검증할 때 사용되는 서명 부분에서 사용된다.


    typ 속성에서는

    토큰의 타입을 지정한다.



    1-2. 내용

    JWT의 내용에는

    토큰에 담는 정보를 포함한다.


    이곳에 포함된 속성들은 클레임 Claim 이라 하며,

    크게 세 가지로 분류된다.

    1 등록된 클레임 Registered Claims
    2 공개 클레임 Public Claims
    3 비공개 클레임 Private Claims

    등록된 클레임은

    필수는 아니지만,

    토큰에 대한 정보를 담기 위해

    이미 이름이 정해져 있는 클레임을 뜻한다.


    등록된 클레임은 다음과 같이 정의돼 있다.

    속성 이름 설명
    iss

    JWT의 발급자 issuer 주체를 나타낸다.
    iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열이다.
    sub JWT의 제목 subject 을 나타낸다.
    aud


    JWT의 수신인 audience 을 나타낸다.
    JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 한다.
    요청을 처리하는 주체가 'aud'값으로 자신을 식별하지 않으면 JWT는 거부된다.
    exp

    JWT의 만료 시간 Expiration 을 나타낸다.
    시간은 NumericDate 형식으로 지정해야 한다.
    nbf Not Before 를 의미한다.
    iat JWT 가 발급된 시간 issued at 을 나타낸다.
    jti

    JWT의 식별자 JWT ID 를 나타낸다.
    주로 중복 처리를 방지하기 위해 사용한다.

    공개 클레임

    키 값을 마음대로 정의할 수 있다.

    다만, 충돌이 발생하지 않을 이름으로 설정해야 한다.


    비공개 클레임

    통신 간에 상호 합의되고

    등록된 클레임과 공개된 클레임이 아닌 클레임을 의미한다.


    jwt 내용의 예시

    {
    	"sub" : "wikibooks payload" ,
        "exp" : "1602076408" ,
        "userId" : "zhyun" , 
        "uswername" : "gimwlgus"
    }

    이렇게 완성된 내용은 Base64Url 형식으로 인코딩되어 사용된다.


    1-3. 서명

    JWT의 서명 부분은

    인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값 을

    가져와 생성된다.


    예를 들어,

    HMAC SHA256 알고리즘을 사용해서 서명을 생성한다면

    다음과 같은 방식으로 생성된다.

    JMACSHA256(
    	base64UrlEncode(header) + "." +
        base64UrlEncode(payload),
        secret  
    )

    서명은

    토큰의 값들을 포함해서 암호화하기 때문에

    메시지가 도중에 변경되지 않았는지

    확인할 때 사용된다.


    2. JWT 디버거 사용하기

    JWT 공식 사이트에서 더욱 쉽게 JWT를 생성해볼 수 있다.

    jwt.io

    아래 화면에서 Decoded의 내용을 변경하면 Encoded의 콘텐츠가 자동으로 반영된다.



        🐢🎈


    5. 스프링 시큐리티와 JWT 적용

    실습!

    6주차까지 계속 사용했던 프로젝트에 이어서 실습 해보려고 한다.


    spring boot 2.7.12 기준 ,

    새로 추가해주어야 할 종속성은 다음과 같다.

    // 시큐리티  ------------------------------------------------------------
    implementation 'org.springframework.boot:spring-boot-starter-security'
    
    // JWT      ------------------------------------------------------------
    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-gson:0.11.2'

    이번 실습은

    로그인 폼을 이용하지 않고,

    JWT를 사용하는 인증필터를 구현하여

    구현한 인증 필터가

    UsernamePasswordAuthenticationFilter 보다 먼저 실행되게 배치하여

    인증 주체를 변경하는 작업을 수행하는 방식으로

    구성되었다.



    1. UserDetails와 UserDetailsService 구현

    먼저 사용자 정보를 담는 엔티티를 생성한다.

    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @Getter @Setter
    @Entity
    public class User extends BaseEntity 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; // 활성화 상태임을 의미
        }
    }

    User 엔티티는 UserDetails 인터페이스를 구현한다.

    UserDetails는

    위에서 학습 했듯이

    UserDetailsService를 통해 입력된 로그인 정보를 가지고

    데이터베이스에서 사용자 정보를 가져오는 역할을 수행한다.


    그리고

    UserDetails 인터페이스에서 구현해야 하는 메서드들이 있는데,

    계정의 상태 변경과 관련된 부분이고

    이번 실습에서 이 부분은 다루지 않을것이기 때문에 true로 설정되었다.


    이번에 생성한 User 엔티티는

    앞으로 토큰을 생성할 때

    토큰의 정보로 사용될 정보와 권한 정보를 갖게 된다.


    엔티티를 조회하기 위한 리포지토리와 서비스 구현

    UserRepository

    public interface UserRepository extends JpaRepository<User, Long> {
    
        User getByUid(String uid);
        
    }

    UserDetailsServiceImpl

    @Slf4j
    @RequiredArgsConstructor
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        private final UserRepository userRepository;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            log.info("[loadByUsername] loadUserByUsername 수행 . username = {} ", username);
            return userRepository.getByUid(username);
        }
    
    }

    상속 받은 UserDetailsService 인터페이스에는

    UserDetails를 반환하는

    loadUserByUsername() 메서드가 포함되어 있기 때문에

    이것을 구현해 주었다.


    UserDetails를 반환해야 한다고 해놓고

    User를 반환한 이유는

    User에 UserDetails를 상속 받아 구현했기 때문에 가능하다.



        🐢🎈


    2. JwtTokenProvider 구현

    이제,

    JWT 토큰을 생성하는 데 필요한 정보를

    UserDetails에서 가져올 수 있기 때문에

    JWT 토큰을 생성하는 JwtTokenProvider를 생성한다.

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class JwtTokenProvider {
    
        private final UserDetailsService service;
        private Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    
        private final long tokenValidMillisecond = 1000L * 60 * 60;
    
    
        public String createToken(String userUid, List<String> roles) {
            log.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(key)
                    .compact();
    
            log.info("[createToken] 토큰 생성 완료");
            return token;
        }
    
    
        // 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성
        public Authentication getAuthentication(String token) {
            log.info("[getAuthentication] 토큰 인증 정보 조회 시작");
    
            UserDetails userDetails = service.loadUserByUsername(this.getUsername(token));
    
            log.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails username : {}", userDetails.getUsername());
            return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
        }
    
    
        public String getUsername(String token) {
            log.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
            String info = Jwts
                    .parserBuilder()
                    .setSigningKey(key).build()
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
            log.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
            return info;
        }
        
        public String resolveToken(HttpServletRequest request) {
            log.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
    
            return request.getHeader("X-AUTH-TOKEN");
        }
    
        public boolean validateToken(String token) {
            log.info("[validateToken] 토큰 유효 체크 시작");
    
            try {
                Jws<Claims> claims = Jwts
                        .parserBuilder()
                        .setSigningKey(key).build()
                        .parseClaimsJws(token);
    
                return !claims.getBody().getExpiration().before(new Date());
            } catch(Exception e) {
                log.info("[validateToken] 토큰 유효 체크 예외 발생");
    
                return false;
            }
        }
    }

        🐢🎈



    3. JwtAuthenticationFilter 구현

    JwtAuthenticationFilter 는

    JWT 토큰으로 인증하고

    SecuritycontextHolder 에 추가하는 필터를 설정하는 클래스이다.

    @Slf4j
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends OncePerRequestFilter {
        
        private final JwtTokenProvider provider;
        
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        
            String token = provider.resolveToken(request);
            log.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
            
            log.info("[doFilterInternal] token 값 유효성 체크 시작");
            if (token != null && provider.validateToken(token)) {
            
                Authentication authentication = provider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                
                log.info("[doFilterInternal] token 값 유효성 체크 완료");
            }
    
            filterChain.doFilter(request, response);
        }
    }

        🐢🎈



    4. SecurityConfiguration 구현

    이제 스프링 시큐리티와 관련된 설정을 진행해본다.

    시큐리티를 설정하는 대표적인 방법은

    SecurityFilterChain을 반환하는 Configuration 클래스를 구현하는 것이다.

    @RequiredArgsConstructor
    @Configuration
    public class SecurityConfiguration {
    
        private final JwtTokenProvider provider;
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
            return httpSecurity
    
                    // UI 사용 비활성화
                    .httpBasic().disable()
    
                    // REST API에서는 CSRF 보안이 필요 없기 때문에 비활성화
                    .csrf().disable()
    
                    // 세션은 로그인 폼에서 사용하므로, 이 프로젝트에선 필요 없다 => STATELESS 설정.
                    .sessionManagement()
                    .sessionCreationPolicy(
                            SessionCreationPolicy.STATELESS
                    ).and()
    
                    // 리소스 권한 설정
                    .authorizeRequests()
                    .antMatchers(
                            "/webjars/**",
                            "/v3/api-docs/**",
                            "/swagger-resources/**",
                            "/swagger-ui.html",
                            "/swagger-ui/**"
                    ).permitAll()
                    .antMatchers(
                            "/sign-api/sign-in",
                            "/sign-api/sign-up",
                            "/sign-api/exception"
                    ).permitAll()
                    .antMatchers("**exception**").permitAll()
                    .anyRequest().hasRole("ADMIN").and()
    
                    // 권한 통과하지 못하는 예외 발생시 예외 전달
                    .exceptionHandling()
                    .accessDeniedHandler(new CustomAccessDeniedHandler()).and()
                    
                    // 인증 과정에서 예외 발생 시 예외 전달
                    .exceptionHandling()
                    .authenticationEntryPoint(new CustomAuthenticationEntryPoint()).and()
    
                    // UsernamePasswordAuthenticationFilter 보다 먼저 JwtAuthenticationFilter 가 실행되게 설정
                    // JwtAuthenticationFilter에서 인증 완료 시 UsernamePasswordAuthenticationFilter는 그냥 통과됨
                    .addFilterBefore(
                            new JwtAuthenticationFilter(provider),
                            UsernamePasswordAuthenticationFilter.class)
    
                    .build();
        }
    
    	// 회원 가입 및 로그인시 사용됨
        @Bean
        public PasswordEncoder passwordEncoder() {
            return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
    }

        🐢🎈



    5. 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

    1. CustomAccessDeninedHandler 클래스 작성

    권한이 없는 리소스에 접근할 경우 발생하는 예외.

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

    2. CustomAuthenticationEntryPoint 클래스 작성

    인증이 실패한 상황을 처리하는

    AuthenticationEntryPoint 인터페이스를 구현한

    CustomAuthenticationEntryPoint 클래스 작성


    그 전에,

    CustomAuthenticationEntryPoint 클래스에서 사용할 DTO 클래스를 하나 만들어준다.

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString
    public class EntryPointErrorResponse {
    
        private String msg;
    
    }

    CustomAuthenticationEntryPoint 클래스 작성

    @Slf4j
    @Component
    public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            ObjectMapper mapper = new ObjectMapper();
            log.info("[commence] 인증 실패로 response.sendError 발생");
    
            EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
            entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
    
            response.setStatus(401);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().write(mapper.writeValueAsString(entryPointErrorResponse));
        }
    
    }

        🐢🎈



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

    User 객체를 생성하기 위해

    회원가입을 구현하고 User 객체로 인증을 시도하는 로그인을 구현!


    회원가입과 로그인의 도메인은 Sign으로 통합해서 표현할 예정이며,

    각각 Sign-up, Sign-in 으로 구분해서 기능을 구현한다.


    먼저 서비스 레이어를 구현.

    SignService

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

    다음으로

    SignService를 구현한 SignServiceImpl 클래스 작성하려고 하는데,

    SignServiceImpl 에서 사용 될 데이터 객체가 몇개 있기 때문에,

    데이터 객체를 먼저 작성 해 준 후

    SignServiceImpl 을 작성하는 순서로 진행한다.

    SignUpResultDto

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString
    public class SignUpResultDto {
    
        private boolean success;
        private int code;
        private String msg;
    
    }

    SignInResultDto

    @Data
    @EqualsAndHashCode(callSuper = true)
    @NoArgsConstructor
    @AllArgsConstructor
    @ToString(callSuper = true)
    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;
        }
    }

    CommonResponse

    @Getter
    @RequiredArgsConstructor
    public enum CommonResponse {
    
        SUCCESS(0, "Success"), FAIL(-1, "Fail");
    
        private final int code;
        private final String msg;
    }

    SignServiceImpl

    @Slf4j
    @RequiredArgsConstructor
    @Service
    public class SignServiceImpl implements SignService {
    
        private final UserRepository userRepository;
        private final JwtTokenProvider jwtTokenProvider;
        private final PasswordEncoder passwordEncoder;
    
        @Override
        public SignUpResultDto signup(String id, String password, String name, String role) {
            log.info("[getSignUp Result] 회원가입 정보 전달 ");
    
            User user;
            if (role.equalsIgnoreCase("admin")) 
                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();
    
            User savedUser = userRepository.save(user);
            SignUpResultDto signUpResultDto = new SignUpResultDto();
            
            log.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");
            if (!savedUser.getName().isEmpty()) {
                log.info("[getSignUpResult] 정상 처리 완료");
                setSuccessResult(signUpResultDto);
            } else {
                log.info("[getSignUpResult] 실패 처리 완료");
                setFailResult(signUpResultDto);
            }
    
            return signUpResultDto;
        }
    
        @Override
        public SignInResultDto signin(String id, String password) throws RuntimeException {
            log.info("[getSignInResult] signDataHandler 로 회원 정보 요청");
            
            User user = userRepository.getByUid(id);
            log.info("[getSignInResult] Id : {}", id);
            
            log.info("[getSignInResult] 패스워드 비교 수행");
            if (!passwordEncoder.matches(password, user.getPassword()))
                throw new RuntimeException();
            
            log.info("[getSignInResult] SignInResultDto 객체 생성");
            SignInResultDto signInResultDto = SignInResultDto.builder()
                    .token(jwtTokenProvider.createToken(String.valueOf(user.getUid()), user.getRoles()))
                    .build();
            
            log.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());
        }
    
    }


    이제

    회원가입과 로그인을 API로 노출하는 컨트롤러를 생성해야 하는데,

    사실상

    서비스 레이어로 요청을 전달하고 응답하는 역할만 수행하기 때문에

    코드만 작성.

    @Slf4j
    @RequiredArgsConstructor
    @RestController
    @RequestMapping("/sign-api")
    public class SignController {
    
        private final SignService service;
    
        @PostMapping("/sign-in")
        public SignInResultDto signIn(
                @RequestParam String id,
                @RequestParam String password
        ) throws RuntimeException {
            log.info("[signIn] 로그인을 시도하고 있습니다. id : {} , pw : ****", id);
            
            SignInResultDto dto = service.signin(id, password);
    
            if (dto.getCode() == 0)
                log.info("[signIn] 정상적으로 로그인 되었습니다. id : {} , token : {}", id, dto.getToken());
    
            return dto;
        }
    
        @PostMapping("/sign-up")
        public SignUpResultDto signUp(
                @RequestParam String id,
                @RequestParam String password,
                @RequestParam String name,
                @RequestParam String role
        ) {
            log.info(
                    "[signUp] 회원가입을 수행합니다. id: {} , password: **** , name : {} , role: {}",
                    id, name, role);
    
            SignUpResultDto dto = service.signup(id, password, name, role);
    
            log.info("[signUp] 회원가입을 완료했습니다. id : {}", id);
    
            return dto;
        }
        
        @GetMapping("/exception")
        public void exceptionTest() throws RuntimeException {
            throw new RuntimeException("접근이 금지되었습니다.");
        }
        
        @ExceptionHandler(RuntimeException.class)
        public ResponseEntity<Map<String, String>> ExceptionHandler(RuntimeException e) {
            HttpHeaders responseHeaders = new HttpHeaders();
    
            HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
            
            log.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);
        }
    }

        🐢🎈



    7. SignController를 제외한, 나머지 Controller에 Header 추가

    회원가입, 로그인을 담당하는 SignController 를 제외한

    나머지 Controller의 메서드에

    @ReauestHeader 값을 받게 하여

    인증 토큰 검증을 진행할 수 있게 설정해준다.


    코드 : Github


        🐢🎈



    8. 스프링 시큐리티 테스트

    이번에는 클라이언트의 입장이 되어

    스프링 시큐리티가 동작하는 상황에서 테스트를 수행해본다.

    Swagger를 활용할 예정이며,

    Swagger 접속 페이지는 다음과 같다.

    http://localhost:8080/swagger-ui/index.html
    

    책에 나와있는 시나리오 테스트 ㄱㄱ

    1. 회원가입 테스트


    2. 로그인 테스트 - 성공


    2. 로그인 테스트 - 실패


    3. 상품 컨트롤러 상품 등록 API 호출


    4. 정상적으로 상품 등록을 마친다.


        🐢🎈

Designed by Tistory / Custom by 얼거스