본문 바로가기

Before/Spring Boot

[SpringBoot] Spring security, JWT를 이용한 인증 기능 추가

기존에 간단하게 Request Body에 서버에서 각각 AES256, SHA256 알고리즘을 이용해 자동생성해준 유저별 아이디와 패스워드만으로

 

간단하게 로그인 기능을 구현해 두었었는데, 프로젝트 진행 과정에서 여러 기능들이 추가되고 대부분의 기능이 유저 정보를 거쳐서

 

비즈니스 로직을 수행해야 했기 때문에 애플리케이션이 더 커지기 전에 Spring Security를 적용해 인증 기능을 구현하기로 했다.

 

 

 

간단하게 이해한 내용.

 

인증 기능은 크게 비교적 전통적인 방식인 세션/쿠키 방식과 최근 개발되는 서비스들에서 많이 이용되는 토큰 방식으로 나뉘는데,

 

 세션/쿠키 방식은 유저 정보를 클라이언트로부터 받아 고유한 아이디를 부여해 세션 저장소에 따로 관리하고 이후로부터

 

클라이언트는 요청에 쿠키를 추가해 인증을 요청하는 방식이다. 세션 저장소에 따로 유저 정보를 관리하기 때문에 stateful하며

 

만약 클라이언트 요청을 하이재킹 당하더라도 쿠키에 담긴 세션 id는 임의로 부여한 값이기 때문에 크게 중요하지 않은 정보이기에

 

위험이 그만큼 적다고 할 수 있다. 그렇지만 세션 저장소에 유저별 정보가 따로 저장이 되어야 하는만큼 서버를 scale out하게 될 경우

 

기존 세션 저장소에 들어가있는 유저들은 새로 증설된 서버로 요청을 보내지 못하고 기존에 요청을 보냈던 서버로 보내야만 하는 문제가

 

발생한다. 그렇기에 세션 저장소에 유저 정보를 관리하지 않아 stateless하게 유저 인증을 수행할 수 있는 토큰 방식이 최근 애플리케이션

 

인증 서버에서 많이 이용되는 추세라고 한다.

 

물론 header, payload, signature로 이루어진 토큰에는 유저정보가 포함되어 중간에 탈취 당할경우 세션/ 쿠키 방식에 비해 개인 정보를

 

유출할 가능성이 높기도 하고, 토큰 자체의 길이가 길어 낭비가 심한점, 또 한 번 발급된 토큰은 서버에서 임의로 막을 방법이 없어 인증 만료

 

기한을 기다려야하고 그 기간내에 서버로부터 개인정보를 탈취할 수 있다는 점등 단점이 없는 것은 아니지만 이 부분은 토큰 유효기간을

 

타이트하게 잡고, SSL 인증서를 적용해 https 프로토콜을 이용하는 것으로 보완하기로 했다.

 

 

간단하게나마 이해를 했으니 일단 적용을 해보고 더 자세하게 공부하는 것으로 프로젝트를 진행한다.

 

 

먼저 gradle 의존성을 추가해준다.

 

 

그 후 security 패키지를 하나 생성해 security에 쓰일 클래스들을 하나하나 추가하자.

 

먼저 Jwt 토큰에 관한 설정값을 상수값으로 저장하는 JwtProperties 클래스를 생성하자

public class JwtProperties {
    // 암호화 키
    public static final String SECRET_KEY = "dsaidmosidqnwpqe";
    
    // 토큰 유효기간 15분
    public static final Long EXPIRATION_TIME = 15 * 60 * 1000L;
}

그 후 Jwt 토큰을 생성해주는 JwtTokenProvider 클래스를 생성한다

 

JwtTokenProvider.java

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    private String secretKey = JwtProperties.SECRET_KEY;

    // 토큰 유효시간 30분
    private long tokenValidTime = JwtProperties.EXPIRATION_TIME;

    private final UserService userService;

    // 객체 초기화, secretKey를 Base64로 인코딩한다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        }
        catch (Exception e) {
            SecurityContextHolder.clearContext();
            return false;
        }
    }
}

 

 

아직 완벽하게 이해하지는 못했지만 JWT authentication은 서버로 요청이 들어왔을 때

 

컨트롤러 단까지 가기 전 필터에서 걸러지는 것 같다. 토큰을 인증할 수 있는 필터 클래스를 만든다.

 

이 클래스는 UsernamePasswordAuthenticationFilter 클래스를 상속받아 구현한다.

 

JwtAuthenticationFilter.java

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 헤더에서 JWT 를 받아옵니다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
        // 유효한 토큰인지 확인합니다.
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
            Authentication authentication = jwtTokenProvider.getAuthentication(token);

            // SecurityContext 에 Authentication 객체를 저장합니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

}

 

그 후 security 기능을 컴포넌트로 등록하기 위해 config 패키지에 WebSecurityConfigureAdapter 클래스를상속받아

 

Config클래스를 만들자.

 

WebSecurityConfig.java

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    // 암호화에 필요한 PasswordEncoder 를 Bean 등록합니다.
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    // authenticationManager를 Bean 등록합니다.
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
                .csrf().disable() // csrf 보안 토큰 disable처리.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
                .and()
                .authorizeRequests() // 요청에 대한 사용권한 체크
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class);
        // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
    }

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

 

기존 swagger를 적용해두었었기 때문에 가장 밑 오버라이딩 된 메서드를 통해 swagger관련 리소스에는 인증을 무시하도록 설정한다.

 

 

이렇게 설정 후 유저 컨트롤러에서 토큰을 생성해 response message에 포함해 리턴하도록 한다.

 

토큰 인증은 잘 수행 되었지만 예외처리를 하려고 기존 다른 기능들처럼 글로벌 예외처리를 수행했더니 웬일인지 JSON으로

 

response message를 뱉는 것이 아닌 html 형식으로 에러 페이지 자체가 리턴이 되는 것을 postman으로 확인하였다

(추후 스크린샷 업로드)

 

이 부분에서 인증이 controller단에 도착하기 전에 걸러진다는 것을 알게 되었다.

 

이 부분에 대해서 찾아보니 authentication entry point와 access denied handler를 커스터마이징해서 적용해야 한다는 것을 알게되었다.

 

적용해보자.

 

먼저 security 패키지에 AuthenticationEntryPoint 인터페이스를 구현한 구현체를 만들자

 

CustomAuthenticationEntryPoint.java

 

@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException, ServletException {
        response.sendRedirect("/exception/entrypoint");
    }
}

여기에서 redirect 해준 경로를 받아 로직을 수행할 controller를 만들자.

 

ExceptionContoller.java

 

@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/exception")
public class ExceptionController {

    @GetMapping(value = "/entrypoint")
    public void entrypointException() {
        throw new CAuthenticationEntryPointException();
    }
}

 

이렇게 적용하면 ResponseController에서 @ControllerAdvice 어노테이션을 이용해 글로벌 예외처리를 해주는 부분에서 예외처리가

 

가능해진다.

 

 

동일한 방식으로 AccessDeniedHandler 인터페이스의 구현체도 만들어 등록해준다.

 

CustomAccessDeniedHandler.java

@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException, ServletException {
        response.sendRedirect("/exception/accessdenied");
    }
}

 

ExceptionController.java

@RequiredArgsConstructor
@RestController
@RequestMapping(value = "/exception")
public class ExceptionController {

    @GetMapping(value = "/entrypoint")
    public void entrypointException() {
        throw new CAuthenticationEntryPointException();
    }

    @GetMapping(value = "/accessdenied")
    public void accessdeniedException() {
        throw new CAccessDeniedException("");
    }
}

 

이후 커스텀 Exception class들인 CAccessDeniedException 클래스와 CAuthenticationEntryPointException을 작성해 

 

ResponseController에 등록해주면 정상적으로 기존 만들어 두었던

 

ResponseMessage형태로 에러 메세지를 리턴하는 것을 확인할 수 있다.

 

 

 

참고한 블로그

 

https://eblo.tistory.com/48

 

RESTful 서비스 설계와 개발 - 메시지와 예외 처리

결과 메시지 설계 Response Message 구성 field type Description code int 결과 코드, 정상인 경우 200 status boolean 정상, 성공인 경우 true timestamp Date 시간 data Map 처리 결과 데이터들 message String..

eblo.tistory.com

https://javaengine.tistory.com/entry/SpringBoot2%EB%A1%9C-Rest-api-%EB%A7%8C%EB%93%A4%EA%B8%B08-%E2%80%93-SpringSecurity-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B8%EC%A6%9D-%EB%B0%8F-%EA%B6%8C%ED%95%9C%EB%B6%80%EC%97%AC

 

SpringBoot2로 Rest api 만들기(8) – SpringSecurity 를 이용한 인증 및 권한부여

이번 시간에는 SpringSecurity를 이용하여 api 서버의 사용 권한을 제한하는 방법에 대해 알아보도록 하겠습니다. 지금까지 개발한 api는 권한 부여 기능이 없어 누구나 회원 정보를 조회, 생성 및 수�

javaengine.tistory.com

 

'Before > Spring Boot' 카테고리의 다른 글

[SpringBoot] Swagger 적용  (0) 2020.08.10
[SpringBoot] Spring Initializr  (0) 2020.08.06