[회원관리] JWT 세팅

2023. 1. 16. 19:58프로젝트/라이프 챌린지

728x90
SMALL

로그인과 같은 회원관리 기능을 구현하기 위해 Spring Security와 JWT 를 활용하여 구현해보자.

 

 

# 간단한 JWT 인증 동작 방식

(1) 클라이언트에서 로그인 요청

(2) 서버에서 인증 과정을 통해 인증이 완료가 되면 Access Token, Refresh Token을 발급

(3) api 요청 시 Access Token 을 HTTP 헤더에 담아 전달하여 요청

 

 

Access Token은 서비스를 요청하거나 api에 접근하기 위한 용도로서 사용되는 토큰이고,

Refresh Token은 접근하고자 하는 Access Token이 만료가 되었을 경우 보험용으로 재발급해주는 토큰이다.

 

# Token 재발급

(1) 서버에서 Access Token이 만료가 되었다는 정보 전달.

(2) 클라이언트에서 Access Token 과 Refresh Token 재발급 요청

(3) 다시 검증 후 새로운 Access Token 과 Refresh Token 재발급

 

Access Token은 외부 유출 문제로 인해 유효기간을 짧게 설정하는데, 정상적인 클라이언트는 유효기간이 끝난 Access Token에 대해 Refresh Token을 사용하여 새로운 Access Token을 발급받을 수 있다.

따라서, Refresh Token의 유효기간은 Access Token의 유효기간보다 길게 설정해야 한다고 생각할 수 있다.

 

그런데, 만약 Refresh Token이 유출되어서 다른 사용자가 이를 통해 새로운 Access Token을 발급받았다면?
이 경우, Access Token의 충돌이 발생하기 때문에, 서버측에서는 두 토큰을 모두 폐기시켜야 한다.

국제 인터넷 표준화 기구(IETF)에서는 이를 방지하기 위해 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여, 사용자가 한 번 Refresh Token으로 Access Token을 발급 받았으면, Refresh Token도 다시 발급 받도록 하는 것을 권장하고 있다.

 

 


본격적으로 Spring Security + JWT 를 세팅해보자

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
    implementation "com.querydsl:querydsl-apt:${queryDslVersion}"

    //security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    
    // lombok validation
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

build.gradle 에 Spring Security와 JWT 관련 Dependency를 추가해준다

어노테이션을 이용해 회원가입 같은 api를 호출할 때 양식에 맞지 않는 데이터인지 확인하기 위한 validation dependency 도 추가해준다..

추가해준 뒤, 그래들 코끼리를 눌러주어 프로젝트에 반영시켜주자.

 

 

 

Token

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Token {
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long token_pk_id;

    @Column
    private String grantType;

    @Column(nullable = false)
    private String accessToken;

    @Column(nullable = false)
    private String refreshToken;

    @Column(nullable = false)
    private String member_id;
}

발급된 Access Token, Refresh Token이 저장될 엔티티이다.

  • grantType : JWT에 대한 인증 타입이다. (Bearer) HTTP 헤더에 prefix로 붙여주는 부분이기도 하다.
  • accessToken : 액세스 토큰
  • refreshToken : 리프레시 토큰 (액세스 토큰이 만료가 되면 재발급 해주는 용도의 토큰)
  • member_id : 어떤 계정에 대한 토큰인지 알기 위한 사용자의 아이디

 

 

 

TokenRepository

import com.example.lifechallenge.domain.Token;
import org.springframework.data.jpa.repository.JpaRepository;

@Repository
public interface TokenRepository extends JpaRepository<Token, Long> {
}

토큰을 저장시킬 Repository도 만들어준다.

 

 

 

application.properties

jwt.secret={시크릿 키}

토큰을 암호화 및 복호화할 시크릿 키를 application.properties에 추가해준다.

yml 파일을 만들어서 넣어주기도 하지만 나는 기본적으로 존재하는 application.properties에 추가해주었다.

 

 

 

TokenDto

import lombok.Builder;
import lombok.Getter;
import java.util.Date;

@Getter
@Builder
public class TokenDto {
    private String grantType;
    private String accessToken;
    private String refreshToken;
    private Date accessTokenExpiresIn;
}

토큰 발급 시 임시적으로 저장할 Dto 객체를 만든다.

  • grantType : JWT에 대한 인증 타입이다. (Bearer) HTTP 헤더에 prefix로 붙여주는 부분이기도 하다.
  • accessToken : 액세스 토큰
  • refreshToken : 리프레시 토큰 (액세스 토큰이 만료가 되면 재발급 해주는 용도의 토큰)
  • accessTokenExpiresIn : 액세스 토큰 만료 기간

 

 

JwtTokenProvider

import com.example.lifechallenge.domain.Token;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtTokenProvider {

    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public TokenDto generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + 86400000);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + 86400000))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

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

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

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

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).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;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

# JwtTokenProvider 클래스를 만들기 이전에 build.gradle에 넣은 Dependency들을 먼저 프로젝트에 반영시켜놓아야한다.

  • @Component 로 인해 Bean 객체임을 지정해주었다.
  • 생성자 주입을 통해 앞서 application.properties에 추가해주었던 시크릿 키를 활용하여 암호화 복호화에 사용될 Key 객체를 주입받는다.
  • generateToken 메소드를 통해 사용자 인증 정보를 받은 뒤, 해당 정보를 토대로 Authorities(권한), AccessToken, RefreshToken을 생성한다. 생성하고나서 앞서 AccessToken 과 RefreshToken을 저장할 TokenDto 객체에 Builder를 통해 대입 저장시켜놓는다.
  • validateToken 메소드는 토큰 정보가 유효한지 검증하기 위한 메소드이다.
  • getAuthentication 메소드는 AccessToken에 들어있는 정보들을 꺼내는 메소드이다.
  • AccessToken 과 RefreshToken을 생성할 때 만료기간을 86400000 으로 지정해주었는데, 이는 1일을 의미한다.
    • 1일 : 24 * 60 * 60 * 1000 = 86400000

 

 

JwtAuthenticationFilter

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

클라이언트에서 요청을 할 시 JWT 인증을 하기위해 설치하는 커스텀 필터이다.

UsernamePasswordAuthenticationFilter 이전에 실행이 된다.

이전에 실행된다는 뜻은 JwtAuthenticationFilter를 통과하면 UsernamePasswordAuthenticationFilter 이후의 필터는 통과한 것으로 본다는 뜻이다.

쉽게 말해서, Username + Password를 통한 인증을 Jwt를 통해 수행한다는 것이다.

  • doFilter 내에서 요청받은 Token을 resolveToken 메소드를 통해 가져온다.
  • Token이 null이 아니고, validateToken을 통한 검증도 통과되었을 경우, JwtTokenProvider에 getAuthentication 메소드를 통하여 Authentication (인증) 객체를 가져온다.
  • 가져온 Authentication 객체를 SecurityContext에 저장한다.

 

 

SecurityConfig

import com.example.lifechallenge.jwt.JwtAuthenticationFilter;
import com.example.lifechallenge.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/lc/login").permitAll()
                .antMatchers("/lc/register").permitAll()
                .antMatchers("/lc/test").hasRole("USER")
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

Spring Security 설정을 위한 클래스이다.

JWT는 Spring Security 위에서 동작되기 때문에 우선적으로 Spring Security에 대한 설정부터 진행해야 한다.

  • Config 설정 파일에 @Configuration 을 설정하여 Bean으로 설정해준다.
  • @EnableWebSecurity를 통해 웹 보안을 위한 설정 클래스 객체임을 지정해준다.
  • filterChain 메소드에 웹 HTTP에 관한 여러 설정을 해줄 수 있다.
    • httpBasic().disable().csrf().disable() : rest api이므로 basic auth 및 csrf 보안을 사용하지 않는다는 설정이다.
    • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : JWT를 사용하기 때문에 세션을 사용하지 않는다는 설정이다.
    • antmatchers("/lc/login").permitAll() : () 안에 기입된 api 주소는 접근 인증이 딱히 필요치 않음을 명시한다. 즉, 누구나 접근이 가능한 주소를 뜻한다.
    • antmatchers("/lc/test").hasRole("USER") : ()안에 기입된 api 주소는 "USER" 권한이 있어야지만 접근이 가능하다.
    • anyRequest().authenticated() : 그 밖의 모든 요청과 접근에 대해서는 반드시 인증 정보가 있어야 된다.
    • addFilterBefore(A, B) : A filter를 B filter보다 먼저 추가하여 사용하겠다는 뜻이다.
  • JWT를 사용하기 위해서는 기본적으로 password encoder 가 필요한데, 여기서는 Bcrypt encoder를 사용하였다.

 

 

 

QuerydslConfig

import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;

@Slf4j
@Configuration
@RequiredArgsConstructor
public class QuerydslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em){
        log.info("회원관리 기능 절차(jwt) -> QuerydslConfig - jpaQueryFactory 메소드 (EntityManager : {})", em);
        return new JPAQueryFactory(em);
    }
}

Querydsl을 사용하기 위해서 dependency 추가와 같은 설정은 해주었지만 추가로 Config 파일을 만들어서 Bean으로 지정하여 생성해주어야 사용이 가능하다.

  • @Configuration 으로 Config 파일임을 명시함과 동시에 Component 로 지정되어 있기 때문에 Bean 객체로 지정된다.
  • JPAQueryFactory를 Bean 객체로 지정되었고, EntityManager를 인자값으로 받아서 영속성 컨텍스트를 사용한다는 것을 알 수 있다. 

 

 

 

Member

import lombok.*;

import javax.persistence.*;

@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Entity
public class Member extends Timestamped {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long member_pk_id;

    @Column(nullable = false)
    private String member_id;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String nickname;

    @Column(nullable = false)
    private String address;
    
}

인증을 위한 회원 혹은 유저들의 정보가 저장될 Member 객체이다.

@Entity 를 명시해줌으로서 관리되는 DB 테이블이라고 설정한다.

일단 회원 정보에 있어서 가장 기본적이고 중요한 정보속성들만 생성해두었다.

이후, 기능에 추가되는 사항이 있으면 변동 사항이 있을 것이다.

  • member_pk_id : 엔티티의 고유 구분값이다. (pk)
    • @Id : pk 값으로 고유하게 구분되는 값이라는 것을 명시해준다.
    • @GeneratedValue(Strategy = Generation.IDENTITY) : 기본 키 생성을 데이터베이스에 위임한다. 즉, DB 가 알아서 id 값을 기입해준다.
      • 그 밖에 SEQUENCE, TABLE, AUTO 전략이 있다.
  • member_id : 유저 아이디
  • password : 계정 비밀번호
  • nickname : 닉네임
  • address : 살고있는 주소

TimeStamped 를 만들어서 extends 해주었는데, 이는 계정 생성일자와 수정일자를 자동으로 반영되어 저장되게끔 하기 위함이다.

반영하기 위해서는 application 실행 단에서 @EnableJpaAuditing을 설정해주어야한다.

 

 

 

UserDetailsImpl

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDetailsImpl implements UserDetails {

    private String member_id;
    private String password;
    private List<String> roles = new ArrayList<>();


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities(){
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword(){
        return password;
    }

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

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

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

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

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

UserDetails 인터페이스를 상속받는 UserDetailsImpl 구현 클래스를 만든다.

Spring Security에서 사용자의 정보를 담기 위한 인터페이스가 UserDetails이다.

이것을 상속받은 UserDetailsImpl을 통해 사용자의 정보를 담는다.

 

 

 

MemberRepository

import com.example.lifechallenge.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

}

Member 엔티티에 관련된 DB 관리를 위한 Repository이다.

JPA를 사용하는 인터페이스로서 지금 만들고자 하는 프로젝트는 주로 QueryDSL을 사용하여 DB 관리를 진행할 것이지만 저장과 같은 간단한 작업은 JPA가 비교적 간단한 명령어로 실행할 수 있기 때문에 Repository를 만들어준다.

 

 

 

UserDetailsServiceImpl

import com.example.lifechallenge.domain.Member;
import com.example.lifechallenge.domain.UserDetailsImpl;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.List;

import static com.example.lifechallenge.domain.QMember.member;

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final JPAQueryFactory queryFactory;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String member_id) throws UsernameNotFoundException {

        if (queryFactory
                .selectFrom(member)
                .where(member.member_id.eq(member_id))
                .fetchOne() == null) {

            throw new UsernameNotFoundException(member_id + " - 없는 계정정보 입니다.");

        } else {
            Member auth_member = queryFactory
                    .selectFrom(member)
                    .where(member.member_id.eq(member_id))
                    .fetchOne();

            return createUserDetails(auth_member);
        }
    }

    private UserDetails createUserDetails(Member member) {
        return UserDetailsImpl.builder()
                .member_id(member.getMember_id())
                .password(passwordEncoder.encode(member.getPassword()))
                .roles(List.of(member.getRoles().toArray(new String[0])))
                .build();
    }
}

UserDetailsService 를 상속받아 구현한 UserDetailsServiceImpl 클래스를 생성하고, loadUserByUsername을 오버라이딩 한다.

loadUserByUsername 내에서 유저가 로그인한 정보가 조회했을 시, 정상적으로 존재하는 정보라면 createUserDetails 메소드를 통해 UserDetailsImpl에 builder로 아이디, 비밀번호, 권한을 대입 저장시킨다.

유저 정보를 조회하는 건 QueryDSL을 통해 조회하도록 하였다..

 

 

 

일단 초기 Spring Security 와 JWT 세팅 및 회원관리를 위한 Member 엔티티와 그에 대한 Spring Security에서 사용할 UserDetails / UserDetailsService 객체들에 대한 설정을 완료하였다.

이제 회원가입 기능 부터 정리하면서 구현해보자

 

728x90
반응형
LIST

'프로젝트 > 라이프 챌린지' 카테고리의 다른 글

[회원관리] 회원탈퇴  (0) 2023.01.21
[회원관리] 로그아웃  (0) 2023.01.21
[회원관리] 로그인  (0) 2023.01.20
[회원관리] 회원가입  (0) 2023.01.17
라이프 챌린지 프로젝트 시작 계기  (0) 2023.01.14