Spring Security와 JWT Token 프로젝트 도입

김건우·2022년 12월 5일
3

Spring Security

목록 보기
1/4
post-thumbnail

해당 블로그의 프로젝트 Git Repository입니다.

https://github.com/KimGunWoo9595/springboot-mustache-lion

JWT란?

JWT(Json Web Token)은 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다. 현재 병원사이트를 개발하고있는데 보안성에 취약한 쿠키나 서버의 저장공간을 차지하는 세션 대신 보다 효과적인 인증 방법을 구현하기 위해 JWT토큰 인증 방식을 채택하였다.REST API를 사용 중인데, 웹 상에서 Form을 통해 로그인하는 것이 아닌, API 접근을 위해 프론트엔드에게 인증 토큰을 발급하고 싶을 때, 적절한 인증 수단이라고 생각해서 이를 Spring Security와 함께 적용해보려 한다.

Spring Security + JWT의 기본 동작 원리는 다음과 같다.
1. 클라이언트에서 ID와 Password로 로그인을 요청
2. 서버에서 DB를 조회 후 해당 ID/PW를 가진 User가 존재한다면, Access Token과 Refresh Token을 발급해준다.
3. 클라이언트는 발급받은 Access Token을 헤더에 담아서 서버가 허용한 API를 사용할 수 있게 된다.


1. SpringSecurity와 JWT 토큰을 발급받기위해 build.gradle에 의존성을 추가해주자!

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-test'
    implementation 'io.jsonwebtoken:jjwt:0.9.1'

2.application.yml에 jwt_token_secret추가

appication.yml에 다음의 값을 추가해주자! 그리고 보안상의 이유로 jwt_token_secret의 진짜 값은 Environment variables에 넣어주자!

jwt:
  token:
    secret: hello

  • 빨간색으로 표시한 것이 jwt_token_secret의 값이다.

3. SpringSecurity를 설정할 Configuration 클래스 제작

@EnableWebSecurity

@EnableWebSecurity을 보면 WebSecurityConfiguration.class, HttpSecurityConfiguration.class들을 import해서 실행시켜주는 것을 알 수 있다. 해당 annotation을 붙여야지 Securiry를 활성화 시킬 수 있다. springSecurityFilterChain이 자동으로 포함되어진다.

@Value

application.propertis 나 application.yml의 설정한 내용이나 값을 주입시켜주는 어노테이션 (밑의 코드를 보면 secretKey변수에 yml에 설정한 값이 담긴다.)

아래의 코드를 보면 회원가입(join)과 로그인(login)은 permitAll()로 모두 허용해준다. 그 다음 메서드의 순서도 굉장히 중요한데 Post 방식의 URI는 authenticated() 메서드를 주어서 POST요청에서 인증된 사용자인지 확인한다.


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserService userService;
    @Value("${jwt.token.secret}") // yml에 저장된 값을 가져온다.
    private String secretKey;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                .antMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll() // join, login은 언제나 가능
                .antMatchers(HttpMethod.POST,"/api/v1/**").authenticated()
                // 모든 post요청을 인증된사용자인지! 순서 중요! authenticated 🡪 인증된 사용자인지 확인한다
                // .antMatchers("/api/**").authenticated() // 다른 api는 인증 필요
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt사용하는 경우 씀
                .and()
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                //UserNamePasswordAuthenticationFilter 적용하기 전에 JWTTokenFilter를 적용 하라는 뜻.
                .build();
    }
}

4. JWT 토큰을 관리해주는 클래스 제작

토큰 생성, 관리, 유효성 검사 메서드

  1. generateToken(userName,key,expiredTimeMs) : JWT토큰을 생성해주는 메서드
  2. getUserName(token,key) : 토큰에서 userName 꺼내오는 메서드
  3. isExpired(token,secretkey) : 토큰의 유효기간 확인 메서드
package com.mustache.bbs.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;

public class JwtTokenUtil {
    //토큰 생성 메서드
    public static String generateToken(String userName, String key, long expiredTimeMs) {
        Claims claims = Jwts.claims(); //일종의 map
        claims.put("userName", userName); // Token에 담는 정보를 Claim이라고 함
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiredTimeMs))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }


    //토큰에서 userName 꺼내오는 메서드
    public static String getUserName(String token, String key) {
        return extractClaims(token, key).get("userName").toString();
    }

    private static Claims extractClaims(String token, String key) {
        return Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
    }

    //토큰 만료확인 메서드
    public static boolean isExpired(String token, String secretkey) {
        // expire timestamp를 return함
        Date expiredDate = extractClaims(token, secretkey).getExpiration();
        return expiredDate.before(new Date());
    }
}

이제 다음과 같이 JWT토큰 발급에 필요한 것을 세팅해주었다. 앞서 말했듯이 해당 병원사이트를 개발하는 도중에 **로그인에 성공하여 유효한 JWT토큰을 발급받은 사용자만 병원에 대한 리뷰를 쓸 수 있는 기능을 제작하고 싶었다. 먼저 그 과정을 그림으로 한번 보겠다.

맨 앞에서 Spring Security + JWT의 기본 동작 원리를 설명했듯이 클라이언트에서 ID와 Password로 로그인을 요청서버에서 DB를 조회 후 해당 ID/PW를 가진 User가 존재한다면,Token과 발급해준다클라이언트는 발급받은 Token을 헤더에 담아서 POST/api/v1/reviews URI를 타고 리뷰를 쓰는 것을 개발 할 것이다.

그러기위해서는 앞서 설명한 코드 중 하나인 SecurityConfig 클래스에서 securityFilterChain()의 메서드를 다시봐야한다.

permitAll() 🡪 모든 접근자를 항상 승인 하는 메서드
authenticated() 🡪 **인증된 사용자인지 확인 하는 메서드

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
 return httpSecurity
 .httpBasic().disable()
 .csrf().disable()
 .cors().and()
 .authorizeRequests()
 .antMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll() // join, login은 언제나 가능
 .antMatchers(HttpMethod.POST, "/api/**").authenticated() // 모든 post요청을 인증된 사용자인지 check

그래서 RestApi중 post방식의 URI는 모두 인증 과정을 거치게 되는 것이다.
우선 그러면 다음과정을 설명하기전에 JWT토큰 인증을 못받은 사용자가 병원리뷰를 쓰려고 한다면 어떠한 응답이 발생하는지 보자.

인증받지 못한 USER의 병원리뷰쓰기 응답

로그인에 성공하지 못한 회원은 JWT토큰 값을 받지 못하였고 Authorization 헤더에 토큰 값이 없거나 유효하지 않기 때문에 403 Forbidden의 상태코드와 응답을 받게된다.

  • 인증 받지 못한 사용자의 리뷰달기
  • 비인증 사용자의 응답값 -> 403 Forbidden
  • Post요청에 인증 필요(Authenticated)를 걸었기 때문에 호출이 막히게 된다.

5.로그인(JWT토큰 발급)

  • 로그인처리를 먼저 해주겠다. 이미 회원가입을 한userName과 password를 이용하여 로그인을 하면 다음과 같은 응답값이나온다.

로그인 요청

로그인 성공 후 JWT토큰 발급 성공(형광색 -> JWT토큰)

로그인에 성공하여 JWT토큰값이 헤더에 담기게 된다.

다음 아래의 코드는 로그인에 관련된 코드이다.

로그인 Controller 코드

//로그인 컨트롤러
    @PostMapping("/login")
    public Response<UserLoginResponse> login(@RequestBody UserLoginRequest userLoginRequest) {
        log.info("userLoginRequest : {} ",userLoginRequest);
       String token = userService.login(userLoginRequest.getUserName(), userLoginRequest.getPassword());
        return Response.success(new UserLoginResponse(token)); // 로그인 성공 시 토큰만 반환
    }

로그인 service 코드

     //로그인 -> (1.아이디 존재 여부 2.비밀번호 일치 여부)
     public String login(String userName,String password) {
        log.info("서비스 아이디 비밀번호 :{} / {}" , userName,password);
        //1.아이디 존재 여부 체크
        User user = userRepository.findUserByUsername(userName)
                 .orElseThrow(() -> new HospitalReviewException(ErrorCode.NOT_FOUNDED, String.format("%s는 가입된 적이 없습니다.", userName)));
         //2.비밀번호 유효성 검사
         if (!encoder.matches(password, user.getPassword())) {
             throw new HospitalReviewException(ErrorCode.INVALID_PASSWORD,"해당 userName의 password가 잘못됐습니다");
         }
         //두 가지 확인중 예외 안났으면 Token발행
         String token = JwtTokenUtil.generateToken(userName, secretKey, expireTimeMs);
         return token;
     }

6.SpringSecurity의 FilterChain 적용하기

앞서 그림에 FilterChain의 과정이 있었는데. 다음과 같은 과정을 거치게된다.
1. 헤더에서 JWT토큰 꺼내기
2. 해당 토큰이 유효한 토큰인지 확인하기
3. Token에서 UserName 꺼내기
4. 꺼낸 UserName 회원에 인가(Authentication)하기

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserService userService;
    @Value("${jwt.token.secret}") // yml에 저장된 값을 가져온다.
    private String secretKey;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                .antMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll() // join, login은 언제나 가능
                .antMatchers(HttpMethod.POST,"/api/v1/**").authenticated()
                // 모든 post요청을 인증된사용자인지! 순서 중요! authenticated 🡪 인증된 사용자인지 확인한다
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt사용하는 경우 씀
                .and()
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)//UserNamePasswordAuthenticationFilter 적용하기 전에 JWTTokenFilter를 적용 하라는 뜻.
                .build();
    }
}

위의 코드에서 다음의 메소드가있다 🔽

.addFilterBefore(new JwtTokenFilter(userService, secretKey),UsernamePasswordAuthenticationFilter.class)

의 코드는 UserNamePasswordAuthenticationFilter를 적용하기 전에 JWTTokenFilter를 적용 한다는 뜻이다. StateLess한 RestAPI의 Token인증이기 때문에 UsernamePasswordAuthenticationFilter앞에 Token인증이 들어가야 한다.

FilterChain을 이해하기 위한 사전지식

UsernamePasswordAuthenticationFilter 란?

UsernamePasswordAuthenticationFilter란 Form based Authentication 방식으로 인증을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터이다.
쉽게 설명하자면 유저가 로그인 창에서 Login을 시도할 때 보내지는 요청에서 아이디(username)와 패스워드(password) 데이터를 가져온 후 인증을 위한 토큰을 생성 후 인증을 다른 쪽에 위임하는 역할을 하는 필터이다.

OncePerRequestFilter란?

서블릿 컨테이너에서나 요청 당 한 번의 실행을 보장하는 것을 목표로 하는 filter이다.중요한 점은 요청 당 한번의 실행을 보장한다는 점이다.


7. JWT 인증필터 적용하기

Jwt토큰의 인증 방식을 설명하기위해 앞의 2개의 필터방식을 소개하였다. 이제부터 소개할 JwtTokenFilter 클래스는 OncePerRequestFilter의 기능을 상속받을것이다. 그래서 매 요청마다 JwtTokenFilter인증 방식이 적용될 것이다.



@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {

    private final UserService userService;
    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        log.info("authorizationHeader:{}", authorizationHeader);

        //1.header에서 jwt토큰 꺼내기기
       if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        // token분리
        String token;

        try {
            //Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6Imt5ZW9uZ3JvazUiLCJpYXQiOjE2Njk2NT ~~~
            //형태로 들어오므로 .split(“ “)로 token을 분리 한다.
            token = authorizationHeader.split(" ")[1];
        } catch (Exception e) {
            log.error("token 추출에 실패 했습니다.");
            filterChain.doFilter(request, response);
            return;
        }
        // Token이 만료 되었는지 Check
        if(JwtTokenUtil.isExpired(token, secretKey)){
            filterChain.doFilter(request, response);
            return;
        };

        // token에서 userName 꺼내기
        String userName = JwtTokenUtil.getUserName(token, secretKey);
        log.info("사용자 이름 : {}",userName);

        User user = userService.getUserByUserName(userName);
        log.info("userRole : {} ",user.getRole());

        //문 열어주기, Role 바인딩
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(user.getUsername(), null, List.of(new SimpleGrantedAuthority(user.getRole().name())));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 권한 부여
        filterChain.doFilter(request, response);

    }
}

위의 코드에는 1. Authorization 토큰을 꺼내는 과정 2. 헤더에서 Token을 분리하는 과정 3. 해당 토큰이 유효한지 check하는 과정 4. 토큰이 없다면 접근을 거부하는 과정이 들어가 있다.

1. 토큰을 꺼내는 과정

  • request.getHeader(HttpHeaders.AUTHORIZATION);<- 헤더에서 token을 꺼내온다.
  • token이 Null이거나 Bearer로 시작되지 않는다면 doFilter()를 싷행시킨다음 return;(더 이상의 과정 중지) 시킨다.
@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
    private final UserService userService;
    private final String secretKey;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //헤더에서 토큰 꺼내기
        final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        log.info("authorizationHeader:{}", authorizationHeader);
        // 토큰이 없거나
       if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

1-2. 순수 TOKEN Value 추출

  • Bearer의 인증방식을 사용할 것이기 때문에 다음과 같은 코드가 나온다.
  • Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6Imt5ZW9uZ3Jv~~ 의 형식으로 헤더에 들어오기 때문에 .split(“ “) 빈칸뒤로 split 해줘서 token에 담아주자.
 	try {
            //Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6Imt5ZW9uZ3JvazUiLCJpYXQiOjE2Njk2NT ~~~
            //형태로 들어오므로 .split(“ “)로 token을 분리 한다.
            token = authorizationHeader.split(" ")[1];
        } catch (Exception e) {
            log.error("token 추출에 실패 했습니다.");
            filterChain.doFilter(request, response);
            return;
        }

2. 유효성 체크(토큰의 만료 확인)

  • service단에 유효성을 체크하는 메서드
//토큰 만료확인 메서드
    public static boolean isExpired(String token, String secretkey) {
        // expire timestamp를 return함
        Date expiredDate = extractClaims(token, secretkey).getExpiration();
        return expiredDate.before(new Date());
    }
  • JWT 인증필터에서의 유효성 체크 메서드 사용(Controller)
  • 만료되었다면 return;
      // Token이 만료 되었는지 Check
        if(JwtTokenUtil.isExpired(token, secretKey)){
            filterChain.doFilter(request, response);
            return;
        };

3. 토큰에서 userName꺼내기 userName 없을 시 거부하기

  • 앞에서 JwtTokenUtil클래스에 아래의 코드의 메소드가 있었다.
    JwtTokenUtil 클래스(JWT토큰 생성 및 관리 기능의 클래스)🔽
public class JwtTokenUtil {
 // Key로 token을 열어서 Claims꺼내기
 private static Claims extractClaims(String token, String key) {
 return Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
 }
 public static String getUserName(String token, String secretKey) {
 return extractClaims(token, secretKey).get("userName", String.class);
 }
}

JwtTokenFilter 클래스🔽

@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
			//..
			//..
			//..
        //token에서 userName 꺼내기
        String userName = JwtTokenUtil.getUserName(token, secretKey);
        //해당 userName의 User객체를 영속성 컨텍스트에서 가져오기
        User user = userService.getUserByUserName(userName);
        //해당 user가 존재하지 않는다면 접근 거부하기
		User user = userService.getUserByUserName(userName);
}

위 코드에서 마지막 줄인 userService.getUserByUserName(userName);의 서비스단에 존재하는 메서드를 보겠다.

public User getUserByUserName(String userName) {
        return userRepository.findUserByUsername(userName)
                .orElseThrow(() -> new HospitalReviewException(ErrorCode.NOT_FOUNDED, ""));
    }
  • Optional.elseThrow()를 사용해서 user가 없을 시 에러를 내주었고 ExceptionHandler가 처리를 해줄것이다. 그래서 해당 토큰의 사용자가 없을 시 인증을 거부할 것이다.

8. 리뷰 작성하기(JWT 인증 성공한 회원만 리뷰쓰기 가능)

  • 다음의 코드는 POST/api/v1/reviews 컨트롤러이다
  • 기대하는 응답값은 리뷰를 작성한 회원의 userName이 나오는 것이다.

ReviewController🔽


@RestController @Slf4j
@RequestMapping("/api/v1/reviews")
@RequiredArgsConstructor
public class ReviewController {

    private final ReviewService reviewService;
    
    //SpringSecurity에서 인증된 토큰을 얻은 User만 review를 쓸 수 있는 API
    @PostMapping
    public ResponseEntity<String> write(@RequestBody ReviewCreateRequest dto, Authentication authentication) {
        log.info("Controller user:{},authentication :{}", authentication.getName(),authentication.isAuthenticated());
        return ResponseEntity.ok().body(reviewService.write(dto.getUserName()));

    }
}

authentication.getName() : 인증성공한 User가 누구인지 나오는 메서드
authentication.isAuthenticated() : 인증 통과 여부 메서드 (인증 통과 -> true)

ReviewService🔽


@Service
@RequiredArgsConstructor

public class ReviewService {

    public String write( String userName) {
        return userName + "님의 리뷰가 등록되었습니다.";
    }
}

중요! point

인증 성공 회원의 리뷰쓰기

POST/api/v1/reviews를 타면 앞에 contoller와 service를 통해 토큰을 넘겨주어야한다 ReviewCreateRequest에(DTO) 요청 값을 넣어주었고 제일 중요한 Authorization Header에 Bearer + JWTToken을 넣어 주었다.

유효한 토큰 값이라면 기대한봐아 같이 아래의 응답값이 나온다.🔽


인증 실패 회원의 리뷰쓰기

  • 아래의 요청 헤더를 보면 일부러 Barer wrong Token으로 Authorization Header에 넣어 주었다.

앞서 말했듯이 유효하지 않은 JWT 토큰이므로 인증이 거부가 된다.
인증이 거부가 되어서 다음과 같은 500에러가 발생한다. 인증 거부
🔽

profile
Live the moment for the moment.

0개의 댓글