[Spring] 스프링 시큐리티 - 인증, 인가, JWT

Miin·2024년 1월 1일
0

Spring

목록 보기
16/17
post-custom-banner

✔️인증(authentication)

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

ex) 로그인 성공시 애플리케이션 서버는 응답으로 사용자에게 토큰(token) 전달, 로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소스 접근 불가

✔️인가(authorization)

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

ex) 로그인한 사용자가 특정 게시판에 접근해 글을 보려는 경우 게시판 접근 등급 확인 후 접근 허가 또는 거부

✔️접근 주체(principal)

애플리케이션의 기능을 사용하는 주체 - 사용자, 디바이스, 시스템 등

인증 과정을 통해 접근 주체가 신뢰할 수 있는지 확인하고, 인가 과정을 통해 접근 주체에게 부여된 권한을 확인하는 과정 등을 거침.


🔐스프링 시큐리티

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

스프링 시큐리티 동작 구조

서블릿 필터(Servlet Filter) 기반 동작

  • DisplatchServlet 앞에 필터 배치
  • 필터체인(FilterChain)은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain 의미
  • 클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해 필터와 서블릿 매핑

스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 다음과 같이 DelegatingFilterProxy 사용

DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트(Application Context) 사이에서 다리 역할을 수행하는 필터 구현체.

표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(FilterChainProxy)를 내부에 가지고 있음. 필터체인 프록시는 스프링부트의 자동 설정에 의해 자동 생성

필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter) 사용 가능

필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정돼 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용

❗❗스프링 부트 버전 3.0 미만에서만 가능❗❗

보안 필터체인은 WebSecurityConfigurerAdapter 클래스를 상속받아 설정, @Order 어노테이션을 지정해 순서를 정의하는 것이 중요

별도의 설정이 없다면 스프링 시큐리티에서는 SecurityFilterChain에서 사용하는 필터 중 UsernamePasswordAuthenticationFilter를 통해 인증 처리

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

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

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

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

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

  6. UserDetailService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체 생성

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

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

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



📌JWT(JSON Web Token)

당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰

  • URL로 이용할 수 있는 문자열로만 구성돼 있기 때문에 HTTP 구성요소 어디든 위치 가능
  • 디지털 서명이 적용돼 있어 신뢰 가능
  • 주로 서버와의 통신에서 권한 인가를 위해 사용

JWT 구조

헤더

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

{
	"alg": "HS256", // HMAC SHA256 알고리즘 사용
    "typ": "JWT"
}
  • alg 속성: 해싱 알고리즘 지정(보통 SHA256, RSA), 토큰을 검증할 때 사용되는 서명 부분에서 사용
  • typ 속성: 토큰 타입 지정

완성된 헤더는 Base64Url 형식으로 인코딩돼 사용

내용

토큰에 담는 정보를 포함. 이곳에 포함된 속성들 ➡️ 클레임(Claim)

  1. 등록된 클레임(Registered Claims)
    필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임

    • iss: 발급자(Issuer) 주체. iss 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열
    • sub: 제목(Subject)
    • aud: 수신인(Audience). JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 함, 요청을 처리하는 주체가 aud 값으로 자신을 식별하지 않으면 JWT는 거부됨
    • exp: 만료시간(Expiration). 시간은 NumericDate 형식
    • nbf: Not Before
    • iat: 발급된 시간(Issued at)
    • jti: 식별자(JWT ID). 중복 처리 방지를 위해 사용

  2. 공개 클레임(Public Claims)
    키 값을 마음대로 정의 가능

  3. 비공개 클레임(Private Claims)
    통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임

    {
      "sub": "Corner payload",
      "name": "Corner",
      "iat": 1516239022
    }

완성된 내용은 Base64Url 형식으로 인코딩돼 사용

서명

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

HMACSHA256(
  base64UrlEncode(header) + "_" +
  base64UrlEncode(payload),
  secret
  )

서명은 토큰의 값들을 포함해서 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용


💡 JWT 공식 사이트에서 쉽게 JWT 생성 가능 - https://jwt.io/#debugger-io



스프링 시큐리티와 JWT 적용

pom.xml 의존성 추가

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

User 엔티티 생성

UserDetails 인터페이스 구현 - UserDetailsService를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할 수행

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

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

    /**
     * 계정이 만료되었는지 체크하는 로직
     * 이 예제에서는 사용하지 않으므로 true 값 return
     *
     * @return true
     */
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 계정이 잠겼는지 체크하는 로직
     * 이 예제에서는 사용하지 않으므로 true 값 return
     *
     * @return true
     */
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 계정의 패스워드가 만료되었는지 체크하는 로직
     * 이 예제에서는 사용하지 않으므로 true 값 return
     *
     * @return true
     */
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

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

UserDetails 인터페이스 메서드

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

User 리포지토리

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

User 서비스

@Slf4j
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

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

}
  • UserDetailsService 인터페이스를 구현해 loadUserByUsername() 메서드 구현
  • UserDetails 객체를 리턴하는데, UserDetails 구현체로 User 엔티티를 생성했기 때문에 User 객체를 리턴함

JwtTokenProvider 구현

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    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() {
        log.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");

        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));

        log.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }

    // JWT 토큰 생성
    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(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret 값 세팅
                .compact();

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

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

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

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

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

            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            log.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }

}
  • 토큰 생성을 위해 secretKey 값 정의
  • @Value의 값은 application.properties 파일에서 정의
    springboot.jwt.secret=corner!@#$

🔷 init 메서드

  • @PostConstruct 어노테이션 : 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 가리킴
    JwtTokenProvider 클래스에는 @Component 어노테이션이 지정돼 있어 애플리케이션이 가동되면서 빈으로 자동 주입됨, 그때 @PostConstruct가 지정돼 있는 init() 메서드 자동 실행
  • secretKey를 Base64 형식으로 인코딩

🔷 createToken 메서드

  • JWT 토큰의 내용에 값을 넣기 위해 Claims 객체 생성
  • setSubject() 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값 사용
  • 해당 토큰을 사용하는 사용자의 권한을 확인할 수 있는 role 값 별개로 추가
  • Jwts.builder() 사용해 토큰 생성

🔷 getAuthentication 메서드

  • 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할.

  • Authentication을 구현하는 편한 방법은 UsernamePasswordAutenticationToken을 사용하는 것

  • 이 토큰 클래스를 사용하려면 초기화를 위한 UserDetails 필요. 이 객체는 UserDetailsService를 통해 가져오게 됨. 이때 사용되는 Username 값은 getUsername() 메서드를 통해 구현하였음.

🔷 getUsername 메서드

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

🔷 resolveToken 메서드

  • HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 리턴
  • 클라이언트가 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출 가능

🔷 validateToken 메서드

  • 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴하는 역할

JwtAuthenticationFilter 구현

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

@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    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);
        log.info("[doFilterInternal] token 값 추출 완료. token : {}", token);

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

        filterChain.doFilter(servletRequest, servletResponse);
    }

}
  • 스프링 부트에서는 필터를 여러 방법으로 구현할 수 있는데, 가장 편한 구현 방법은 필터를 상속받아 사용하는 것 - 대표적으로 많이 사용되는 상속 객체는 GenericFilterBeanOncePerRequestFilter

  • doFilter() 메서드는 서블릿을 실행하는 메서드인데, 이를 기준으로 앞에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후 실행됨

  • 위 코드의 로직은 JwtTokenProvider를 통해 servletRequest에서 토큰을 추출하고, 토큰에 대한 유효성을 검사를 하며, 토큰이 유효하면 Authentication 객체를 생성해 SecurityContextHolder에 추가하는 작업을 수행함


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() // REST API는 UI를 사용하지 않으므로 기본설정을 비활성화

                .csrf().disable() // REST API는 csrf 보안이 필요 없으므로 비활성화

                .sessionManagement()
                .sessionCreationPolicy(
                        SessionCreationPolicy.STATELESS // JWT Token 인증방식으로 세션은 필요 없으므로 비활성화
                )

                .and()
                .authorizeRequests() // 리퀘스트에 대한 사용권한 체크
                .antMatchers("/sign-api/sign-in", "/sign-api/sign-up",
                        "sign-api/exception").permitAll() // 가입 및 로그인 주소는 허용

                .antMatchers(HttpMethod.GET, "/product/**").permitAll() // product로 시작하는 Get 요청은 허용

                .antMatchers("**exception**").permitAll()

                .anyRequest().hasRole("ADMIN") // 나머지 요청은 인증된 ADMIN만 접근 가능

                .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())

                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class); // JWT Token 필터를 id/password 인증 필터 이전에 추가

    }

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

💡 SecurityConfiguration 클래스의 주요 메서드 두 가지

  1. WebSecurity 파라미터를 받은 configure() 메서드
  2. HttpSecurity 파라미터를 받은 configure() 메서드

🔷 HttpSecurity를 설정하는 configure 메서드

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

지금부터 configure() 메서드에 작성돼 있는 코드를 설정별로 구분해 설명해 보겠다. 모든 설정은 전달받은 HttpSecurity에 설정하게 된다.

🔹httpBasic().disable()

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

🔹csrf().disable()

REST API에서는 CSRF 보안이 필요없기 때문에 비활성화하는 로직.

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

🔹sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

REST API 기반 애플리케이션의 동작 방식 설정.

현재 예제는 JWT 토큰으로 인증을 처리하며, 세션은 사용하지 않기 때문에 STATELESS로 설정

🔹autorizeRequest()

애플리케이션에 들어오는 요청에 대한 사용 권한 체크.

이어서 사용한 antMatchers() 메서드는 antPattern을 통해 권한을 설정하는 역할을 함

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

🔹.exceptionHandling().accessDeniedHandler()

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

🔹.exceptionHandling().authenticationEntryPoint()

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


💡 스프링 시큐리티는 각각의 역할을 수행하는 필터들이 체인 형태로 구성돼 순서대로 동작

  • 이 필터의 등록은 HttpSecurity 설정에서 진행
  • addFilterChain() 메서드를 사용해 어느 필터 앞에 추가할 것인지 설정 가능
  • 현재 구현된 설정은 스프링 시큐리티에서 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter 앞에 앞에서 생성한 JwtAuthenticationFilter를 추가하겠다는 의미

🔷 WebSecurity를 사용하는 configure 메서드

WebSecurity는 HttpSecurity 앞단에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있음
=> 인증과 인가가 모두 적용되기 전에 동작하는 설정

예제에서는 Swagger에 적용되는 인증 인가를 피하기 위해 ignoring() 메서드를 사용해 Swagger와 관련된 경로에 대한 예외 처리 수행(인증, 인가를 무시하는 경로를 설정)


커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

앞의 인증과 인가 과정의 예외 상황에서 CustomAccessDeniedHandlerCustomAuthenticationEntryPoint로 예외를 전달하고 있는데, 해당 클래스를 작성해보겠다.

@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException exception) throws IOException {
        log.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
    
}
  • AccessDeniedHandler 인터페이스의 구현체 클래스 생성
  • AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외. 이 예외 처리를 위해 AccessDeniedHandler 인터페이스가 사용되고, handle() 메서드를 오버라이딩함

다음은 인증이 실패한 상황을 처리하는 AuthenticationEntryPoint 인터페이스를 구현한 CustomAuthenticationEntryPoint 클래스 코드이다.

@Slf4j
@Component
// 인증 실패시 결과를 처리해주는 로직을 가지고 있는 클래스
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

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

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

    private String msg;

}
  • 메시지를 담기 위해 EntryPointErrorResponse 객체를 사용해 메시지를 설정하고, response에 상태 코드(status)와 콘텐츠 타입(Content-type) 등을 설정한 후 ObjectMapper를 사용해 EntryPointErrorResponse 객체를 바디 값으로 파싱


회원가입과 로그인 구현

SignService 인터페이스

public interface SignService {

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

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

}

SignServiceImpl 클래스

SingService 인터페이스 구현

@Service
@Slf4j
public class SignServiceImpl implements SignService {

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

        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] 패스워드 일치");

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

    // 결과 모델에 api 요청 성공 데이터를 세팅해주는 메소드
    private void setSuccessResult(SignUpResultDto result) {
        result.setSuccess(true);
        result.setCode(CommonResponse.SUCCESS.getCode());
        result.setMsg(CommonResponse.SUCCESS.getMsg());
    }

    // 결과 모델에 api 요청 실패 데이터를 세팅해주는 메소드
    private void setFailResult(SignUpResultDto result) {
        result.setSuccess(false);
        result.setCode(CommonResponse.FAIL.getCode());
        result.setMsg(CommonResponse.FAIL.getMsg());
    }
    
}
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;
    }
    
}
  • 현재 애플리케이션에서는 ADMINUSER로 권한 구분

  • signUp() 메서드는 그에 맞게 전달받은 role 객체를 확인해 User 엔티티의 roles 변수에 추가해서 엔티티 생성

  • 패스워드는 PasswordEncoder를 활용해 암호화하여 저장 -> 별도의 @Configuration 클래스를 생성하고 @Bean 객체로 등록

    @Configuration
    public class PasswordEncoderConfiguration {
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
    
    }
  • 로그인은 미리 저장돼 있는 계정 정보와 요청을 통해 전달된 계정 정보가 일치하는지 확인하는 작업으로, singIn() 메서드는 아이디와 패스워드를 입력받아 처리하게 됨.

    1. id를 기반으로 UserRepository에서 User 엔티티 가져옴
    2. PasswordEncoder를 사용해 데이터베이스에 저장돼 있던 패스워드와 입력받은 패스워드가 일치하는지 확인하는 작업 수행
    3. 패스워드가 일치하여 인증을 통과하면 JwtTokenProvider를 통해 id와 role 값을 전달해서 토큰 생성 후 Response에 담아 전달

SignController 클래스

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

    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 {
        log.info("[signIn] 로그인을 시도하고 있습니다. id : {}, pw : ****", id);
        SignInResultDto signInResultDto = signService.signIn(id, password);

        if (signInResultDto.getCode() == 0) {
            log.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) {
        log.info("[signUp] 회원가입을 수행합니다. id : {}, password : ****, name : {}, role : {}", id,
                name, role);
        SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);

        log.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;

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

Response로 전달되는 SignUpResultDto와 SignInResultDto 클래스

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

}


스프링 시큐리티 테스트

정상적인 동작 시나리오

  1. 회원가입에 성공한다.
  2. 회원가입에 성공한 계정 정보를 기반으로 로그인을 성공한다.
    • 로그인에 성공하면서 토큰을 발급받는다.
  3. 상품 컨트롤러의 상품 등록 API를 호출한다.
    • API 호출 시 로그인 과정에서 받은 토큰을 헤더에 추가해서 전달한다.
  4. 정상적으로 상품 등록을 마친다.

1. 회원가입

2. 로그인

  • 결과값에서 응답으로 온 토큰값 확인 가능
  • 앞으로 인증이 필요한 리소스에 접근할 때는 이 토큰값을 헤더에 추가해서 전달해야 함

3. 상품등록


비정상적인 동작 시나리오 - 1. 인증 예외 발생

  1. 회원가입에 성공한다.
  2. 회원가입에 성공한 계정 정보를 기반으로 로그인을 성공한다.
    • 로그인에 성공하면서 토큰을 발급받는다.
  3. 상품 컨트롤러의 상품 등록 API를 호출한다.
    • API 호출 시 로그인 과정에서 받은 토큰을 변조해서 헤더에 추가한 후 전달한다.
  4. 인증 예외 메시지가 응답으로 돌아온다.

❗ 토큰 값을 임의의 값으로 입력하고 요청 -> 인증 실패

인증에 실패했기 때문에 CustomAuthenticationEntryPoint에 구현한 예외 상황에 대한 메시지가 담긴 응답이 애플리케이션에서 생성되고 클라이언트에게 전달된 것을 볼 수 있음


비정상적인 동작 시나리오 - 2. 인가 예외 발생

  1. 회원가입에 성공한다.
  2. 회원가입에 성공한 계정 정보를 기반으로 로그인을 성공한다.
    • 로그인에 성공하면서 토큰을 발급받는다.
  3. 상품 컨트롤러의 상품 등록 API를 호출한다.
    • API 호출 시 로그인 과정에서 받은 토큰을 헤더에 추가해서 전달한다.
  4. 인가 예외 발생으로 /exception으로 리다이렉트 후 예외 메시지가 응답으로 돌아온다.

❗ USER 권한을 설정하여 회원가입(ADMIN 권한 X) & 로그인 ➡️ 권한이 없을 때 발생하는 인가 예외 발생

[INFO ] [http-nio-9090-exec-10] com.springboot.jpa.config.security.JwtTokenProvider [getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : user
[INFO ] [http-nio-9090-exec-10] com.springboot.jpa.config.security.JwtAuthenticationFilter [doFilterInternal] token 값 유효성 체크 완료
[INFO ] [http-nio-9090-exec-10] com.springboot.jpa.config.security.CustomAccessDeniedHandler [handle] 접근이 막혔을 경우 경로 리다이렉트
[ERROR] [http-nio-9090-exec-8] com.springboot.jpa.controller.SignController ExceptionHandler 호출, null, 접근이 금지되었습니다.


💡 어떤 애플리케이션을 개발하느냐에 따라 서비스의 특성에 맞게 스프링 시큐리티를 적용해야 한다. 어느 애플리케이션이든 회원가입과 로그인 기능은 필수인데, 이에 대한 인증 인가에 대해 배우면서 보안의 중요성을 깨달을 수 있었다. 더 공부하여 OAuth나 소셜 로그인을 연동해서도 스프링 시큐리티를 구현해보고 싶다.



출처 : (책) 스프링 부트 핵심 가이드 / 장정우, 위키북스

profile
컴퓨터공학전공 학부생 Back-end Developer
post-custom-banner

0개의 댓글