Notice
Recent Posts
Recent Comments
Link
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Archives
Today
Total
관리 메뉴

개발자

[JWT]Spring Security + JWT 로그인 구현하기 본문

개발자/workflow 리팩토링 프로젝트(SpringBoot,JPA,MySQL)

[JWT]Spring Security + JWT 로그인 구현하기

GoGo개발 2023. 7. 2. 11:20

이제 spring security의 작동원리에 대해 알아보았고 JWT를 이용해서 로그인을 구현하는 방법을 알아보겠습니다.

 

스프링 시큐리티는 기본적으로 세션을 기반으로 한 인증이 진행되는데, JWT(Json Web Token)는 말그대로 토큰을 이용하여 인증을 처리하게 됩니다.

 

세션과 토큰의 차이점과 토큰을 이용해서 로그인을 구현하게 된 이유는 추후 포스팅한 후 링크를 걸겠습니다.

 

JWT 란?

 

JWT는 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰입니다. Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token으로 JSON 객체를 사용하여 토큰 자체에 정보를 저장하는 Web Token입니다.  JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 안전하게 전달합니다.

 

claim기반 이란?

claim이라는 사용자에 대한 프로퍼티나 속성을 이야기 합니다. 토큰 자체가 정보를 가지고 있는 방식인데, JWT는 이 Claim을 JSON을 이용해서 정의 합니다. 예를 들어 jwt 구조에서 Payload 부분에는 토큰에 담을 정보가 들어있습니다. 여기에 담는 정보의 한 ‘조각’ 을 클레임(claim) 이라고 부르고, 이는 name / value 의 한 쌍으로 이뤄져있습니다. 토큰에는 여러개의 클레임 들을 넣을 수 있습니다.

 

{
    "id":"terry"
    ,"role":["admin","user"]
    ,"company":"pepsi"
}

 

Claim을 JSON으로 서술한 예입니다. 이 JSON 자체를 토큰으로 사용하는 것이 Claim 기반의 토큰 방식입니다.

 

 

JWT의 구조 

 

JWT는 Header, Payload, Signature의 세 부분으로 이루어지며, Json 형태인 각 부분은 Base64Url로 인코딩
되어 표현됩니다. 또한 각각의 부분을 이어 주기 위해 . 구분자를 사용여 구분합니다. 추가로 Base64Url는 암호화된 문자열이 아니고, 같은 문자열에 대해 항상 같은 인코딩 문자열을 반환합니다.

 

 

JWT 는 . 을 구분자로 3가지의 문자열로 되어있습니다. 구조는 다음과 같이 이루어져있습니다

 

출처 - https://velopert.com/2389

 

JWT 토큰을 만들때는 JWT 를 담당하는 라이브러리가 자동으로 인코딩 및 해싱 작업을 해줍니다.

 

JWT의 구조에 관해서는 다른 블로들이 잘 정리되어 있어 대신하겠습니다.

https://velog.io/@dnjscksdn98/JWT-JSON-Web-Token-%EC%86%8C%EA%B0%9C-%EB%B0%8F-%EA%B5%AC%EC%A1%B0

 

[JWT] JSON Web Token 소개 및 구조

JSON Web Token이란? JSON Web Token (JWT) 은 웹표준 (RFC 7519) 으로서 두 개체에서 JSON 객체를 사용하여 가볍고 자가수용적인 (self-contained) 방식으로 정보를 안전성 있게 전달해주고, 사용자에 대한 속성을

velog.io

https://velopert.com/2389

 

[JWT] JSON Web Token 소개 및 구조 | VELOPERT.LOG

지난 포스트에서는 토큰 기반 인증 시스템의 기본적인 개념에 대하여 알아보았습니다. 이 포스트를 읽기 전에, 토큰 기반 인증 시스템에 대해서 잘 모르시는 분들은 지난 포스트를 꼭 읽어주세

velopert.com

 

또 여기서 중요한 개념이있는데요. JWT발급해줄때 AccessTokenRefreashToken으로 나누어 발급해 주어야 한다는 것입니다.  

 

AccessToken 과 RefreashToken

 

Access Token 만을 통한 인증 방식의 문제는 만일 제 3자에게 탈취당할 경우 보안에 취약하다는 점이 있습니다.

Access Token은 발급된 이후, 서버에 저장되지 않고 토큰 자체로 검증을 하며 사용자 권한을 인증하기 떄문에, Access Token이 탈취되면 토큰이 만료되기 전 까지, 토큰을 획득한 사람은 누구나 권한 접근이 가능해 지기 때문이다.

JWT는 발급한 후 삭제가 불가능하기 때문에, 접근에 관여하는 토큰에 유효시간을 부여하는 식으로 탈취 문제에 대해 대응을 하여야 합니다.

 

 Refresh Token은 긴 유효기간을 가지면서, Access Token이 만료됐을 때 새로 재발급해주는 열쇠가 된다. 따라서 만일 만료된 Access Token을 서버에 보내면, 서버는 같이 보내진 Refresh Token을  DB에 있는 것과 비교해서 일치하면 다시 Access Token을 재발급하는 간단한 원리이다. 그리고 사용자가 로그아웃을 하면 저장소에서 Refresh Token을 삭제하여 사용이 불가능하도록 하고 새로 로그인하면 서버에서 다시 재발급해서 DB에 저장합니다.

 

 

 

이제 JWT를 구현해봅시다. 이 블로그는 spring security를 이미 적용했다는 가정하에 진행됩니다.(스프링 시큐리티 form-login으로 구현한 상태에서 JWT로 변경하는 것) spring security가 진행되지 않은 분들은 아래 포스팅을 먼저 봐주시고 오시면 됩니다.

 

 

먼저 의존성 주입을 해줍니다.

 

 

저는 멀티 모듈 프로젝트이기 때문에 루트모듈에 jwt 의존성을 주입해주었습니다.

그런뒤 SecurityFilterChain을 빈으로 등록한 config 클래스에 jwt 클래스들의 생성자주입을 해주는데요 각 클래스들은 아래에서 하나씩 살펴보도록 하겠습니다.

 

 

그런뒤 security 설정을 한 부분을 봅시다.

 

이미 spring security에서 했던 부분들은 넘어가고 하나씩 살펴보겠습니다.

 

http.httpBasic().disable()

 rest api 이므로 basic auth 인증을 사용하지 않는다는 설정입니다.

 

sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

이 부분은 JWT를 사용하는 경우 session을 사용하지 않기 때문에 스프링 시큐리티에서 session을 생성하거나 사용하지 않도록 설정해주는 것입니다.

 

.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)

이 부분은  Jwt 인증을 위하여 직접 구현한 JwtAuthenticationFilterUsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정입니다.

 

JwtAuthenticationFilter를 왜 먼저 설정해주어야할까요? 

 

일단 먼저일반적으로 Spring  Security에 로그인 요청을 날리면 UsernamePasswordAuthenticationFitler로 해당 아이디, 비밀번호 입력값이 해당 필터로 갑니다. 하지만 JWT의 경우 폼로그인이 아닌 json 데이터로 들어온 값을 DB값과 대조하여 맞을 경우 토큰을 던져줘야 합니다. 때문에 JWT인증을 위한 필터(JwtAuthenticationFilte)를 새로 만들고 UsernamePasswordAuthentciationFilter대신 우리가 만든 JWT필터를 적용시켜 주어야 하기 때문입니다.


다 자음으로 JwtAuthenticationFilter 를 작성해봅시다. 우리가 작성한 JwtAuthenticationFilter는 Authentication 객체를 가지고 와서 SecurityContext에 저장 해주는 클래스입니다. 이부분은 커스텀에 따라 조금씩 달라지기도 합니다

 

 JwtAuthenticationFilterOncePerRequestFilter를 상속받아 만들게 됩니다. 그리고 JwtTokenProvider를 생성자주입 하면되는데요. 이 provider는 아래에서 살펴보도록 하겠습니다. 

 

OncePerRequestFilter는 또 무엇일까요?

 

https://codediary21.tistory.com/95

 

위그림은 스프링 시큐리티가있는 스푸릉 부트 아키텍처 입니다. 요청이 들어오면 OncePreRequestFilter가 먼저 실행되는데 결론부터 말하자면 이 필터는 한 번 들어온 요청에 대해서 한번만 인증을 거치도록 보장해주는 필터입니다. 필터가 한번만 인증을 해야하는 이유는 뭘까요?

 

쉽게 이야기 하자면 일단 Filter는 서블릿이 실행하기 전이나 후에 호출하여 동작합니다. 요청이 Servlet으로 전달되면 RequestDispatcher는 이를 다른 서블릿으로도 전달할 수도 있습니다.만약에 다른 서블릿도 동일한 필터를 거치도록 설정되어 있으면 해당 시나리오는 중복된 인증과정을 여러번 거치게 되면서 동일한 필터가 여러 번 호출되게 됩니다. filter 에서 한번 검사하고 난 뒤 RequestDispatcher에 의해 다른 요청으로 forward 된다면 또 다시 filter에서 검사할 수 있기 때문에 한 번 들어온 요청에 대해서는 한번만 인증을 거치도록 하는 필터를 구현해야 하는 거죠. 이러한 중복 처리를 방지하기 위한 Filter가 OncePerRequestFilter 입니다.

 

이제 Filter 구현을 마무리해봅시다.

 

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //1.Request Header 에서 JWT 토큰 추출
        String jwt = resolveToken(request);

        //2.validateToken으로 토큰 유효성 검사(토큰 있다면실행 / login요청 페이지에서는 적용X)
        if(StringUtils.hasText(jwt) && jwtTokenProvider.vaildateToken(jwt)){
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

OncePerRequestFilter을 상속받았기 때문에 doFilterInternal()을 오버라이드 해줍니다. 

처음으로 API요청 헤더에서 JWT 토큰을 추출하여 jwt 변수에 담아주도록합니다.

 

private String resolveToken(HttpServletRequest request) {
    String token = request.getHeader("Authorization");
    if(StringUtils.hasText(token) && token.startsWith("Bearer")){
        return token.substring(7); // "Bearer "를 뺀 값, 즉 토큰 값
    }
    return null;
}

api요청시 인증을 위해 "Bearer 토큰값" 형식으로 헤더에 담아 보내주기때문에 토큰값만 추출하기 위한 메소드입니다.

 

 

그런뒤 jwtTokenProvider로 추출한 jwt를 보내서 인증을 마무리 한 후 유효한 인증일 경우 Authentication 객체를 가져와 SecurityContext에 저장해주면 이필터가 마무리됩니다.

 

. doFilter()는 다음 filter-chain을 실행하는 것이며, 마지막 filter-chain인 경우 Dispatcher Servlet이 실행됩니다.

 

그럼 이제 중요한 jwtTokenProvider를 살펴봐야겠죠! 

jwtTokenProvider란 JWT(Token)을 생성하고 유효성을 검증하는 데 사용되는 클래스나 컴포넌트입니다. 일반적으로 JWT 관련 작업을 추상화하고 캡슐화하는 역할을 합니다.

우리는 이 클래스에서 JWT를 생성하고 토큰을 검증하고 Authentication 객체를 반화해볼 것입니다.

 

 

jwt 서명을 위해서는 (인코딩된 헤더+"."+페이로드+시크릿 키)를 해시로 묶고 헤더에서 정의한 암호화 알고리즘으로 암호화를 해야합니다. 시크릿 키가 공개되지 않은 한 임의로 서명 해시를 생성할 수 없습니다. 따라서 이 시크릿키가 중요하겠죠? 이 시크릿키는 노출되어선 안되는 정보이기 때문에 별도의 profile을 생성해 안전하게 보관합니다.

기존 yml에 secret key를 아래와 같이 설정해 줍니다.

 

jwt:
  secret: VlwEyVBsYt9V7zq57TejMnVUyewkrqwekkeWERkdfksazblYcfPQye08f7MGEWRweVA9XkHa

 

제가 사용할 복호화 알고리즘은 HS256이고 이를 사용하기 위해서 시크릿 키는 최소256 bits 이상의 값이어야 합니다.

 

다음으로는 토큰을 생성해주는 메소드를 만들어 줍니다.

    //토큰 생성
    public TokenDto generateToken(Authentication authentication) {
        //권한
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        String authoritiesName = authentication.getName();

        long timeOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 변환(시차)

        //Access Token
        String accessToken = Jwts.builder()
                .setSubject(authoritiesName)
                .claim("auth", authorities)
                .setIssuedAt(new Date(System.currentTimeMillis()+timeOffset))
                .setExpiration(new Date(System.currentTimeMillis()+1000*60*30+timeOffset))//30분
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        //Refresh Token
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(System.currentTimeMillis()+7 * 24 * 60 * 60 * 1000+timeOffset))//7일
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return TokenDto.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

 

1. 유저정보를 가지고 토큰을 발급해주기 때문에 authroites 권한 정보를 가져와 줍니다.

 

2. AccessToken을 발급해 줍니다

 2-(1) JwtBuilder객체를 생성하고 Jwts.builder() 메서드를 이용한다.

 2-(2) .setSubject() : 토큰의 용도를 명시한다. 토큰 사용자 식별자.(Payload에 담길 데이터)

         .setIssuesAt() : 토큰 생성 시간을 설정한다.

         .setExpiration() : 토큰 만료 시간을 설정한다.

         .signWith() : 어떤 알고리즘과 키값으로 sign할지(Header와 Payload를 인코딩+해싱) 설정한다.

         .claim() : 추가적인 클레임 설정(Payload에 담길 데이터)

         마지막으로 압축하고 서명하기위해  .compact(); : 토큰을 생성한다.

       

여기서 처음 인코딩하기위해 사용한 알고리즘은 SignatureAlgorithm.HS512 이었는데요. 

java.lang.IllegalArgumentException: Base64-encoded key bytes may only be specified for HMAC signatures. If using RSA or Elliptic Curve, use the signWith(SignatureAlgorithm, Key) method instead.

위의 오류가 발생했었습니다. jwt 암호화 알고리즘에서 문제가 생겼던 것인데 이유가 비밀 키를 사용하고 있으므로 "HS"가 있는 HMAC 알고리즘을 사용해야 한다고 합니다. 따라서 HS256, HS523, HS384알고리즘을 사용해야 합니다. 저는 HS256알고리즘으로 바꾸어 주었더니 오류가 해결되었습니다.

 

3.Refresh Token을 발급해줍니다.

  : refresh token이 accesstoken에 비해 담겨있는 정보가 간단한데 이유는 Refreash Token은 주로 서버측에서만 사용되며, 클라이언트에게 반환되는 경우는 드뭅니다. 그래서 추가적인 ㄴ클레임 없이 단순히 만료 시간만 설정하면 간단하게 생성합니다. 이렇게 함으로써 토큰의 크기를 줄이고, 서버에서 처리 시간을 단축 할 수 있습니다

 

4.response로 내려줄 미리만들어놓은 TokenDto 클래스에 담아줍니다.

 

그 다음은 토큰을 꺼내 인증된 유저인지 확인하고 이전 JwtAuthenticationFilter필터에서 쓰일 Aythentication객체를 반환해보겠습니다

public  Authentication getAuthentication(String accessToken){
    Claims claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(accessToken).getBody();
    if(claims.get("auth") == null){
        throw new RuntimeException("권한 정보가 없는 토큰입니다");
    }

    //권한
    Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get("auth").toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());

    // UserDetails 객체를 만들어서 Authentication 리턴
    UserDetails principal = new User(claims.getSubject(), "", authorities);
    return  new UsernamePasswordAuthenticationToken(principal, "", authorities);
}

1. 토큰을 생성했다면, 이것을 우리가 알아볼 수 있고, 사용할 수 있는 형태로 변환하는 방법이 필요합니다.(토큰 복호화)

우선 jwtToken은 위에서 보았듯이 String 형태로 생성이 됩니다. 우리는 이것을 우리가 사용하기 위한 형태로 parsing하기 위해서 Jwts.parserBuilder()를 이용해야합니다.

그 후 token을 생성할 때 사용했던 key를 set해주어야 합니다.(setSignKey(sercretKey)) 다음으로는 parseClaimsJws() 메소드를 이용해 토큰을 Jws로 파싱합니다.

마지막으로 getBody()를 이용해 앞서 토큰에 저장했던 data들이 담긴 claims를 얻어올 수 있습니다.

 

2. 그다음 클레임에서 권한정보를 얻어옵니다.

 

3. 이 클레임을 통해 User 객체를 생성하여

claims.getSubject()를 통해 JWT 토큰에서 추출한 Subject 값을 사용하여 인증된 사용자의 식별자를 설정합니다. 두 번째 매개변수는 인증된 사용자의 비밀번호를 나타내는데, 해당 부분은 빈 문자열로 설정되어 있습니다. 이는 JWT 토큰 기반 인증에서는 비밀번호가 필요하지 않기 때문에 비워두는 것입니다. 세 번째 매개변수는 사용자의 권한(authorities) 정보를 나타내며, 이는 claims에서 추출한 값을 사용합니다.다. 이렇게 UserDetails를 생성하고, 이를 기반으로 UsernamePasswordAuthenticationToken을 생성하여 인증 객체를 반환합니다

 

마지막으로 토큰 정보를 검증하는 메소드까지 만들어 줍니다.

//토근 정보를 검증
public boolean vaildateToken(String token){
    try {
        Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
        return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e){
        log.info("invalid JWT Token", e);
    } catch (ExpiredJwtException e){
        log.info("Expired JWT Token", e);
    } catch (UnsupportedJwtException e){
        log.info("Unsupported JWT Token", e);
    } catch (IllegalArgumentException e){
        log.info("JWT claims string is empty", e);
    }
    return false;
}

 

이제 provider 생성이 끝났습니다. 이제 토큰발급과 로그인을 위한 controller, service까지 만들어 보겠습니다

 

 

login Controller 입니다.

 

로그인 Service 입니다.

 

package com.workFlow.WFrefactoring.employee.service;

import com.workFlow.WFrefactoring.employee.dto.EmployeeRequset;
import com.workFlow.WFrefactoring.security.config.JwtTokenProvider;
import com.workFlow.WFrefactoring.security.dto.TokenDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly=true)
@RequiredArgsConstructor
@Slf4j
public class EmployeeLoginService {

    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;

    @Transactional
    public TokenDto login(EmployeeRequset.LoginEmployee request){
        // 1. Login ID/PW 를 기반으로 Authentication 객체 생성
        // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(request.getMail(), request.getPw());
        // 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
        // authenticate 매서드가 실행될 때 EmployeeDetailService에서 만든 loadUserByUsername 메서드가 실행
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenDto tokenDto = jwtTokenProvider.generateToken(authentication);
        return tokenDto;
    }


}

 

주석을 달아놓았으니 추가 설명은 생략하도록 하겠습니다.

 

이제 포스트맨을 사용해서 api 테스트를 해보겠습니다. "employees/login" api는 config에서 시큐리티적용이 되지않는 리소스로 빼두었습니다.

 

login api로 mail과 pw를 보내게되면 처리과정을 대략 이렇습니다.

 

JwtAuthenticationFilter -> 헤더에서 토큰 추출 ->  토큰이 있는지 없는지 검증 -> 없다면 controller,service를 거쳐 토큰 발급 시작

 

자 이렇게  토큰이 정상적으로 발급되었습니다.

 

그렇다면 시큐리티가 적용된 다른 리소스에 접근이 가능한지 확인해 볼 차례입니다.

 

컨트롤러에 해당 메소드를 만들어놓고 접근가능시 success를 리턴해주도록 하겠습니다.

 

해당 Api의 헤더에 방금 발급받은 access 토큰값을 넣고 리소스에 접근해보겠습니다.

 

이때는 대략 처리과정은 이렇습니다.

JwtAuthenticationFilter -> 토큰 복호화 -> 토큰 검증(vaildataToken) -> 토큰으로부터 클레임을 만들고, 이를통해 User 객체생성, UsernamePasswordAuthenticationToken으로 보내 인증된유저인지 확인 후 Authentication 객체를 반환(시큐리티영역) 

 

인증이 성공하여 "success" 가 리턴되었습니다.

 

이 포스팅에서는 JWT에 관한부분만 다루었기 때문에 Sprin Security가 선행되어야 이해가 갈 수 있습니다. 시큐리티의 흐름안에서 jwt를 사용하는 것이기 때문에 사이사이 jwt 필터나 provider가 추가되어 security에 대한 부분설명이 없기때문에 선행하신 후 포스팅을 보는 것을 추천드립니다. 

 

참고블로그

https://velog.io/@sunil1369/Spring-Spring-Security-Jwt

https://thalals.tistory.com/436

https://iseunghan.tistory.com/365

https://velog.io/@chullll/Spring-Security-JWT-%ED%95%84%ED%84%B0-%EC%A0%81%EC%9A%A9-%EA%B3%BC%EC%A0%95

https://jangjjolkit.tistory.com/26

https://wildeveloperetrain.tistory.com/58

https://willbfine.tistory.com/570

https://velog.io/@coastby/SpringSecurity-JwtAuthenticationFilter-%EA%B5%AC%ED%98%84

https://gilssang97.tistory.com/56

https://velog.io/@layl__a/Spring-jwt-%EC%95%94%ED%98%B8%ED%99%94-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Access-Token-Refresh-Token-%EC%9B%90%EB%A6%AC-feat-JWT