개발 지식 기록/북스터디

[스프링 부트 핵심 가이드] 13. 서비스의 인증과 권한 부여

엉망진창좌충우돌 2023. 10. 15. 21:19

본 게시글은 '스프링 부트 핵심 가이드' 책의 내용을 정리한 것입니다.

저자 : 장정우

출판사 : 위키북스


13.1 보안 용어 이해

 

13.1.1 인증

 

인증(Authentication) : 사용자가 누구인지 확인하는 단계 ex) 로그인

 

13.1.2 인가

 

인가(Authorization) : 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정

 

13.1.3 접근 주체 

 

접근주체(Principal) : 애플리케이션의 기능을 사용하는 주체

 

 

13.2 스프링 시큐리티

 

스프링 시큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링의 하위 프로젝트 중 하나이다.

 

 

13.3 스프링 시큐리티의 동작 구조

 

스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 동작한다.  DispatcherServlet 앞에 필터가 배치되어 있다.

필터체인(FilterChain)은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다. 클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑한다. 스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 DelegatingFilterProxy를 사용한다.

출처 : https://bwgjoseph.com/spring-security-custom-pre-authentication-flow

DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트(Application Context) 사이에서 다리 역할을 수행하는 필터 구현체이다. 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(FilterChainProxy)를 내부에 가지고 있다. 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성된다.

필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용할 수 있다. 필터체인 프록시에서 사용할 수 있는 보안 필터체인은 List 형식으로 담을 수 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용하게 된다.

보안 필터체인은 WebSecurityConfigurerAdapter 클래스를 상속받아 설정할 수 있다. 여러 보안 필터체인을 만들기 위해서는 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러 개 생성하면 된다. WebSecurityConfigurerAdapter  클래스에는 @Order 어노테이션을 통해 우선순위가 지정돼 있는데, 2개 이상 생성할 경우 상속받은 클래스에서 @Order 어노테이션을 이용해 순서를 정의해야 한다.

 

별도 설정이 없다면 스프링 시큐리티에서는 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.

수행 과정

1. 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고 그중 UsernamePasswordAuthenticationFilter에서 인증을 처리

2. AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰 생성

3. AuthenticationManager에게 토큰 전달. AuthenticationManager는 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager.

4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰 전달

5. AuthenticationProvider는 토큰 정보를 UserDetailsService에 전달

6. UserDetailsService는 전달받은 정보를 통해 DB에서 일치하는 사용자를 찾아 UserDetails 객체 생성

7. 생성된 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하면 ProviderManager로 권한을 담은 토큰을 전달

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

9. AuthenticationFilter는 검증된 토큰을 SecurityContextHilder에 있는 SecurityContext에 저장

 

 

13.4 JWT

 

JWT(JSON Web Token)는 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다. URL로 이용할 수 있는 문자열로만 구성돼 있으며(HTTP 구성요소 어디든 위치할 수 있다), 디지털 서명이 적용돼 있어 신뢰할 수 있다. 주로 서버와의 통신에서 권한 인가를 위해 사용된다. 

 

13.4.1 JWT의 구조

 

JWT는 점('.')으로 구분된 세 부분으로 구성된다.

출처 : https://www.lgcns.com/blog/cns-tech/security/1786/

  • 헤더(Header)
    • 검증과 관련된 내용을 담는다.
    • alg 속성에서는 해싱 알고리즘 지정한다. 토큰을 검증할 때 사용되는 서명 부분에서 사용된다.
    • typ 속성에서는 토큰의 타입을 지정한다.
    • Base64Url 형식으로 인코딩 돼 사용된다.

 

  • 내용(Payload)
    • 토큰에 담는 정보를 포함한다.
    • 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 세 가지로 분류된다.
      • 등록된 클레임(Registered 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)이다. 주로 중복 처리를 방지하기 위해 사용된다.
      • 공개 클레임(Public Claims) : 키 값을 마음대로 정의할 수 있다.
      • 비공개 클레임(Private Claims) : 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임
    • Base64Url 형식으로 인코딩 돼 사용된다.

 

  • 서명(Signature)
    • 인코딩 된 헤더, 인코딩 된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성된다.
    • 서명은 토큰의 값들을 포함해서 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용된다.
HMACSHA256(
	base64UrlEncode(header)+"."+
    base64UrlEncode(payload),
    secret
)

 

 

13.5 스프링 시큐리티와 JWT 적용

 

실습을 위해 구성한 프로젝트 설정은 다음과 같다.

  • groupId : com.springboot
  • artifactId : security
  • name : security
  • Developer Tools : Lombok, Spring Configuration Processor
  • Web : Spring Web
  • SQL : Spring Data JPA, MariaDB Driver

+ SwaggerConfiguration 클래스 생성 & pom.xml에 의존성 추가

+ 7장 프로젝트 파일들

 

스프링 시큐리티와 JWT 의존성 추가한다.

  <dependencies>

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

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

  </dependencies>

 

13.5.1 UserDetails와 UserDetailsService 구현

 

사용자 정보 담는 엔티티 생성

@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;

    @JsonProperty(access = 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 = 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;
    }
}

UserDetails 인터페이스를 구현하고 있으며, UserDetails는 UserDetailsService를 통해 입력된 로그인 정보를 가지고 DB에서 사용자 정보를 가져오는 역할을 수행한다. 이 엔티티는 토큰을 생성할 때 토큰의 정보로 사용될 정보와 권한 정보를 갖게 된다.

 

UserDetails 인터페이스의 메서드

  • getAuthorities() : 계정이 가지고 있는 권한 목록 리턴
  • getPassword() : 계정의 비밀번호를 리턴
  • getUsername() : 계정의 이름을 리턴. 일반적으로 아이디를 리턴한다.
  • isAccountNonExpired() : 계정이 만료됐는지 리턴. true는 만료되지 않았다는 의미.
  • isAccountNonLocked() : 계정이 잠겨있는지 리턴. true는 잠기지 않았다는 의미.
  • isCredentialNonExpired() : 비밀번호가 만료됐는지 리턴. true는 만료되지 않았다는 의미.
  • isEnabled() : 계정이 활성화돼 있는지 리턴. true는 활성화 상태를 의미.

 

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

public interface UserRepository extends JpaRepository<User, Long> {

    User getByUid(String uid); // 현재 ID값은 인덱스 값이니 id 값을 토큰 정보로 사용하기 위해 메서드 생성

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

}

UserDetailsService 인터페이스를 구현한다. UserDetailsService는 loadUserByUsername() 메서드를 구현하도록 정의돼 있다.

UserDetails는 스프링 시큐리티에서 제공하는 개념으로, UserDetails의 username은 각 사용자를 구분할 수 있는 ID를 의미한다. loadUserByUsername 리턴 타입이 UserDetails인데 UserDetails의 구현체로 User 엔티티를 생성했기에 User 객체를 리턴하도록 구현된 것이다.

 

13.5.2 JwtTokenProvider 구현

 

JWT 토큰을 생성하는 JwtTokenProvider를 구현

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

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

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

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

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

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

    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;
    }

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

    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;
        }
    }
}

 

application.properties 파일에서 secretKey 값을 정의한다.

springboot.jwt.secret=flature!@#

 

◎ init() 메서드는 secretKey를 Base64 형식으로 인코딩한다.

@PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 가리킨다. JwtTokenProvider 클래스에는 @Component 어노테이션이 지정돼 있어 애플리케이션이 가동되면서 빈으로 자동 주입된다. 그래서 init() 메서드도 자동으로 실행된다.

 

◎ createToken() 메서드에서는 JWT 토큰의 내용에 값을 넣기 위해 Claims 객체를 생성한다.

setSubject() 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값을 사용한다. 사용자 권한 확인을 위한 role 값을 추가하였고, Jwts.builder()를 사용해 토큰을 생성한다.

 

◎ getAuthentication() 메서드는 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할을 한다.

Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것이다. UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있고, AbstractAuthenticationToken은 Authentication의 구현체이다.

UsernamePasswordAuthenticationToken의 초기화를 위해 UserDetails가 필요한데, loadUserByUsername() 메서드를 통해 UserDetails를 가져오는 것을 확인할 수 있다.

 

◎ getUsername() 메서드는 JWT 토큰에서 회원 구별 정보를 추출하는 메서드이다.

앞서 UserDetails를 가져올 때 사용했던

this.getUsername(token)

부분이 getUsername() 메서드이다.

Jwts.parser()를 통해 secretKey를 설정하고 클레임을 추출해서 토큰을 생성할 때 넣었던 sub 값을 추출한다.

 

◎ resolveToken() 메서드는 HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 리턴한다.

 

◎validateToken() 메서드는 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴한다.

 

13.5.3 JwtAuthenticationFilter 구현

 

JwtAuthenticationFilter는 JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스이다.

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

JwtAuthenticationFilter를 OncePerRequestFilter를 상속받아 구현한다. 스프링 부트의 필터는 여러 방법으로 구현할 수 있으나, 대표적으로 많이 사용되는 상속 객체는 GenericFilterBean과 OncePerRequestFilter이다.

 

※ GenericFilterBean vs OncePerRequestFilter

더보기

GenericFilterBean은 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상 클래스이다. 다만 서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장해 두고 동일한 클라이언트의 요청을 받으면 재활용하는 구조여서 GenericFilterBean을 상속받으면 RequestDispatcher에 의해 다른 서블릿으로 디스패치되면서 필터가 두 번 실행되는 현상이 발생할 수 있다.

이를 해결하기 위해 등장한 것이 OncePerRequestFilter이며, 이 클래스 역시 GenericFilterBean을 상속받는다.  OncePerRequestFilter를 상속받아 구현한 필터는 매 요청마다 한 번만 실행되게끔 구현된다.

 

참고 : https://g4daclom.tistory.com/115

 

OncePerRequestFilter로부터 오버라이딩한 doFilterInternal() 메서드가 있다. 마지막 부분에 있는 doFilter() 메서드는 서블릿을 실행하는 메서드인데, doFilter() 메서드를 기준으로 앞에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후에 실행된다.

 

doFilterInternal() 메서드에서 JwtTokenProvider를 통해 servletRequest에서 토큰을 추출하고, 토큰에 대한 유효성을 검사한다. 토큰이 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 작업을 수행한다.

 

13.5.4 SecurityConfiguration 구현

 

스프링 시큐리티를 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것이다.

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

SecurityConfiguration은 WebSecurity 파라미터를 받은 configure() 메서드와 HttpSecurity 파라미터를 받은 configure() 메서드로 구성되어 있다.

 

스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해 진행한다.

 

HttpSecurity의 기능

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

 

configure() 메서드 코드 설명

  • httpBasic().disable() : UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화한다. (REST API는 UI를 사용하지 않으므로 비활성화)
  • csrf().disable() : REST API에서는 CSRF 보안이 필요 없기 때문에 비활성화. CSRF는 Cross-Site Request Forgery의 줄임말로 '사이트 간 요청 위조'를 의미한다. '사이트 간 요청 위조'란 웹 애플리케이션의 취약점 중 하나로서 사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 함으로써 특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등의 작업을 하는 공격 방법이다. 스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급해서 클라이언트로부터 요청을 받을 때마다 토큰을 검증하는 방식으로 동작한다. 브라우저 사용 환경이 아니라면 비활성화해도 크게 문제가 되지 않는다.
  • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : REST API 기반 애플리케이션의 동작 방식을 설정한다. JWT 토큰으로 인증을 처리하고 세션을 사용하지 않기 때문에 STATELESS로 설정한다.
  • authorizeRequests() : 애플리케이션에 들어오는 요청에 대한 사용 권한을 체크한다. antMatchers() 메서드는 antPattern을 통해 권한을 설정하는 역할을 한다.
  • exceptionHandling().accessDeniedHandler() : 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달한다.
  • exceptionHandling().authenticationEntryPoint() : 인증 과정에서 예외가 발생할 경우 예외를 전달한다.

 

스프링 시큐리티는 필터들이 체인 형태로 구성돼 순서대로 동작한다. 앞서 설정한 JwtAuthenticationFilter 등록을 HttpSecurity 설정에서 진행한다. addFilterBefore() 메서드를 통해 어느 필터 앞에 추가할 것인지 설정할 수 있는데, 위에 구현된 부분은 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하겠다는 의미이다. 

 

WebSecurity는 HttpSecurity 앞단에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있다. 즉, 인증과 인가가 모두 적용되기 전에 동작하는 설정이다. 그렇기 때문에 다양한 곳에서 사용되지 않고 인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용한다. 예제에서는 Swagger와 관련된 경로를 인증, 인가를 무시하는 경로로 설정한 것이다.

 

13.5.5 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

 

앞서 구현한 코드에서 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외를 전달했다. 이를 구현한다.

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

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

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

CustomAccessDeniedHandler는 AccessDeniedHandler 인터페이스의 구현체이며 handle() 메서드를 오버라이딩한다.

HttpServletReqeust와 HttpServletResponse, 액세스 권한이 없는 리소스에 접근할 경우 발생하는 AccessDeniedException를 파라미터로 가져온다. response에서 리다이렉트 하는 setRedirect() 메서드를 활용하는 방식으로 구현했다.

 

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

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

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException ex) throws IOException {
        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));
    }
}

CustomAuthenticationEntryPoint는 AuthenticationEntryPoint 인터페이스의 구현체이며, commence() 메서드를 오버라이딩한다.

 

EntryPointErrorResponse는 다음과 같이 구현한다.

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

    private String msg;

}

commence() 메서드는 HttpServletRequest, HttpServletResponse, AuthenticationException을 매개변수로 받는데, 이번 경우에는 리다이렉트가 아닌 직접 Response를 생성해서 클라이언트에 응답하는 방식으로 구현했다.

 

컨트롤러에는 응답을 위한 설정들이 자동으로 구현되기 때문에 별도의 작업이 필요하지 않았지만 여기서는 응답값을 설정할 필요가 있기에 EntryPointErrorResponse 객체를 사용해 메시지를 담고, response에서 상태 코드(status)와 콘텐츠 타입(Content-type) 등을 설정한 후 ObjectMapper를 사용해 EntryPointErrorResponse 객체를 바디 값으로 파싱한다.

 

메시지를 설정할 필요가 없다면 다음처럼 간단하게 인증 실패 코드만 전달할 수 있다.

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

 

13.5.6 회원가입과 로그인 구현

 

회원가입과 로그인을 위한 Service interface와 ServiceImpl

public interface SignService {

    SignUpResultDto signUp(String id, String password, String name, String role);

    SignInResultDto signIn(String id, String password) throws RuntimeException;

}
@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("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 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() 메서드를 통해 회원가입을 구현한다.

id와 name, 인코딩 된 password, 전달받은 role이 admin인 경우 ROLE_ADMIN, admin이 아닌 경우 ROLE_USER인 User 엔티티를 생성한다.

passwordEncoder는 다음처럼 구현한다.

@Configuration
public class PasswordEncoderConfiguration {

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

}

별도로 클래스를 만들지 않고 이전에 작성한 @Configuration 클래스 내부에 작성해도 된다.

이렇게 생성된 엔티티를 UserRepository를 통해 저장한다.

 

signIn() 메서드를 통해 로그인을 구현한다.

입력된 id를 통해 User 엔티티를 가져오고, 입력된 password와 PasswordEncoder를 사용해 DB에 저장된 password가 일치하는지 확인한다. 일치하면 JwtTokenProvider를 통해 id와 role 값을 전달해서 토큰을 생성한 후 Response에 담아 전달한다.

 

setSuccessResult() 메서드와 setFailResult() 메서드는 회원가입과 로그인 메서드에서 사용할 수 있게 설정돼 있으며, 각 메서드는 DTO를 전달받아 값을 설정한다.

SignUpResultDto, SingInResultDto, CommonResponse 클래스는 다음과 같이 작성되어 있다.

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

    private boolean success;

    private int code;

    private String msg;

}
@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;
    }

}
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;
    }

}

 

회원가입과 로그인 컨트롤러 구현

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

}

컨트롤러 부분에서는 서비스 레이어로 요청을 전달하고 응답을 하는 역할만 수행한다.