JWT Login 1

김창모·2023년 5월 31일
0

SpringBoot

목록 보기
14/19

이번시간엔 JWT 로그인을 구현해보자.

JWT 구현 순서

  1. 의존성 추가
  2. application.yml 에 Jwt Secret 키를 등록해준다.
  3. UserDetails 를 구현한 CustomUserDetails
  4. 사용자 인증 인가를 처리할 UserDetailService
  5. JWT 토큰을 생성하고 검증하는 JwtProvider 를 만든다.
  6. JWT 토큰을 사용하여 요청에 대한 인증 인가를 처리하는 OncePerRequestFilter 를 확장한 필터를 구현한다.
  7. SpringSecurityConfig 클래스에 SecurityFilterChain 빈을 등록한다.

의존성 추가

build.gradle 파일 dependencies 에
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

JwtSecretKey

jjwt 라이브러리의 클래스를 사용하여 Key 타입 값을 설정하기 위해 사용할 예정.

application.yml 파일에 Jwt Secret 을 추가해준다.

jwt:
  secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHb11

CustomUserDetails

SpringSecurity 에서 사용되는 인터페이스 UserDetails 의 구현체로 사용자의 인증 정보와 권한 정보를 제공하는 메서드를 정의한다.

package com.hello.hello.utils;

import com.hello.hello.domain.entity.Member;
import java.util.Collection;
import java.util.stream.Collectors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public class CustomUserDetails implements UserDetails {

    private final Member member;

    public CustomUserDetails(Member member) {
        this.member = member;
    }
    
    // Member Entity 를 필드로 가지고 생성자 주입을 이용해 초기화 해준다.

    public Member getMember() {
        return member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return member.getRoles().stream().map(o -> new SimpleGrantedAuthority(o.name()))
                .collect(Collectors.toList());
    }

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

    @Override
    public String getUsername() {
        return member.getEmail();
    }
    
    // Member Entity 의 권한 , password , username 에 대한 Getter
	
    
    // 계정 만료
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    
    // 계정 락
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
	
    // 암호의 유효기간 확인
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
	
    // 계정 활성화
    @Override
    public boolean isEnabled() {
        return true;
    }
}

CustomUserDetailsService

SpringSecurity 에서 사용되는 인터페이스 UserDetailsService의 구현체로
사용자 정보를 가져오는 역할을 담당한다.

package com.hello.hello.service;

import com.hello.hello.domain.entity.Member;
import com.hello.hello.repository.MemberJpaRepository;
import com.hello.hello.utils.CustomUserDetails;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
		
    // 생성자 주입 방식으로 MemberJpaRepository 를 주입받는다.
    private final MemberJpaRepository memberJpaRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    	// email 을 받아 memberjparepository 를 사용하여 member entity 를 찾는다.
        Member member = memberJpaRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException(email + "회원을 찾을수 없습니다."));
                
		// 찾은 member 를 인자로 CustomUserDetails 를 생성하여 리턴해준다.
        return new CustomUserDetails(member);
    }
}

JwtProvider

JWT의 생성 유효성 검증, 정보 추출 등을 하는 클래스 이며 SpringSecurity 와 함께 사용된다.

package com.hello.hello.utils;

import com.hello.hello.domain.Authority;
import com.hello.hello.service.CustomUserDetailsService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Set;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
@Transactional
public class JwtProvider {
	// application.yml 에서 등록한 jwt.secret 값을 넣어준다.
    @Value("${jwt.secret}")
    private String secretKey;
	
    // 우리가 사용할 비밀 키값이다.
    private Key key;

    private final long exp = 1000L * 60 * 60;

    private final CustomUserDetailsService customUserDetailsService;
    
    // @PostConstruct 는 아래 메서드를 해당 클래스의 인스턴스가 생성된 후에 자동으로 호출되도록 해준다.
    @PostConstruct
    protected void init() {
    // init 메서드를 통해 application.yml 에 등록한 String jwt secret 을 
    // Key 타입의 key 값으로 설정해준다.
        key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    public String createToken(String username, Set<Authority> roles) {
        // username, roles 를 받아 토큰을 생성한다.

        //jjwt library 를 사용하여 claims 를 생성하고 username 을 subject 값으로 설정
        Claims claims = Jwts.claims().setSubject(username);

        // 생성한 클래임에 키 "role" 값 role 설정.
        claims.put("roles", roles);

        Date now = new Date();

// Jwts란 ?
// JWT 토큰을 생성하고 처리하기 위한 Java 의 라이브러리인 jjwt 의 클래스로
// JWT의 생성 파싱 검증을 쉽게 처리할수 있는 기능을 제공해준다.


/*
Jwts.builder(): JWT를 생성하기 위한 빌더 객체를 생성합니다.
setHeaderParam(): JWT 헤더에 파라미터를 추가합니다.
setSubject(): JWT의 subject(주제) 클레임을 설정합니다. 주로 사용자 식별 정보를 포함합니다.
claim(): JWT의 클레임을 설정합니다. 클레임은 payload에 포함되는 정보를 의미하며, 사용자 정의 데이터를 포함할 수 있습니다.
setExpiration(): JWT의 만료 시간을 설정합니다.
signWith(): JWT를 서명하는 알고리즘과 비밀키를 설정합니다.
compact(): 최종적으로 JWT를 생성하여 문자열 형태로 반환합니다.
*/

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + exp))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

	/*
		getAuthentication() 은 Jwt 토큰을 인자로 받아 
        customUserDetailsService 의 loadUserByUsername() 메서드를 호출하여
        UserDetails 객체를 받아
        UsernamePasswordAuthenticationToken(userDetails,"{password}",userDetails.getAuthorities()) 를 반환.
        
        userDetails : 인증된 사용자 정보
        "" : JWT 로그인 방식에선 실제 비밀번호가 필요하지 않으므로 ""
        userDetails.getAuthorities() : 사용자의 권한 정보
        
	*/
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(this.getMember(token));

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
	
    
    /*
		getMember() 는 JWT 토큰을 인자로 받아
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
        를 통해 토큰 유효성 검사를 하고 토큰의 클레임을 추출한다.
        추출한 클레임에 getBody().getSubject() 를 호출하여 사용자의 식별자 subject 를 반환.

    */
    public String getMember(String token) {

        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
        } catch (ExpiredJwtException e) {
            e.printStackTrace();
            return e.getClaims().getSubject();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    }
	
    
    /*
    	HttpServletRequest 에서 토큰을 추출하는 메서드이다.
        요청에 대해 Authorization 이라는 헤더 값 (토큰)을 추출한다.
    */
    
    public String resolveToken(HttpServletRequest request) {

        return request.getHeader("Authorization");
    }
    
    /*
    	토큰을 인자로 받아 유효성 검사를 하는 메서드이다.
        token 이 "BEARER" 로 시작하지 않는 경우에는 유효한 토큰으로 간주하고
        "BEARER" 로 시작할 경우 "BEARER" 을 제외한 토큰값을 얻어
        토큰 구문을 분석하고 클레임을 추출한 후
        토큰의 만료 시간이 지났는지 판단한다.
    */

    public boolean validateToken(String token) {

        try {
            if (!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
                return true;
            } else {
                token = token.split(" ")[1].trim();
            }
            Jws<Claims> claimsJws = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);

            return !claimsJws.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

JwtAuthenticationFilter

SpringSecurity 의 OncePerRequestFilter 의 자식 클래스로
JWT 토큰을 추출하고 토큰이 유효한 경우에만 인증 정보를 설정하여 보안 컨텍스트에 저장한다.

package com.hello.hello.utils;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
		
        // jwtProvider.revolveToken() 메서드로 토큰 추출
        String token = jwtProvider.resolveToken(request);
		
        
        // token 이 null 이 아니고 validateToken 을 통과하면
        // 인증정보를 현재 보안 컨텍스트에 설정한다.
        if (token != null && jwtProvider.validateToken(token)) {
            token = token.split(" ")[1].trim();
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request,response);

    }
}

SpringSecurityConfig

SpringSecurity 의 설정을 담당하는 클래스로
구성 요소를 설정하고 보안 규칙과 인증/인가 방식을 정의한다.

package com.hello.hello.config;

import com.hello.hello.utils.JwtAuthenticationFilter;
import com.hello.hello.utils.JwtProvider;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/*
@Configuration
@EnableWebSecurity 을 사용하여 Spring Security 구성을 활성화한다.
*/

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfig {

    private final JwtProvider jwtProvider;
	
    
    // 앞서 한번 언급했지만 User 정보인 Password 를 그대로 저장하는 것은 보안상 좋지 않다.
    // PasswordEncoder 를 통해 변경하여 저장할 예정이라 Bean으로 수동 등록해주었다.
    // 처음 사용한 수동 빈 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
	
    
    // 기존의 WebSecurityConfigurerAdapter 를 상속받아 구현하는 방법이 deprecated 되면서
    // 수동 빈으로 등록하는 방법을 사용하겠다.
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    /* http 객체를 통해 다양한 보안 설정을 구성할 것이다.
       .csrf().disable() csrf 보호 기능을 비활성화 한다.
       csrf 란 : 정상적인 사용자가 의도치 않은 위조 요청을 보내는것을 의미한다.
       그럼 사용해야 하는것 아니냐 ? 
       rest api 를 이용한 서버에서 jwt 로그인 방식을 사용하면 stateless 하기 때문에 
       csrf 공격으로부터 안전하다.
    */
        http.csrf().disable()
        /*
        	Spring Security HTTP 응답 헤더에 대한 설정이며 H2 Console 을 사용하기 때문에 비활성화
        */
                .headers().frameOptions().disable()
                .and()
               /*
               	세션 관리 설정으로 세션 관리 방식을 stateless 로 설정한 것이다.
               */.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                /*
                	.authorizeRequests() 메서드로 URL에 대한 접근 권한을 설정한다.
                */
                .authorizeRequests()
                /*
                	아래 URL에 대해
                */
                .antMatchers("/member","/h2-console/**")
                /*
                	모든 요청에 대해 허용한다.
                */
                .permitAll()
                /*
                	그 외의 요청에 대해선
                */
                .anyRequest()
                /*
                	인증된 사용자만 접근을 허용한다.
                */
                .authenticated()
                .and()
                /*
                	JWT 인증 필터를 추가한다.
                */
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
                /*
                	인증 및 인가 예외에 대한 처리 방식을 설정한다.
                */
                .exceptionHandling()
                /*
                	권한이 없는 사용자의 처리를 담당한다.
                */
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response,
                                       AccessDeniedException accessDeniedException)
                            throws IOException, ServletException {
                        response.setStatus(403);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("test/html; charset=UTF-8");
                        response.getWriter().write("권한이 없는 사용자입니다.");
                    }
                })
                /*
                	인증되지 않은 사용자의 처리를 담당한다.
                */
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response,
                                         AuthenticationException authException) throws IOException, ServletException {
                        response.setStatus(401);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("인증되지 않은 사용자입니다.");
                    }
                });

        return http.build();

    }
}

0개의 댓글