[Spring] 스프링 시큐리티 + JWT

이다혜·2024년 3월 14일
0

Spring

목록 보기
27/27

출처 : https://substantial-park-a17.notion.site/JWT-7a5cd1cf278a407fae9f35166da5ab03

1. JWT 인증 방식 시큐리티 동작 원리

회원가입

  1. 회원가입 정보를 DTO에 담아서 컨트롤러로 보낸다.

  2. 컨트롤러에서 서비스로 DTO를 전달한다.

  3. DTO의 정보를 기반으로 엔티티를 생성한다.

  4. 엔티티를 DB에 저장한다.

로그인(인증)

  1. usernamePasswordAuthenticationFilter는 사용자의 로그인 정보를 받아 Authentication 객체를 생성한다.

  2. AuthenticationManager는 이 Authentication 객체를 활용하여 인증 요청을 처리한다.

  3. AuthenticationManagerUserDetailsService를 호출하여 사용자의 세부 정보를 데이터베이스에서 조회한다.

  4. UserDetailsService는 조회한 사용자 정보를 UserDetails 객체에 담아 반환한다.

  5. 로그인이 성공하면 JWTUtil에서 토큰을 만들어서 응답으로 반환한다.

경로 접근(인가)

  1. JWT Filter: 사용자가 특정 경로에 접근 요청을 보내면, 서버는 JWT Filter를 통해 요청의 헤더에서 JWT를 찾는다. JWT는 사용자의 인증 정보를 담고 있는 토큰이다.

  2. 토큰 검증: 서버는 JWT를 찾은 후에 이를 검증한다.

  3. 세션 생성: 토큰이 유효하다면 서버는 이 요청에 대한 일시적인 세션을 생성한다. 이 세션은 요청이 처리되는 동안에만 유효하며, 요청이 끝나면 세션은 소멸된다.


2. 프로젝트 생성 및 의존성 추가

필수 의존성

  • Lombok
  • Spring Web
  • Spring Security
  • Spring Data JPA
  • MySQL Driver

JWT 필수 의존성

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'
}

3. SecurityConfig 클래스

스프링 시큐리티의 인가 및 설정을 담당하는 클래스이다.

SecurityFilterChain은 스프링 시큐리티의 필터 체인을 정의하는 빈이다.

SecurityConfig

@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();
    }
    
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }
}

csrf disable

rest api를 이용하면 서버에 인증 정보를 보관하지 않기 때문에 굳이 csrf를 활성화할 필요가 없다.

form 로그인 방식 disable

jwt를 사용할 것이기 때문에 form 로그인은 막는다.

http basic 인증 방식 disable

클라이언트가 서버에 요청을 보낼 때마다 인증 정보를 담아 보내는 인증 방식인 http basic을 비활성화 한다.

경로별 인가 작업

/login, /, /join 경로로 들어오는 요청은 인증 없이 접근을 허용한다.

/admin 경로는 "ADMIN" 즉 관리자 권한을 가진 사용자만 접근할 수 있도록 설정한다.

그 외의 모든 요청은 인증된 사용자만 접근할 수 있도록 한다.

세션 설정

서버가 클라이언트의 상태를 저장하지 않고 요청 간에 독립적으로 처리하도록 만든다.

BCryptPasswordEncoder 등록

스프링 시큐리티에서 제공하는 비밀번호 암호화를 위한 클래스이다.

사용자의 비밀번호를 암호화하여 저장하고, 로그인 시에 입력된 비밀번호를 암호화된 비밀번호와 비교하여 인증하는 데 사용된다.


4. DB 연결

데이터베이스는 MySQL을 사용하고 데이터 접근은 Spring Data Jpa를 사용한다.

application.yml

DB 연결 설정

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/데이터베이스이름
    username: 아이디
    password: 비밀번호

Hibername ddl 설정

spring:
  jpa:
    hibernate:
      ddl-auto: create

5. 회원가입

UserEntity

@Entity
@Setter
@Getter
public class UserEntity {

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

    private String username;
    private String password;

    private String role;
}

JoinDTO

@Setter
@Getter
public class JoinDTO {

    private String username;
    private String password;
}

JoinController

username과 password를 DTO에 담아서 컨트롤러로 전송하고
컨트롤러는 DTO를 서비스로 넘긴다.

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

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

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

        return "ok";
    }
}

JoinService

@Service
@RequiredArgsConstructor
public class JoinService {

    private final UserRepository  userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public void joinProcess(JoinDTO joinDTO) {

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

        Boolean isExist = userRepository.existsByUsername(username);

        if (isExist) {

            return;
        }

        User data = new User();

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

        userRepository.save(data);
    }
}

UserRepository

public interface UserRepository extends JpaRepository<User, Long> {

    Boolean existsByUsername(String username);
}

6. 로그인 필터 구현

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

form 로그인 방식에서는 username과 password를 전송하면 UsernamePasswordAUthentication 필터에서 회원 검증을 시작했는데

우리는 form 로그인 방식을 비활성화 했기 때문에 로그인 진행을 위해 필터를 커스텀하여 등록해야 한다.

LoginFilter

커스텀한 UsernamePasswordAUthentication 필터이다.

@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

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

	//로그인 성공시 실행하는 메소드 
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

    }

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

SecurityConfig에 커스텀 로그인 필터 등록

http
	.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);

7. 로그인 검증

UserRepository

public interface UserRepository extends JpaRepository<UserEntity, Integer> {

    Boolean existsByUsername(String username);
		
    UserEntity findByUsername(String username);
}

CustomUserDetailsService

UserDetailsService를 커스텀한 서비스이다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

	// 사용자의 아이디를 받아 해당하는 사용자 정보를 가져오는 메소드
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
				
        UserEntity userData = userRepository.findByUsername(username);

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

        return null;
    }
}

CustomUserDetails

UserDetails를 커스텀한 클래스이다.

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final 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 true;
    }

    @Override
    public boolean isAccountNonLocked() {

        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {

        return true;
    }

    @Override
    public boolean isEnabled() {

        return true;
    }
}

8. JWT 발급 및 검증

  • 로그인 성공 -> JWT 발급
  • 접근 -> JWT 검증

JWT는 Header.Payload.Signature 구조로 이루어져 있으며
signature(서명)은 header와 payload와 암호화 키를 header에 명시된 암호화 알고리즘으로 암호화 한 것이다.

암호화 키 저장

application-secret.yml 파일에 저장하고 .gitignore에 해당 파일을 포함시켜 노출되지 않도록 한다.

spring:
  jwt:
    secret: 암호화 키
   

JWTUtil

  • 토큰 payload에 저장될 정보
    • username
    • role
    • 생성일
    • 만료일

@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());
    }
	// 주어진 JWT 토큰에서 "username" 정보를 추출
    public String getUsername(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
    }
    // 주어진 JWT 토큰에서 "role" 정보를 추출
    public String getRole(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }
	// 주어진 JWT 토큰이 만료되었는지 여부를 확인
    public Boolean isExpired(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }
    // 주어진 사용자 이름(username), 역할(role), 만료 시간(expiredMs)을 사용하여 JWT를 생성
    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();
    }
}

9. 로그인 성공 JWT 발급

로그인에 성공하면 LoginFilter에 successfulAuthentication 메소드가 실행된다.
여기서 토큰을 생성하여 응답 헤더에 담는다.

public class LoginFilter extends UsernamePasswordAuthenticationFilter {


    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
				
				//UserDetailsS
        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.addHeader("Authorization", "Bearer " + token);
    }
}

10. JWT 검증 필터

요청에 담긴 JWT를 검증하기 위한 필터를 커스텀하여 filter chian에 등록해야한다.

해당 필터를 통해 요청 헤더에 JWT가 존재하는지 확인하고, 존재하는 경우 해당 토큰을 검증한다.

JWTFilter

@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
				
		//request에서 Authorization 헤더를 찾음
        String authorization= request.getHeader("Authorization");
				
		//Authorization 헤더 검증
        if (authorization == null || !authorization.startsWith("Bearer ")) {

            System.out.println("token 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);

            return;
        }

		//토큰에서 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);
    }
}

Filter Chain에 JWTFilter 등록

//JWTFilter 등록
http
  .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

0개의 댓글