혼자 하는 Spring 프로젝트 - 6 : JWT, accessToken, refreshToken

꾸준하게 달리기~·2023년 7월 19일
0

솔로 프로젝트

목록 보기
6/11
post-thumbnail

들어가기 앞서

이전에
https://velog.io/@dlsrjsdl6505/%ED%98%BC%EC%9E%90-%ED%95%98%EB%8A%94-Spring-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-5-TDD%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1-Controller-chatGPT-%EC%99%B8%EB%B6%80-API-%EC%82%AC%EC%9A%A9

여기까지 구현했다.
이제 로그인 검증을 위한 JWT를 구현할 차례이다.
JWT에 대한 자세한 설명보다는, JWT가 대충 무엇이다!
를 안다는 전제 하에 설명을 시작하겠다 :)

JWT란? (JSON Web Token)

JWT는 데이터를 안전하고 간결하게 전송하기 위해 고안된
인터넷 표준 인증 방식으로,
토큰 인증 방식에서 가장 범용적으로 사용되며
JSON 포맷의 토큰 정보를 인코딩 후,
인코딩 된 토큰 정보를 Secret Key로 서명한 메시지를
Web Token으로 인증 과정에 사용.
(내가 작성할 토큰 방식의 인증 절차는 아래 이미지에 순서대로 존재한다.)

총 4개의 요청이 있다.
위의 두 요청은, 로그인 후 인증 후 JWT 발급
(JWTAuthenticationfilter, MemberDetailService)
아래의 두 요청은, JWT 발급 후 HTTP 매서드를 사용했을때의 검증 과정
(JWTVerificationfilter)
이다.




로그인 로직 구현을 위해, JWT를 통해 accessToken을 발급받을 생각이다.
또한 refreshToken은 Redis를 사용하여 구현할 예정이다.

JWT 자격 증명을 위한 로그인 인증 기능을 구현하기 전에 먼저 로그인 인증 흐름을 간단하게 확인해 보자.

사용자의 로그인 인증 성공 후, JWT가 클라이언트에게 전달되는 과정은 아래와 같다.

  1. 클라이언트가 서버 측에 로그인 인증 요청(Username/비밀번호 서버 측에 전송)

  2. 로그인 인증을 담당하는 Security Filter(JwtAuthenticationFilter)가 클라이언트의 로그인 인증 정보 수신

  3. Security Filter가 수신한 로그인 인증 정보를 AuthenticationManager에게 전달해 인증 처리를 위임

  4. AuthenticationManager가 내가 만든
    MemberDetailsService에게 사용자의 UserDetails 조회를 위임

  5. 내가 만든MemberDetailsService가 사용자의 크리덴셜을 DB에서 조회한 후, AuthenticationManager에게 사용자의 UserDetails를 전달

  6. AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리

  7. JWT 생성 후, 클라이언트의 응답으로 전달

어려우니, 다시한번 설명하자면,

로그인 요청 ->

해당 요청으로 인증 필터(JWTAuthenticationfilter) 의 attemptAuthentication 매서드에서 역직렬화(LogdinDto)하여 검증되지 않은 Authentication을 생성 (Authentication은 Spring Security 내의 Manager와 Provider에 의해 운반됨.)->

해당 검증 이전의 Authentication이 Manager와 Provider을 통해 MemberDetailService에 전달 ->

MemberDetailService에서 크리덴셜(DB)의 정보를 보고 검증 이전의 Authentication를 검증된 Authentication으로 변경 ->

검증된 Authentication이 다시 인증 필터(JWTAuthenticationfilter)로 전달되고 SecurityContext에 검증된 Authentication 저장 + 인증 필터(JWTAuthenticationfilter)에서 인증 성공시 사용되는 매서드(successfulAuthentication)에서 토큰을 생성.



이후 로직은, 저장된 인증 정보와 생성된 JWT를 통해, 클라이언트가 나에게 해당 토큰을 제공하면, 옳은지 인식하고 검증해주는 것이다.
(JwtVerificationFilter의 doFilterInternal 매서드.)

여기까지를 집중해서 읽고,
코드의 주석들도 자세히 읽는다면 충분히 이해할 수 있을것이다! :)

성공핸들러, 실패핸들러는
핵심적인 로직이 아닌 성공시, 실패시의 로그와 같은 역할을 담아주었다.
그래서
Handler 클래스들은 제외하고 설명하도록 하겠다.
먼저!

의존성을 추가해주자 ㅎㅎ

implementation 'io.jsonwebtoken:jjwt-api:0.11.5' //JWT




JwtTokenizer (JWT 를 생성하고 검증)

// JWT를 생성하는 JwtTokenizer
@Component
public class JwtTokenizer { //JWT 를 생성하고 검증하는 역할을 수행하는 클래스
    @Getter
    @Value("${jwt.key}")
    private String secretKey;       //환경변수

    @Getter
    @Value("${jwt.access-token-expiration-minutes}")
    private int accessTokenExpirationMinutes;

    @Getter
    @Value("${jwt.refresh-token-expiration-minutes}")
    private int refreshTokenExpirationMinutes;

    public String encodeBase64SecretKey(String secretKey) {
        //Plain Text 형태인 Secret Key의 byte[]를 Base64 형식의 문자열로 인코딩
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    //인증된 사용자에게 JWT를 최초로 발급해 주기 위한 JWT 생성 메서드
    public String generateAccessToken(Map<String, Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setClaims(claims) //JWT에 포함시킬 Custom Claims를 추가. Custom Claims에는 주로 인증된 사용자와 관련된 정보를 추가
                .setSubject(subject) //JWT에 대한 제목을 추가
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key) //서명을 위한 Key(java.security.Key) 객체를 설정
                .compact();
    }
    
    //RefreshToken 생성 매서드 (위의 AccessToken 발급과 유사)
    public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }

	//나중에 Client가 제공한 토큰의 정보를 꺼내와서 
    //옳은지 검증하기 위해 사용되는 매서드
    public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jws<Claims> claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jws);
        return claims;
    }

	//토큰에 비밀키 장착
    public void verifySignature(String jws, String base64EncodedSecretKey) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);

        Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(jws);
    }


    //JWT의 만료 일시를 지정하기 위한 메서드로 JWT 생성 시 사용
    public Date getTokenExpiration(int expirationMinutes) {
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.MINUTE, expirationMinutes);
        Date expiration = calendar.getTime();

        return expiration;
    }

    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        //getKeyFromBase64EncodedKey() 메서드는 JWT의 서명에 사용할 Secret Key를 생성
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);


        if (keyBytes.length < 32) { //길이늘려줌
            byte[] paddedKeyBytes = new byte[32];
            System.arraycopy(keyBytes, 0, paddedKeyBytes, 0, keyBytes.length);
            keyBytes = paddedKeyBytes;
        }

        //HMAC 알고리즘을 적용한 Key(java.security.Key) 객체를 생성
        Key key = Keys.hmacShaKeyFor(keyBytes);

        return key;
    }
}

JWT를 정의하고, 토큰 생성, 그다음 인증과 검증을 위한 public 매서드들로 이루어져 있다.
여기서 Claims 클래스는, Key Value 쌍으로 이루어진 JWT에 포함된 정보들이다.
ex) - (Username, Ingeon2)




LoginDto (로그인 정보)

@Getter
public class LoginDto {
    private String username;
    private String 비번;
}
//로그인 인증 정보 역직렬화(Deserialization)를 위한 LoginDTO 클래스

해당 클래스는, JWTAuthenticationfilter 클래스에서
역직렬화하여 인증에 사용할 예정이다.




MemberDetailService

데이터베이스에서 사용자의 크리덴셜을 조회한 후, 조회한 크리덴셜을 AuthenticationManager에게 전달하는 Custom UserDetailsService를 구현
Authentication을 검증시켜주는곳.

//UserDetailsService를 구현한 MemberDetailsService 클래스
@Component
public class MemberDetailService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final CustomAuthorityUtils authorityUtils;

    public MemberDetailService(MemberRepository memberRepository, CustomAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

        return new MemberDetails(findMember);
        //★여기의 MemberDetails로 내부적으로 Authentication 검증해줌★
    }

    //MemberDetails 클래스 추가
    //UserDetails 인터페이스를 구현하고 있고 또한 Member 엔티티 클래스를 상속.
    //이렇게 구성하면 데이터베이스에서 조회한 회원 정보를 Spring Security의 User 정보로 변환하는 과정과
    //User의 권한 정보를 생성하는 과정을 캡슐화
    private final class MemberDetails extends Member implements UserDetails {
        MemberDetails(Member member) {
            setMemberId(member.getMemberId());
            setEmail(member.getEmail());
            set비번(member.get비번());
            setRoles(member.getRoles());
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorityUtils.createAuthorities(this.getRoles());
        }

        @Override
        public String getUsername() {
            return getEmail();
        }

        @Override
        public boolean isAccountNonExpired() {
            return true;
        }

        @Override
        public boolean isAccountNonLocked() {
            return true;
        }

        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }

        @Override
        public boolean isEnabled() {
            return true;
        }
    }

}




JwtAuthenticationFilter

로그인 인증 요청을 처리하는 CustomSecurityFilter인 JwtAuthenticationFilter.
Username비번AuthenticationFilter를 상속한다.

Username비번AuthenticationFilter는 폼 로그인 방식에서 사용하는 디폴트 Security Filter로써,
폼 로그인이 아니더라도 Username/비번 기반의 인증을 처리하기 위해 Username비번AuthenticationFilter를 확장해서 구현했다.

public class JwtAuthenticationFilter extends Username비번AuthenticationFilter {
    private final AuthenticationManager authenticationManager;
    private final JwtTokenizer jwtTokenizer;



    //DI 받은 AuthenticationManager는 로그인 인증 정보(Username/비번)를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부를 판단합니다.
    //DI 받은 JwtTokenizer는 클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenizer = jwtTokenizer;
    }


    //메서드 내부에서 인증을 시도하는 로직을 구현
    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {

        //클라이언트에서 전송한 Username과 비번을 DTO 클래스로 역직렬화 하기 위해 ObjectMapper 인스턴스를 생성
        ObjectMapper objectMapper = new ObjectMapper();
        //역직렬화
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);

        // Username과 비번 정보를 포함한 Username비번AuthenticationToken을 생성
        Username비번AuthenticationToken authenticationToken =
                new Username비번AuthenticationToken(loginDto.getUsername(), loginDto.get비번());

        //Username비번AuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리를 위임
        return authenticationManager.authenticate(authenticationToken);
    }


    //클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출
    //(MemberDetailService 이후 인증이 검증되면 호출)
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws ServletException, IOException {
        //authResult.getPrincipal()로 Member 엔티티 클래스의 객체를 얻음
        Member member = (Member) authResult.getPrincipal();

        //토큰 생성
        String accessToken = delegateAccessToken(member);
        String refreshToken = delegateRefreshToken(member);

        //토큰들을 헤더에 담음
        response.setHeader("Authorization", "Bearer " + accessToken);
        response.setHeader("Refresh", refreshToken);

        //MemberAuthenticationSuccessHandler의 onAuthenticationSuccess() 메서드를 호출하기위해
        this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
    }


    //Access Token을 생성하는 구체적인 로직
    private String delegateAccessToken(Member member) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", member.getEmail());
        claims.put("roles", member.getRoles());

        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());

        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }
    
    //Refresh Token을 생성하는 구체적인 로직
    private String delegateRefreshToken(Member member) {
        String subject = member.getEmail();
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }


}




JwtVerificationFilter

인증 후 JWT를 발급해주고, Client가 나에게 해당 토큰을 준다면,
JWT에 대해 검증 작업을 수행.

public class JwtVerificationFilter extends OncePerRequestFilter {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;


    public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
                                 CustomAuthorityUtils authorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.authorityUtils = authorityUtils;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, ServletException, IOException {
        try {
            Map<String, Object> claims = verifyJws(request);
            
            //여기 set 매서드로 핵심적 검증 로직 완성.
            setAuthenticationToContext(claims);
        } catch (ExpiredJwtException ee) {
            request.setAttribute("exception", ee);
        } catch (Exception e) {
            request.setAttribute("exception", e);
        }

        filterChain.doFilter(request, response);
    }


    //true이면 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛰도록 해줌
    //즉, 검증조차 필요없는 자격미달 토큰에 관한 로직 작성.
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String authorization = request.getHeader("Authorization");

        return authorization == null || !authorization.startsWith("Bearer");
    }

    // JWT를 검증하는 데 사용되는 private 메서드
    private Map<String, Object> verifyJws(HttpServletRequest request) {
        String jws = request.getHeader("Authorization").replace("Bearer ", "");
        
        // JWT 서명을 검증하기 위한 Secret Key를 얻음
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        //JWT에서 Claims를 파싱
        //Claims가 정상적으로 파싱이 되면 서명 검증 역시 자연스럽게 성공
        Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();

        return claims;
    }

    private void setAuthenticationToContext(Map<String, Object> claims) {
        // JWT에서 파싱 한 Claims에서 username을 얻음
        String username = (String) claims.get("username");

        //JWT의 Claims에서 얻은 권한 정보를 기반으로 List<GrantedAuthority를 생성
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));

        //username과 List<GrantedAuthority를 포함한 Authentication 객체를 생성
        Authentication authentication = new Username비번AuthenticationToken(username, null, authorities);

        //SecurityContext에 Authentication 객체를 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}




SecurityConfiguration

위에서 만든 모든 클래스들과
여기엔 작성하지 않은 성공, 실패 핸들러를 포함해서
최종적으로 Configuration, 조율해주는
핵심적인 클래스

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {


    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils customAuthorityUtils;

    public SecurityConfiguration(JwtTokenizer jwtTokenizer,
                                 CustomAuthorityUtils customAuthorityUtils) {
        this.jwtTokenizer = jwtTokenizer;
        this.customAuthorityUtils = customAuthorityUtils;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()//CSRF 공격에 대한 Spring Security에 대한 설정을 비활성화
                .cors().configurationSource(corsConfigurationSource())
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션을 생성하지 않도록 설정
                .and()
                .formLogin().disable() //폼 로그인 방식을 비활성화
                .httpBasic().disable() //HTTP Basic 인증 방식을 비활성화
                .exceptionHandling()
                .authenticationEntryPoint(new MemberAuthenticationEntryPoint())
                .accessDeniedHandler(new MemberAccessDeniedHandler())
                .and()
                .apply(new CustomFilterConfigurer())//Custom Configurer를 추가해 내가 만든 Configuration을 추가
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        //.antMatchers(HttpMethod.PATCH, "/members/**").hasRole("USER") //이런식으로 역할에 따른 제한 가능
                        .anyRequest().permitAll() //우선은 모든 HTTP request 요청에 대해서 접근을 허용하도록 설정
                );

        return http.build();
    }

    //비번Encoder Bean 객체를 생성
    @Bean
    public 비번Encoder 비번Encoder() { //memberService에서 DI 받아 사용
        return 비번EncoderFactories.createDelegating비번Encoder();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        //현재는 제한을 둘 필요가 없기에, 모든 제한을 허용
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setExposedHeaders(Arrays.asList("*"));
        configuration.addAllowedHeader("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    //CustomFilterConfigurer는 우리가 구현한 JwtAuthenticationFilter를 등록하는 역할
    public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
        @Override
        public void configure(HttpSecurity builder) throws Exception {
            //getSharedObject(AuthenticationManager.class)를 통해 AuthenticationManager의 객체를 얻
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);

            //JwtAuthenticationFilter를 생성하면서 JwtAuthenticationFilter에서 사용되는 AuthenticationManager와 JwtTokenizer를 DI
            JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);  // (2-4)
            //디폴트 로그인 request URL 변경
            jwtAuthenticationFilter.setFilterProcessesUrl("/members/login");

            //AuthenticationSuccessHandler와 AuthenticationFailureHandler를 JwtAuthenticationFilter에 등록
            jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler());
            jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler());

            JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, customAuthorityUtils);

            //addFilter() 메서드를 통해 Filter들을 Spring Security Filter Chain에 추가
            builder.addFilter(jwtAuthenticationFilter)
                    .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
        }
    }


}




application.yml에서의 환경변수 설정

JwtTokenizer 클래스에서 @Value 애너테이션으로 지정해놓은 내용들을 담았다.

jwt:
  key: ${JWT_SECRET_KEY}
  access-token-expiration-minutes: 3
  refresh-token-expiration-minutes: 420




물론 위 전부 다, 각각의 코드마다 이해를 돕도록
주석을 달아놓아 최대한 설명을 붙였다.




결과

위와 같이 코드를 짜고,
MemberService의 createMember 매서드에
비밀번호 암호화 + 멤버의 이메일에 따른 역할 부여 를 위해 아래 코드를 추가해주었다.

멤버를 저장하고 나서는

위 두 사진과 같이 암호화 + 역할 부여까지 잘 이루어질 수 있었고,
아래와 같이 로그인 요청을 보내면


아래와 같이 accessToken도 잘 발급받을 수 있었다.

마치면서,
다음에는 refreshToken을 redis에 저장하는 코드를 작성할 예정이다.

소스코드 : https://github.com/ingeon2/soloSpringProject



PS. 벨로그 자체에서, 비밀번호를 영어로 한 패스워드라는 영단어가 본문에 들어가면 비공개 처리하는듯 하다,
그래서, 본문의 모든 "영어 패스워드"를 "비번" 으로 교체했고,
"비번" 을 "영어 패스워드" 로 이해해야 한다.

profile
반갑습니다~! 좋은하루 보내세요 :)

1개의 댓글

comment-user-thumbnail
2023년 7월 19일

많은 도움이 되었습니다, 감사합니다.

답글 달기

관련 채용 정보