JWT 의존 라이브러리 추가

  • build.gradle
...
...

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	implementation 'org.mapstruct:mapstruct:1.5.2.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.2.Final'
	implementation 'org.springframework.boot:spring-boot-starter-mail'
	implementation 'com.google.code.gson:gson'

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

  // (2) JWT 기능을 위한 jjwt 라이브러리
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly	'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

...
...
  • SecurityConfiguration 추가 JWT를 적용해 보기 전에 Spring Security를 이용한 보안 강화를 위해 최소한의 보안 구성을 먼저 하겠습니다.
ackage com.codestates.config;

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.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers().frameOptions().sameOrigin() // (1)
            .and()
            .csrf().disable()        // (2)
            .cors(withDefaults())    // (3)
            .formLogin().disable()   // (4)
            .httpBasic().disable()   // (5)
            .authorizeHttpRequests(authorize -> authorize
                    .anyRequest().permitAll()                // (6)
            );
        return http.build();
    }

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

    // (8)
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));   // (8-1)
        configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE"));  // (8-2)

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

(1)에서는 H2 웹 콘솔을 정상적으로 사용하기 위해 .frameOptions().sameOrigin()을 추가합니다.

  • .frameOptions().sameOrigin()을 호출하면 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용합니다.

(2)에서는 CSRF(Cross-Site Request Forgery) 공격에 대한 Spring Security 설정을 비활성화합니다.

  • 우리는 로컬 환경에서 Spring Security에 대한 학습을 진행하므로 CSRF 공격에 대한 설정이 필요하지 않습니다.

(3)에서는 CORS 설정을 추가합니다. .cors(withDefaults()) 일 경우, corsConfigurationSource라는 이름으로 등록된 Bean을 이용합니다.

  • CORS를 처리하는 가장 쉬운 방법은 CorsFilter를 사용하는 것인데 CorsConfigurationSource Bean을 제공함으로써 CorsFilter를 적용할 수 있습니다.

(4)에서는 폼 로그인 방식을 비활성화합니다.

  • HTTP Basic 인증 방식은 우리 코스에서 사용하지 않으므로 HTTP Basic 인증 방식을 비활성화합니다.

(7)에서는 PasswordEncoder Bean 객체를 생성합니다.

(8)에서는 CorsConfigurationSource Bean 생성을 통해 구체적인 CORS 정책을 설정합니다.

  • setAllowedOrigins()을 통해 모든 출처(Origin)에 대해 스크립트 기반의 HTTP 통신을 허용하도록 설정합니다.
  • setAllowedMethods()를 통해 파라미터로 지정한 HTTP Method에 대한 HTTP 통신을 허용합니다.
  • CorsConfigurationSource 인터페이스의 구현 클래스인 UrlBasedCorsConfigurationSource 클래스의 객체를 생성하여 모든 URL에 CORS 정책을 적용합니다.

회원 가입 로직 수정

  • MemberDto.Post 패스워드 필드 추가
public class MemberDto {
    @Getter
    @AllArgsConstructor
    public static class Post {
        @NotBlank
        @Email
        private String email;

        // (1) 패스워드 필드 추가
        @NotBlank
        private String password;

        @NotBlank(message = "이름은 공백이 아니어야 합니다.")
        private String name;

        @Pattern(regexp = "^010-\\d{3,4}-\\d{4}$",
                message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.")
        private String phone;
    }

    ...
    ...
}
  • Member 엔티티 클래스에 패스워드 필드 추가
@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member extends Auditable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;

    @Column(nullable = false, updatable = false, unique = true)
    private String email;

    // (1) 추가 Member 엔티티 클래스에도 패스워드 필드를 추가
    @Column(length = 100, nullable = false)
    private String password;

    ...
    ...

    // (2) 추가 @ElementCollection 애너테이션을 이용해 사용자 등록 시, 사용자의 권한을 등록하기 위한 권한 테이블을 생성
    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();

    ...
    ...
}
  • 사용자 등록 시, 패스워드와 사용자 권한 저장
import com.codestates.auth.utils.CustomAuthorityUtils;
...
...

@Transactional
@Service
public class MemberService {
    private final MemberRepository memberRepository;
    private final ApplicationEventPublisher publisher;

    // (1) 추가 PasswordEncoder와 CustomAuthorityUtils 클래스를 DI 받도록 필드를 추가
    private final PasswordEncoder passwordEncoder;
    private final CustomAuthorityUtils authorityUtils;

    // (2) 추가 PasswordEncoder와 CustomAuthorityUtils 클래스를 DI 받도록 필드를 추가
    public MemberService(MemberRepository memberRepository,
                         ApplicationEventPublisher publisher,
                         PasswordEncoder passwordEncoder,
                         CustomAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.publisher = publisher;
        this.passwordEncoder = passwordEncoder;
        this.authorityUtils = authorityUtils;
    }

    public Member createMember(Member member) {
        verifyExistsEmail(member.getEmail());

        // (3) 추가: 생성자 DI용 파라미터 추가 패스워드를 단방향 암호화
        String encryptedPassword = passwordEncoder.encode(member.getPassword());
        member.setPassword(encryptedPassword);

        // (4) 추가: DB에 User Role 저장 사용자의 권한 정보를 생성
        List<String> roles = authorityUtils.createRoles(member.getEmail());
        member.setRoles(roles);

        Member savedMember = memberRepository.save(member);

        publisher.publishEvent(new MemberRegistrationApplicationEvent(this, savedMember));
        return savedMember;
    }

    ...
    ...
}

JWT 자격 증명을 위한 로그인 인증 구현

  • 사용자의 로그인 인증 성공 후, JWT가 클라이언트에게 전달되는 과정
  1. 클라이언트가 서버 측에 로그인 인증 요청(Username/Password를 서버 측에 전송)
  2. 로그인 인증을 담당하는 Security Filter(JwtAuthenticationFilter)가 클라이언트의 로그인 인증 정보 수신
  3. Security Filter가 수신한 로그인 인증 정보를 AuthenticationManager에게 전달해 인증 처리를 위임
  4. AuthenticationManager가 Custom UserDetailsService(MemberDetailsService)에게 사용자의 UserDetails 조회를 위임
  5. Custom UserDetailsService(MemberDetailsService)가 사용자의 크리덴셜을 DB에서 조회한 후, AuthenticationManager에게 사용자의 UserDetails를 전달
  6. AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리
  7. JWT 생성 후, 클라이언트의 응답으로 전달

1번부터 7번 과정 중에서 우리는 JwtAuthenticationFilter 구현(2번 ~ 3번, 7번), MemberDetailsService(5번)을 구현합니다.

4번과 6번은 Spring Security의 AuthenticationManager가 대신 처리해 주므로 신경 쓸 필요가 없습니다.