본문 바로가기
카테고리 없음

[Spring Security] JWT로그인 구현 - 1. 필터 구조, CustomUserDetailsService

by 빠작 2023. 8. 13.

JWT 로그인을 구현하면서 이해한 내용을 정리해보았다.

인프런에서 제공하는 아래 무료 강의를 보고 작성한 코드이다.

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0#

 

[무료] 스프링부트 시큐리티 & JWT 강의 - 인프런 | 강의

스프링부트 시큐리티에 대한 개념이 잡힙니다., 스프링부트 시큐리티,제가 공부했던 걸 쉽게 설명드릴게요 🙂 스프링부트 시큐리티 github https://github.com/codingspecialist/-Springboot-Security-OAuth2.0-V3 htt

www.inflearn.com

 

나는 프론트엔드 부분을 추가했기 때문에 JWT 필터 부분은 강의와 같지만 컨트롤러가 좀 다르다.

그리고 회원 엔티티도 강의와 다르게 만들었기 때문에 UserDetailsService 쪽도 차이가 있을 것이다.

이 글은 그냥 JWT 구현 방식의 구조에 대해 이해하기 위해서 참고만 하면 좋을 것 같다. 그래도 아무것도 모르고 코드를 따라만 치던 내가 이걸 이해한 끝에 작성한 글이니 읽어보면 도움은 될 것이라고 믿는다. 


코드 설명 전 전체적인 구조를 파악하고 가는 것이 좋을 것 같아 정리해보았다.

 

1. 필터 구조

가장 핵심이 되는 시큐리티 필터 구조이다.

Spring Security 필터 중 Authentication과 관련된 필터는 일부이고, 그 중 가장 먼저 등장하는 것이 UsernamePasswordAuthentication filter이다. 그 앞에 JWT 필터를 끼워넣었다. 

Security filterchain 구조. 이 중 건드린 것은 JWT filter와 UsernamePasswordAuthentication filter 뿐이기에 두 개만 크게 표시해두었다.

 

 

왜 JWT 필터를 제일 앞에 넣었나?

JWT 토큰을 가지고 있는 사용자는 바로 인증을 받을 수 있도록 하기 위해서이다.

JWT 토큰의 장점 중 하나가 바로 DB에 접근하지 않아도 된다는 점이다. 처음에 인증된 사용자에게만 토큰을 발급해주기 때문에, 토큰과 함께 들어온 요청에 대해서는 그 토큰의 유효성만 판단하면 될 뿐, 사용자 정보를 대조해볼 필요는 없다. UsernamePasswordAuthentication filter는 DB에 접근하여 사용자 정보를 대조하는 필터이기에 이 앞에 넣은 것이다.

 

그럼 JWT 토큰으로 인증이 이루어지니까 UsernamePasswordAuthentication filter는 필요없는 것 아닌가?

토큰 유효 기간이 만료된 회원은 회원가입/로그인을 통해 인증받은 후 JWT 토큰을 새로 발급받아야 한다.

이 때의 인증을 위해 UsernamePasswordAuthentication filter가 필요하다.

 

그런데 여기서는 UsernamePasswordAuthentication filter를 사용하지는 않았다.

대신 컨트롤러에서 이 필터가 하는 역할을 똑같이 수행하도록 했다. 필터의 역할은 Authentication 객체를 만들어 AuthenticationManager에게 넘겨주는 것인데, 컨트롤러에서 이 과정을 수행하도록 했다.

 


 

2. UsernamePasswordAuthentication 필터

이 필터는 SecurityFilterChain에 원래 등록된 필터 중 하나로, 인증과 관련된 필터들 중 제일 앞에 등장한다.

이 필터에서 UsernamePasswordAuthenticationToken이라는 Authentication 타입 객체를 만들어 AuthenticationManager을 호출하면, 내부의 AuthenticationProvider가 DB에 저장된 회원정보를 대조하여 인증을 해줄지 말지 결정한다.

 

아직 이런 기초 개념에 익숙하지 않으면 아래 글을 읽어보고 오면 더 좋을 것 같다.

https://bbazackcoding.tistory.com/9

 

[Spring] Spring Security 기본 개념-1. Authentication, Authentication Manager & Provider (+User, UsernamePasswordToken)

JWT 로그인을 구현하면서 너무 많은 개념이 등장해 이해하기 힘들었다. 그래서 Spring 공식 문서를 읽고 이해한 내용을 정리해보았다. 우선 스프링 시큐리티에서 인증이 일어나는 전체 과정을 한

bbazackcoding.tistory.com

 

이렇게 진행되는 과정을 컨트롤러에서 똑같이 수행하도록 했다.

 

그래서 내가 해줘야 할 일은

1. AuthenticationProvider 수정하기

2. 인증이 된 사용자에게 JWT 토큰 발급해주기

 


 

2. 구현 : AuthenticationProvider 수정하기 - CustomUserDetailsService

이 과정을 거친다는 것은

1. JWT 토큰이 없거나 만료된 기존 회원이 로그인

2. 비회원이 접근을 시도

둘 중 하나이다.

 

따라서 DB 조회를 통해 로그인을 시도한 사용자가 회원인지를 확인해야 한다. 이 과정은 DAOAuthenticationProvider에서 일어난다. DAOAuthenticationProviderUserDetailsService를 통해 접근한 사용자를 DB에서 검색하여 대조하고 인증을 진행한다.

내부에 존재하는 UserDetailsService.

 

이 과정에서 DB에서 사용자 정보를 찾기 위해 UserDetailsService에 있는 loadUserByUsername메서드를 사용한다. 얻어온 사용자 정보를 담기 위한 일종의 DTO로서 사용되는 것이 바로 UserDetails 타입 객체이다.

retrieve는 검색하다는 뜻. 즉 DB에서 사용자 정보를 검색하는 메서드가 DAOAuthenticationProvider에 존재하고, 여기서 loadUserbyUsername 메서드가 사용된다.

 


 

본인이 등록한 회원 엔티티의 특성에 따라 UserDetails UserDetailsService를 커스텀하여 사용할 수 있다.

나는 UserDetails은 Spring에서 제공하는 User을 사용했는데, 이는 이름과 비밀번호만 저장 가능하다.

그리고 나는 회원 엔티티의 이름 속성을 nickname으로 저장했기에, loadUserbyUsername 쿼리문을 새로 짜야해서 

UserDetailsService만 따로 커스텀했다.

 

아래는 코드 전문이다.

@Component("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(final String username) {
        System.out.println(username);
        return memberRepository.findOneWithAuthoritiesByNickname(username)
                .map(member -> createUser(username, member))
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    private org.springframework.security.core.userdetails.User createUser(String username, Member member) {

        List<GrantedAuthority> grantedAuthorities = member.getAuthorities().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
                .collect(Collectors.toList());

        return new org.springframework.security.core.userdetails.User(
                member.getNickname(),
                bCryptPasswordEncoder.encode(member.getPw()),
                grantedAuthorities
        );
    }
}

loadUserbyUsername은 memberRepository에서 username을 가지는 회원을 찾고, 이 회원의 정보를 바탕으로 user을 생성한다. user을 생성하는 함수로 추가로 createUser을 정의해주었다.

 


 

자 이제 이 파트에서 더 건드릴 것은 없다.

DB에서 사용자 정보를 제대로 담아올 수 있는 방법을 제공했으니, Provider에서 알아서 정보를 비교하고 인증을 해줄지 말지 결정할 것이다. 여기서 인증에 성공했다면 사용자에게 JWT 토큰을 발급하여 돌려주면 된다.

 


 

3. 구현 : 인증된 사용자에게 JWT 토큰 발급하기 - 컨트롤러 생성

이제 컨트롤러를 살펴보자.

사용자가 입력한 아이디와 비밀번호로 UsernamePasswordAuthenticationToken을 만든다. 이 역시 Authentication 타입인데, username과 password만 담는 간단한 꼴이다.

이 토큰을 AuthenticationManager로 넘기면 2번에서 봤던 DAOAuthenticationProvider가 인증여부를 결정해준다. 인증에 성공하면 AuthenticationManagerAuthentication 객체를 리턴하는데, 이를 SecurityContextHolder에 저장해준다.

    @PostMapping("/LoginForm")
    public ResponseEntity<TokenDTO> processingLoginForm(@RequestBody MemberDTO memberDTO) throws Exception {
    
            //일단 usernamepasswordtoken을 발행하는데, 이는 아직 권한 부여가 되지 않은 토큰.
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=new UsernamePasswordAuthenticationToken(memberDTO.getNickname(),memberDTO.getPw());

            //이 토큰에게 권한을 부여해줌
            Authentication authentication=authenticationManagerBuilder.getObject().authenticate(usernamePasswordAuthenticationToken);
            
            //context holder 에 이 유저에 대한 정보를 저장함.
            SecurityContextHolder.getContext().setAuthentication(authentication);

            //이제 권한을 부여받은 이 토큰을 가지고 jwt를 생성해줌.
            String jwt= tokenProvider.createToken(authentication);

            HttpHeaders httpHeaders=new HttpHeaders();
            httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER,"Bearer"+jwt);

            return new ResponseEntity<>(new TokenDTO(jwt),  httpHeaders, HttpStatus.OK);
    }

 

그리고 이 Authentication 객체를 가지고 JWT 토큰을 생성한다.

생성된 JWT 토큰은 헤더에 담겨 다시 리턴된다.