포트폴리오/Eighteen

Spring Security + JWT 토큰을 통한 로그인 구현

do_hyuk 2024. 5. 16. 10:05

현재 앱 개발을 위해 REST API를 사용 중인데 로그인한 사용자 정보를 Session에 저장하는 방식은

REST의 stateless를 위반하기 때문에 JWT를 선택하였다.


JWT

JWT(Json Web Token)은 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다.

JWT 이해하기

Security + JWT 기본 동작 원리

기본 동작 원리는 간단하다.

  1. 클라이언트에서 ID/PW를 통해 로그인 요청
  2. 서버에서 DB에 해당 ID/PW를 가진 User가 있다면, Access Token Refresh Token을 발급해준다.
  3. 클라이언트는 발급받은 Access Token을 헤더에 담아서 서버가 허용한 API를 사용할 수 있게 된다.

Access Token & Refresh Token에 관한 정보는 여기를 클릭

만약 Refresh Token이 유출되어서 다른 사용자가 이를 통해 새로운 Access Token을 발급받았다면?

이 경우, Access Token의 충돌이 발생하기 때문에, 서버 측에서는 두 토큰을 모두 폐기시켜야 한다.

 

국제 인터넷 표준화 기구(IETF)에서는 이를 방지하기 위해 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여,

사용자가 한 번 Refresh Token으로 Access Token을 발급 받았으면, Refresh Token도 다시 발급 받도록 하는 것을 권장하고 있다.

 

새로운 Access Token + Refresh Token에 대한 재발급 원리는 다음과 같다.

Access Token + Refresh Token에 대한 재발급 과정

 


코드 작성

1. 라이브러리 설정

Spring Security와 JWT를 사용하기 위해 다음 라이브러리들을 추가해준다.

build.gradle
dependencies {
 
    ...
    
    //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'
}

2. JWT DTO 생성

클라이언트에 토큰을 보내기 위해 JwtDto를 생성한다.

JwtDto.java
@Builder
@Data
@AllArgsConstructor
public class JwtDto {
    private String grantType;
    private String accessToken;
    private String refreshToken;
}

 

JwtDto 필드에 있는 grantType은 JWT에 대한 인증 타입이다.

이 글에서는 OAuth2.0에 널리 사용되는 Bearer 인증 방식을 사용한다.


3. 암호 키 설정

터미널에 다음 명령어를 작성하여 랜덤으로 암호 키를 생성한다.

해당 키를 application-jwt.yaml에 설정한다.

키는 토큰의 암호화와 복호화에 사용될 것이고, HS256 알고리즘을 사용하기 위해 32글자 이상으로 설정한다.

 

터미널
openssl rand -hex 32

 

application-jwt.yaml
jwt:
	secret: 랜덤 암호 키

4. JwtProvider 구현

Spring Security와 JWT를 사용하여 인증과 권한 부여를 처리하는 클래스이다.

이 클래스에서 JWT 토큰의 생성, 복호화, 검증 기능을 구현하였다.

 

JwtProvider.java
@Slf4j
@Component
public class JwtProvider {

    private final Key key;

    // application-jwt.yaml에서 secret 값 가져와서 key에 저장
    public JwtProvider(@Value("${jwt.secret}") String secretKey){
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

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

        long now = (new Date()).getTime();
        
        // Access Token 생성 -> (현재시간 + 30분(밀리초))
        Date accessTokenExpiration = new Date(now + 1000 * 60 * 30);
        String accessToken = Jwts.builder()
                .subject(authentication.getName())
                .claim("auth", authorities)
                .expiration(accessTokenExpiration)
                .signWith(key)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .expiration(new Date(now + 1000 * 60 * 60 * 24))
                .signWith(key)
                .compact();

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

    // Jwt를 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken){
        // Jwt 토큰 복호화
        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)
                        .toList();

        // UserDetails 객체를 만들어서 Authentication return
        // UserDetails: interface, User: UserDetails 를 구현한 class
        UserDetails principal = new User(claims.getSubject(),"",authorities);
        return new UsernamePasswordAuthenticationToken(principal,"",authorities);
    }

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                    .verifyWith((SecretKey) key)
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("invalid JWT", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty", e);
        }
        return false;
    }

    // accessToken
    private Claims parseClaims(String accessToken){
        try {
            return Jwts.parser()
                    .verifyWith((SecretKey) key)
                    .build()
                    .parseSignedClaims(accessToken)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

}

 

generateToken()

  • 인증(Authentication) 객체를 기반으로 Access Token과 Refresh Token 생성
  • Access Token: 인증된 사용자의 권한 정보와 만료 시간을 담고 있음
  • Refresh Token: Access Token의 갱신을 위해 사용 됨

 

getAuthentication()

  • 주어진 Access token을 복호화하여 사용자의 인증 정보(Authentication)를 생성
  • 토큰의 Claims에서 권한 정보를 추출하고, User 객체를 생성하여 Authentification 객체로 반환
  • Collection<? extends GrantedAuthority> 로 리턴받는 이유
    → 권한 정보를 다양한 타입의 객체로 처리할 수 있고, 더 큰 유연성 확장성을 가질 수 있음

   [Authentification 객체 생성 과정]

  1. 토큰의 클레임에서 권한 정보를 가져옴. "auth" 클레임은 토큰에 저장된 권한 정보를 나타냄
  2. 가져온 권한 정보를 SimpleGrantedAuthority 객체를 변환하여 컬렉션에 추가
  3. UserDetails 객체를 생성하여 주체(subject)와 권한 정보, 기타 필요한 정보를 설정
  4. UsernamePasswordAuthentificationToken 객체를 생성하여 주체와 권한 정보를 포함한 인증(Authentication) 객체를 생성

 

validateToken()

  • 주어진 토큰을 검증하여 유효성을 확인
  • Jwts.parser()를 사용하여 토큰의 서명 키를 설정하고, 예외 처리를 통해 토큰의 유효성 여부를 판단
  • IllegalArgumentExeception 발생하는 경우
    → 토큰이 올바른 형식이 아니거나 클레임이 비어있는 경우 등에 발생
  • claim.getSubject()는 주어진 토큰의 클레임에서 "sub" 클레임의 값을 반환
    → 토큰의 주체를 나타냄 ex) 사용자의 식별자나 이메일 주소

 

parseClaims()

  • 클레임(Claims): 토큰에서 사용할 정보의 조각
  • 주어진 Access Token을 복호화하고, 만료된 토큰인 경우에도 Claims 반환
  • parseSignedClaims() 메서드가 JWT 토큰의 검증과 파싱을 모두 수행

5. JwtAuthenticationFilter 구현

클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로, UsernamePasswordAuthenticationFilter 이전에

실행할 것이다. 클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고, 유효한 토큰인 경우 해당 토큰의 인증 정보(Authentication)를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다.

 

JWT를 통해 username + password 인증을 수행한다는 뜻

 

JwtAuthenticationFilter.java
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtProvider jwtProvider;

    @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 && jwtProvider.validateToken(token)) {
            // 토큰이 유효할 겨우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
            Authentication authentication = jwtProvider.getAuthentication(token);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

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

 

resolveToken()

  1. 주어진 HttpServletRequest에서 토큰 정보를 추출하는 역할
  2. "Authorization"헤더에서 "Bearer" 접두사로 시작하는 토큰을 추출하여 반환

 

doFilter()

  1. resovleToken() 메서드를 사용하여 요청 헤더에서 JWT를 추출
  2. JwtProvider의 validateToken() 메서드로 JWT의 유효성 검증
  3. 토큰이 유효하면 JwtProvider의 getAuthentication() 메서드로 인증 객체 가져와서 SecurityContext에 저장
    → 요청을 처리하는 동안 인증 정보가 유지된다.
  4. chain.doFilter()를 호출하여 다음 필터로 요청을 전달

6. SecurityConfig 설정

Spring Security의 설정을 담당하는 SecurityConfig를 작성한다.

 

SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    //권한 확인을 하지 않는 uri
    private static final String[] PERMIT_ALL_PATTERNS = new String[] {

            "/h2-console/**", "/test",

            "/v1/api/member/**",

            "/v1/api/message/**",
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{

        return httpSecurity
                // REST API이므로 basic auth 및 csrf 보안을 사용하지 않음
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable) // 1번
                .cors(AbstractHttpConfigurer::disable) // 2번
                .headers((headerConfig) -> headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) // 3번

                // JWT를 사용하기 때문에 세션을 사용하지 않음
                .sessionManagement(configurer -> configurer
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(PERMIT_ALL_PATTERNS).permitAll()
                )

                // JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider),
                        UsernamePasswordAuthenticationFilter.class)

                .exceptionHandling((exceptionConfig) ->
                        exceptionConfig.authenticationEntryPoint(unauthorizedEntryPoint).accessDeniedHandler(accessDeniedHandler)
                ).getOrBuild();// 401 403 관련 예외처리
    }

    private final AuthenticationEntryPoint unauthorizedEntryPoint =
            (request, response, authException) -> {
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                String json = new ObjectMapper().writeValueAsString(ErrorCode.UNAUTHORIZED);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                PrintWriter writer = response.getWriter();
                writer.write(json);
                writer.flush();
            };

    private final AccessDeniedHandler accessDeniedHandler =
            (request, response, accessDeniedException) -> {
                response.setStatus(HttpStatus.FORBIDDEN.value());
                String json = new ObjectMapper().writeValueAsString(ErrorCode.FORBIDDEN);
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                PrintWriter writer = response.getWriter();
                writer.write(json);
                writer.flush();
            };

}

 

SecurityFilterChain() HttpSecurity를 구성하여 보안 설정을 정의

  • HTTP Basic 인증 비활성화:
    REST API에서는 HTTP Basic 인증을 사용하지 않기 때문에 이를 비활성화합니다.
  • CSRF 보호 비활성화:
    REST API는 주로 stateless한 특성을 가지므로 CSRF 보호가 필요하지 않습니다.
  • CORS 보호 비활성화:
    CORS 설정을 비활성화합니다. 필요에 따라 CORS 설정을 추가할 수 있습니다.
  • X-Frame-Options 헤더 비활성화:
    X-Frame-Options 헤더를 비활성화하여 iframe 내에서 페이지가 로드될 수 있도록 합니다.
  • 세션 관리 설정:
    JWT를 사용하기 때문에 서버에서 세션을 생성하지 않도록 설정합니다. 
    SessionCreationPolicy.STATELESS는 세션을 사용하지 않음을 의미합니다.
  • 허용된 요청 패턴 설정:
    PERMIT_ALL_PATTERNS 배열에 정의된 URL 패턴에 대해 인증 없이 접근을 허용합니다.
  • JWT 인증 필터 추가:
    JWT 인증을 위해 직접 구현한 JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가합니다.
  • 예외 처리 설정:
    인증 실패 및 접근 거부 시의 예외 처리를 설정합니다. unauthorizedEntryPoint와 accessDeniedHandler는
    각각 401 Unauthorized와 403 Forbidden 응답을 처리합니다.

 

 

BCryptPasswordEncoder()

  • BCryptPasswordEncoder를 생성하여 반환
  • BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 클래스이다.
    이 클래스는 비밀번호를 안전하게 저장하기 위해 해시 알고리즘을 사용한다.

7. 인증을 위한 Domain, Repository 설정

자신의 프로젝트에 맞는 요구사항을 설정해주고, UserDetails interface를 구현한다.

인증은 phoneNumber와 password로 진행할 것이다.

 

UserPrivacy.java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"phoneNumber"}, name = "PHONE_NUMBER_UNIQUE")})
public class UserPrivacy extends BaseEntity implements UserDetails {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id", updatable = false,
            unique = true, nullable = false)
    private Integer id;

    @Column(nullable = false)
    private String phoneNumber;

    @Column(nullable = false)
    private String password;

    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
    private LocalDateTime birthDay;

    private String email;


    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    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 getUsername() {
		// TODO: UsernamePasswordAuthenticationToken 말고 커스텀 Token으로 변경
        return this.phoneNumber;
    }

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

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

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

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

 

getAuthorites()

  • 유저가 가지고 있는 권한(authority) 목록을 SimpleGrantedAuthority로 변환하여 반환
  • 나머지 Override 메서드들은 전부 true로 반환하도록 설정

UserRepository.java
public interface UserRepository extends JpaRepository<UserPrivacy,Integer> {

    Optional<UserPrivacy> findByPhoneNumber(String phoneNumber);
    boolean existsByPhoneNumber(String phoneNumber);
}

8. 인증을 위한 Service 구현

UserService.java
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class UserService {

    private final UserRepository userRepository;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtProvider jwtProvider;
    private final BCryptPasswordEncoder passwordEncoder;

    public UserPrivacy save(SignUpRequestDto requestDto){
        List<String> roles = new ArrayList<>();
        roles.add("USER");

        try{
            return userRepository.save(requestDto.toEntity(
                    passwordEncoder.encode(requestDto.password()),roles)
            );

        } catch (DataIntegrityViolationException e){
            if(e.getMessage().toUpperCase().contains("PHONE_NUMBER_UNIQUE")){
                throw new OccupiedException(ErrorCode.EXISTS_MEMBER);
            }
            throw e;
        }
    }

    @Transactional(readOnly = true)
    public Jwt signIn(LoginRequestDto requestDto){

        // 1. phoneNumber + password 를 기반으로 Authentication 객체 생성
        // 이때 authentication 은 인증 여부를 확인하는 authentication 값이 false
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(requestDto.phoneNumber(), requestDto.password());

        // 2. 실제 검증 authentication() 메서드를 통해 요청된 User 에 대한 검증 진행
        // authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByPhoneNumber 메서드 실행
        Authentication authentication =
                authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 생성 후 반환
        return jwtProvider.generateToken(authentication);
    }
}

 

save()

  1. 회원 가입 요청으로 들어온 SignRequestDto에서 비밀번호 암호화와 기본 역할인 USER를 가진 사용자 정보 저장
  2. 만약 이미 존재하는 회원일 경우 OccupiedException(커스텀) 던져주기

try catch 문을 통해 회원가입 동시성 문제 해결

 

SignIn()

  1. 로그인 요청으로 들어온 phoneNumber + password를 기반으로 Authentication 객체 생성
  2. authenticate() 메서드를 통해 요청된 Member에 대한 검증 진행
    ➡︎ loadUserByUsername 메서드가 실행됨 (어떤 객체를 검증할 것인지에 대해 직접 구현해야 함)
  3. 검증에 성공하면 인증된 Authentication 객체를 기반으로 JWT 토큰 생성

9. CutomUserDetailsService 구현

CustomUserDetailsService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String phoneNumber) throws UsernameNotFoundException {
        return userRepository.findByPhoneNumber(phoneNumber)
                .map(this::createUserDetails)
                .orElseThrow(() ->
                        new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
    }

    // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 return
    private UserDetails createUserDetails(UserPrivacy userPrivacy) {
        List<GrantedAuthority> authorities = userPrivacy.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        return User.builder()
                .username(userPrivacy.getUsername())
                .password(userPrivacy.getPassword())
                .authorities(authorities)
                .build();
    }
}

 

 

Postman을 사용한 회원가입 및 로그인 기능 자동 테스트