728x90

개요

JWT 공부를 시작했을 때 JWT를 왜 사용하는지를 한동안 고민에 잠겼고 결론에 대해서 정리하고 싶어서 작성하게 되었다.

 

JWT의 STATELESS 상태에 대한 집착

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService; // 기본 세션 로그인
    private final CustomOauth2UserService customOAuth2UserService; // OAuth2 등록

    // AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
  
    private final JWTUtil jwtUtil; //JWTUtil 주입

    // AuthenticationManager Bean 등록
    // AuthenticationConfiguration 인자를 또 받기 때문에 주입
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/login", "/", "/join").permitAll()
                        .requestMatchers("/admin").hasAuthority("ADMIN")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form.disable())
                .httpBasic(auth -> auth.disable())
                .sessionManagement(session -> session // jwt 방식에서는 session --> STATELESS
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable())
                // --> 우리는 formLogin 을 disable 해놨기 때문에 UsernamePasswordAuthenticationFilter 을 직접 커스텀 해야한다.
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class)
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class)
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(configurationSource())
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
	@Bean
    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET","POST","DELETE"));
        source.registerCorsConfiguration("/**",config);
        return source;
    }
}
  • 위의 시큐리티 JWT config를 구성한 코드를 보면
  • SessionCreationPolicy.STATELESS 상태가 등장하는 것을 볼 수 있다.
  • 그래서 나는 이 STATELESS 상태에 초점이 맞추어 생각을 했다.
    --> JWT 구현을 위해 STATELESS 상태가 필요하지만 STATELESS에 초점을 잡고 생각을 하니 문제가 많아졌다.
    --> 아래를 읽다보면 왜 문제인지 알 수 잇을 것이다.

 

문제 상황

  • 일단 기본적으로 JWT를 사용하면 토큰 탈취의 문제로 기존 Access 토큰과 함께 Refresh 토큰의 도입을 이야기한다.
  • 따라서 Refresh, Access라는 2가지의 토큰을 발급해주는데, Refresh 토큰 요청는  Access 토큰 만료보다 주기 자체가 길기 때문에 탈취 당할 확률은 낮다고는 하지만 어찌되었든 탈취 당할 수 있다.
  • 그래서 이 탈취 당할 확률을 방지하기 위해 서버측에서 어떠한 방법을 구현해야 하는데 이 부분에 대해서 많은 고민을 했다. 




레디스, 데이터베이스의 도입

  • 만약, 토큰이 탈취 되었을때 서버의 제어권과 로그아웃 문제 등 을 생각해보자.
  • 일단 토큰이 탈취되면 토큰의 만료 기간 까지 서버측은 고통을 받을 것이다.
  • 따라서 아예 서버를 꺼버리거나 서버 비밀키를 변경하는 상황까지 가는 불상사가 발생할 것이다.
  • 혹은 프론트 서버측 로그아웃을 구현할 수도 있는데 이때 이미 해커가 토큰을 복제 했다면 해커는 그 토큰을 가지고 계속 서버에 접속할 수 있기 때문에 여전히 문제가 있다.
  • 그래서 서버측에서 이러한 문제에 대해서 구현해줘야 하는데 이를 위해 서버측 Redis와 같은 저장소에 발급한 Refresh 토큰을 저장해서 로그아웃을 하거나 토큰이 탈취 당했을 때 블랙리스트를 도입하여 블랙리스트에 저장한다는 구현들이 많았다.
  • 그래서 로그아웃 상태거나 탈취된 토큰은 Redis 서버에서 제거하여 앞으로 Access 토큰 재발급이 불가능하도록 설정하는 것이었다.



하지만 모순...

  • 위처럼 서버에서 레디스같은 저장소를 도입하는 것을 이야기 했지만 나는 그것은 모순이라고 생각했다.
  • Refresh들을 저장하기 위한 Redis를 도입해버리면 사실상 세션 클러스터링을 작업하고 세션 방식을 사용하는 것이 좋지 않을까? 라는 생각을 하게 된 것이다.
  • 왜냐면 나는 jwt config 를 작성하면서 앞 단에서 세션 STATELESS 작업을 했는데, 뒷단인 다른 곳에서 상태 저장이 생겨버리는 것이 아닌가 라는 생각이 든 것이다.
  • 그래서 탈취를 막으면서도 Redis를 도입하지 않을 방법에 대해서 고민을 했지만.....
  • 시원한 해답은 얻지를 못했다. 아래처럼 계속 둘레에 빠져 한 곳만 바로보고 있던 것이다.
    STATELESS → 그런데 Redis → 그럼 차라리 세션 → 왜 JWT를 사용했지? 

 

결론 : JWT를 왜 사용하는가?

우리는 우리가 할려는 일에 대해서 목표가 무엇인지 판단해야 한다.
우리가 JWT의 목적을 확인하지 않고 구현에만 열중한다면 무엇을 하는지도 모르는 것에 불과하다. 따라서 JWT의 STATELESS한 상태에만 목적을 두는 것이 아닌 JWT가 왜 필요한지를 생각했고 해답을 찾았다.


JWT를 사용한 이유

  • 모바일 앱
    • JWT가 사용된 주 이유는 결국 모바일 앱의 등장이다.
    • 모바일 앱의 특성상 주로 JWT 방식으로 인증/인가를 진행한다.
    • 그렇기 때문에 결국 세션 STATLESS는 앱의 등장으로 인해 부수적인 효과인 것이다.
  • 장시간 로그인과 세션
    • 장기간 동안 로그인 상태를 유지하려고 세션 설정을 하면 서버 측 부하가 많이 가기 때문에 JWT 방식을 이용하는 것도 한 방법이다.

위의 이런 이유때문에 jwt가 만들어졌고 JWT의 목적이 STATELESS가 아니기 때문에 나중에 로그아웃에 대해서 레디스나 디비에 리프레시 토큰을 저장하는 로직을 추가하는 것이 올바르다고 판단을 내렸다. 

728x90
728x90

개요

시큐리티를 사용해 OAuth2 로그인을 진행하고자 한다. 나는 그중 카카오와 네이버에 대해 해보겠다.

앞에서 진행한 코드를 그대로 사용해서 이어서 진행한다. 

Spring Security + Session 를 사용한 회원가입, 로그인, 로그아웃 구현 :: 미정 (tistory.com)

 

OAuth2 에 대한 설명은 

Spring : OAuth2 로그인 방식 :: 미정 (tistory.com) 

 

Spring : OAuth2 로그인 방식

OAuth 2.0 이란?서비스에서 사용자 개인정보와 인증에 대한 책임을 지지 않고 신뢰할 만한 타사 플랫폼에 위임개인정보 관련 데이터를 직접 보관하는 것은 리스크가 크며 보안적으로 문제되지 않

eesko.tistory.com

이걸 보면 된다.

 

 

 

네이버, 카카오 소셜 로그인 신청

나는 네이버와 카카오에 대해서 신청을 했고

이 분의 영상을 보고 네이버에 대한 신청은 했다. 

https://youtu.be/L8yAtjjOhDo?feature=shared

 

카카오 신청은 이 분의 글을 보고 신청 했다.

[Spring Boot] 로그인 기능 구현 (6) - 카카오 로그인 (OAuth 2.0) — 공대생의 코딩 일기 (tistory.com)

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6

oauth2

 

 

프로젝트 파일 구조

 

 

 

의존성 설치

  • Build.gradle
	// OAuth2 Client
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

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

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

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //타임리프에서 스프링시큐리티를 쓸수있게.
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    implementation 'org.jsoup:jsoup:1.14.3'

    //validation사용
    implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
  • Build.gradle 에 security와 OAuth2 의존성을 추가해준다.

 

  • Application.yml
spring:
  application:
    name: [projectname]

  datasource:
    url: jdbc:mysql://localhost:3306/[db]
    username: [usernmae]
    password: [password]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: [설정]
            client-secret: [설정]
            scope:
              - account_email
              - profile_nickname
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            client-name: Kakao
            client-authentication-method: client_secret_post

          naver:
            client-id: [설정]
            client-secret: [설정]
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            client-name: Naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email

        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          naver:
            authorization_uri: https://nid.naver.com/oauth2.0/authorize
            token_uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user_name_attribute: response

server:
  port: 8080
  • application.yml 파일에 OAuth2 설정을 해준다. 
  • 위에서 신청한 네이버와 카카오 설정들을 맞게 설정해준다.

 


1. 엔티티 수정

  • User.java
@Entity
@Table(name = "users")
@Getter @Setter
public class User {

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

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

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String usernick;

    @Column(name = "registrationDate", nullable = false)
    private LocalDateTime registrationDate  = LocalDateTime.now();

    private String filename;
    private String filepath;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
    
    /**
     * Oauth2 관련
     */
    // providerId : 구굴,카카오,네이버 로그인 한 유저의 고유 ID가 들어감
    private String loginId; // Unique identifier for OAuth users

    // provider : google, kakao, naver 이 들어감
    private String provider;
}
  • 아래 2개의 칼럼을 추가해준다.
  • 그리고 위의 엔티티는 최종이고 아래 부분은 이전 엔티티에서 닉네임 컬럼 부분을 보면 usernick이 중복 불가능하게 했지만 그 부분을 수정해줬다.
  • 하지만 현재 디비에 값들이 저장된 상태라서 엔티티에서 변경을 한후 sql 구문을 통해 수정해줬다.
// 변경 전
@Column(nullable = false, unique = true)
private String usernick;

// 변경 후 
@Column(nullable = false)
private String usernick;
# 1 
SHOW INDEX FROM users WHERE Column_name = 'usernick';

# 2 Key_name 의 값을 확인

# 3
ALTER TABLE users DROP INDEX UK5rqu0cqcxxar2go1qlddeia02;

 

2. Oauth2 응답 객체

  • OAuth2Response.java
public interface OAuth2Response {

    //제공자 (Ex. naver, google, ...)
    String getProvider();

    //제공자에서 발급해주는 아이디(번호)
    String getProviderId();

    //이메일
    String getEmail();

    //사용자 실명 (설정한 이름)
    String getName();
}
  • 각 사이트마다 인증 데이터 규격이 다르기 때문에 OAuth2Response 인터페이스를 만들어 구별한다.
  • 카카오, 구글, 네이버, 깃허브 등등 이 인터페이스를 상속받아 각각의 규격에 맞게 구현한다.
  • 현재 나는 사용자 이메일, 설정 이름만 가져왔고 프로필 이미지, 생일 등등 가져올 수 있다.

 

Q. 여기서 잠깐!

  • 네이버와 카카오, 구글의 사용자 정보 response 를 보자.
getAttributes : 
{
	id=12345678, 
	connected_at=2024-07-26T09:56:54Z, 
	properties={
    	nickname=엉이
    }, 
	kakao_account={
    	profile_nickname_needs_agreement=false, 
                             profile={nickname=엉이, is_default_nickname=false},
                             has_email=true, 
                             email_needs_agreement=false, 
                             is_email_valid=true, 
                             is_email_verified=true, 
                             email=엉이@kakao.com
    }
}

 provider : kakao
getAttributes :
{
	resultcode=00,
    	message=success,
    	response={
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : naver
getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google
  • 각 사이트마다 출력 값들이 다르다.
  • 그러기 때문에 기본적인 형태가 다르기 때문에 위에서 만든 OAuth2Response 인터페이스를 만들어서 네이버, 카카오 응답 객체에 맞게 고칠 수 있도록 기본적인 틀을 만들어 둔다.
  • 그럼 카카오, 네이버, 구글 등 OAuth2Response 인터페이스를 상속받아서 구현하면 된다.

 

 

  • KakaoResponse.java
getAttributes : 
{
	id=12345678, 
	connected_at=2024-07-26T09:56:54Z, 
	properties={
    	nickname=엉이
    }, 
	kakao_account={
    	profile_nickname_needs_agreement=false, 
                             profile={nickname=엉이, is_default_nickname=false},
                             has_email=true, 
                             email_needs_agreement=false, 
                             is_email_valid=true, 
                             is_email_verified=true, 
                             email=엉이@kakao.com
    }
}

 provider : kakao

위의 출력값을 보면서 아래 코드를 이해하자.

public class KakaoResponse implements OAuth2Response{

    private Map<String, Object> attribute;

    public KakaoResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("Kakao attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");
        return kakaoAccount.get("email").toString();
    }

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");
        return properties.get("nickname").toString();
    }
}
  • 카카오는 출력 데이터 내부 안에 kakao_account 라는 데이터가 존재하고 그 안에 사용자 이메일, 이름 등에 대한 값이 있기 때문에 get()으로 값을 꺼내온다.

 

 

 

  • NaverResponse.java
getAttributes :
{
	resultcode=00,
    	message=success,
    	response={
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : naver

위의 출력값을 보면서 아래 코드를 이해하자.

public class NaverResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public NaverResponse(Map<String, Object> attribute) {
        this.attribute = (Map<String, Object>) attribute.get("response");
        System.out.println("naver attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }
}
  • 네이버는 데이터 내부 안에  response 라는 데이터가 또 있고 그 안에 사용자의 정보에 대한 값이 저장되어 있기 때문에 get으로 가져와준다.

 

 

  • googleResponse.java
getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google
public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("google attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }
}

구글에 대해서는 카드 인증이 필요해서 귀찮기 때문에.. 하지는 않았지만 정리를 위해 작성했다. 실제 구글 oauth2는 신청하지 않았기에 사용하지 않았다.

 

 

 

3. OAuth2User

  • CustomOAuth2User.java
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2Response oAuth2Response;
    private final String role;

    public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {
        this.oAuth2Response = oAuth2Response;
        this.role = role;
    }

	// 현재는 null 했지만 이 값을 지정하면 
    // 카카오,네이버 등 로그인했으면 거기에 맞는 프로필 사진이나 가져올 수 있음
    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

	// role에 해당하는 값
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new SimpleGrantedAuthority(role));
        return collection;
    }
    
    @Override
    public String getName() {
        return oAuth2Response.getName();
    }

	// 사용안함
    public String getUsername() {
        return oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
    }
}
  • 카카오, 네이버, 구글 서비스로부터 받은 특정 사이트의 응답 값과 롤에 대한 값을 받는 클래스이다. 
  • 이 클래스를 통해 특정 값과 롤에 대해 정의한다.

 

4. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService; // 기본 세션 로그인
    private final CustomOauth2UserService customOAuth2UserService; // OAuth2 등록

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login").permitAll()
                        .requestMatchers("/oauth-login/admin").hasRole("ADMIN")
                        .requestMatchers("/oauth-login/info").authenticated()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login") // 로그인 페이지의 경로
                        .loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable())
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(configurationSource()))
                // OAuth 등록
               .oauth2Login(oauth2 -> oauth2
                        .loginPage("/api/users/login") // 커스텀 로그인 방식 지정
                        // customOAuth2UserService 등록
                        .userInfoEndpoint(userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2UserService))
                        .failureUrl("/loginFailure")
                        .defaultSuccessUrl("/api/users/info")
                        .authorizationEndpoint(authorization -> authorization
                                .baseUri("/oauth2/authorization")
                        )
                        .permitAll()
                );
        return http.build();
    }

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

    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET","POST","DELETE"));
        source.registerCorsConfiguration("/**",config);
        return source;
    }
}
  • Oauth2 설정을 해준다.

 

 

5. OAuth2UserService 구현

  • OAuth2UserService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {
    // DefaultOAuth2UserService는 OAuth2UserService의 구현체라서 
    // DefaultOAuth2UserService 또는 OAuth2UserService 아무거나 상속해도 된다.

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
	
    // loadUser --> 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드
    // userRequest 를 통해 카카오, 네이버, 구글 등등 인증 데이터가 넘어 올 것이다.
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        System.out.println("OAuth2User attributes: " + oAuth2User.getAttributes());
  		
        // 네이버, 구글, 깃허브 등등 어떤 어떤 인증 값인지 구별하기 위해 인증 제공자를 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        
        // 각 사이트마다 인증 데이터 규격이 다르기 때문에 OAuth2Response 를 만들어 구별
        // 인증 제공자에 따라 OAuth2Response를 생성
        OAuth2Response oAuth2Response = null;
        
        // 그 값이 네이버면
        if (registrationId.equals("naver")) {
            log.info("naver 로그인");
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
      	// 그 값이 카카오면
        } else if (registrationId.equals("kakao")) {
            log.info("kakao 로그인");
            oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        // 둘다 아니면
        } else {
            System.out.println("로그인 실패");
            throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
        }

        // 데이터 베이스 저장 관련 구현
        
        String provider = oAuth2Response.getProvider();
        String providerId = oAuth2Response.getProviderId();

        String username = provider + " " + providerId;
        Optional<User> userOptional = userRepository.findByUsername(username);

        String roleName = "USER";
        Optional<Role> roleOptional = roleRepository.findByName(roleName);
        Role role;
        if (roleOptional.isEmpty()) {
            role = new Role(roleName);
            role = roleRepository.save(role);  // Save the new role and get the persisted instance
        } else {
            role = roleOptional.get();
        }

        User user;
        if (userOptional.isEmpty()) {
            user = User.builder()
                    .username(username)
                    .email(oAuth2Response.getEmail())
                    .roles(Set.of(role))
                    .loginId(oAuth2Response.getProviderId())
                    .provider(oAuth2Response.getProvider())
                    .password("defaultPassword")
                    .registrationDate(LocalDateTime.now())
                    .usernick(oAuth2Response.getName())
                    .build();
            userRepository.save(user);
        } else {
            user = userOptional.get();
            user.setUsername(username);
            user.setEmail(oAuth2Response.getEmail());
            user.setLoginId(oAuth2Response.getProviderId());
            user.setProvider(oAuth2Response.getProvider());
            user.getRoles().add(role);
            user.setRegistrationDate(LocalDateTime.now());
            user.setUsernick(oAuth2Response.getName());
            userRepository.save(user);
            roleName = user.getRoles().iterator().next().getName();
        }

        System.out.println("User saved: " + user);

		// 특정 사이트의 응답 값과 역할을 받는 CustomOAuth2User 클래스
        // 인증된 사용자 정보를 반환
        return new CustomOAuth2User(oAuth2Response, roleName);
    }
}
  • Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
  • 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
  • loadUser는 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드이다.
  • 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.

 

 

6. 컨트롤러

  • UserController.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/api/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

    @GetMapping("/login")
    public String showLoginForm() {
        return "/user/loginform";
    }

    // 일반 로그인 회원의 정보 가져오기
    @GetMapping("/home")
    public String index(Model model, Authentication authentication) {

        String username = authentication.getName();
        Optional<User> userOptional = userServiceimpl.findByEmail(username);
        if (userOptional.isPresent()) {
            model.addAttribute("user", userOptional.get());
            return "user/home"; // user/home.html 템플릿으로 이동
        }
        return "redirect:/api/users/login";
    }

    // oauth2 유저의 정보 가져오기
    @GetMapping("/info")
    public String info(Model model, Authentication authentication) {
        CustomOAuth2User userDetails = (CustomOAuth2User) authentication.getPrincipal();

        model.addAttribute("name", userDetails);
        return "/user/info";
    }
}
  • 해당 컨트롤러에서 /api/users/info앤트포인트의 값으로 화면이 보여질 것이다.

 

 

7. 로그인 폼 및 로그인 한 회원의 정보를 보여주기

  • loginform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/oauth2/authorization/kakao">카카오 로그인</a>
<a href="/oauth2/authorization/naver">네이버 로그인</a>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
  • info.html
  • OAuth2 로그인 후 보여지는 화면이다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<h1> 당신은 OAUth2 로그인을 하셨습니다. </h1>
<p>안녕하세요, <span th:text="${name}"></span>님</p>
<p>안녕하세요, 당신이 로그인한 플랫폼 : <span th:text="${name.username}"></span>님</p>
<p>안녕하세요, 플랫폼 이름 : <span th:text="${name.name}"></span>님</p>
<p>안녕하세요, 당신의 등급 :  <span th:text="${name.authorities}"></span>님</p>
</body>
</html>
  • home.html
  • 일반 로그인 후 보여지는 화면이다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<p>안녕하세요, 로그인 : <span th:text="${user.email}"></span>님</p>
<p>이름: <span th:text="${user.username}"></span></p>
</body>
</html>

 

이렇게 하면 기본 Oauth2 세션 로그인은 성공할 것이고 이제 여기에 jwt를 사용해서 구현해볼 것이다.

728x90
728x90

OAuth 2.0 이란?

서비스에서 사용자 개인정보와 인증에 대한 책임을 지지 않고 신뢰할 만한 타사 플랫폼에 위임
  • 개인정보 관련 데이터를 직접 보관하는 것은 리스크가 크며 보안적으로 문제되지 않도록 안전하게 관리해야 하고 ID/PW 에 관련된 지속적인 해킹 공격 등 여러 가지 신경써야 할 부분이 많음
  • 신뢰할 수 있는 플랫폼(구글, 페이스북, 네이버, 카카오 등)에 개인정보, 인증 기능을 맡기면 서비스는 인증 기능에 대한 부담을 줄일 수 있음

 

OAuth2 동작 방식

OAuth2 코드 방식 출처 : 3. 동작 원리 (devyummi.com)

저 사진에서 숫자대로 설명하겠다.

 

1. 사용자가 네이버, 카카오, 구글, 깃허브 등 로그인을 요구한다. 

2. 사용자의 요청을 OAuth2AuthorizationRequestRedirectFilter 에 의해서 첫 번째로 잡혀지게 된다. 그럼 그 요청을

3. 등록된 외부 소셜 로그인 지원서비스(네이버,카카오,구글,깃허브 등)의 인증서버에 도착하게 된다. 여기서 인증서버는 우리는 주소를 갖고있다.

4. 해당 서비스 로그인 페이지를 사용자에게 응답해준다. 네이버,카카오 로그인 창이 뜨는 것이다. 그럼 우리는 거기서 네이버나 카카오 같이 똑같이 로그인을 진행하는 것이다.

5. 로그인이 성공적으로 되면 3번에서 인증서버가 우리의 주소를 갖고있다고 했으니깐, 우리의 주소로 다시 리다이렉트된다. 이때 리다이렉트가 된다면 성공했다는 말과 함께 특정한 코드를 같이 날려준다.

6. OAuth2LoginAuthenticationFilter에서 해당 요청을 받게 된다. 그럼 이 필터는 뒷단인

7. OAuth2LoginAuthenticationProvider 클래스로 코드와 특정 정보를 넘겨준다. 

8. 그럼 네이버, 카카오 같은 인증 서버로 엑세스 토큰을 발급 받기위해 코드와 특정정보를 보낸다.

9. 엑세스 토큰을 받으면 코드는 소멸된다. OAuth2LoginAuthenticationProvider는 받은 엑세스 토큰을 사용하여 유저 정보를 획득하기 위해 네이버, 카카오와 같은 리소스 서버로 엑세스 토큰을 보내준다.

10. 리소스 서버는 받은 엑세스 토큰을 검증한 뒤로 유저 정보를 다시 돌려준다.

11. OAuth2UserDetailsService 에서 OAuth2UserDetails 객체로 부터 로그인을 마무리하고 등록되게 된다.

 

 

각각의 필터가 동작하는 주소 -- 관습

  • 위의 1번 과정에서 로그인을 시도할 때를 보면
  • OAuth2AuthorizationRequestRedirectFilter의 주소가 "/oauth2/authorization/서비스명" 인데 이것을 그대로 따르는 것이 좋다.
/oauth2/authorization/서비스명

// ex 
/oauth2/authorization/naver
/oauth2/authorization/google

 

  • 위의 5번 과정에서 로그인을 하고 나서 코드를 같이 날려준다고 했는데, 이때의 주소가 "/login/oauth2/code/서비스명" 인데 이것을 그대로 따라주자.
  • OAuth2LoginAuthenticationFilter : 외부 인증 서버에 설정할 redirect_uri 이다.
/login/oauth2/code/서비스명

// ex
/login/oauth2/code/naver
/login/oauth2/code/google
  • 모든 필터에 저 주소들이 디폴트 값으로 지정되어 있기 때문에 이런 관습을 따르는 것이 좋다.
  • 커스톰해서 사용은 가능한데.. 그냥 관습을 따르자.

 

OAuth2 인증 및 동작을 위한 변수들

  • 스프링에서 OAuth2 설정을 하면 특정 변수들만 설정을 해도
  • OAuth2AuthorizationRequestRedirectFilter → OAuth2LoginAuthenticationFilter → OAuth2LoginAuthenticationProvider 까지의 과정을 추가 설정하지 않아도 자동으로 진행한다.
  • 즉 우리는 가장 마지막 단계인 UserDetailsService, UserDetails 만 구현하면 된다.
  • 따라서 사용자는 UserDetailsServiceUserDetails만 구현하면 된다.

 

  • 위의 로직들이 동작하기 위해서 변수 설정이 필요하다고 했는데  .yml 파일에 등록할 값들이다. 
  • registration 은 필수 등록 해야 하는 정보고 provider 는 대형 사이트일수록 고정적으로 제공하기 때문에 그냥 그대로 가져다 쓰면 된다.
spring:
  security:
    oauth2:
      client:
        registration: -- [외부 서비스에서 우리 서비스를 특정하기 위해 등록하는 정보 : 필수] --
          kakao: -- [카카오] --
            client-id: -- [서비스에서 발급 받은 아이디] --
            client-secret: -- [서비스에서 발급 받은 비밀번호] --
            scope: --[리소스 서버에서 가져올 데이터 범위 >> 이메일, 닉네임, 아이디, 성별 등등 선택 가능] --
              - account_email
              - profile_nickname
            authorization-grant-type: authorization_code -- [코드 방식을 사용하기로 했으니] --
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao --[서비스에 등록한 우리쪽 로그인 설공 url] --
            client-name: Kakao -- [서비스명] --
            client-authentication-method: client_secret_post

          naver:
            client-id: 3a_Puh_atCEjQRdro0ry
            client-secret: FyiSA5KCZP
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            client-name: Naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email

        provider: -- [이 부분은 변경없이 그대로 가져올 것] --
          kakao: -- [카카오] --
            authorization-uri: https://kauth.kakao.com/oauth/authorize -- [ 서비스 로그인 창 주소] --
            token-uri: https://kauth.kakao.com/oauth/token -- [토큰 발급 서버 주소] --
            user-info-uri: https://kapi.kakao.com/v2/user/me -- [ 사용자 정보 획득 주소] --
            user-name-attribute: id -- [응답 데이터 변수] --
          naver:
            authorization_uri: https://nid.naver.com/oauth2.0/authorize
            token_uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user_name_attribute: response

 

728x90
728x90

 

개요

개인 프로젝트를 진행하는 중 시큐리티 부분은 아주 중요하고 헷갈리기 때문에 정리해놓기 위해 포스팅 하기로 했다.

일단 이 부분은 이전에 작성한 jwt를 사용하지 않는 기본적인 세션방식의 시큐리티 로그인이다. 둘의 차이를 명확하게 이해하기 위해 작성한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

 

프로젝트 파일 구조

 

 

 

의존성 설치

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

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

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //타임리프에서 스프링시큐리티를 쓸수있게.
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    implementation 'org.jsoup:jsoup:1.14.3'

    //validation사용
    implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
  • Build.gradle 에 security 의존성을 추가해준다.
spring:
  application:
    name: testsecu

  datasource:
    url: jdbc:mysql://localhost:3306/[db이름]
    username: [자신의 아이디]
    password: [자신의 비밀번호]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

server:
  port: 8080
  • application.yml 파일에 mysql, jpa 설정을 해준다.

 

INSERT INTO roles (id, name) VALUES (1, 'ADMIN');
INSERT INTO roles (id, name) VALUES (2, 'USER');
  • 그리고 role 엔티티에 위에처럼 미리 값을 집어넣어 준다.

 

 

 

1. 엔티티 생성

  • User.java
@Entity
@Table(name = "users")
@Getter @Setter
public class User {

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

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

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String username;

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

    @Column(name = "registrationDate", nullable = false)
    private LocalDateTime registrationDate  = LocalDateTime.now();

    private String filename;
    private String filepath;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
}
  • 일단 나는 이메일과 비밀번호로 로그인을 할 것이다.
  • 이메일(아이디), 비밀번호, 사용자이름, 닉네임, 가입일자는 필수고 나중에 사진 등록을 위한 파일경로,파일이름 칼럼도 추가해줬다.

 

  • Role.java
@Entity
@Table(name = "roles")
@Getter @Setter
public class Role {

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

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

 

 

 

2. DTO 설정

  • UserDto.java
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {

    private Long id;
    private String email;
    private String password;
    private String passwordCheck;
    private String username;
    private String usernick;

    public static UserDto of(User user) {
        UserDto userDto = new UserDto();
        userDto.setId(user.getId());
        userDto.setEmail(user.getEmail());
        userDto.setPassword(user.getPassword());
        userDto.setUsername(user.getUsername());
        userDto.setUsernick(user.getUsernick());
        return userDto;
    }
}
  • User 엔티티에서 회원가입에 필요한 DTO를 하나 만들어서 코드를 더욱 깔끔하게 구현했다.

 

 

3. Repository 설정

  • UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
    Optional<User> findByUsername(String username);

    boolean existsByUsernick(String userName);
    boolean existsByEmail(String email);
}
  • 이메일로 로그인을 할것이기 때문에 findByEmail이라는 메서드가 필요하고,
  • findByUsername은 관리자가 회원가입할 때 역할을 분리하기 위한 메서드이다.

 

  • RoleRepository.java
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {

    Role findByName(String name);
}

 

 

4. Service 설정

  • UserService.java
@Service
public interface UserService {

    void signUp(UserDto userDto);

    Optional<User> findByEmail(String email);

    void deleteUser(String email);

}
  • 원래의 나라면 Service는 Class로 만들었을 테지만, 팀원 중 한명이 유지보수에 좋다고 해서 분리해보겠다.

 

  • UserServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    @Transactional
    public void signUp(UserDto userDto) {
        if (!userDto.getPassword().equals(userDto.getPasswordCheck())) {
            throw new RuntimeException("비밀번호가 다릅니다.");
        }
        if (userRepository.existsByEmail(userDto.getEmail())) {
            throw new RuntimeException("이메일이 존재합니다.");
        }
        if (userRepository.existsByUsernick(userDto.getUsernick())) {
            throw new RuntimeException("닉네임이 존재합니다.");
        }

        Role role = roleRepository.findByName(userDto.getUsername().equals("admin") ? "ADMIN" : "USER");

        User user = new User();
        user.setRoles(Collections.singleton(role));
        user.setEmail(userDto.getEmail());
        user.setUsernick(userDto.getUsernick());
        user.setUsername(userDto.getUsername());
        user.setPassword(passwordEncoder.encode(userDto.getPassword()));

        userRepository.save(user);
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    @Override
    public void deleteUser(String email) {
        Optional<User> emailOptional = userRepository.findByEmail(email);
        if (emailOptional.isPresent()) {
            userRepository.delete(emailOptional.get());
        } else {
            throw new RuntimeException("삭제할 사용자가 존재하지 않습니다.");
        }
    }
}
  • signUp 메서드를 보면 username이 admin이면 Role을 ADMIN으로 주고 그게 아니면 USER로 주는 것으로 진행했다.

 

 

5. 컨트롤러

  • Usercontroller.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

    @GetMapping("/login")
    public String showLoginForm() {
        return "/user/loginform";
    }

//    @PostMapping("/login")
//    public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
//        try {
//            UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
//            Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
//            SecurityContextHolder.getContext().setAuthentication(authentication);
//        } catch (Exception e) {
//            redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
//        }
//    }

    // 일반 로그인 회원의 정보 가져오기
    @GetMapping("/home")
    public String index(Model model, Authentication authentication) {

        String username = authentication.getName();
        Optional<User> userOptional = userServiceimpl.findByEmail(username);
        if (userOptional.isPresent()) {
            model.addAttribute("user", userOptional.get());
            return "user/home"; // user/home.html 템플릿으로 이동
        }
        return "redirect:/api/users/login";
    }
}
  • userDto로부터 회원가입에 필요한 정보를 받아서 회원가입을 한다.
  • 여기서 애를 먹었는데, @Postmapping("/login")에서 로그인이 들어오면 그 정보를 가지고 있었는데 저부분 때문에 자꾸 로그인이 안됐다.
  • @Postmapping("/login")  메소드에서 CustomUserDetailsService를 통해 사용자를 로드하고 Authentication을 설정하려고 하지만, 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있다.
  • Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
  • 로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하는 것이 좋다.

 

 

 

6. UserDetails

  • CustomUserDetails.java
public class CustomUserDetails implements UserDetails {

    private final String email;
    private final String password;
    private final Collection<? extends GrantedAuthority> authorities;

    public CustomUserDetails(String email, String password, Set<Role> roles) {
        this.email = email;
        this.password = password;
        this.authorities = roles.stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

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

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

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

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

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

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • spring Security의 UserDetails 인터페이스를 상속하여 정의한다.
  • 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
  • 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
  • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
  • 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.(Key값 : Security ContextHolder)
  • Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.

 

7. UserDetailsService

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

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("로그인이 들어왓나?");
        log.info("조회할 이메일: {}", username);
        Optional<User> userOptional = userRepository.findByEmail(username);
        if (userOptional.isEmpty()) {
            log.info("사용자가 없다");
            throw new UsernameNotFoundException("사용자가 존재하지 않습니다: " + username);
        }

        User foundUser = userOptional.get();
        Set<Role> roles = foundUser.getRoles();

        return new CustomUserDetails(
                foundUser.getEmail(),
                foundUser.getPassword(),
                roles
        );
    }
}
  • 사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
  • 만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고 사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
  • Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다.
  • CustomUserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
  • Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
  • 즉, loadUserByUsername 메서드는 로그인을 진행할 때 주어진 사용자 이름(email, 로그인 진행 시 아이디)을 기반으로 사용자의 세부 정보를 검색하고 반환하는 역할을 한다.
  • jwt 방식을 사용한다면 이 방식은 필요없다.

 

7.  SecurityConfig

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

    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login") // 로그인 페이지의 경로
                        .loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
//                        .usernameParameter("email") // 변경된 필드명
//                        .passwordParameter("password")
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

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

 

 

 

 

9. 회원가입 폼

  • singupform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>회원가입 페이지</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #f4f4f4;
        }

        h1 {
            color: #333;
        }

        form {
            background-color: #fff;
            padding: 20px;
            border-radius: 5px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }

        label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }

        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }

        button {
            width: 100%;
            padding: 10px;
            background-color: #5cb85c;
            border: none;
            border-radius: 5px;
            color: #fff;
            font-weight: bold;
            cursor: pointer;
        }

        button:hover {
            background-color: #4cae4c;
        }

        .error {
            color: red;
            font-size: 0.9em;
        }
    </style>
</head>
<body>
<form action="/api/users/signup" method="post">
    <h1>회원가입 페이지</h1>
    <hr/>

    <label for="email">Email</label>
    <input type="email" id="email" name="email" placeholder="Email" required/>

    <label for="username">Username</label>
    <input type="text" id="username" name="username" placeholder="Username" required/>

    <label for="usernick">User Nickname</label>
    <input type="text" id="usernick" name="usernick" placeholder="User Nickname" required/>

    <label for="password">Password</label>
    <input type="password" id="password" name="password" placeholder="Password" required/>

    <label for="passwordCheck">Confirm Password</label>
    <input type="password" id="passwordCheck" name="passwordCheck" placeholder="Confirm Password" required/>

    <button type="submit">회원가입</button>
</form>
</body>
</html>

 

 

 

10. 로그인 폼

  • singupform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
  • 로그인 폼의 action URL은  "/login"으로 설정되어야 한다.
  • SecurityConfig에서 설정한 loginProcessingUrl과 일치해야 한다.

 

여기까지 하면 시큐리티 세션을 이용한 로그인 로그아웃이 가능하다. 


겪은 문제에 대해서 정리...

 

트러블 슈팅 문제

 

문제 1.

기존에 나는 이메일로 로그인을 할것이라서 loginform.html 로그인 폼에서 name="email"과 name="password"가 필드 이름으로 설정하였다.

하지만 Spring Security는 기본적으로 username과 password라는 필드명을 기대하기 때문에 로그인 폼의 필드 이름을 수정하거나, SecurityConfig에서 필드명을 커스터마이즈해야 한다. 이거 때문에 애 좀 먹었다...

 

해결 1.

<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>

가장 간단한 해결책은 로그인 폼에서 기본 필드명을 사용하는 것이다.

Spring Security는 기본적으로 username과 password라는 필드명을 기대하기 때문에 저렇게 바꾼다.

 

 

해결 2.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login")
                        .loginProcessingUrl("/login")
                        .usernameParameter("email") // 변경된 필드명
                        .passwordParameter("password")
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

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

SecurityConfig에서 필드명 커스터마이즈를 하는 것이다. 필드명을 커스터마이즈해야 하는 경우, SecurityConfig에서 설정을 추가로 조정해야 하면 된다.  

.usernameParameter("email") // 변경된 필드명
.passwordParameter("password")
위 두개를 넣으면 해결 된다. 하지만 첫 번째 방법이 편하니깐....ㅎㅎ

 

 

문제 2. @PoastMapping("login") 의 충돌 

@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

    @GetMapping("/login")
    public String showLoginForm() {
        return "/user/loginform";
    }

//    @PostMapping("/login")
//    public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
//        try {
//            UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
//            Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
//            SecurityContextHolder.getContext().setAuthentication(authentication);
//        } catch (Exception e) {
//            redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
//        }
//    }

    @GetMapping("/home")
    public String index(Model model) {
        model.addAttribute("user", new UserDto());
        return "/home";
    }

 

해결1.

  • 위에서 spring Security의 UserDetails 인터페이스를 상속하여 정의한 CustomUserDetails 클래스가 있을 것이다.
  • 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
  • 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
  • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 된다!!!!!
  • 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.
    (Key값 : Security ContextHolder)
  • Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.

 

결론.

  • 로그인을 진행하고 @Postmapping("/login")  메소드를 사용해서 로그인을 진행할려고 했는데 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있었다.
  • Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
  • 로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하니깐 오류는 해결되었다.

 

 

 

 

다음으로는 Oauth2 로그인 방식에 대해 쓰겠다.

728x90
728x90

Spring Security 환경설정

시큐리티 적용시 기본적으로 로그인화면이 나온다. 기본 Username/Password =? user/시스템 로그에 출력됨

Spring Security 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'

 • 스프링 시큐리티를 사용하기 위한 스타터

• 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성

• 스프링 시큐리티를 테스트 하기 위한 의존성

 

spring:
  security:
    user:
      name: user
      password: 1111

 


SecurityConfig

Spring Security의 환경설정을 구성하기 위한 클래스다!

 

HttpSecurity로 대부분 구현한다고 생각하면 된다. 참고로 현재는 WebSecurityConfigurerAdapter는 사용을 안한다.

  • 먼저 config 패키지에 SecurityConfig라는 시큐리티 설정 파일을 만들어 주고 필요한 @bean들을 추가해 사용할 수 있다. --> 사진을 찾다보니 현재 WebSecurityConfigurerAdapter는 시큐리티3부터 사용을 안하지만 HttpSecurity에 대한 설명이 나와있어서 사용했다. 현재 WebSecurityConfigurerAdapter 를 상속하지 않는다!
  • 이제는 @Bean 으로 SpringSecurityFilterChain 을 구현한다.
  • config 클래스에@EnableWebSecurity 어노테이션을 달아서 시큐리티 설정을 해준다.
@Configuration
@EnableWebSecurity
public class SecurityConfig{

	// 패스워드 암호화 관련 메소드
  @Bean 
  public PasswordEncoder passwordEncoder(){
      return new BCryptPasswordEncoder();
  }

	// 특정 HTTP 요청에 대한 웹 기반 보안 구성
	// 시큐리티 대부분의 설정을 담당하는 메소드
	@Bean 
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
			http	
				.csrf(AbstractHttpConfigurer::disable)
				.httpBasic(AbstractHttpConfigurer::disable)
				.authorizeHttpRequests((authorize) -> authorize
						.requestMatchers("/signup", "/", "/login").permitAll()
						.anyRequest().authenticated()
				)
				// Form 로그인을 활용하는경우 (JWT에는 필요없음)
				.formLogin(form -> form
	            		.loginPage("/loginform") 
	            		.loginProcessingUrl("/login") 
              			.defaultSuccessUrl("/")
              			.permitAll()
        			)
				.logout((logout) -> logout
				.logoutUrl("/logout")
             			.logoutSuccessUrl("/")
				.invalidateHttpSession(true)
				)
				.sessionManagement(sessionManagement -> sessionManagement
             			.maximumSessions(1) 
             			.maxSessionsPreventsLogin(true)
             			.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        			)
			);
		return http.build();
	}

	// 이외에도 등록해서 사용하면 된다..
}

 

코드설명

  • filterChain() : 특정 Http 요청에 대해 웹 기반 보안 구성. 인증/인가 및 로그아웃을 설정한다.
    • .csrf(Cross site Request forgery) : 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여 웹 서버에 사용자가 의도하지 않은 요청을 전달하는 것. 즉, 정상적인 사용자가 의도치 않은 위조요청을 보내는 것을 의미한다.
      • REST API를 이용한 개발을 진행 할 예정일 때, Rest Api 환경에서는 Session 기반 인증과 다르기 때문에 서버에 인증 정보를 보관하지 않고, 권한 요청시 필요한 인증정보(OAuth2, Jwt토큰 등)요청을 포함하기 때문에 굳이 불필요한 csrf 보안을 활성화할 필요가 없다.
      • 따라서 csrf는 disable 처리
    • .HttpBasic()
      • HttpBasic() : Http basic Auth 기반으로 로그인 인증창이 뜬다.
    • .authorizeHttpRequests() : 인증, 인가가 필요한 URL 지정
      • anyRequest() : requestMatchers에서 지정된 URL 외의 요청에 대한 설정
      • authenticated() : 해당 URL에 진입하기 위해서는 인증이 필요함
      • requestMatchers("Url").permitAll() : requestMatchers에서 지정된 url은 인증, 인가 없이도 접근 허용
      • Url에 /**/ 와 같이 ** 사용 : ** 위치에 어떤 값이 들어와도 적용 (와일드 카드)
      • hasAuthority() : 해당 URL에 진입하기 위해서 Authorization(인가, 예를 들면 ADMIN만 진입 가능)이 필요함
        • .hasAuthority(UserRole.ADMIN.name()) 와 같이 사용 가능
    • formLogin() : Form Login 방식 적용
      • loginPage() : 로그인 페이지 URL
      • defaultSuccessURL() : 로그인 성공시 이동할 URL
      • failureURL() : 로그인 실패시 이동할 URL
    • logout() : 로그아웃에 대한 정보
      • invalidateHttpSession() : 로그아웃 이후 전체 세션 삭제 여부
    • sessionManagement() : 세션 생성 및 사용여부에 대한 정책 설정
      • SessionCreationPolicy() : 정책을 설정
      • SessionCreationPolicy.Stateless : 4가지 정책 중 하나로, 스프링 시큐리티가 생성하지 않고 존재해도 사용하지 않는다. (JWT와 같이 세션을 사용하지 않는 경우에 사용)

BCryptPasswordEncoder

BCrype 인코딩을 통하여 비밀번호에 대한 암호화를 수행한다.

password를 암호화해줌

  • Spring Security에서 비밀번호를 안전하게 저장할 수 있도록 비밀번호의 단방향 암호화를 지원한다.
    -> PasswordEncoder 인터페이스와 구현체들

  • encode() : 비밀번호를 암호화(단방향)
  • matches() : 암호화된 비밀번호와 암호화되지 않은 비밀번호가 일치하는지 비교
  • upgradeEncoding() : 인코딩된 암호화를 다시 한번 인코딩 할 때 사용 (true일 경우 다시 인코딩, default=false)

 

PasswordEncoder가 제공하는 구현 클래스

  • StandardPasswordEncoder : SHA-256을 이용해 암호를 해시한다. (강도가 약한 해싱 알고리즘이기 때문에 지금은 많이 사용되지 않는다.)
  • BCryptPasswordEncoder : bcrypt 강력 해싱 함수로 암호를 인코딩
  • NoOpPasswordEncoder : 암호를 인코딩하지 않고 일반 텍스트로 유지(테스트 용도로만 사용.)
  • SCryptPasswordEncoder : scrypt 해싱 함수로 암호를 인코딩한다.
@Bean // 패스워드 암호화 관련 메소드
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}
  • 현재 사용되는 알고리즘에서 취약성이 발견되어 다른 인코딩 알고리즘으로 변경하고자 할 때 대응하기 좋은 방법은 DelegatingPasswordEncoder을 사용하는 것
@Bean // DelegatingPasswordEncoder: 여러 인코딩 알고리즘을 사용할 수 있게 해주는 기능
	public static PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}

 

 


기타 참고용

 

Configure 작성 문법 바뀐 부분

스프링 3.0 이상의 버전부터는 스프링 시큐리티 버전도 바뀌어서 기존의 Configuration과는 다르게 작성해야 한다. WebSecurity, HttpSecurity 모두 큰 변화를 맞이 했는데, 그중 하나가 lambdas 형식의 작성법이다.

  • 람다식을 파라미터로 전달하여 아래와 같이 사용한다.
.formLogin(formLogin -> formLogin
						.loginPage("/login")
						.defaultSuccessUrl("/home"))

 

 

 

HttpSecurity

스프링시큐리티의 각종 설정은 HttpSecurity로 대부분 하게 된다!

Spring Boot 3.1(Spring 6.1) Security Config: 'csrf()' is deprecated and marked for removal

  • 스프링 부트 버전이 올라가면서 작성방식에 차이가 생김

Spring boot 3.0.6, Spring security 6, jwt적용 및 인증, 예외 처리

  • 버전이 올라가면서 동작방식이 달라짐

 

 

HttpSecurity - 리소스(URL) 접근 권한 설정

특정 리소스의 접근 허용 또는 특정 권한을 가진 사용자만 접근을 가능하게 할 수 있다.

http
		
       .authorizeHttpRequests(authorizeRequest -> authorizeRequest
		
        // 해당 경로는 모든 권한을 허용한다.
    	.requestMatchers(HttpMethod.GET, "/login**", "/web-resources/**", "/actuator/**").permitAll()

	// 해당 경로는 어드민 권한이 있어야한다.
    	.requestMatchers(HttpMethod.GET, "/admin/**").hasAnyRole("ADMIN")

	// 해당 경로는 유저 권한이 있어야 한다.
    	.requestMatchers(HttpMethod.GET, "/order/**").hasAnyRole("USER")

	// 나머지는 모두 권한이 필요하다.
    	.anyRequest().authenticated()
  • requestMatchers
    • 특정 리소스에 대해서 권한을 설정한다.
  • permitAll
    • 리소스의 접근을 인증절차 없이 허용한다.
  • authenticated
    • 리소스의 접근을 인증절차를 통해 허용한다.
  • hasAnyRole
    • 해당 권한을 가진 사용자만 접근을 허용한다.
  • anyRequest
    • 모든 리소스를 의미하며, anyMatcher로 설정하지 않은 리소스를 말한다. 

 

HttpSecurity - 로그인처리 설정

로그인 FORM 페이지를 이용하여 로그인하는 방식을 사용하려고 할때 여러가지 설정을 할 수 있다.

// Form 로그인을 활용하는경우 (JWT에는 필요없음)
// .formLogin(Customizer.withDefaults()); // Security가 제공하는 로그인 방식 사용
.formLogin(formLogin -> formLogin
        .loginPage("/login")
        .loginProcessingUrl("/loginProc")
        .usernameParameter("userId")
        .passwordParameter("userPw")
        .permitAll())

 


JwtAuthenticationFilter 사용

HttpSecurity - 커스텀 필드 등록 ⭐

커스텀 필터를 생성해서 등록할 수 있다!

.addFilterBefore(jwtAuthenticationFilter, 
	UsernamePasswordAuthenticationFilter.class)
    // UsernamePasswordAuthenticationFilter가 기존 시큐리티 세션 방식의 로그인 필터이기 때문에
    // UsernamePasswordAuthenticationFilter 앞에 커스텀한 필터 체인을 넣어준다.
  • addFilterBefore
    • 지정된 필터 앞에 커스텀 필터를 추가한다.
  • addFilterAfter
    • 지정된 필터 뒤에 커스텀 필터를 추가한다.
  • addFilterAt
    • 지정된 필터의 순서에 커스텀 필터가 추가된다.

 

 

JwtAuthenticationFilter

  • JwtAuthenticationFilter.java
    • jwt 방식으로 로그인을 진행할 것이기 때문에 커스텀한 필터이다.
@Order(0)
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = parseBearerToken(request);

        // 토큰값이 유요하다면 검증을 시작한다.
        if (token != null && tokenProvider.validToken(token)) {
            // 토큰 검증
            Authentication authentication = tokenProvider.getAuthentication(token);

            // SecurityContextHolder => 인증정보를 담는다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.info("Security Context에 {} 인증 정보를 저장했다", authentication.getPrincipal());
        } else {
            log.info("유효한 JWT 토큰이 없습니다, uri: {}", request.getRequestURI());
        }

        filterChain.doFilter(request, response);
    }

    /**
     * Authorization Bearer 제거(공백포함 7글자)
     * @param request 요청 request
     * @return token (없는경우 null)
     */
    private String parseBearerToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION))
                .filter(token -> token.length() >= 7 && token.substring(0, 7).equalsIgnoreCase("Bearer "))
                .map(token -> token.substring(7))
                .orElse(null);
    }
}

 


JwtAuthenticationFilter에 대해서는 이전 블로그에 자세하게 적어놓았다.
Spring Security + JWT (RefreshToken, AccessToken)를 사용한 로그인, 로그아웃 구현 - 4편 :: 미정 (tistory.com)

 

Spring Security + JWT (RefreshToken, AccessToken)를 사용한 로그인, 로그아웃 구현 - 4편

개요* 1~3편 정리1편에서 기본적인 세팅은 끝이 났다.2편에서는 SecurityConfig에 대해서 보안 설정을 했다.3편에서는 RefreshToken과 BlackListToken에 대해서 엔티티와 레포지토리, 서비스를 작성했다. 4편

eesko.tistory.com

 

jwt에 대해서 알고싶다면

[멋쟁이사자처럼 백엔드 TIL] Spring Security : JWT(JSON Web Token) :: 미정 (tistory.com)

728x90
728x90

개요

스프링 시큐리티를 사용해 로그인/로그아웃을 진행하던 중 기존 방식인 세션 기반 방식의 단점이 있기 때문에 토큰 기반 방식인 로그인 방식을 구현하기로 결정했다. 노션에 정리한 내용을 바탕으로 블로그에 작성한다.!

 

 

0. 시큐리티를 사용한 로그인 방식 종류

  • 시큐리티를 사용한 로그인 방식에는 일반적으로 (1)세션 기반 방식 (2)토큰 기반 방식(JWT) 으로 나뉜다.
  • 두 방식은 어느 것이 더 뛰어나다고 하긴 애매하고, 각 방식의 장단점과 요구사항을 분석하여 상황에 맞게 결정하는 것이 좋다.

1. Spring Security + JWT 동작 원리

Spring Security + JWT 동작 원리

  1. 클라이언트에서 서버로 ID/PW로 로그인을 요청한다.
  2. 서버에서 검증 과정을 거쳐 해당 유저가 존재하면, Access Token + Refresh Token 을 발급한다.
  3. 클라이언트는 요청 헤더에 2번에서 발급받은 Access Token 을 포함하여 API를 요청한다.
  • 여기에서 Access Token과 Refresh Token은 웹 ・ 앱 어플리케이션에서 인증 및 권한 부여를 관리하기 위해 사용되는 토큰이다.

Access Token + Refresh Token 재발급 원리

🖤 Access Token

➡︎ 인증된 사용자가 특정 리소스에 접근할 때 사용되는 토큰

  • 클라이언트는 Access Token을 사용하여 인증된 사용자의 신원을 확인하고, 서비스 또는 리소스에 접근
  • 유효 기간이 지나면 만료 (expired)
  • 만료된 경우, 새로운 Access Token을 얻기 위해 Refresh Token 사용

🖤 Refresh Token

➡︎ Access Token의 갱신을 위해 사용되는 토큰

  • 일반적으로 Access Token과 함께 발급
  • Access Token이 만료되면 Refresh Token을 사용하여 새로운 Access Token 발급
  • 사용자가 지속적으로 인증 상태를 유지할 수 있도록 도와줌 (매번 로그인 다시 하지 않아도 됨)
  • 보안 상의 이유로 Access Token보다 긴 유효 기간 가짐

2. JWT란?

위에서 말한 JWT가 뭔지 보자.

  • JSON Web Token의 줄임말로 JSON 객체로 정보를 주고 받을 때 안전하게 전송하기 위한 방식
  • JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web 토큰이다.
  • JWT는 필요한 정보를 자체적으로 지니는 Self-Contained 방식으로 정보를 안정성 있게 전달한다.
  • 로그인 기능 구현 등에 사용한다.

3. JWT 구조

  • JWT는 Header, Payload, Signature로 이루어져 있으며 각각 점으로 구분한다.
    • ex) xxxxxxx.yyyyyyy.zzzzzzzzz

  • JSON으로 포맷된 각 부분은 Base64로 인코딩되어 표현되며, 각각의 구성 요소는 . 로 구분한다.
  • Base64는 암호화 된 문자열을 반환하는 게 X

3.1. Header

  • 토큰의 헤더는 alg와 typ로 구성된다.
  • Header은 일반적으로 토큰 유형(JWT)과 사용중인 서명 알고리즘(HMAC, SHA256 등)이 포함됨
{
    "typ": "JWT",
    "alg": "HS256"
}
  • alg: 해싱 알고리즘. 서명(Signature) 및 토큰 검증에 사용 (헤더를 암호화 하는 것이 아니고, 서명을 해싱하기 위한 알고리즘 지정)
  • typ: 토큰의 타입

3.2. Payload

  • 토큰의 페이로드에는 토큰에서 사용할 정보의 조각들인 클레임(Claim)이 담겨있다.
  • 클레임은 등록된 클레임(Registered Claim)공개 클레임(Public Claim)비공개 클레임(Private Claim) 으로 나누어지며, key-value 형태로 존재한다.
  • 등록된 클레임(Registered Claim) => 권장되긴 하지만 필수는 아님
    • 등록된 클레임은 토큰 정보를 표현하기 위해 이미 정해진 종류의 데이터들로, 모두 선택적으로 작성이 가능하며 사용할 것을 권장한다.
      • iss: 토큰 발급자(issuer)
      • sub: 토큰 제목(subject), unique한 값을 사용한다. 주로 사용자 이메일 사용
      • aud: 토큰 대상자(audience)
      • exp: 토큰 만료 시간(expiration), NumericDate 형식으로 되어 있어야 함 ex) 1480849147370
      • nbf: 토큰 활성 날짜(not before)
      • iat: 토큰 발급 시간(issued at), 토큰 발급 이후의 경과 시간
      • jti: JWT 토큰 식별자(JWT ID), 중복 방지를 위해 사용하며, 일회용 토큰(Access Token) 등에 사용
  • 공개 클레임(Public Claim)
    • 공개 클레임은 사용자 정의 클레임으로, 공개용 정보를 위해 사용된다. 충돌 방지를 위해 URI 포맷을 이용한다. => 원하는 정보들을 넣으면 됨
  • 비공개 클레임(Private Claim : 개인 클레임**)**
    • 비공개 클레임은 사용자 정의 클레임으로, 서버와 클라이언트 사이에 임의로 지정한 정보를 저장한다.
{
    // 등록된 클레임
    "iss": "chb2005.tistory.com",
    "sub": "123456789",
    "exp": "1659002265",
    // 개인 클레임
    "userName": "changbum",
    "isAdmin": false
}

3.3. Signature(서명)

  • 서명(Signature)은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다.
  • 서명은 헤더와 페이로드, 그리고 비밀 키를 기반으로 합쳐 생성된 암호화한 결과값이며, 해당 토큰이 변조되지 않았음을 확인하기 위한 메커니즘이다.
  • [ 서명 생성 과정 ]
    1. 헤더(Header)와 페이로드(Payload)의 값을 각각 BASE64로 인코딩
    2. 인코딩한 값을 비밀 키를 이용해 헤더(Header)에서 정의한 알고리즘으로 해싱
    3. 해싱한 값을 다시 BASE64로 인코딩하여 생성

4. JWT의 장점과 단점

👍🏻 장점

  • auth0을 이용하면 아이디를 카카오, 네이버처럼 다른 사이트에서 이용할 수 있음
  • 서버는 비밀키만 알고 있으면 되기 때문에 세션 방식과 같이 별도의 인증 저장소가 필요하지 않음 => 서버측 부하 감소
  • 서버측 부하를 낮출 수 있고 독립적이기 때문에 능률적으로 접근 권한 관리를 할 수 있고 분산/클라우드 기반 인프라 스트럭처에 잘 대응할 수 있음
  • 여러개의 서버를 사용하는 대형 서비스 같은 경우에 접근 권한 관리가 매우 효율적임 => 확장성이 좋음
  • 별도의 인증 저장소가 필요하지 않아서 인증서버와 db에 의존하지 않아도 됨
  • Refresh Token까지 활용한다면 더 높은 보안성을 가질 수 있음

👎🏻 단점

  • 서버로부터 받은 토큰이 쿠키 또는 로컬스토리지, 세션스토리지에 저장이 되므로 탈취당할 위험이 있으므로 token에 중요 정보를 넣지 않아야 함
  • Payload의 정보(Claim)가 많아질 수록 토큰이 커짐
  • 로그아웃 시 JWT 방식은 세션이 없는 stateless 방식이기 때문에 토큰 관리가 어려움
  • 토큰에 넣는 데이터가 많아질수록 토큰이 길어지는데 API를 호출할 때마다 토큰 데이터를 서버에 전달해야하므로 그만큼 네트워크 대역폭 낭비가 심할 수 있음
  • 한번 발급된 token은 수정, 폐기가 불가 ➡︎ Access token, Refresh token 사용
728x90
728x90

 

 

개요

* 1~3편 정리

1편에서 기본적인 세팅은 끝이 났다.

2편에서는 SecurityConfig에 대해서 보안 설정을 했다.

3편에서는 RefreshToken과 BlackListToken에 대해서 엔티티와 레포지토리, 서비스를 작성했다.

 

4편에서는 본격적으로 JWT 코드를 짜볼 것이다.

코드도 많이 길고 복잡하다.. 

 

 

<- 대략적인 파일 구조

<- 파일 구조에 맞게 세팅한다.

 

 

 

 

 

 

 

 

 

 

 

 

 


1. 사용자 인증 및 권한 관리를 위해 설정할 것

1.1 CustomUserDetails.java

public class CustomUserDetails implements UserDetails {
    private final String userName; // 이름
    private final String password;  // 비밀번호
    private final List<GrantedAuthority> authorities; // 권한 목록

	// 생성자
    public CustomUserDetails(String userName, String password, List<String> roles){
        this.userName = userName;
        this.password = password;
        this.authorities = roles.stream() // 권한 관련 작업을 하기 위한 role return
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }
`
`	// 사용자가 가진 권한 반환
	// SimpleGrantedAuthority 객체를 사용하여 역할을 권한으로 변환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
	// 사용자의 비밀번호를 반환
    @Override
    public String getPassword() {
        return password;
    }
	// 사용자의 이름을 반환
    @Override
    public String getUsername() {
        return userName;
    }
	// 사용자의 계정 만료 상태
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
	// 잠금 상태
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
	// 자격 증명 만료 상태
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
	// 활성화 상태
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • spring Security의 UserDetails 인터페이스를 상속하여 정의한다.
  • 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
  • SimpleGrantedAuthority 는 역할 이름을 그대로 사용한다는 의미로 데이터베이스에 ROLE_ADMIN, ROLE_USER 이렇게 되어 있다면 이걸 그대로 사용하겠다는 의미이다.
  • List<String> 형태의 역할을 생성자로 받고, 이를 GrantedAuthority로 변환하며 신원을 증명하는 데 사용하는 정보 --> 비밀번호나 토큰
  • 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
    • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
  • 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.
    (Key값 : Security ContextHolder)
  • Security ContextHolder 의 내부는 Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails가 UserDetails를 설정해준다고 보면 된다.

 

1.2 CustomUserDetailsService.java

  • 일단 이 클래스는 Spring Security의 UserDetailsService 인터페이스를 상속받아 구현한다.
  • 나는 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService 라는 클래스를 구현했다.
  • 이전까지는 UserService에서 findUserByLoginId 이런 식으로 User을 불러왔었다.
  • 하지만, 위에서 말했듯 기존 세션 방식에서의 Security는 UserDetails 가 필요하기 때문에 따로 설정이 필요하다.
    => 위에서 만든 CustomUserDetails 를 return하거나 org.springframework.security.core.userdetails.User를 불러와 리턴하는 방법이 있다.
  • 둘의 방식과 쓰임은 똑같다.

   

Q. CustomUserDetailsService.java 는 어떻게 사용될까?

  • 사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
  • 만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고
  • 사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
  • Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다..
    • UserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
  • Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
  • UserDetailsService 를 작성하는 방법은 다양하게 작성이 가능하고 나는 그 중 아래 2가지로 구현을 해봤다.

 

1.2.1. UserBuilder를 사용하여 반환

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

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

        Optional<User> user = userRepository.findByUserName(username); // 데이터베이스에서 사용자 이름을 기준으로 사용자를 조회
        if (!user.isPresent()) {
            throw new UsernameNotFoundException("해당 사용자가 존재하지 않습니다. : " + username);
        }

        // UserBuilder를 사용하여 UserDetails 객체를 생성 --> 사용자 정보를 쉽게 생성하고 구성
        // withUsername(username)를 호출하여 사용자 이름을 설정
        UserBuilder userBuilder = org.springframework.security.core.userdetails.User.withUsername(username);
        userBuilder.password(user.get().getPassword()); // 암호화된 비밀번호를 설정
        userBuilder.roles(user.get().getRole().stream()
                .map(role -> role.getRoleName().name())
                .toArray(String[]::new));
        return userBuilder.build();
    }
}
  • 첫 번째 방법은 UserBuilder를 사용하여 UserDetails 객체를 생성하는 방법이다.
  • Spring Security에서 제공하는 UserBuilder는 UserDetails 객체를 유연하게 생성하고 설정할 수 있도록 도와준다.
  • UserBuilder는 org.springframework.security.core.userdetails.User 클래스 내부에 정적 메서드로 정의되어 있다.
  • org.springframework.security.core.userdetails.User 클래스의 withUsername 정적 메서드를 사용하여
    UserBuilder 인스턴스를 생성한다.
  • 이때 사용자 이름, 암호화된 비밀번호, 역할을 설정하여 UserBuilder를 반환한다. 
  • 이를 통해 UserDetails 객체를 커스텀 하지 않고도 쉽게 생성할 수 있다. -> 즉, 위에서 만든 CustomUserDetails 클래스는 사용 하지 않고도 시큐리티가 제공하는 기능을 사용하여 사용자 정보를 데이터베이스에서 꺼낼 수 있다.
  • 이 방식은 간결하고 가독성이 높아, UserDetails 객체를 쉽게 생성할 수 있다.

 

 

 

1.2.2 CustomUserDetails 클래스에 직접 값 반환

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

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

        Optional<User> userOptional = userRepository.findByUserName(username); // 데이터베이스에서 사용자 이름을 기준으로 사용자를 조회
        if (!userOptional.isPresent()) {
            throw new UsernameNotFoundException("해당 사용자가 존재하지 않습니다. : " + username);
        }

        User foundUser = userOptional.get();
        Set<Role> roles = foundUser.getRoles();

        List<String> roleNames = roles.stream()
            .map(role -> role.getRoleName().name()) // "ROLE_" 접두사를 제거하지 않음
            .collect(Collectors.toList());

        return new CustomUserDetails(
                foundUser.getUsername(),
                foundUser.getPassword(),
                roleNames
        );
    }
}
  • 두 번째 방법이다. 
  • 위에서 만든 CustomUserDetails에 사용자 이름, 암호화된 비밀번호, 역할을 담아 반환해주는 것이다. 
  • 어찌됐던 두 개의 사용법은 다 똑같고 반환 부분도 거의 비슷하다. 
  • 하지만 첫 번째 방법과 달리 위에서 만든 CustomUserDetails 클래스를 사용하면 사용자 정보를 추가하거나 커스텀할 수 있다.
  • 특정 요구 사항에 맞게 클래스와 메서드를 자유롭게 정의할 수 있다.

 

Q. 위의 코드들을 보면 org.springframework.security.core.userdetails.User 클래스를 호출하거나, CustomUserDetails 클래스를 불러오던데 무슨 차이인지?

 

A.

  • org.springframework.security.core.userdetails.User 클래스는 UserDetails 인터페이스를 시큐리티 자체적으로 구현하고 있다.
  • 즉, org.springframework.security.core.userdetails.User 클래스는 UserDetails 인터페이스를 구현한 기본 제공 구현체로, 사용자 이름, 비밀번호, 권한 정보를 포함하고 있다.
  • CustomUserDetails 클래스를 안불러와도 사용이 가능하다는 뜻이다. 
  • 위에서도 설명했듯이 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.
    --> (Key값 : Security ContextHolder)
  • 이때 Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 CustomUserDetails 클래스에서 UserDetails를 설정해준다고 보면 되고 org.springframework.security.core.userdetails.User 클래스는 자체적으로 UserDetails 값을 가지고 있다고 보면 된다.

 

Q. 어떤 방법이 적절한가? --> 실무에서는?

  • 단순한 요구 사항: 간단한 사용자 인증을 처리해야 하고, 특별한 커스터마이징이 필요 없다면 UserBuilder를 사용
  • 확장성 필요: 사용자 정보에 추가적인 필드를 포함하거나, 커스터마이징된 사용자 정보를 사용해야 한다면 CustomUserDetails를 사용 --> 선호!
  • 실무에서는 UserDetails를 상속하여 새로운 커스텀 UserDetails 를 만드는 것을 선호한다.

 

 


2. JWT 설정

이제 본격적으로 JWT 리프레시 토큰, 엑세스 토큰 발급을 위한 코드를 작성하겠다.


2.1 JwtAuthenticationToken.java

public class JwtAuthenticationToken extends AbstractAuthenticationToken {
    private String token; // JWT 토큰
    private Object principal; // 사용자 정보
    private Object credentials; // 자격 증명 정보

	// 인증이 된 사용자 정보와 권한을 설정
    public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities , Object principal, Object credentials) {
        super(authorities); // 권한 설정
        this.principal = principal; // 사용자를 식별하는 고유한 엔티티 --> 사용자의 사용자 이름,  ID
        this.credentials = credentials; // 사용자가 자신의 신원을 증명하는 데 사용하는 정보 -> 비밀번호, 토큰
        this.setAuthenticated(true); // 사용자가 가질 수 있는 권한이나 역할 --> ROLE_USER, ROLE_ADMIN
    }

	// 인증되지 않은 상태로 JWT 토큰을 설정
    public JwtAuthenticationToken(String token){
        super(null); // 권한 없음
        this.token = token;
        this.setAuthenticated(false); // 인증되지 않은 상태 설정
    }

	// 자격 증명 정보를 반환
    @Override
    public Object getCredentials() {
        return this.credentials;
    }

	// 사용자 정보를 반환
    @Override
    public Object getPrincipal() {
        return this.principal;
    }
}
  • JWT 기반으로 한 인증을 나타내는 토큰을 구현하는 클래스이다.
  • AbstractAuthenticationToken을 상속받아 인증 정보를 반환하는 메서드를 제공한다.
  • 인증되지 않은 상태와 인증된 상태를 구분하여 생성자 제공한다.
  1. Principal
    사용자를 식별하는 고유한 엔티티 --> 사용자의 사용자 이름 또는 ID
  2. Credentials
    사용자가 자신의 신원을 증명하는 데 사용하는 정보 --> 비밀번호나 토큰
  3. Authorities
    사용자가 시스템에서 가질 수 있는 권한이나 역할 --> ROLE_USER나 ROLE_ADMIN
  • 사용자가 시스템에 로그인을 시도하면, Credentials를 통해 자신의 신원을 증명하고, 시스템은 Principal을 통해 사용자를 식별하며, 이후 Authorities를 통해 사용자가 어떤 작업을 수행할 수 있는지를 결정한다.

 

 

2.2 JwtTokenizer.java

@Component
@Slf4j
@Getter
public class JwtTokenizer {
    private final byte[] accessSecret; // Access Token 서명에 사용할 비밀키
    private final byte[] refreshSecret; // Refresh Token 서명에 사용할 비밀키

    public static Long ACCESS_TOKEN_EXPIRE_COUNT = 30 * 60 * 1000L; // Access Token의 만료 시간 (30분)
    public static Long REFRESH_TOKEN_EXPIRE_COUNT = 7 * 24 * 60 * 60 * 1000L; // Refresh Token의 만료 시간 (7일)

    public JwtTokenizer(@Value("${jwt.secretKey}") String accessSecret, 
    			@Value("${jwt.refreshKey}") String refreshSecret){
        this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8);
        this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8);
    }

    // JWT 토큰을 생성하는 메서드
    private String createToken(Long id, String email, String username, List<RoleName> roles, long expireCount, byte[] secret) {
        Claims claims = Jwts.claims().setSubject(email); // JWT 클레임 설정
        claims.put("userId", id); // 사용자 ID 클레임 추가
        claims.put("username", username); // 사용자 이름 클레임 추가
        claims.put("roles", roles); // 사용자 권한 클레임 추가

        Date now = new Date();
        Date expiration = new Date(now.getTime() + expireCount); // 만료 시간 계산

        return Jwts.builder() // JWT 빌더
                .setClaims(claims) // 클레임 설정
                .setIssuedAt(now) // 발급 시간 설정
                .setExpiration(expiration) // 만료 시간 설정
                .signWith(SignatureAlgorithm.HS256, getSigningKey(secret)) // 서명 설정
                .compact(); // JWT 생성
    }

    // Access Token을 생성하는 메서드
    public String createAccessToken(Long id, String email, String username, List<RoleName> roles) {
        return createToken(id, email, username, roles, ACCESS_TOKEN_EXPIRE_COUNT, accessSecret);
    }

    // Refresh Token을 생성하는 메서드
    public String createRefreshToken(Long id, String email, String username, List<RoleName> roles) {
        return createToken(id, email, username, roles, REFRESH_TOKEN_EXPIRE_COUNT, refreshSecret);
    }

	// secretKey - byte형식
    public static Key getSigningKey(byte[] secretKey) {
        return Keys.hmacShaKeyFor(secretKey); // 서명에 사용할 키 생성
    }

	// Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
	// 토큰에서 유저 아이디 얻기
    public Long getUserIdFromToken(String token) {
        Claims claims = parseToken(token, accessSecret); // 토큰 파싱
        return Long.valueOf((Integer) claims.get("userId")); // 사용자 ID 반환
    }

	// 토큰 복호화
    public Claims parseToken(String token, byte[] secretKey) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey(secretKey)) // 서명 키 설정
                .build()
                .parseClaimsJws(token) // 토큰 파싱
                .getBody(); // 클레임 반환
    }

	// accessToken 토큰 복호화
    public Claims parseAccessToken(String accessToken) {
        return parseToken(accessToken, accessSecret);
    }

	// refreshToken 토큰 복호화
    public Claims parseRefreshToken(String refreshToken) {
        return parseToken(refreshToken, refreshSecret);
    }

    public boolean isTokenExpired(String token, byte[] secretKey) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(getSigningKey(secretKey)) // 서명 키 설정
                    .build()
                    .parseClaimsJws(token);
            return false; // 토큰이 유효할 때 false를 반환해야 함
        } catch (ExpiredJwtException e) {
            log.error("Token expired", e);
        } catch (Exception e) {
            log.error("Token invalid", e);
        }
        return true; // 토큰이 만료되었거나 유효하지 않을 때 true를 반환
    }

    public boolean isAccessTokenExpired(String accessToken) {
        return isTokenExpired(accessToken, accessSecret);
    }

    public boolean isRefreshTokenExpired(String refreshToken) {
        return isTokenExpired(refreshToken, refreshSecret);
    }

	// 주어진 Refresh Token을 사용하여 새로운 Access Token 생성
    public String refreshAccessToken(String refreshToken) {
        Claims claims = parseRefreshToken(refreshToken); // Refresh Token 파싱
        Long userId = claims.get("userId", Long.class); // 사용자 ID 추출
        String email = claims.getSubject(); // 이메일 추출
        String username = claims.get("username", String.class); // 사용자 이름 추출
        List<RoleName> roles = (List<RoleName>) claims.get("roles"); // 사용자 권한 추출
        return createAccessToken(userId, email, username, roles); // 새로운 Access Token 생성
    }
}
  • JWT 토큰을 생성하고 파싱하는 역할을 하는 클래스이다.
  • 이 클래스에 JWT를 활용하는데에 필요한 메서드들을 한 데에 모아놓았다.
  • Access Token 및 Refresh Token의 생성 메서드를 제공하며 토큰 만료 여부를 확인하는 메서드도 제공한다. 
  • Refresh Token을 사용하여 Access Token이 만료된 경우 새로운 Access Token을 생성하는 메서드를 제공한다.
    • (Access Token : 30분 // Refresh Token : 7일)로 토큰의 만료시간을 정했다.

 

주요 메서드

  • createToken(Long id, String email, String username, List<RoleName> roles, long expireCount, byte[] secret)
    • JWT 토큰을 생성한다.
    • Access Token과 Refresh Token 를 생성하는 기본 메서드이다.
  • createAccessToken(Long id, String email, String username, List<RoleName> roles)
    • 액세스 토큰을 생성한다.
    • createToken 메서드를 호출하여 Access Token의 만료 시간을 ACCESS_TOKEN_EXPIRE_COUNT로 설정하고, 비밀 키를 accessSecret으로 설정하여 Access Token을 생성한다.
  • createRefreshToken(Long id, String email, String username, List<RoleName> roles)
    • 주어진 사용자 정보를 바탕으로 새로운 Refresh Token을 생성한다.
    • createToken 메서드를 호출하여 Refresh Token을 생성한다.
    • Refresh Token은 일반적으로 더 긴 만료 기간을 가지기 때문에 사용자가 다시 로그인하지 않고도 새로운 Access Token을 발급받을 수 있도록 한다.
  • parseToken(String token, byte[] secretKey)
    • 토큰을 파싱하여 클레임 정보를 얻는다.
    • 추출된 클레임 정보를 바탕으로 사용자의 ID, 이메일, 역할 등의 정보를 확인할 수 있다.
    • 파싱 과정에서 토큰의 서명도 검증하여 토큰이 변조되지 않았음을 확인다.
  • isTokenExpired(String token, byte[] secretKey)
    • 토큰이 만료되었는지 확인한다.
    • 토큰을 파싱하여 유효성을 검증하고, 만료 여부를 확인한다.
    • 만약 토큰이 유효하면 false를 반환하고, 만료되었거나 유효하지 않으면 true를 반환한다.
    • 만료된 토큰이나 서명이 유효하지 않은 토큰에 대해서는 예외를 처리다.
  • refreshAccessToken(String refreshToken)
    • 주어진 Refresh Token을 사용하여 새로운 Access Token을 생성한다.
    • Refresh Token을 파싱하여 클레임 정보를 추출하고, 해당 정보를 바탕으로 새로운 Access Token을 생성한다.
    • 이를 통해 사용자는 Refresh Token을 이용하여 새로운 Access Token을 발급받아 계속해서 인증된 상태를 유지할 수 있다.

 

 

2.3 JwtAuthenticationFilter.java

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer; // JWT 토큰 생성 및 검증을 위한 JwtTokenizer
    private final JwtBlacklistService jwtBlacklistService;
    private final RefreshTokenService refreshTokenService;

    private static final List<String> PERMIT_ALL_PATHS = List.of(
            "/", "/css/.*", "/api/login", "/api/.*",
            "/userregform", "/css/.*", "/files/.*", "/loginform"
         //   "/oauth2/**", "/login/oauth2/code/github","/registerSocialUser","/saveSocialUser"
            // 추가적으로 permitAll 경로들을 여기에 추가
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestPath = request.getRequestURI(); // 요청 경로 추출

        // 인증이 필요 없는 경로인지 확인
        if (isPermitAllPath(requestPath)) {
            filterChain.doFilter(request, response); // 다음 필터로 넘어감
            return;
        }

        // 쿠키에서 Access Token을 얻어냄
        String token = getToken(request);

        if (!StringUtils.hasText(token)) {
            // Access Token이 없는 경우 처리
            handleMissingToken(request, response);
        } else {
            // Access Token이 있는 경우 처리
            handleTokenValidation(request, response, token);
        }

        filterChain.doFilter(request, response); // 다음 필터로 넘어감

        log.info("토큰 제대로?? : {}", token);
    }

    // 요청 경로가 인증 없이 접근 가능한지 확인하는 메서드
    private boolean isPermitAllPath(String requestPath) {
        return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
    }

    // Access Token이 없는 경우 처리하는 메서드
    private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 쿠키에서 Refresh Token을 얻어옴
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken)) {
            try {
                // Refresh Token이 DB에 존재하는지 확인
                if (refreshTokenService.isRefreshTokenValid(refreshToken)) {
                    // Refresh Token이 유효한지 확인
                    if (!jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
                        // Refresh Token이 유효한 경우 새로운 Access Token 발급
                        String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);
                        // 새로운 Access Token을 쿠키에 설정
                        setAccessTokenCookie(response, newAccessToken);
                        // 새로운 Access Token으로 인증 정보 설정
                        getAuthentication(newAccessToken);
                    } else {
                        // Refresh Token이 만료된 경우
                        handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
                    }
                } else {
                    // Refresh Token이 DB에 없는 경우
                    handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
                }
            } catch (ExpiredJwtException e) {
                // Refresh Token이 만료된 경우
                handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
            }
        } else {
            // Refresh Token도 없는 경우
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
        }
    }

    // Access Token이 있는 경우 처리하는 메서드
    private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
        try {
            // 토큰이 블랙리스트에 있는지 확인
            if (jwtBlacklistService.isTokenBlacklisted(token)) {
                // 토큰이 블랙리스트에 있으면 인증 실패 처리
                handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
            } else {
                // 블랙리스트에 없으면 인증 정보 설정 시도
                getAuthentication(token);
            }
        } catch (ExpiredJwtException e) {
            // Access Token이 만료된 경우 Refresh Token 확인
            handleExpiredAccessToken(request, response, token, e);
        } catch (UnsupportedJwtException e) {
            // 지원하지 않는 토큰인 경우
            handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
        } catch (MalformedJwtException e) {
            // 잘못된 형식의 토큰인 경우
            handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
        } catch (IllegalArgumentException e) {
            // 토큰이 없는 경우
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
        } catch (Exception e) {
            // 그 외 다른 예외 발생 시
            handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
        }
    }

    // Access Token이 만료된 경우 처리하는 메서드
    private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
        log.warn("Access token expired: {}", token);
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken) && !jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
            // Refresh Token이 유효한 경우 새로운 Access Token 발급
            String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);
            // 새로운 Access Token을 쿠키에 설정
            setAccessTokenCookie(response, newAccessToken);
            // 새로운 Access Token으로 인증 정보 설정
            getAuthentication(newAccessToken);
        } else {
            // Refresh Token이 없거나 만료된 경우 처리 필요
            handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
        }
    }

    // 쿠키에서 Access Token을 추출하는 메서드
    private String getToken(HttpServletRequest request) {
        // 추가
        String authorization = request.getHeader("Authorization");
        if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
            return authorization.substring(7);
        }

        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    // 쿠키에서 Refresh Token을 추출하는 메서드
    private String getRefreshToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("refreshToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    // 새로운 Access Token을 쿠키에 설정하는 메서드
    private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
        Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
        accessTokenCookie.setHttpOnly(true); // XSS 보호를 위해 HttpOnly 설정
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
        response.addCookie(accessTokenCookie);
    }

    // Access Token을 사용하여 인증 정보 설정하는 메서드
    private void getAuthentication(String token) {
        Claims claims = jwtTokenizer.parseAccessToken(token);
        String email = claims.getSubject();
        Long userId = claims.get("userId", Long.class);
        String username = claims.get("username", String.class);
        List<GrantedAuthority> authorities = getGrantedAuthorities(claims);


        CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));

        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null);
        SecurityContextHolder.getContext().setAuthentication(authentication);

    }

    // JWT Claims에서 권한 정보를 추출하는 메서드
    private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
        List<String> roles = (List<String>) claims.get("roles");
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (String role : roles) {
            authorities.add(() -> role);
        }
        return authorities;
    }

    // 예외 처리 메서드
    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
        handleException(request, exceptionCode, logMessage, null);
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
        request.setAttribute("exception", exceptionCode.getCode());
        log.error(logMessage, e);
        throw new BadCredentialsException(logMessage, e);
    }
}
  • 토큰 발급 및 검증에 있어서 아주 중요한 핵심이다.
  • 클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로,  UsernamePasswordAuthenticationFilter 이전에 실행 될 것이다.
  • 클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고, 유효한 토큰인 경우 해당 토큰의 인증 정보(Authentication)를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다.
  • OncePerRequestFilter를 상속받아 요청 당 한 번 실행되는 필터 클래스이다.
  • 요청 경로에 따라 인증이 필요 없는 경로를 처리하거나, Access Token을 검증하고 Access Token이 유효하지 않거나 존재하지 않는 경우, Refresh Token을 이용하여 새로운 Access Token을 발급한다.
  • 만약 Access Token이 유효하면, 사용자 정보를 추출하여 인증 정보 설정한다.
  • 이 필터는 결과적으로 JWT를 통해 username + password 인증을 수행하는 핵심이다.

 

주요 메서드

  • doFilterInternal()
    • 필터링 로직을 구현하는 주요 클래스이며 요청이 인증이 필요한지 확인하고, 필요시 토큰을 검증하여 인증 정보를 설정한다.
    • 요청 경로가 인증 없이 접근 가능한지 확인하고, Access Token을 추출하여 유효성을 검증한다.
    • 필요한 경우 새로운 Access Token을 발급하고, 검증된 토큰을 통해 인증 정보를 설정다.
  • isPermitAllPath()
    • 인증이 필요 없는 경로인지 확인한다.
    • 요청 경로가 인증 없이 접근 가능한 경로 목록에 포함되어 있는지 확인하여, 포함되어 있으면 true, 아니면 false를 반환
  • handleMissingToken()
    • Access Token이 없는 경우 Refresh Token을 사용하여 새로운 Access Token을 발급한다.
    • Refresh Token이 유효하지 않으면 적절한 예외를 처리한다.
  • handleTokenValidation()
    • Access Token이 있는 경우 처리
    • 토큰이 블랙리스트에 있는지 확인하고, 블랙리스트에 없으면 토큰을 검증하여 인증 정보를 설정한다.
    • 만료된 토큰이나 지원되지 않는 토큰, 잘못된 형식의 토큰에 대해서는 적절한 예외를 처리한다.
  • handleExpiredAccessToken()
    • Access Token이 만료된 경우 처리
    • 만료된 Access Token을 확인하고, Refresh Token이 유효한 경우 새로운 Access Token을 발급
    • Refresh Token이 없거나 만료된 경우 적절한 예외를 처리한다.
  • getToken()
    • 쿠키에서 Access Token을 추출
    • 요청 헤더에서 Authorization 헤더를 확인하고, 쿠키에서 Access Token을 추출하여 반환
  • getRefreshToken()
    • 쿠키에서 Refresh Token 추출
  • setAccessTokenCookie()
    • 새로운 Access Token을 쿠키에 설정하여 클라이언트에게 반환
  • getAuthentication()
    • Access Token을 사용하여 인증 정보를 설정한다.
    • 토큰을 파싱하여 사용자 정보와 권한 정보를 추출하고, 이를 통해 Spring Security의 인증 정보를 설정다.
  • getGrantedAuthorities()
    • JWT 클레임에서  권한 정보를 추출한다.
    • JWT 클레임에서 사용자 역할 정보를 추출하여 Spring Security에서 사용할 수 있는 권한 목록으로 변환다.
  • handleException()
    • 예외 처리 메서드

 

동작 과정

1. JwtAuthenticationFilter는 요청을 처리하며, JwtTokenizer를 사용하여 토큰을 검증하고 필요한 경우 새로운 Access Token을 생성한다.

2. JwtAuthenticationFilter는 토큰이 유효한 경우, JwtAuthenticationToken을 사용하여 Spring Security의 인증 컨텍스트에 사용자 정보를 설정한다.

  • JwtTokenizer는 토큰 생성, 파싱, 만료 여부 확인, 새로운 Access Token 생성 등 JWT 관련 기능을 제공한다.
  • JwtAuthenticationToken은 인증된 사용자 정보를 저장하고, 인증 컨텍스트에 설정하는 역할을 수행

 

 

 

2.4 CustomAuthenticationEntryPoint.java

@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    //시큐리티가 인증되지 않은 사용자가 (인증해야만 사용할 수 있는) 리소스에 접근 할때 동작하게 하는 인터페이스
    //사용자가 인증되지 않았을때.. 어떻게 처리할지를 구현함.
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String exception = (String)request.getAttribute("exception");

        //어떤요청인지를 구분..
        //RESTful로 요청한건지..  그냥 페이지 요청한건지 구분해서 다르게 동작하도록 구현.
        if(isRestRequest(request)){
            handleRestResponse(request, response, exception);
        } else {
            handleRestResponse(request, response, exception);
        }
    }

    //페이지로 요청이 들어왔을 때 인증되지 않은 사용자라면 무조건 /loginform으로 리디렉션 시키겠다.
    private void handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
        log.error("Page Request - Commence Get Exception : {}", exception);

        if (exception != null) {
            // 추가적인 페이지 요청에 대한 예외 처리 로직을 여기에 추가할 수 있습니다.
        }

        response.sendRedirect("/loginform");
    }

    private boolean isRestRequest(HttpServletRequest request) {
        String requestedWithHeader = request.getHeader("X-Requested-With");
        return "XMLHttpRequest".equals(requestedWithHeader) || request.getRequestURI().startsWith("/api/");
    }

	// RESTful 요청에 대한 인증 실패 응답을 처리한다.
    private void handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
        log.error("Rest Request - Commence Get Exception : {}", exception);

        if (exception != null) {
            if (exception.equals(JwtExceptionCode.INVALID_TOKEN.getCode())) {
                log.error("entry point >> invalid token");
                setResponse(response, JwtExceptionCode.INVALID_TOKEN);
            } else if (exception.equals(JwtExceptionCode.EXPIRED_TOKEN.getCode())) {
                log.error("entry point >> expired token");
                setResponse(response, JwtExceptionCode.EXPIRED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.UNSUPPORTED_TOKEN.getCode())) {
                log.error("entry point >> unsupported token");
                setResponse(response, JwtExceptionCode.UNSUPPORTED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.NOT_FOUND_TOKEN.getCode())) {
                log.error("entry point >> not found token");
                setResponse(response, JwtExceptionCode.NOT_FOUND_TOKEN);
            } else {
                setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
            }
        } else {
            setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
        }
    }

    private void setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        HashMap<String, Object> errorInfo = new HashMap<>();
        errorInfo.put("message", exceptionCode.getMessage());
        errorInfo.put("code", exceptionCode.getCode());
        Gson gson = new Gson();
        String responseJson = gson.toJson(errorInfo);
        response.getWriter().print(responseJson);
    }
}
  • 인증되지 않은 사용자가 보호된 리소스에 접근하려 할 때, Spring Security에서 자동으로 호출되는 클래스이다.
  • AuthenticationEntryPoint 인터페이스를 상속하고 주로 인증되지 않은 사용자에게 적절한 응답을 반환하는 역할을 한다.

 

주요 메서드

  • commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
    • 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출된다.
    • 요청이 RESTful API 요청인지, 일반 페이지 요청인지 구분하여 각각 다르게 처리한다.
  • handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception)
    • 페이지 요청에 대한 인증 실패 응답을 처리한다.
    • 인증되지 않은 사용자를 로그인 폼으로 리디렉션한다.
  • isRestRequest(HttpServletRequest request)
    • 요청이 RESTful API 요청인지 확인한다.
    • X-Requested-With 헤더가 XMLHttpRequest인 경우나 요청 URI가 /api/로 시작하는 경우 RESTful 요청으로 간주한다.
  • handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception)
    • RESTful 요청에 대한 인증 실패 응답을 처리한다.
    • 예외 코드에 따라 적절한 응답을 설정한다.
  • setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode)
    • HTTP 응답을 설정한다.
    • JSON 형식으로 예외 정보를 포함하여 응답을 작성한다.

 

 

2.5 JwtExceptionCode.java

public enum JwtExceptionCode {

    UNKNOWN_ERROR("UNKNOWN_ERROR", "UNKNOWN_ERROR"),
    NOT_FOUND_TOKEN("NOT_FOUND_TOKEN", "Headers에 토큰 형식의 값 찾을 수 없음"),
    INVALID_TOKEN("INVALID_TOKEN", "유효하지 않은 토큰"),
    EXPIRED_TOKEN("EXPIRED_TOKEN", "기간이 만료된 토큰"),
    UNSUPPORTED_TOKEN("UNSUPPORTED_TOKEN", "지원하지 않는 토큰"),
    BLACKLISTED_TOKEN("BLACKLISTED_TOKEN", "로그아웃하여 블릭리스트에 올라간 토큰");

    @Getter
    private String code;

    @Getter
    private String message;

    JwtExceptionCode(String code, String message) {
        this.code = code;
        this.message = message;
    }
}
  • JWT 관련 예외 코드를 정의한다.

 

 

2.6 UserRestController.java

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserRestController {

    private final UserService userService;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenizer jwtTokenizer;
    private final RefreshTokenService refreshTokenService;
    private final JwtBlacklistService jwtBlackListService;

    @PostMapping("/login")
    public ResponseEntity login(@RequestBody @Valid UserLoginDto userLoginDto,
                                BindingResult bindingResult, HttpServletResponse response) {
        System.out.println("로그인 성공 시 유저이름과 비밀번호 출력 테스트");
        System.out.println(userLoginDto.getUsername());
        System.out.println(userLoginDto.getPassword());

        // username,password가 null일때 --> 정해진 형식에 맞지 않을때
        if(bindingResult.hasErrors()){
            return new ResponseEntity(HttpStatus.BAD_REQUEST);
        }

        // username과 password 값을 잘 받아왔다면
        // 우리 서비스에 가입한 사용자 인지 확인
        Optional<User> user = userService.findByUserName(userLoginDto.getUsername());
        
        // 요청정보에서 얻어온 비밀번호와 우리 서비스가 갖고있는 비밀번호가 일치하는지 확인
        if(!passwordEncoder.matches(userLoginDto.getPassword(), user.get().getPassword())) {
            // 비밀번호가 일치하지 않을때
            return new ResponseEntity("비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED);
        }
        // username과 password가 맞다면
        // 롤객체를 꺼내서 롤의 이름만 리스트로 얻어온다.
        List<RoleName> roles = user.get().getRole().stream().map(Role::getRoleName).collect(Collectors.toList());

        // 로그아웃 할 떄 refreshToken이 삭제가 되지 않았을 경우를 대비해 로그인 할 떄 기존의 refreshToken을 제거해준다.(이중장치 느낌)
        refreshTokenService.deleteRefreshToken(user.get().getUserId());

        // 사용자가 유효하면 JwtTokenizer를 사용해 accessToken과 refreshToken을 생성
        String accessToken = jwtTokenizer.createAccessToken(
                user.get().getUserId(), user.get().getEmail(), user.get().getUserName(), roles);
        String refreshToken = jwtTokenizer.createRefreshToken(
                user.get().getUserId(), user.get().getEmail(), user.get().getUserName(), roles);
        // 리프레시토큰을 디비에 저장
        RefreshToken refreshTokenEntity = new RefreshToken();
        refreshTokenEntity.setValue(refreshToken);
        refreshTokenEntity.setUserId(user.get().getUserId());

        refreshTokenService.addRefreshToken(refreshTokenEntity);

        // 응답으로 보낼 값들을 준비
        UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.get().getUserId())
                .username(user.get().getUserName())
                .build();

        Cookie accessTokenCookie = new Cookie("accessToken",accessToken);
        accessTokenCookie.setHttpOnly(true);  //보안 (쿠키값을 자바스크립트같은곳에서는 접근할수 없어요.)
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000)); //30분 쿠키의 유지시간 단위는 초 ,  JWT의 시간단위는 밀리세컨드

        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT/1000)); //7일

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);
        
        return new ResponseEntity(loginResponseDto, HttpStatus.OK);
    }
	
    // 인증된 사용자만 접근할 수 있는 테스트 엔드포인트
    @GetMapping("/authtest")
    public ResponseEntity<String> authTest(){
        return ResponseEntity.ok("authTest");
    }

	// 리프레시 토큰을 사용해 새로운 액세스 토큰을 발급
    @PostMapping("/refreshToken")
    public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response){
        //1. 쿠키로부터 리프레시토큰을 얻어온다. --> 요청의 쿠키에서 refreshToken을 추출
        String refreshToken = null;
        Cookie[] cookies = request.getCookies();
        if(cookies != null){
            for(Cookie cookie : cookies){
                if("refreshToken".equals(cookie.getName())){
                    refreshToken = cookie.getValue();
                    break;
                }
            }
        }
        //2-1. 없다 --> 오류로  응담
        if(refreshToken == null){
            return new ResponseEntity(HttpStatus.BAD_REQUEST);
        }
        //2-2. 있을때
        //3. 토큰으로부터 정보 얻어오기 --> JwtTokenizer를 사용해 리프레시 토큰을 파싱하고, 사용자 정보를 조회
        Claims claims = jwtTokenizer.parseRefreshToken(refreshToken);
        Long userId = Long.valueOf ((Integer)claims.get("userId"));

        User user = userService.getUser(userId).orElseThrow(() -> new IllegalArgumentException("사용자를 찾지 못했습니다."));

        //4. accessToken 생성
        List roles = (List)claims.get("roles");


        String accessToken = jwtTokenizer.createAccessToken(userId, user.getEmail(), user.getUserName(),  roles);

        //5. 쿠키 생성 response로 보내고
        Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact( JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));

        response.addCookie(accessTokenCookie);

        // 6. 적절한 응답결과(ResponseEntity)를 생성해서 응답
        UserLoginResponseDto responseDto = UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.getUserId())
                .username(user.getUserName())
                .build();


        return new ResponseEntity(responseDto, HttpStatus.OK);
    }
	
    // 로그아웃 처리
    @GetMapping("/logout")
    public void logout(@CookieValue(name = "accessToken", required = false) String accessToken, @CookieValue(name = "refreshToken", required = false) String refreshToken, HttpServletResponse response) {
        System.out.println("로그아웃 들어왔나");
        if (accessToken == null) {
            // accessToken이 존재하지 않으면 로그인되지 않은 상태로 간주하고 처리할 수 있습니다.
            try {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("Access token not found in cookies.");
            } catch (IOException e) {
                e.printStackTrace();
            }
            return;
        }

        // JWT 토큰 추출
        String jwt = accessToken;
        System.out.println("jwt: " + jwt);

        // 토큰의 만료 시간 추출
        Date expirationTime = Jwts.parser()
                .setSigningKey(jwtTokenizer.getAccessSecret())
                .parseClaimsJws(jwt)
                .getBody()
                .getExpiration();
        System.out.println("만료시간: " + expirationTime);

        // 블랙리스트에 토큰 저장
        JwtBlacklist blacklist = new JwtBlacklist(jwt, expirationTime);
        jwtBlackListService.save(blacklist);

        // SecurityContext를 클리어하여 현재 세션을 무효화
        SecurityContextHolder.clearContext();

        // accessToken 쿠키 삭제
        Cookie accessCookie = new Cookie("accessToken", null);
        accessCookie.setPath("/");
        accessCookie.setMaxAge(0);
        response.addCookie(accessCookie);

        // 데이터베이스에서 리프레시 토큰을 삭제
        Cookie refresCcookie = new Cookie("refreshToken", null);
        refresCcookie.setPath("/");
        refresCcookie.setMaxAge(0);
        response.addCookie(refresCcookie);

        // 로그아웃 전 db에 저장되어있는 refreshToken을 삭제한다.
        refreshTokenService.deleteRefreshToken(refreshToken);

        // /login 페이지로 리디렉션
        try {
            response.sendRedirect("/");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • 사용자 인증 관련 REST API를 제공한다.
  • 로그인 요청 시 이 앤드포인트에서 토큰을 쿠키에 저장한다.

주요 메서드

  • login()
    • 사용자 로그인 처리 로직
  • refreshToken()
    • 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급
  • logout()
    • 사용자 로그아웃 처리 로직

 

2.7 login.form

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>로그인</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    <link rel="stylesheet" href="/css/login.css">
</head>
<body style="background-color: #f0f2f5;">
<section class="vh-100">
    <div class="container py-5 h-100">
        <div class="row d-flex justify-content-center align-items-center h-100">
            <div class="col col-xl-10">
                <div class="card" style="border-radius: 25px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
                    <div class="col-md-6 col-lg-7 d-flex align-items-center">
                        <div class="card-body p-4 p-lg-5 text-black">
                            <form id="loginForm" action="/api/login" method="post">
                                <div class="d-flex align-items-center mb-3 pb-1">
                                    <span class="h1 fw-bold mb-0">로그인</span>
                                </div>
                                <div class="form-outline mb-4">
                                    <input type="text" id="username" name="username" class="form-control form-control-lg" required="required" placeholder="아이디를 입력하세요"/>
                                </div>
                                <div class="form-outline mb-4">
                                    <input type="password" id="password" name="password" class="form-control form-control-lg" required="required" placeholder="비밀번호를 입력하세요"/>
                                </div>
                                <div class="pt-1 mb-4">
                                    <button type="button" class="btn btn-dark btn-lg btn-block" onclick="loginUser()">로그인</button>
                                </div>
                                <a th:href="@{/oauth2/authorization/github}" class="btn btn-primary">Login with GitHub</a>
                                <p class="pb-lg-2" style="color: #393f81;">아직 회원이 아니신가요? <a th:href="@{/userregform}" style="color: #393f81;">회원가입</a></p>
                                <p class="mb-5 pb-lg-2" style="color: #393f81;">로그인 없이 이용하고 싶으신가요? <a th:href="@{/}" style="color: #393f81;">이동하기</a></p>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</section>
<script>
    function loginUser() {
        var username = document.getElementById('username').value;
        var password = document.getElementById('password').value;

        var data = {
            username: username,
            password: password
        };

        fetch('/api/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        })
            .then(response => {
                if (!response.ok) {
                    throw new Error('로그인 실패');
                }
                return response.json();
            })
            .then(data => {
                // 로그인 성공 처리
                console.log('로그인 성공:', data);
                // 성공 시 다음 페이지로 이동 혹은 필요한 작업 수행
                window.location.href = '/trending'; // 예시: 블로그 페이지로 이동
            })
            .catch(error => {
                console.error('로그인 에러:', error);
                // 실패 시 에러 처리 혹은 사용자에게 알림
                alert('로그인 실패. 다시 시도해주세요.');
            });
    }
</script>
</body>
</html>
  • jwt는 RestAPI 환경에서 사용하기 좋은 토큰이다.
  • 그래서 fetch 를 사용하여 서버측에서 토큰을 검증하면 클라이언트 쿠키에 등록하는 방식으로 코드를 구성했다.
  • 참고로 위의 저 js 코드에는 엑세스 토큰이 만료되면 리프레시 토큰으로 재발급하는 과정의 코드는 없다.
  • 오직 로그인시에 쿠키를 발급해주는 과정만 담겨있다.

 


3. 로그인/ 로그아웃 과정 정리

로그인 과정

  1. 클라이언트가 /api/login 엔드포인트에 사용자 로그인 정보를 담아 POST 요청을 보낸다.
  2. 이때, UserRestController의 login 메서드 호출되며 요청에서 UserLoginDto 객체를 받아 사용자의 로그인 정보를 확인한다.
  3. UserService를 사용하여 데이터베이스에서 사용자 정보를 조회하고, PasswordEncoder를 사용하여 비밀번호를 검증한다.
  4. 데이터베이스에 등록된 사용자라면, JwtTokenizer를 사용해 accessToken과 refreshToken을 생성한다.
  5. 생성된 accessToken과 refreshToken을 쿠키에 설정한다.
    -> accessToken은 일반적으로 짧은 만료 시간을 가지며, refreshToken은 더 긴 만료 시간을 가진다.
  6. 클라이언트에게 accessToken과 refreshToken을 포함한 응답을 보낸다.
  7. 즉, 로그인에 성공되면 클라이언트의 브라우저에 accessToken과 refreshToken이 쿠키로 저장된다.
  8. 쿠키는 만료 시간이 설정되어 있기 때문에 만료 시간이 지나면 쿠키는 자동으로 삭제된다.
  9. accessToken이 만료되면 클라이언트는 서버에 요청할 때 refreshToken을 사용하여 새로운 accessToken을 발급한다.
  10. 이때 클라이언트는 accessToken이 만료되었음을 감지하고, 쿠키에 저장된 refreshToken을 사용하여 /api/refreshToken 엔드포인트에 요청을 보낸다.
  11. 서버는 요청에서 refreshToken을 추출하고 이 토큰은 보통 쿠키에서 추출되며, HttpServletRequest 객체의 getCookies() 메서드를 사용해 접근할 수 있다.
  12. 서버는 JwtTokenizer를 사용하여 refreshToken의 유효성을 확인하고, 해당 토큰의 클레임에서 사용자 정보를 추출한다. 이 과정에서 refreshToken의 만료 여부도 확인한다.
  13. refreshToken이 유효하면, 서버는 새로운 accessToken을 생성한다. 이때 refreshToken은 보통 갱신되지 않으며, 여전히 유효한 상태로 유지된다.
  14. 서버는 새로 생성된 accessToken을 쿠키에 설정하여 클라이언트에 응답으로 보낸다.
  15. 새로운 accessToken을 사용하여 인증된 요청을 계속할 수 있다. 클라이언트는 응답에서 새로운 accessToken을 쿠키에서 받아서 저장하고, 이후의 요청에서 이 새로운 accessToken을 사용한다.
  16. 이 과정에서 리프레시 토큰의 만료 시간이 지나지 않았다면 리프레시 토큰은 그대로 유효하므로 새로 발급된 accessToken으로 계속해서 접근할 수 있다.
  17. 만약, refreshToken이 만료되면 클라이언트는 더 이상 새로운 accessToken을 발급받을 수 없다.
    -> 사용자는 다시 로그인을 해야 한다.
  18. refreshToken이 만료되지 않았으면, 사용자는 계속해서 새로운 accessToken을 발급받아 사용할 수 있다.
    -> 이로 인해 사용자는 계속해서 인증된 상태를 유지할 수 있다.

 

로그아웃 과정

  1. 클라이언트가 /api/logout 엔드포인트에 요청을 보낸다.
  2. UserRestController의 logout 메서드 호출하며 요청에서 accessToken과 refreshToken을 쿠키에서 추출한다.
  3. accessToken의 만료 시간을 확인하고 블랙리스트에 저장하여 이후 검증에서 차단한다.
  4. 쿠키에서 accessToken과 refreshToken을 삭제한다.
  5. 데이터베이스에서 해당 리프레시 토큰을 삭제한다.
  6. 로그아웃 후 사용자는 자동으로 메인 홈 페이지로 리디렉션된다.
728x90
728x90

 

개요

앞서 1편에서 기본 파일 구조와 기본 세팅을 했고, 2편에서 security 보안 설정을 했다.

이번 3편에서는 jwt 패키지에 대해서 작성할 것이다. 

일단.. 엔티티, 레포지토리, 서비스를 먼저 만들어 주자.

refreshToken, blackListToken 관리하기 위한 엔티티, 레포지토리, 서비스 생성

 

<- jwt의 대략적인 파일 구조

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

1. 엔티티 생성

  • RefreshToken
@Entity
@Table(name = "refresh_token")
@Getter
@Setter
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // 리프레시 토큰의 고유 식별자

    @Column(name = "user_id")
    private Long userId; // 이 리프레시 토큰과 연관된 사용자의 식별자

    private String value; // 리프레시 토큰의 실제 값 -> 이 값은 서버가 클라이언트의 요청을 인증하는 데 사용
}
  • 위 엔티티는 주로 액세스 토큰의 재발급을 위한 리프레시 토큰을 저장하는 데 사용된다.

Q. 리프레시 토큰이 뭔데?

A. 리프레시 토큰은 일반적으로 액세스 토큰보다 긴 유효 기간을 가지도록 설정하며 엑세스 토큰이 만료가 되면 클라이언트는 리프레시 토큰을 사용하여 새로운 액세스 토큰을 요청한다.

즉, 서버는 리프레시 토큰을 검증하고, 유효한 경우 새로운 액세스 토큰을 발급한다.

 

  • JwtBlacklist
@Entity
@Table(name = "jwt_blacklist")
@Getter
@Setter
@NoArgsConstructor
public class JwtBlacklist {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // 블랙리스트 항목의 고유 식별자

    private String token; // 블랙리스트에 포함된 JWT 문자열

    @Column(name = "expiration_time")
    private Date expirationTime; // 블랙리스트에 있는 JWT의 만료 시간

    public JwtBlacklist(String token, Date expirationTime) {
        this.token = token;
        this.expirationTime = expirationTime;
    }
}
  • 사용자가 로그아웃하거나 토큰이 만료된 경우, 해당 토큰을 블랙리스트에 추가하여(이 때 token 필드에 JWT 문자열을 저장) 더 이상 사용되지 않도록 한다.
  • JWT 인증 필터에서 요청이 들어올 때, 토큰이 블랙리스트에 있는지 확인하여 유효성을 검사한다. (token 필드를 사용하여 검증 수행)
  • 블랙리스트에 있는 토큰은 서버에서 인식할 수 없으며, 인증 요청 시 해당 토큰이 블랙리스트에 포함되어 있으면 인증을 거부한다.

Q. 왜 블랙리스트를 써야하나?

  • 사용자가 로그아웃을 요청했을 때, 클라이언트 측에서는 캐시에 저장된 jwt를 제거하지만 서버에서는 jwt를 무력화할 수단이 없다.
  • 때문에 서버에서는 Jwt에 대한 블랙 리스트를 만들어 Jwt의 유효기간이 만료될 때 까지 Redis와 같은 DB에서 관리하는것을 말한다.
  • 만약 사용자가 Jwt를 이용하여 인가를 요청했을 시, Black List의 조회를 통해 사용자가 로그아웃한 Jwt인지 아닌지를 판별하는 것이다.

 

 

 

 

2. Repository 설정

  • RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByValue(String value); // 리프레시 토큰 값으로 RefreshToken 엔티티 조회
    
    boolean existsByValue(String token); // 리프레시 토큰 값이 데이터베이스에 존재하는지 여부

    Optional<RefreshToken> findByUserId(Long userId); // 사용자 ID로 RefreshToken 엔티티 조회
}
  • JwtBlacklistRepository
public interface JwtBlacklistRepository extends JpaRepository<JwtBlacklist, Long> {
    boolean existsByToken(String token); // JWT 토큰이 블랙리스트에 존재하는지 여부 확인
}

각 메서드에 대해서 주석으로 설명함

 

 

 

3. Service 설정

  • RefreshTokenService
@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;
	
    // 리프레시 토큰 추가
    @Transactional
    public RefreshToken addRefreshToken(RefreshToken refreshToken) {
        return refreshTokenRepository.save(refreshToken);
    }

	// 리프레시 토큰 조회
    @Transactional
    public Optional<RefreshToken> findRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByValue(refreshToken);
    }

	// 리프레시 토큰 삭제
    public void deleteRefreshToken(String refreshToken) {
        refreshTokenRepository.findByValue(refreshToken).ifPresent(refreshTokenRepository::delete);
    }

	// 사용자 기반 리프레시 토큰 삭제
    public void deleteRefreshToken(Long userId) {
        refreshTokenRepository.findByUserId(userId).ifPresent(refreshTokenRepository::delete);
    }

	// 리프레시 토큰 유효성 검증
    public boolean isRefreshTokenValid(String refreshToken) {
        return refreshTokenRepository.existsByValue(refreshToken);
    }
}
  • RefreshTokenService는 리프레시 토큰(Refresh Token)을 관리하는 데 관련된 기능을 제공한다.
  • 리프레시 토큰은 사용자가 인증된 상태를 유지하도록 돕는 역할을 하고 새로운 액세스 토큰을 발급받기 위해 사용된다.

관련 메서드

  • 리프레시 토큰 추가 (addRefreshToken)
    • RefreshToken 객체를 데이터베이스에 저장하여 새로운 리프레시 토큰을 추가한다.
    • 이 메서드는 리프레시 토큰을 발급하고 저장하는 데 사용된다.
  • 리프레시 토큰 조회 (findRefreshToken)
    • 주어진 리프레시 토큰 값을 사용하여 데이터베이스에서 해당 리프레시 토큰을 찾는다.
    • 리프레시 토큰이 유효한지 확인하고 관련 정보를 반환하는 데 사용된다.
  • 리프레시 토큰 삭제 (deleteRefreshToken)
    • 주어진 리프레시 토큰 값을 사용하여 데이터베이스에서 해당 리프레시 토큰을 삭제한다.
    • 사용자가 로그아웃하거나 토큰을 무효화할 때 사용된다.
  • 사용자 기반 리프레시 토큰 삭제 (deleteRefreshToken(Long userId))
    • 특정 사용자 ID에 연관된 리프레시 토큰을 삭제한다.
    • 사용자가 로그아웃하거나 리프레시 토큰을 관리할 때 사용된다.
  • 리프레시 토큰 유효성 검증 (isRefreshTokenValid)
    • 주어진 리프레시 토큰이 데이터베이스에 존재하는지 확인하여 유효성을 검증한다.
    • 유효한 리프레시 토큰인지 여부를 반환한다.

 

  • JwtBlacklistService
@Service
@RequiredArgsConstructor
public class JwtBlacklistService {

    private final JwtBlacklistRepository jwtBlacklistRepository;
    
    // 블랙리스트에 추가
    public void save(JwtBlacklist blacklist) {
        jwtBlacklistRepository.save(blacklist);
    }
    
    // 블랙리스트 저장
    public void addToBlacklist(String token, Date expirationDate) {
        JwtBlacklist blacklist = new JwtBlacklist(token, expirationDate);
        jwtBlacklistRepository.save(blacklist);
    }

	// 토큰 블랙리스트 확인
    public boolean isTokenBlacklisted(String token) {
        return jwtBlacklistRepository.existsByToken(token);
    }
}
  • JwtBlacklistService는 블랙리스트에 관리되는 JWT의 기능을 제공하여, 사용자가 로그아웃하거나 특정 JWT를 무효화할 때 사용하는 서비스이다.
  • 블랙리스트는 특정 JWT가 더 이상 유효하지 않다는 것을 기록한다.

관련 메서드

  • 블랙리스트에 추가 (addToBlacklist)
    • 주어진 JWT와 만료 시간을 사용하여 새로운 JwtBlacklist 항목을 생성하고, 이를 데이터베이스에 저장한다.
    • 이 메서드는 특정 JWT를 블랙리스트에 추가하여 더 이상 유효하지 않게 만든다.
  • 블랙리스트 저장 (save):
    • JwtBlacklist 객체를 데이터베이스에 저장한다.
    • 이 메서드는 블랙리스트 항목을 생성하고 저장하는 데 사용된다..
  • 토큰 블랙리스트 확인 (isTokenBlacklisted):
    • 주어진 JWT가 블랙리스트에 포함되어 있는지 확인한다.
    • 이 메서드는 JWT가 블랙리스트에 있는 경우 true를 반환하여, 해당 토큰이 더 이상 유효하지 않음을 알린다.

 

 

 

 

글이 길어져서 jwt 관련해서는 4편에서 이어서 쓰겠다. 4편에서....

728x90

+ Recent posts