상세 컨텐츠

본문 제목

[스프링2] 13장. 서비스의 인증과 권한 부여

23-24/Spring 2

by YUZ 유즈 2024. 1. 5. 10:00

본문

728x90

 

이번장에서는 보안과 관련된 용어와 개념을 알아보고 스프링에 보안을 적용할 때 사용하는 스프링 스큐리티(Spring Security)에 대해 알아보겠습니다. 

 


 

먼저 보안과 관련된 용어를 간단하게 설명하겠습니다.

 

인증(authentication)

: 사용자가 누구인지 확인하는 단계를 의미합니다. 대표적으로 '로그인'이 있습니다. 로그인이 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰(token)을 전달합니다. 로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소스에 접근할 수 없게 됩니다.

 

인가(authorization)

: 앞서 설명한 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정을 의미합니다. 일반적으로 인증 단계에서 발급받은 토큰은 인가 내용을 포함하고 있습니다. 

 

접근 주체(principal)

: 애플리케이션의 기능을 사용하는 주체를 의미합니다. 사용자나, 디바이스, 시스템 등이 될 수 있습니다. 

 

 

스프링 스큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나입니다. 보안과 관련된 많은 기능을 제공하여 더욱 편리하게 기능을 설계할 수 있습니다. 

 

 

 

1. JWT

 

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

 

 

JWT는 아래의 세 부분으로 구성됩니다.

  • 헤더(Header)
  • 내용(Payload)
  • 서명(Signature)

 

 

헤더

 

JWT의 헤더는 검증과 관련된 내용을 담고 있습니다. 헤더에는 아래와 같이 두가지 정보를 포함하고 있는데, 바로 alg typ 속성입니다.

{
    "alg":"HS256"
    "typ":"JWT"
}

 

alg 속성에서는 해싱 알고리즘을 지정합니다. 해싱 알고리즘은 보통 SHA256나 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명 부분에서 사용됩니다. 위 예제에 작성되어 있는 HS256은 'HMACSHA256' 알고리즘을 사용한다는 의미입니다. 그리고 typ 속성에서는 토큰의 타입을 지정합니다. 이렇게 완성된 헤더는 Base64Url 형식으로 인코딩되어 사용됩니다. 

 

 

 

내용

 

JWT 내용에는 토큰에 담는 정보를 포함합니다. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 3가지로 분류됩니다.

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

 

등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기위해 이미 이름이 정해져있는 클레임을 뜻합니다. 

  • iss: JWT의 발급 주체를 나타냅니다. 문자열이나 URI를 포함하는 대소문자를 구문하는 문자열 입니다.
  • sub: JWT의 제목입니다.
  • aud: JWT의 수신인입니다. 요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 JWT는 거부됩니다. 
  • exp: JWT의 만료시간입니다. 시간은 NumericDate 형식으로 지정해야합니다.
  • nbf: 'Not Before'을 의미합니다.
  • iat:JWT가 발급된 시간입니다.
  • jti: JWT의 식별자입니다. 중복 처리 방지를 위해 주로 사용됩니다.

 

공개 클레임은 키 값을 마음대로 정의할 수 있지만, 충돌이 발생하지 않을 이름으로 설정해야 합니다.

비공개 클레임은 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미합니다. 

 

{
"sub": "wikibooks paylod",
"exp": "1602076408",
"userId": "wikibooks",
"username": "flature"
}

 

 

 

서명

 

JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성됩니다. 예를 들어 HMAX SHA256 알고리즘을 사용하여 서명을 생성한다면 아래와 같은 방식으로 생성됩니다.

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

 

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

 

 

 

아래 URL로 접속하면 더욱 쉽게 JWT를 생성할 수 있습니다. 

 

https://jwt.io/#debugger-io

 

 

 

 

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

 

이제 애플리케이션에 스프링 시큐리티와 JWT를 적용해봅시다.  다음과 같이 프로젝트를 설정 후, SwaggerConfiguration 클래스와 그에 따른 의존성을 추가합니다. 그리고 7장에서 사용한 프로젝트 코드를 가져와주세요.

 

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

 

 

그리고 인증과 인가 코드를 작성하기 위해 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>

 

스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성돼있습니다. 이 필터에서는 인증이 실패하면 로그인 폼이 포함된 화면을 전달하게 되는데, 이 실습 프로젝트에서는 이러한 화면이 없습니다. 따라서 JWT를 사용하는 인증필터를 구현하고, UsernamePasswordAuthenticationFilter 앞에 인증 필터를 배치하여 인증 주체를 변경하는 작업을 수행하는 방식으로 구성하겠습니다. 

 

 

먼저 다음과 같이 사용자 정보를 담는 엔티티를 생성합니다.

// data/entity/User.java

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

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

 

User 엔티티는 UserDetails 인터페이스를 구현하고 있습니다. UserDetails 는 UserDetailsService를 통해 입력된 로그인 정보를 가지고 데이터 베이스에서 사용자 정보를 가져오는 역할을 수행합니다. UserDetails 인터페이스가 가진 메서드는 다음과 같습니다. 

 

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

 

이번 예제에서는 계정의 상태 변경은 다루지 않을 예정이라 모두 true로 리턴합니다. 이 엔티티는 토큰을 생성할 때 토큰의 정보로 사용될 정보와 권한 정보를 갖게 됩니다.

 

 

이 엔티티를 조회하는 기능을 구현하기 위해 리포지토리와 서비스를 구현하겠습니다.

// data/repository/UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {

    User getByUid(String uid);

}
// service/impl/UserDetailsServiceImpl.java

@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 인터페이스를 상속받아 loadUserByUsername() 메서드를 구현했습니다.

 

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

 

UserDetails는 스프링 시큐리티에서 제공하는 개념으로 UserDetails의 username은 각 사용자를 구분할 수 있는 ID를 의미합니다. loadUserByUsername을 보면 username을 가지고 UserDetails 객체를 리턴하게 되어있는데, UserDetails의 구현체로 User 엔티티를 생성했기에 User 객체를 리턴하게끔 구현한 것 입니다.

 

 

 

이제 JWT 토큰을 생성하는데 필요한 정보를 UserDetails에서 가져올 수 있기 때문에 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 = "secretKey";
    private final long tokenValidMillisecond = 1000L * 60 * 60; 

    @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 초기화 완료");
    }


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

 

토큰을 생성하기 위해 secretKey가 필요하여 위에서 secretKey 값을 정의했습니다. @Value의 값은 application.properties 파일에서 아래와 같이 정의할 수 있습니다.

 

springboot.jwt.secret=flature!@#

 

 

 

이제 메서드를 살펴봅시다. 먼저 init() 메서드입니다.

  @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 초기화 완료");
    }

 

여기서 사용한 @PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 가리킵니다. JwtTokenProvider 클래스에는 @Component 어노테이션이 지정돼있어 애플리케이션이 가동되면 빈으로 자동 주입됩니다. 그때 @PostConstruct가 지정돼 있는 init() 메서드가 자동으로 실행됩니다. init() 메서드에서는 secretKey를 Base64형식으로 인코딩합니다. 

 

 

 

다음은 createToken() 메서드입니다.

 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 토큰의 내용에 값을 넣기 위해 Claims 객체를 생성합니다. setSubject() 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값을 사용합니다. 세 번째 줄에서는 해당 토큰을 사용하는 사용자의 권한을 확인할 수 있도록 roles 값을 별개로 추가했습니다. 그 후 builder를 통해 토큰을 생성합니다. 

 

 

 

다음은 getAuthentication() 메서드입니다. 

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

 

이 메서드는 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할을 합니다. Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것 입니다. UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있는데 이것은 Authentication 인터페이스의 구현체입니다. 이 토큰 클래스를 사용하기 위해 초기화를 위한 UserDetails가 필요한데, 이 객체는 UserDeatilsService를 통해 가져올 수 있습니다. 이때 사용되는 Username은 아래와 같이 구현합니다.

 

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

 

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

 

 

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

 

다음은 resolveToken() 메서드입니다. 이 메서드는 HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 리턴합니다. 클라이언트가 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능합니다. 헤더 이름은 임의로 변경할 수 있습니다. 

 

 

마지막으로 validateToken() 메서드입니다. 

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

 

이 메서드는 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴하는 역할을 합니다. 

 

 

 

 

2-1. JwtAuthenticationFilter 구현

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

// config/security/JwtAuthenticationFliter.java

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

 

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

 

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

 

 

 

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

 

configure() 메서드에 작성된 코드를 설명하겠습니다.

 

  • httpBasic().disable() : UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화합니다.
  • csrf().disable(): REST API에서는 CSRF 보안이 필요없기 때문에 비활성화하는 로직입니다. 스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급해서 클라이언트로부터 요청받을 때마다 토큰을 검증하는 방식으로 동작합니다. 브라우저 사용 환경이 아니라면 비활성화해도 크게 문제되지 않습니다. 
  • sessionCreationPolicy(SessionCreationPolicy.STATELESS):  REST API 기반 애플리케이션의 동작 방식을 설정합니다. 현재 프로젝트는 JWT Token 인증방식으로, 세션이 필요 없으므로 비활성화합니다.
  • authorizeRequest(): 애플리케이션에 들어오는 요청에 대한 사용 권한을 체크합니다. 이전에 사용한 antMatchers() 메서드는 antPattern을 통해 권한을 설정하는 역할을 합니다. 
  • exceptionHandling().accessDeniedHandler(): 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생할 경우 예외를 전달합니다. 
  • exceptionHandling().authenticationEntryPoint(): 인증 과정에서 예외가 발생할 경우 예외를 전달합니다. 

 

앞서 설명했듯, 스프링 시큐리티는 각각의 역할을 수행하는 필터들이 체인 형태로 구성돼 순서대로 동작합니다. 이번 프로젝트에서는 JWT로 인증하는 필터를 생성했으며, 이 필터의 등록은 HttpSecurity 설정에서 진행합니다. addFilterBefore() 메서드를 사용해 어느 필터 앞에 추가할 것인지 설정할 수 있는데, 현재 구현돼 있는 설정은 스프링 시큐리티에서 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter 앞에 앞에서 생성한 JwtAuthenticationFilter를 추가하겠다는 의미입니다. 

 

 

그 다음은 WebSecurity를 사용하는 configure() 메서드입니다. WebSecurity는 HttpSecurity 앞단에 적용되며 전체적으로 스프링 시큐리티의 영향권 밖에 있습니다. 즉, 인증과 인가가 모두 적용되기 전에 동작하는 설정입니다. 예제에서는 Swagger에 적용되는 인증과 인가를 피하기 위해 ignoring() 메서드를 사용해 Swagger와 관련된 경로에 대한 예외 처리를 수행했습니다. 

 

 

 

2-3. 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

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

 

 AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외입니다. 이 예외를 처리하기 위해  AccessDeniedHandler 인터페이스가 사용됩니다. 이번 예제에서는 response에서 리다이렉트하는 sendRedirect() 메서드를 활용하는 방식으로 구현했습니다. 

 

 

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

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

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

    private String msg;

}

 

이 클래스의 구조는 AccessDeniedHandler와 크게 다르지 않으며, commence() 메서드를 오버라이딩해서 코드를 구현합니다. 이번 예제에서는 예외 처리를 위해 리다이렉트가 아니라 직접 Response를 생성해서 클라이언트에게 응답하는 방식으로 구현돼 있습니다. 응답할 메시지를 담기위해서 EntryPointErrorResponse 객체를 사용하고, response에 상태코드와 콘텐츠 타입 등을 설정한 후 바디값으로 파싱합니다. 

 

 

만약 메시지를 설정할 필요없다면 다음과 같이 인증 실패코드만 전달할 수 있습니다.

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

 

 

 

 

2-4. 회원가입과 로그인 구현

이번에는 앞서 만든 User 객체를 생성하기 위해 회원가입을 구현하고 User 객체로 인증을 시도하는 로그인을 구현하겠습니다. 먼저 서비스 레이어를 구현하겠습니다.

// service/SignService.java

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

 

회원가입과 로그인을 구현하기 위해 세 가지 객체에 대한 의존성을 주입 받고, 회원가입을 구현했습니다. 현재 애플리케이션에서는 ADMIN과 USER로 권한을 구분하고 있습니다. signup() 메서드는 그에 맞게 전달받은 role 객체를 확인해 User 엔티티의 roles 변수에 추가해서 엔티티를 생성합니다. 패스워드는 암호화해서 저장해야 하기 때문에 PasswordEncoder를 활용해 인코딩을 수행합니다. PasswordEncoder는 아래와 같이 별도의 @Configuration 클래스를 생성하고 @Bean 객체로 등록하도록 구현했습니다. 

 

 

// config/PasswordEncoderConfiguration.java

@Configuration
public class PasswordEncoderConfiguration {

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

}

 

빈 객체를 등록하기 위해 생성된 클래스이기 때문에 SecurityConfiguration 클래스같은 이미 생성된 @Configuration 클래스 내부에 passwordEncoder() 메서드를 정의해도 충분합니다.

 

이렇게 생성된 엔티티를 UserRepository를 통해 저장합니다. 또한, 회원으로 가입한 사용자의 아이디와 패스워드를 통해 로그인을 수행할 수 있습니다. 로그인은 미리 저장돼 있는 계정 정보와 요청을 통해 전달된 계정 정보가 일치하는지 확인하는 작업입니다. 

 

 

setSuccessResult() 메서드와 setFailResult() 메서드는 결과 데이터를 설정하는 메서드로, 회원가입과 로그인 메서드에서 사용할 수 있게 설정되어 있습니다. 각 메서드는 DTO를 전달받아 값을 설정하는데 다음과 같이 작성돼 있습니다. 

// common/CommonResponse

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로 노출하는 컨트롤러와 컨트롤러에서 사용될 DTO 객체를 생성하겠습니다.

 

// controller/SignController.java

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

}
// data/dto/SignUpResultDto

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

    private boolean success;

    private int code;

    private String msg;

}
// data/dto/SignInResultDto

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

}

 

 

 

 

3. 스프링 스큐리티 테스트

 

정상적인 동작 시나리오는 다음과 같습니다.

 

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

 

 

 

먼저 Swagger 페이지에 접속해 1번을 진행합니다. 

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

 

 

입력이 완료되었다면 위와 같은 회원가입 완료 화면이 출력됩니다.

 

 

이제 로그인을 수행합니다. 

 

로그인이 성공적으로 수행되었다면, 응답으로 온 토큰값도 확인할 수 있습니다. 앞으로 인증이 필요한 리소스에 접근할 때 이 토큰값을 헤더에 추가해서 전달해야 합니다. 

 

 

이제 상품을 등록해보겠습니다. 

 

 

 

만일 헤더 값을 입력할 수 있는 폼이 나오지 않는다면 ProductController 클래스의 createProduct() 메서드에 Swagger 어노테이션을 지정합니다. 

 

@ApiImplicitParams({
    @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "로그인 성공 후 발급 받은 access_token", required = true, dataType = "String", paramType = "header")
})
@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {
    long currentTime = System.currentTimeMillis();
    ProductResponseDto productResponseDto = productService.saveProduct(productDto);

    LOGGER.info("[createProduct] Response Time : {}ms", System.currentTimeMillis() - currentTime);
    return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}

 

 

 

지금까지 정상적인 동작 시나리오를 마쳤습니다. 이제 비정상적인 동작 시나리오를 살펴보도록 하겠습니다. 비정상적인 동작은 인증이 실패한 경우와, 인가가 실패한 경우로 구분할 수 있습니다. 먼저 인증이 실패한 상황을 알아보겠습니다.

 

 

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

 

 

1,2 번 과정은 위와 동일하여 이번 테스트에서 그대로 진행하겠습니다. 이제 토크을 변조하여 전달하겠습니다.

 

이렇게 토큰값을 변조하고 API를 호출하면 인증이 실패했다는 메시지가 담긴 응답이 애플리케이션에서 생성되고 클라이언트에게 전달된 것을 볼 수 있습니다. 

 

 

 

그럼 두번째 비정상적인 동작 시나리오를 살펴보겠습니다. 인가 과정에서 예외가 발생하는 상황입니다. 

 

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

 

 

인가 예외를 발생시키기 위해 USER 권한으로 회원가입하여 시나리오를 진행하겠습니다. 

 

 

그리고 로그인을 수행합니다.

 

 

 

이제 발급받은 토큰을 이용해 상품 등록 API를 호출합니다. 

 

 

 

아래와 같이 권한이 없을 때 발생하는 인가 예외가 발생합니다. 

 

 

 

 


정리

 

이번 장에서는 스프링 시큐리티를 다루는 방법에 대해 알아보았습니다. 이번 프로젝트에서는 간단하게 구현하고자 생략한 부분이 많지만, 로그인 폼을 사용해 로그인과 회원가입 기능을 개발할 수 있으며, OAuth나 소셜 로그인을 연동해서도 구현 가능합니다. 또는 외부에 노출되지 않는 일부 서비스는 성능상의 이점을 살리기 위해 스프링 시큐리티를 적용하지 않을 수도 있습니다. 스프링 시큐리티가 어떤 구성 요소로 이루어져있는지 이해한 후, 다양한 응용 방법에 대해 배우길 바랍니다. 

 

 


 

QUIZ

  1. ( 인증 )은 사용자가 누구인지 확인하는 단계이고, ( 인가 )는 ( 인증 )을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지 확인하는 과정을 의미한다.
  2. ( JWT )는 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다.
  3. JWT의 내용의 ( 등록된 클레임 )은 필수는 아니지만 토큰에 대한 정보를 담기위해 이미 이름이 정해져있는 클레임으로, iss, sub, nbf 등이 있다.
  4. 스프링 시큐리티는 기본적으로 ( UsernamePasswordAuthenticationFilter )를 통해 인증을 수행하도록 구성돼있다.
  5. UserDetails 인터페이스가 가진 메서드 중 계정의 이름을 리턴하는 메서드는 ( getUsername() )이고, 계정이 활성화돼 있는지 리턴하는 메서드는 ( isEnabled() )이다.
  6. ( JwtAuthenticationFilter )는 JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스이다.
  7. 스프링 스큐리티는 각각의 역할을 수행하는 필터들이 ( 체인 형태 )로 구성돼 ( 순서 )대로 동작한다. 
  8. User 엔티티를 조회하는 UserDetailsServiceImpl 서비스를 구현하세요. 
  9. JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스를 구현하되, OncePerRequestFilter 객체를 상속받으세요. 

 

 

8. 

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

}

9.

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

 


[출처] 장정우, 『스프링 부트 핵심 가이드』, 위키북스(2022), p.374-426

 

Corner Spring 2

Editor : 이조

728x90

관련글 더보기