블로그 이미지

suddiyo


꾸준히 기록하고 성장하는 백엔드 개발자 💻
Today
-
Yesterday
-
Total
-

ABOUT ME

-

  • [Spring] Spring Security + JWT 로그인 구현하기 - 2
    Spring 2023. 6. 15. 01:43

    📝 지난 포스팅

    ➡︎ Spring Security + JWT 로그인 구현하기 - 1

     

    [Spring] Spring Security + JWT 로그인 구현하기 - 1

    Session vs Token 사용자 인증 방식은 일반적으로 세션 기반 방식과 토큰 기반 방식(JWT)으로 나뉜다. 두 방식은 어느 것이 더 뛰어나다고 하긴 애매하고, 각 방식의 장단점과 요구사항을 분석하여 상

    suddiyo.tistory.com

     

    지난 포스팅에서 Spring Security + JWT 로그인 작동 원리를 알아봤으니, 이번 포스팅에서는 본격적으로 기능을 구현해 볼 것이다.

     


    1️⃣ 라이브러리 설정

    Spring Security와 JWT를 사용하기 위해 다음 라이브러리들을 추가해준다.

     

    build.gradle

    dependencies {
     
       ...
        
       // Spring Security
       implementation 'org.springframework.boot:spring-boot-starter-security'
     
       // JWT
       implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
       implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
       implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    }

    2️⃣ JwtToken DTO 생성

    클라이언트에 토큰을 보내기 위해 JwtToken DTO를 생성한다.

     

    JwtToken.java

    @Builder
    @Data
    @AllArgsConstructor
    public class JwtToken {
        private String grantType;
        private String accessToken;
        private String refreshToken;
    }

    JwtToken의 필드 중  grantType는 JWT에 대한 인증 타입이다.

    이 포스팅에서는 단순하고 직관적이며 널리 사용되는 "Bearer" 인증 방식을 사용할 것이다. 

    이 인증 방식은 Access Token을 HTTP 요청의 Authorization 헤더에 포함하여 전송한다. 

    ex) Authorization: Bearer <access_token>

     


    3️⃣ 암호 키 설정

    터미널에 다음 명령어를 작성하여 랜덤으로 암호 키를 생성한 후, 생성된 secret key를 application.yml에서 설정한다.

    해당 키는 토큰의 암호화 복호화에 사용될 것이다. HS256 알고리즘을 사용하기 위해 32글자 이상으로 설정해준다.

    openssl rand -hex 32

     

    application.yml

    ...
    
    jwt:
      secret: 64461f01e1af406da538b9c48d801ce59142452199ff112fb5404c8e7e98e3ff

    4️⃣ JwtTokenProvider 구현

    Spring Security와 JWT 토큰을 사용하여 인증과 권한 부여를 처리하는 클래스이다.

    이 클래스에서 JWT 토큰의 생성, 복호화, 검증 기능을 구현하였다.

     

    JwtTokenProvider.java

    @Slf4j
    @Component
    public class JwtTokenProvider {
        private final Key key;
    
        // application.yml에서 secret 값 가져와서 key에 저장
        public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
            byte[] keyBytes = Decoders.BASE64.decode(secretKey);
            this.key = Keys.hmacShaKeyFor(keyBytes);
        }
    
        // Member 정보를 가지고 AccessToken, RefreshToken을 생성하는 메서드
        public JwtToken generateToken(Authentication authentication) {
            // 권한 가져오기
            String authorities = authentication.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.joining(","));
    
            long now = (new Date()).getTime();
    
            // Access Token 생성
            Date accessTokenExpiresIn = new Date(now + 86400000);
            String accessToken = Jwts.builder()
                    .setSubject(authentication.getName())
                    .claim("auth", authorities)
                    .setExpiration(accessTokenExpiresIn)
                    .signWith(key, SignatureAlgorithm.HS256)
                    .compact();
    
            // Refresh Token 생성
            String refreshToken = Jwts.builder()
                    .setExpiration(new Date(now + 86400000))
                    .signWith(key, SignatureAlgorithm.HS256)
                    .compact();
    
            return JwtToken.builder()
                    .grantType("Bearer")
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .build();
        }
    
        // Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
        public Authentication getAuthentication(String accessToken) {
            // Jwt 토큰 복호화
            Claims claims = parseClaims(accessToken);
    
            if (claims.get("auth") == null) {
                throw new RuntimeException("권한 정보가 없는 토큰입니다.");
            }
    
            // 클레임에서 권한 정보 가져오기
            Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
    
            // UserDetails 객체를 만들어서 Authentication return
            // UserDetails: interface, User: UserDetails를 구현한 class
            UserDetails principal = new User(claims.getSubject(), "", authorities);
            return new UsernamePasswordAuthenticationToken(principal, "", authorities);
        }
    
        // 토큰 정보를 검증하는 메서드
        public boolean validateToken(String token) {
            try {
                Jwts.parserBuilder()
                        .setSigningKey(key)
                        .build()
                        .parseClaimsJws(token);
                return true;
            } catch (SecurityException | MalformedJwtException e) {
                log.info("Invalid JWT Token", e);
            } catch (ExpiredJwtException e) {
                log.info("Expired JWT Token", e);
            } catch (UnsupportedJwtException e) {
                log.info("Unsupported JWT Token", e);
            } catch (IllegalArgumentException e) {
                log.info("JWT claims string is empty.", e);
            }
            return false;
        }
    
    
        // accessToken
        private Claims parseClaims(String accessToken) {
            try {
                return Jwts.parserBuilder()
                        .setSigningKey(key)
                        .build()
                        .parseClaimsJws(accessToken)
                        .getBody();
            } catch (ExpiredJwtException e) {
                return e.getClaims();
            }
        }
    
    }

    🖤 generateToken()

    • 인증(Authentication) 객체를 기반으로 Access Token Refresh Token 생성
    • Access Token: 인증된 사용자의 권한 정보와 만료 시간을 담고 있음
    • Refresh Token: Access Token의 갱신을 위해 사용 됨

     

    🖤 getAuthentication()

    • 주어진 Access token을 복호화하여 사용자의 인증 정보(Authentication)를 생성
    • 토큰의 Claims에서 권한 정보를 추출하고, User 객체를 생성하여 Authentication 객체로 반환
    • 🧐 Collection<? extends GrantedAuthority>로 리턴받는 이유
      ➡️ 권한 정보를 다양한 타입의 객체로 처리할 수 있고, 더 큰 유연성 확장성을 가질 수 있음

    [ Authentication 객체 생성하는 과정 ]

    1. 토큰의 클레임에서 권한 정보를 가져옴. "auth" 클레임은 토큰에 저장된 권한 정보를 나타냄
    2. 가져온 권한 정보를 SimpleGrantedAuthority 객체로 변환하여 컬렉션에 추가
    3. UserDetails 객체를 생성하여 주체(subject)와 권한 정보, 기타 필요한 정보를 설정
    4. UsernamepasswordAuthenticationToken 객체를 생성하여 주체와 권한 정보를 포함한 인증(Authentication) 객체를 생성

     

    🖤 validateToken()

    • 주어진 토큰을 검증하여 유효성을 확인
    • Jwts.parserBuilder를 사용하여 토큰의 서명 키를 설정하고, 예외 처리를 통해 토큰의 유효성 여부를 판단
    • 🧐 IllegalArgumentException 발생하는 경우
      ➡️ 토큰이 올바른 형식이 아니거나 클레임이 비어있는 경우 등에 발생
    • claim.getSubject()는 주어진 토큰의 클레임에서 "sub" 클레임의 값을 반환
      ➡️ 토큰의 주체를 나타냄. ex) 사용자의 식별자나 이메일 주소

     

    🖤 parseClaims()

    • 클레임(Claims): 토큰에서 사용할 정보의 조각
    • 주어진 Access token을 복호화하고, 만료된 토큰인 경우에도 Claims 반환
    • parseClaimsJws() 메서드가 JWT 토큰의 검증과 파싱을 모두 수행

    5️⃣ JwtAuthenticationFilter 구현

    클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로, UsernamePasswordAuthenticationFilter 이전에 실행 할 것이다.

    클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고, 유효한 토큰인 경우 해당 토큰의 인증 정보(Authentication)를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다.

     

    JWT를 통해 username + password 인증을 수행한다는 뜻!

     

     

    JwtAuthenticationFilter.java

    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends GenericFilterBean {
        private final JwtTokenProvider jwtTokenProvider;
    
    
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            // 1. Request Header에서 JWT 토큰 추출
            String token = resolveToken((HttpServletRequest) request);
    
            // 2. validateToken으로 토큰 유효성 검사
            if (token != null && jwtTokenProvider.validateToken(token)) {
                // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            chain.doFilter(request, response);
        }
    
        // Request Header에서 토큰 정보 추출
        private String resolveToken(HttpServletRequest request) {
            String bearerToken = request.getHeader("Authorization");
            if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
                return bearerToken.substring(7);
            }
            return null;
        }
    }

    🖤 doFilter()

    1. resolveToken() 메서드를 사용하여 요청 헤더에서 JWT 토큰을 추출
    2. JwtTokenProvider의 validateToken() 메서드로 JWT 토큰의 유효성 검증
    3. 토큰이 유효하면 JwtTokenProvider의 getAuthentication() 메서드로 인증 객체 가져와서 SecurityContext에 저장
      ➡︎ 요청을 처리하는 동안 인증 정보가 유지된다 ✨
    4. chain.doFilter()를 호출하여 다음 필터로 요청을 전달

     

    🖤 resolveToken()

    • 주어진 HttpServletRequest에서 토큰 정보를 추출하는 역할
    • "Authorization" 헤더에서 "Bearer" 접두사로 시작하는 토큰을 추출하여 반환

    6️⃣ SecurityConfig 설정

    Spring Security의 설정을 담당하는 SecurityConfig를 작성한다.

     

    SecurityConfig.java

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
        private final JwtTokenProvider jwtTokenProvider;
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
            return httpSecurity
                    // REST API이므로 basic auth 및 csrf 보안을 사용하지 않음
                    .httpBasic().disable()
                    .csrf().disable()
                    // JWT를 사용하기 때문에 세션을 사용하지 않음
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .authorizeHttpRequests()
                    // 해당 API에 대해서는 모든 요청을 허가
                    .requestMatchers("/members/sign-in").permitAll()
                    // USER 권한이 있어야 요청할 수 있음
                    .requestMatchers("/members/test").hasRole("USER")
                    // 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정
                    .anyRequest().authenticated()
                    .and()
                    // JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build();
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            // BCrypt Encoder 사용
            return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
    
    
    }

    🖤 filterChain() ➡︎ HttpSecurity를 구성하여 보안 설정을 정의

    • httpBasic.disable(): Basic 인증을 사용하지 않음
    • csrf().disable(): CSRF(Cross-Site Request Forgery) 보안을 비활성화
    • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): JWT를 사용하기 때문에 세션을 사용하지 않음
    • authorizeHttpRequests(): 요청에 대한 인가 규칙 설정
    • requestmatchers("members/sign-in").permitAll(): "members/sign-in" 경로에 대한 요청은 모든 사용자에게 허용
    • requestmatchers("members/test").hasRole("USER") "members/test" 경로에 대한 요청은 "USER" 권한을 가진 사용자만 허용
    • anyRequest().authenticated(): 나머지 모든 요청은 인증을 필요로 함
    • addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class): JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하여 JWT 인증을 처리

     

    🖤 passwordEncoder()

    • DelegatingPasswordEncoder를 생성하여 반환
    • DelegatingPasswordEncoder는 여러 암호화 알고리즘을 지원하며, Spring Security의 기본 권장 알고리즘을 사용하여 비밀번호를 인코딩

     


    7️⃣ 인증을 위한 Domain, Repository 설정

    자신의 프로젝트에 맞는 요구사항을 설정해주고, UserDetails interface를 구현한다.

    인증은 username password로 진행할 것이다.

     

    Member.java

    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @AllArgsConstructor
    @Builder
    @EqualsAndHashCode(of = "id")
    public class Member implements UserDetails {
        @Id @GeneratedValue
        @Column(name = "member_id", updatable = false, unique = true, nullable = false)
        private Long id;
    
        @Column(nullable = false)
        private String username;
    
        @Column(nullable = false)
        private String password;
    
        private String nickname;
    
        private String address; // 도로명 주소
    
        private String phone;
    
        private String profileImg;
    
        @ElementCollection(fetch = FetchType.EAGER)
        @Builder.Default
        private List<String> roles = new ArrayList<>();
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return this.roles.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return true;
        }
    }

    🖤 getAuthorites()

    • 멤버가 가지고 있는 권한(authority) 목록을 SimpleGrantedAuthority로 변환하여 반환
    • 나머지 Override 메서드들 전부 true로 반환하도록 설정

     

    MemberRepository.java

    public interface MemberRepository extends JpaRepository<Member, Long> {
        Optional<Member> findByUsername(String username);
    }

    8️⃣ 인증을 위한 Service 구현

    MemberService.java

    @Service
    @RequiredArgsConstructor
    @Transactional(readOnly = true)
    @Slf4j
    public class MemberServiceImpl implements MemberService{
        private final MemberRepository memberRepository;
        private final AuthenticationManagerBuilder authenticationManagerBuilder;
        private final JwtTokenProvider jwtTokenProvider;
    
        @Transactional
        @Override
        public JwtToken signIn(String username, String password) {
            // 1. username + password 를 기반으로 Authentication 객체 생성
            // 이때 authentication 은 인증 여부를 확인하는 authenticated 값이 false
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
    
            // 2. 실제 검증. authenticate() 메서드를 통해 요청된 Member 에 대한 검증 진행
            // authenticate 메서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드 실행
            Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    
            // 3. 인증 정보를 기반으로 JWT 토큰 생성
            JwtToken jwtToken = jwtTokenProvider.generateToken(authentication);
    
            return jwtToken;
        }
    }

    🖤 signIn()

    1. 로그인 요청으로 들어온 username + password를 기반으로 Authentication 객체 생성
    2. authenticate() 메서드를 통해 요청된 Member에 대한 검증 진행
      ➡︎ loadUserByUsername 메서드가 실행됨 (어떤 객체를 검증할 것인지에 대해 직접 구현해야 함)
    3. 검증에 성공하면 인증된 Authentication 객체를 기반으로 JWT 토큰 생성

    9️⃣ CustomUserDetailsService 구현

    CustomUserDetailsService.java

    @Service
    @RequiredArgsConstructor
    public class CustomUserDetailsService implements UserDetailsService {
    
        private final MemberRepository memberRepository;
        private final PasswordEncoder passwordEncoder;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            return memberRepository.findByUsername(username)
                    .map(this::createUserDetails)
                    .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
        }
    
        // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 return
        private UserDetails createUserDetails(Member member) {
            return User.builder()
                    .username(member.getUsername())
                    .password(passwordEncoder.encode(member.getPassword()))
                    .roles(member.getRoles().toArray(new String[0]))
                    .build();
        }
    
    }
    • 실제로는 DB 자체에 encoding된 password 값을 갖고 있고 그냥 memer.getPassword()로 encoding된 password를 꺼내는 것이 좋지만, 예제에서는 편의를 위해 검증 객체를 생성할 때 encoding을 해줬다.

     

     


    📝 이어지는 포스팅

    ➡︎ Spring Security + JWT 로그인 구현하기 - 3

     

    [Spring] Spring Security + JWT 로그인 구현하기 - 3

    Spring Security + JWT 로그인 테스트 이번에는 지난 번에 Spring Security + JWT로 구현한 로그인 기능이 잘 동작하는지 테스트를 해볼 것이다! 📝 지난 포스팅 ➡︎ Spring Security + JWT 로그인 구현하기 - 1 [Sp

    suddiyo.tistory.com


    📋 참고 자료

     

    [Spring] Spring Security + JWT 토큰을 통한 로그인

    JWT JWT(Json Web Token)은 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다. 현재 앱개발을 위해 REST API를 사용 중인데, 웹 상에서 Form을 통해 로그인하는 것이

    gksdudrb922.tistory.com

     

    728x90

    댓글

Designed by Tistory.