[SpringSecurity] 스프링 시큐리티 JWT

bien·2024년 7월 24일
0

SpringSecurity

목록 보기
2/2

1. 실습 목표 및 간단한 동작 원리

실습 목표

  • 스프링 시큐리티 6 프레임워크를 활용하여 jwt 기반의 인증/인가를 구현하고 회원 정보 저장(영속성) MySQL 데이터베이스를 활용한다.
  • 서버는 API 서버 형태로 구축한다.
    • 웹페이지를 응답하는 것이 아닌 API 클라이언트의 요청을 통해 데이터 응답만 확인함

구현

  • 인증: 로그인
  • 인가: JWT를 통한 경로별 접근 권한
  • 회원가입

JWT 이증 방식 시큐리티 동작 원리

  • 회원가입: 내부 회원 가입 로직은 세션 방식과 JWT 방식의 차이가 없다.

  • 로그인(인증): 로그인 요청을 받은 후 세션 방식은 서버 세션이 유저 정보를 저장하지만 JWT 방식은 토큰을 생성하여 응답한다.

    • 로그인 경로로 요청이 오면 UsernamePasswordAuthenticationFilter로 로직을 작성하고, AuthenticationManager에서 검증을 진행한다.
    • 검증방법은 DB의 유저 정보를 가져와서 UserDetails에 담아와 AuthenticationManager에서 진행하는 방식이다.
    • 로그인 성공 시 기존의 세션 방식은 서버세션에 회원 정보를 저장하지만, jwt방식은 세션에 회원정보를 남기지 않고 SuccesfulAuthentication에서 JWTFilter를 통해 토큰을 만들어 응답해주는 방식이다.
  • 경로 접근(인가): JWT Filter를 통해 요청의 헤더에서 JWT를 찾아 검증을 하고 일시적 요청에 대한 Session을 생성한다.(생성된 세션은 요청이 끝나면 소멸됨)

    • SecurityAuthenticatinFilter가 검증을 진행하고, 이후 JWTFilter를 커스텀해 필터검증을 진행한다.
    • 만약 토큰이 알맞게 존재하고 정보가 일치하면, SecurityContextHolderSession에 일시적인 세션 정보를 생성한다.
    • 단, 이 방식은 하나의 요청에 대해 일시적으로 세션을 만들고 요청이 끝나면 세션이 사라지게 된다.

버전 및 의존성

  • Spring Boot 3.2.1
  • Security 6.2.1
  • Lombok
  • Spring Data JPA - MySQL
  • Gradle - Groovy
  • IntelliJ Ultimate

기타

  • 스프링 시큐리티 JWT 구현 방법이 아주 많다.
    • 개발자별로 다른 구현을 진행하고, 버전별로도 메서드가 많이 다르다.
    • 최대한 공식 문서에 구현된 형태로 코드를 작성하나, 구현은 다를 수 있다.
  • 간단히 API 서버에서 JWT 구현을 진행했고, 토큰 발급의 경우 단일 토큰으로 진행한다.
    • Access, Refresh로 나누지 않고, 간단하게 한 개로 진행한다.
  • 시리즈가 끝난 후 OAuth2 소셜 로그인이 진행된다.
  • 개념적인 부분은 최소화하고 실습 위주로 진행.
  • JWT 구현을 위한 가장 기본적인 뼈대 코드로, 이후 더 추가 필요.

참조


2. 프로젝트 생성

의존성

필수 의존성

Lombok
Spring Web
Spring Security
Spring Data JPA
MySQL Driver

데이터베이스 의존성 주석처리

  • 임시로 주석 처리 진행 (스프링 부트에서 데이터베이스 의존성을 추가한 뒤 연결을 진행하지 않을 경우 런타임 에러 발생)

JWT 필수 의존성

  • JWT 토큰을 생성하고 관리하기 위해 JWT 의존성을 필수적으로 설정해야 한다.
  • 설정은 build.gradle을 통해 진행하며 이때 버전을 선택하여 적용해야 한다.
  • 대부분은 JWT 0.11.5 버전을 통해 구현하지만 최신 버전은 0.12.3이다.
    • 따라서 0.12.3을 기반으로 구현하지만, 추가적으로 0.11.5 버전에 대한 구현방법도 올릴 예정이다.
      • JWT를 생성하고 내부에서 데이터를 얻는 메소드가 버전마다 많이 상이하다.

0.12.3

dependencies {

    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

0.11.5

dependencies {

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

기본 Controller

  • controller 패키지 하위에 두개의 Controller 클래스 생성

MainController

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@ResponseBody
public class MainController {

    @GetMapping("/")
    public String mainP() {

        return "main Controller";
    }
}

AdminController

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@ResponseBody
public class AdminController {

    @GetMapping("/admin")
    public String adminP() {

        return "admin Controller";
    }
}

3. Security Config 클래스

Security Config 클래스

  • 스프링 시큐리티의 인가 및 설정을 담당하는 클래스.
  • Security Config 구현은 스프링 시큐리티의 세부 버전별로 많이 상이하다.
    • 이번 시리즈는 스프링 시큐리티 6.2.1 버전으로 구현한다.
  • 자주 접할 수 있는 버전에 대한 구현 차이는 아래 영상을 통해 확인 가능

Security Config 클래스 기본 요소 작성

  • 시큐리티 JWT 구현을 위한 Config 클래스의 일부분을 작성할 예정.
  • 먼저 기본적인 설정만 진행하고 시리즈를 진행하며 커스텀 필터 요소들을 추가 구현할 예정.
  • JWT를 통한 인증/인가를 위해서 세션을 STATELESS 상태로 설정하는 것이 중요하다.

config > SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

				//csrf disable
        http
                .csrf((auth) -> auth.disable());

				//From 로그인 방식 disable
        http
                .formLogin((auth) -> auth.disable());

				//http basic 인증 방식 disable
        http
                .httpBasic((auth) -> auth.disable());

				//경로별 인가 작업
        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/login", "/", "/join").permitAll()
												.requestMatchers("/admin").hasRole("ADMIN")
                        .anyRequest().authenticated());

				//세션 설정
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}
  • 세션 방식에서는 세션이 고정되므로 csrf에 대한 방어가 필수적이지만, JWT 방식은 세션을 STATELESS하게 관리하므로 세션에 대한 방어가 필수적이지 않다.
  • jwt 방식에서는 세션이 stateless하게 관리하는게 핵심이므로, Session을 STATELESS하게 설정하는 부분이 가장 중요하다.
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

BCryptPasswordEncoder 등록

시큐리티를 통해 회원가입하여 회원 정보를 저장하고 검증하는 과정은 모두 비밀번호를 해시로 암호화하여 검증하고 진행하게 된다. 따라서 상단에 BCryptPasswordEncoder메서드를 구현해 빈으로 등록해둔다.

SecurityConfig

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }

4. DB연결 및 Entity 작성

데이터베이스 종류와 ORM

  • 회원 정보를 저장하기 위한 데이터베이스로 MySQL 엔진의 데이터베이스를 사용.
  • 데이터베이스 접근은 Spring Data JPA 사용.

데이터베이스 의존성 주석 해제

  • 앞서 Spring Data JPA와 MySQL Driver에 적용해두었던 주석을 해제한다.

변수 설정

application.properties

# DB 연결 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://아이피:3306/데이터베이스?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
spring.datasource.username=아이디
spring.datasource.password=비밀번호

# Hibernate ddl 설정
spring.jpa.hibernate.ddl-auto=none
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
  • 데이터베이스에서 회원 정보를 저장할 테이블을 생성해야 하지만, ddl-auto 설정을 통해 스프링 부트 Entity 클래스 기반으로 테이블을 생성할 수 있다.

회원테이블 Entity 작성: UserEntity

entity > UserEntity.java

@Entity
@Setter
@Getter
public class UserEntity {

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

    private String username;
    private String password;

    private String role;
}

회원 테이블 Repository 작성: UserRepsitory

repository > UserRepository.java

public interface UserRepository extends JpaRepository<UserEntity, Integer> {

}
  • JpaRepository<담을엔티티, id타입>{} 상속

ddl-auto = create 설정 후 실행

  • 데이터베이스에서 회원 정보를 저장할 테이블을 생성해야 하지만 ddl-auto 설정을 통해 스프링 부트 Entity 클래스 기반으로 테이블을 생성할 수 있다.
  • 생성이 끝나면 종료하고 none으로 변경하여 데이터 구조가 날아가지 않도록 주의한다.

5. 회원가입 로직 구현

모식도

  • POST요청으로 /join에 usename과 password 전송
  • 던져준 데이터를 DTO로 받아서 Service-Repository에서 정보를 받아와 회원 정보와 연결할 예정.

JoinDTO

@Setter
@Getter
public class JoinDTO {

    private String username;
    private String password;
}

JoinController

@Controller
@ResponseBody
public class JoinController {
    
    private final JoinService joinService;

    public JoinController(JoinService joinService) {
        
        this.joinService = joinService;
    }

    @PostMapping("/join")
    public String joinProcess(JoinDTO joinDTO) {

        System.out.println(joinDTO.getUsername());
        joinService.joinProcess(joinDTO);

        return "ok";
    }
}

JoinService

@Service
public class JoinService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public JoinService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) {

        this.userRepository = userRepository;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    public void joinProcess(JoinDTO joinDTO) {

        String username = joinDTO.getUsername();
        String password = joinDTO.getPassword();

        Boolean isExist = userRepository.existsByUsername(username);

        if (isExist) {

            return;
        }

        UserEntity data = new UserEntity();

        data.setUsername(username);
        data.setPassword(bCryptPasswordEncoder.encode(password));
        data.setRole("ROLE_ADMIN");

        userRepository.save(data);
    }
}

UserRepository

public interface UserRepository extends JpaRepository<UserEntity, Integer> {

    Boolean existsByUsername(String username);
}

6. 로그인 필터 구현

로그인 모식도

스프링 시큐리티 필터 동작 원리

스프링 시큐리티는 클라이언트의 요청이 여러개의 필터를 거쳐 DispatcherServlet(Controller)으로 향하는 중간 필터에서 요청을 가로챈 후 검증(인증/인가)을 진행한다.

  • 클라이언트 요청 -> 서블릿 필터 -> 서블릿 (컨트롤러)

  • Delegating Filter Proxy

    • 서블릿 컨테이너(톰캣)에 존재하는 필터 체인에 DelegatingFilter를 등록한 뒤 모든 요청을 가로챈다.
  • 서블릿 필터 체인의 DelegatingFilter -> Security 필터 체인 (내부 처리 후) -> 서블릿 필터 체인의 DelegatingFtilter

    • 가로챈 요청은 SecurityFilterChain에서 처리 후 상황에 따른 거부, 리디렉션, 서블릿으로 요청 전달을 진행한다.
    • 좌측은 서블릿 필터, 우측은 시큐리티 필터이다. 이 시큐리티 필터들의 모음을 시큐리티 필터 체인이라고 한다.
  • SecurityFilterChain의 필터 목록과 순서

Form 로그인 방식에서 UsernamePasswordAuthenticationFilter

  • Form 로그인 방식에서는 클라이언트단이 username과 password를 전송한 뒤 Security 필터를 통과하는데 UsernamePasswordAuthentication 필터에서 회원 검증 진행을 시작한다.
  • 회원 검증의 경우 UsernamePasswordAuthenticationFilter가 호출한 AuthenticatinoManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailsService를 통해 받음
  • 우리의 JWT 프로젝트는 SecurityConfig에서 formLogin 방식을 disable 했기 때문에 기본적으로 활성화 되어 있는 해당 필터는 동작하지 않는다.
  • 따라서 로그인을 진행하기 위해서 필터를 커스텀하여 등록해야 한다.

로그인 로직 구현 목표

  • 아이디, 비밀번호 검증을 위한 커스텀 필터 작성
  • DB에 저장되어 있는 회원 정보를 기반으로 검증할 로직 작성
  • 로그인 성공 시 JWT를 반환할 success 핸들러 생성
  • 커스텀 필터 SecurityConfig에 등록

로그인 요청 받기: 커스텀 UsernamePasswordAuthentication 필터 작성

  • 로그인 검증을 위한 커스텀 UsernamePasswordAuthentication 필터 작성

LoginFilter.java

package com.example.springjwt.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    public LoginFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        // 클라이언트 요청에서 username, password 추출
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        // 스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

        // token에 담은 검증을 위한 AuthenticationManager로 전달
        return authenticationManager.authenticate(authToken);
    }

    //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {

    }


}

SecurityConfig 설정

SecurityConfig

  • 커스텀 로그인 필터 등록
  • 필터등록을 위해 필요한 변수: AuthenticationManager, AuthenticationConfiguration
    • AuthenticationManager: Bean 등록해 LoginFilter에 인수로 전달
    • AuthenticationConfiguration: 생성자를 통해 주입받음

@Configuration
@EnableWebSecurity
public class SecurityConfig {

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

    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration) {
        this.authenticationConfiguration = authenticationConfiguration;
    }

    //AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

	
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
    	// ...
        //필터 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요
        http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
	}
}    
  • 참고
    • filter의 순서를 설정하는 메서드 목록

로그인 성공시 JWT 반환

로그인 성공 시 successfulAuthentication() 메소드를 통해 JWT를 응답해야 한다. 따라서 JWT 응답 구문을 작성해야 하는데 JWT 발급 클래스를 아직 생성하지 않았기 때문에 다음 시간에 DB 기반 회원 검증 구현을 진행한 뒤 JWT 발급 및 검증을 진행하는 클래스를 생성할 예정.

테스트

  • 포스트맨으로 LoginFilter의 작동 여부를 확인할 수 있다.
      • 로그가 찍힌것을 확인할 수 있다.

7. DB 기반 로그인 검증 로직

모식도

  • 앞선 강의에서 AuthenticationManager 앞단을 구현.
    • 이번 강의에서는 DB에서 AuthenticationManager까지 로직 구현
  • 구현은 UserDetails, UserDetailService, UserRepository의 회원 조회 메서드를 진행.
    • 이 과정은 세션에서의 과정과 동일. db로부터 정보를 가져오고 UserDetails에 담아 AuthenticationManager에서 검증과정을 거친다.

UserRepository

package com.example.springjwt.repository;

import com.example.springjwt.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<UserEntity, Integer> {
    // ...
    // username을 받아 DB테이블에서 회원을 조회하는 메서드 작성
    UserEntity findByUsername(String username);
    
}

UserDetailsService

service > customUserDetailsService

  • UserDetailsService를 상속받아 서비스를 구현하고, 아이디가 있는 경우 CustomUserDetails에 담아 반환한다.
@Service
public class CustUserServiceDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    private CustUserServiceDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

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

        // DB에서 조회
        UserEntity userData = userRepository.findByUsername(username);

        if (userData != null) {
            // //UserDetails에 담아서 return하면 AutneticationManager가 검증 함
            return new CustomUserDetails(userData);
        }

        return null;
    }
}

UserDetails 커스텀 구현

dto > CustomUserDetails

  • dto성 클래스로, UserDetails를 상속받아 구현하면 스프링 시큐리티에서 이 클래스를 통해 사용자 정보를 확인한다.
public class CustomUserDetails implements UserDetails {

    private final UserEntity userEntity;

    public CustomUserDetails(UserEntity userEntity) {
        this.userEntity = userEntity;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return userEntity.getRole();
            }
        });

        return collection;
    }

    @Override
    public String getPassword() {
        return userEntity.getPassword();
    }

    @Override
    public String getUsername() {
        return userEntity.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

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

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

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

테스트

  • Postman으로 정상 작동 여부를 확인할 수 있다.

LoginFilter.java

  • 로그인 필터에 로그인 성공여부를 판별할 로그를 추가한다.
    //로그인 성공시 실행하는 메소드
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
        System.out.println("success");
    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        System.out.println("fail");
    }
  • 포스트맨으로 로그인을 진행하면, 서버에서 로그를 통해 로그인 성공, 실패 여부를 확인할 수 있다.

8. JWT 발급 및 검증 클래스

JWT 발급과 검증

  • 로그인 시
    • 성공 > JWT 발급
  • 접근 시
    • 성공 > JWT 발급

JWT에 관해 발급과 검증을 담당할 클래스가 필요한데, JWTUtil이라는 클래스에서 JWT 발급, 검증 메서드를 맡을 예정이다.

JWT 생성 원리

JWT는 Header, Payload, Signature 구조로 이루어져있다. 각 요소는 다음의 기능을 수행한다.

  • Header
    • JWT임을 명시
    • 사용된 암호화 알고리즘
  • Payload
    • 정보
  • Signature
    • 암호화 알고리즘(BASE64(Header) + Base64(Payload) + 암호화키)
  • JWT는 내부 정보를 단순 BASE64 방식으로 인코딩하기 때문에 외부에서 쉽게 디코딩할 수 있다.
    • 외부에서 열람해도 되는 정보를 담아야하며, 토큰 자체의 발급처를 확인하기 위해 사용한다.
    • (지폐와 같이 외부에서 그 금액을 확인하고 금방 외형을 따라서 만들 수 있지만, 발급처에 대한 보장 및 검증은 확실하게 해야하는 경우에 사용한다. 따라서 토큰 내부에 비밀번호와 같은 값 입력 금지)

JWT 암호화 방식

암호화 종류

  • 양방향
    • 대칭키: 해당 프로젝트는 양방향 대칭키 방식을 사용한다(HS265)
  • 단방향

암호화 키 저장

암호화 키는 하드코딩 방식으로 구현 내부에 탑재하는 것을 지양히가 때문에 변수 설정 파일에 저장한다.

application.properties

spring.jwt.secret=vmfhaltmskdlstkfkdgodyroqkfwkdbalroqkfwkdbalaaaaaaaaaaaaaaaabbbbb

JWTUtil

  • 토큰 Payload에 저장될 정보
    • username
    • role
    • 생성일
    • 만료일
  • JWTUtil 구현 메소드
    • JWTUtil 생성자
    • username 확인 메소드
    • role 확인 메소드
    • 만료일 확인 메소드

JWTUtil : 0.12.3

package com.example.springjwt.jwt;

@Component
public class JwtUtil {

    private SecretKey secretKey;

    public JwtUtil(@Value("${spring.jwt.secret}") String secret) {
        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getUsername(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("uasername", String.class);
    }

    public String getRole(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public Boolean isExpired(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    public String createJwt(String username, String role, Long expiredMs) {
        return Jwts.builder()
                .claim("username", username)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }

}

JWTUtil : 0.11.5

@Component
public class JWTUtil {

    private Key key;

    public JWTUtil(@Value("${spring.jwt.secret}")String secret) {


				byte[] byteSecretKey = Decoders.BASE64.decode(secret);
        key = Keys.hmacShaKeyFor(byteSecretKey);
    }

    public String getUsername(String token) {

        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("username", String.class);
    }

    public String getRole(String token) {

        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("role", String.class);
    }

    public Boolean isExpired(String token) {

        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration().before(new Date());
    }

    public String createJwt(String username, String role, Long expiredMs) {

				Claims claims = Jwts.claims();
        claims.put("username", username);
        claims.put("role", role);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }
}

9. 로그인 성공 JWT 발급

로그인을 성공했을 경우 JWT 발급하기 위한 구현.

JWTUtil 주입

LoginFilter.java

  • LoginFitler에서 JwtUtil을 주입받아 로그인을 성공하는 경우 JWT 토큰을 발급해줄 예정.
    • 이를 위해 JwtUtil를 주입받아서 사용한다.
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
	private final JWTUtil jwtUtil; //JWTUtil 주입

    public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
				this.jwtUtil = jwtUtil;
    }

SecurityConfig.java

  • SecurityConfig에서 Filter에 JWTUtil 주입
    • 변경된 LoginFilter의 주입 요소를 SecurityConfig에도 반영해준다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtUtil jwtUtil; //JWTUtil 주입

    public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JwtUtil jwtUtil) {
        this.authenticationConfiguration = authenticationConfiguration;
        this.jwtUtil = jwtUtil;
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
    
    // ...
    http
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class);
}

LoginFilter 로그인 성공 successfulAuthentication 메소드 구현

LoginFilter.java

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String username = customUserDetails.getUsername();
        
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        
        String role = auth.getAuthority();
        String token = jwtUtil.createJwt(username, role, 60*60*10L);
        
        response.setHeader("Authorization", "Bearer " + token);
    }

}    
  • 요청으로부터 username, role을 얻어 헤더에 토큰에 담아준다.
  • 이 때, 헤더에 담는 토큰은 RFC 7235 정의에 따라 아래 인증 헤더 형식을 가져야 한다.

// 예시 (띄워쓰기 주의)
Authorization: 타입 인증토큰
Authorization: Bearer 인증토큰string

LoginFilter 로그인 실패 unsuccessfulAuthentication 메소드 구현

  • 실패시 간단하게 401 코드를 반환한다.
    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        response.setStatus(401);
    }

발급 테스트

  • /login 경로로 username과 password를 포함한 POST 요청을 보낸 후 응답 헤더에서 Authorization 키에 담긴 JWT를 확인한다.

LoginFilter 최종

package com.example.springjwt.jwt;

import com.example.springjwt.dto.CustomUserDetails;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.util.Collection;
import java.util.Iterator;

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public LoginFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 클라이언트 요청에서 username, password 추출
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        System.out.println("username = "+ username);

        // 스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

        // token에 담은 검증을 위한 AuthenticationManager로 전달
        return authenticationManager.authenticate(authToken);
    }

    //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String username = customUserDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        String role = auth.getAuthority();
        String token = jwtUtil.createJwt(username, role, 60*60*10L);

        response.setHeader("Authorization", "Bearer " + token);
    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
        response.setStatus(401);
    }


}

10. JWT 검증 필터

스프링 시큐리티 filter chain에 요청이 담긴 JWT를 검증하기 위한 커스텀 필터를 등록해야 한다.

해당 피렅를 통해 요청 헤더 Authorization 키에 JWT가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성한다. (이 세션은 STATELESS 상태로 관리되기 때문에 해당 요청이 끝나면 소멸된다.)

JWTFilter 구현

JWTFilter.java

  • 토큰 자체에 사용자 정보(username, role)을 가지고 있으므로, 비밀번호가 필요하지 않다.
    • CustomDetails 객체 생성을 위해 UserEntity가 필요한데, 비밀번호 값이 유의미 하지 않으므로 굳이 DB를 거쳐가지 않고, 임시 비밀번호 temppassword를 입력한다.
package com.example.springjwt.jwt;

import com.example.springjwt.dto.CustomUserDetails;
import com.example.springjwt.entity.UserEntity;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

public class JWTFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    public JWTFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

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

        // request에서 Authorizaion 헤더를 찾음
        String authorization = request.getHeader("Authorization");

        // Authorization 헤더 검증
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            System.out.println("tokne null");
            filterChain.doFilter(request, response);

            // 조건이 해당되면 메소드 종료 (필수)
            return;
        }

        System.out.println("authorization now");

        // Bearer 부분 제거 후 순수 토큰만 획득
        String token = authorization.split("")[1];

        // 토큰 소멸 시간 검증
        if (jwtUtil.isExpired(token)) {
            System.out.println("token expired");
            filterChain.doFilter(request, response);

            // 조건이 해당되면 메소드 종료 (필수)
            filterChain.doFilter(request, response);
        }

        // 토큰에서 username과 role 획득
        String username = jwtUtil.getUsername(token);
        String role = jwtUtil.getRole(token);

        // userEntity를 생성하여 값 set
        UserEntity userEntity = new UserEntity();
        userEntity.setUsername(username);
        userEntity.setPassword("temppassword");
        userEntity.setRole(role);

        // UserDetails 에 회원 정보 객체 담기
        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

        // 스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        // 세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);
        filterChain.doFilter(request, response);
    }
}

SecurityConfig JWTFilter 등록

SecurityConfig.java

  • 로그인 필터 앞에 JWTFilter를 등록한다.
        // JWTFilter 등록
        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

JWT 요청 인가 테스트

  • 요청 헤더에 JWT를 첨부하고 로그인 권한이 필요한 페이지에 접근을 진행한다.
    • admin으로 로그인해서 받은 토큰을 헤더에 넣고 /admin 경로에 접근하면, 접근이 허용된다. (403 에러가 뜨지 않는다.)

11. 세션 정보

JWTFilter 통과 후 세션 확인

  • JWT 자체가 STATELESS 하게 관리되긴 하지만, JWT를 가지고 JWT필터를 통과하는 순간 일시적으로 세션을 생성하므로, SecurityContextHolder에서 세션에 대한 사용자 이름을 확인할 수 있다.

세션 현재 사용자 아이디

  • SecurityContextHolder.getContext().getAuthentication().getName();

MainController.java

package com.example.springjwt.controller;

import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@ResponseBody
public class MainController {

    @GetMapping("/")
    public String mainP() {
        
        String name = SecurityContextHolder.getContext().getAuthentication().getName();
        
        return "main Controller:" + name;
    }
}

세션 현재 사용자 role

AdminController.java

package com.example.springjwt.controller;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.Collection;
import java.util.Iterator;

@Controller
@ResponseBody
public class AdminController {

    @GetMapping("/admin")
    public String adminP() {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iter = authorities.iterator();
        GrantedAuthority auth = iter.next();
        String role = auth.getAuthority();

        return "admin Contrller:" + role;
    }
}

12. CORS 설정

CORS란

클라이언트가 웹 브라우저를 통해 사이트에 접속하면, 프론트엔드에서 리엑트나 뷰 페이지를 띄워준다. 이때 사용한(3030포트)에서 요청을 보내면 백엔드 서버(8080포트)에서 응답하는데, 웹 브라우저에서 교차출처 리소스 공유를 금지시키므로 다른 두 포트(백엔드와 프론트)의 정보 교환이 이루어지지 않게 된다.

이를 예방하기 위해, 백엔드 단에서 CORS 설정을 해줘야만 한다.

CORS 설정

SecurityConfig.java

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .cors((corsCutomizer -> corsCutomizer.configurationSource(new CorsConfigurationSource() {
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                        CorsConfiguration configuration = new CorsConfiguration();

                        configuration.setAllowedOrigins(Collections.singletonList("http://loclahost3000:"));
                        configuration.setAllowedMethods(Collections.singletonList("*"));
                        configuration.setAllowCredentials(true);
                        configuration.setAllowedHeaders(Collections.singletonList("*"));
                        configuration.setMaxAge(3600L);

                        configuration.setExposedHeaders(Collections.singletonList("Authorization"));
                        
                        return configuration;
                    }
                })));
}

config > CorsMvcConfig

  • 모든 컨트롤러 경로에서 프론트 측 접근을 허용하는 코드
package com.example.springjwt.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry corsRegistry) {
        corsRegistry.addMapping("/**")
                .allowedOrigins("http://localhost:3000");
    }
}

13. JWT의 목표

JWT를 사용한 이유

  • 모바일 앱
    • JWT가 사용된 주 이유는 결국 모바일 앱의 등장. 모바일 앱의 특성상 주로 JWT 방식으로 인증/인가를 진행한다. 결국 STATELESS는 부수적인 효과.
  • 모바일 앱에서의 로그아웃
    • 모바일 앱에서는 JWT 탈취 우려가 거의 없기 때문에 앱단에서 로그아웃을 진행하여 JWT 자체를 제거해버리면 서버측에서는 추가 조치가 필요가 없다.
      • 토큰 자체가 확실하게 없어졌다는 보장이 되기 때문.
  • 장시간 로그인과 세션
    • 장기간 동안 로그인 상태를 유지하려고 세션 설정을 하면 서버 측 부하가 많이 가기 때문에 jwt 방식을 이용하는 것도 한 방법이다.
profile
Good Luck!

0개의 댓글