코딩성장스토리

Spring Security 공부 + Jwt 적용 본문

기타

Spring Security 공부 + Jwt 적용

까르르꿍꿍 2023. 1. 26. 00:04

 

팀 프로젝트를 하면서 여태껏 추상적으로만 알았던 개념들을 구체화 시킨 것들이 많아 졌다.

그 중에 하나가 Security이다. 

 

일단 Spring Security를 공부하면서 당연하지만 잊지 말아야 하는건 Security는 우리의 편의를 위해 만들어진 것이다.

그러니 스트레스 받지 말장... (시큐리티 버전이 업그레이드 되면서 메서드가 많이 달라짐...😩)

 

난 처음에 이 사진을 보고 이게 도대레 뭔 소리인지 감이 안잡히고 스트레스 받았었다... 😂

하지만 공부를 하고 난 후 이 그림은 그냥 완벽한 그림이다... 

 

가장 이해가 안되고 헷갈렸던 부분은 UserDetails이다... 아니 User라는 객체를 만들었는데 UserDetails는 뭘까...

이것 또한 개발의 편의를 위해 만들어진 것이다.  아래 코드가 UserDetails를 구현한 것이다.

@Getter
@RequiredArgsConstructor
public class PrincipalUserDetails  implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(RoleType.USER.getKey()));
    }

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

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

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

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

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

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

각 오버라이드 된 메서드들은 이름만 보면 쉽게 유추가 가능하다. 그런데 Authorities란  뭘까?

이건 우리가 임의로 유저에게 역할을 정해주는 것이다. 즉 일반 사용자 (ROLE_USER)가 있을 수도 있고 관리자(ROLE_ADMIN) 같은 역할들이 나누어 질 수 있다. 그럼 SecurityConfig를 만들어 구분짓게 할 수 있다.. 아래 코드를 보자

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean  //해당 메서의 리턴되는 오브젝트를 ioc로 등록해준다
    public BCryptPasswordEncoder encoderPwd(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()//admin만 들어 갈 수 있음
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("").authenticated()
                        .requestMatchers("/").authenticated()
                        .requestMatchers("/user/**").authenticated()  //권한이 있는 자만 들어올 수 있음
                        .requestMatchers("/admin/**").hasRole("ADMIN")  //시큐리티는 role변수에 ROLE_* 규칙으로 적어야함 그리고 hasRole()에는 ROLE_뺴고 뒤에 적어야
                        .anyRequest().permitAll() //이외의 url은 모든 사람 접근 허용
                )
        return http.build();
    }

}

 

즉 저 위에서 hasRole("ADMIN")을 보듯이 우리가 UserDetails에 저장된 권한을 보고 시큐리티가 알아서 걸러준다!  

 

그럼 이제 궁금한 게 authenticated() 이 부분이다! 사실 Spring Security는 기본적으로 /login url을 지원해 준다.

그리고 loginpage를 따로 지정 할 수 있지만 일단 구조를 보기때문에 넘어가자 

 

 

다시 이 그림을 보자 . UserDetailService를 보자  

@Service
public class PrincipalDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    //시큐리티 session(내부 Authentication(내부 UserDetails))
    //함수 종료시 @AuthenticationPrincipal 어노테이션이 만들어진다.
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("----");
        User user = userRepository.findByUsername(username);
        System.out.println(user);
        if(user != null){
            return new PrincipalDetails(user);
        }
        return null;
    }
}

저기 loadUserByUsername이 로그인 할때 들어오는 username과 password를 DB값과 비교해주는 메서드 이다. 

이것도 디폴트 값으로 /login 이 호출되면 실행 된다.

 

 

난 Jwt로 로그인을 구현 할 생각이다.  그럼 UsernamePasswordAuthenticationFilter 를 상속받아 필터를 추가하자

UsernamePasswordAuthenticationFilter 이란? 시큐리티필터체인에 있는 필터중 하나이고 유저 인증할때 쓰이는 필터이다.

나는 json으로 로그인 정보를 받을려고 한다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final UserRepository userRepository;
    private ObjectMapper mapper = new ObjectMapper();



    //로그인 시도시 이 메서드 실행해서 Authentication에 담음

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("----------------------------");

        //Json으로 받기
        ServletInputStream inputStream = null;
        LoginDto helloData = null;
        try {
            inputStream = request.getInputStream();
            String msgBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            System.out.println("msg Body = " + msgBody);


            helloData = mapper.readValue(msgBody, LoginDto.class);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        String email = helloData.getEmail();
        String password = helloData.getPassword();
        System.out.println(email + " " +password);
        String encodedPassword = passwordEncoder.encode(password);

        Authentication authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
        Authentication authentication =
                authenticationManager.authenticate(authenticationToken);

        PrincipalUserDetails principalDetailis = (PrincipalUserDetails) authentication.getPrincipal();
        System.out.println("Authentication : "+principalDetailis.getUser().getEmail());
        return authentication;
    }
}

 

그리고 이 필터를 보면 authenticationManager가 보이고 이는 위에서 보던 구조 그림에서 나온 것이다.

인증은 SpringSecurity의 AuthenticatonManager를 통해서 처리하게 되는데, 실질적으로는 AuthenticationManager에 등록된 AuthenticationProvider(구조그림에서 authenticationManager 다음에 나오는 것) 에 의해 처리된다. 인증이 성공하면 2번째 생성자를 이용해 인증이 성공한(isAuthenticated=true) 객체를 생성하여 Security Context에 저장 

 

그럼 AuthenticationProvider도 구현해보자 (구현안해도 돌아가긴하는데 내가 임의로 짤 수 있음)

@Component
//Authentication에 담은 정보를 DB와 일치하는지 체크
public class UserAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;



    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String email = authentication.getName();
        String password = (String) authentication.getCredentials();
        PrincipalUserDetails userDetails = (PrincipalUserDetails) userDetailsService.loadUserByUsername(email);
        // 비밀번호 확인
        if (!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
        }
		//이 토큰이 곧 인증 객체
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

아까 봤던 구조에서 밑줄 친 곳 과 화살표를 보자 . 우리는 UsernamepasswordAuhtenticaiton을 통해 인증을 했다. 

그럼 그 결과에 대한 값이 있어야 되야 한다. 그걸 위한게 저 AuthenticaitonSuccessHandler 와 AuthenticaitonFailureHandler

이다.

AuthenticaitonSuccessHandler

@RequiredArgsConstructor
@Component
//로그인 성공시 처리 클래스
public class UserSuccessHandler implements AuthenticationSuccessHandler {

    private final UserRepository userRepository;
    private final JwtService jwtService;

    @Transactional
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        PrincipalUserDetails userDetails = (PrincipalUserDetails) authentication.getPrincipal();
        User user = userRepository.findByEmail(userDetails.getUsername())
                .orElseThrow(() -> new UsernameNotFoundException("가입된 이메일이 존재하지 않습니다."));

        // JWT Token 생성 & Response
        String accessToken = jwtService.createAccessToken(user.getEmail(), user.getId());
        String refreshToken = jwtService.createRefreshToken(user.getEmail());
        user.updateRefreshToken(refreshToken);
        response.setContentType(APPLICATION_JSON_VALUE);
        LoginTokenRes loginTokenRes = new LoginTokenRes(accessToken,refreshToken,signYN);
        new ObjectMapper().writeValue(response.getWriter(), new BaseResponse(loginTokenRes));
    }
}

성공하면 jwt 반환!

 

 

그럼 여기서 궁금점?

로그인을 하는 것은 좋다. 그럼 로그인해서 얻은 jwt로 다른 화면도 들어갈텐데 그때도 필터가 필요하지 않을까?

이 해결법은 간단하다. 

일단 jwt를 가지고 인증하는 필터를 만든다 . 

securityConfig의

filterchain 메서드에 http.addFilter(new JwtAuthorizationFilter(authenticationManager,userRepository)); 를 추가해

인증 전에 내가 만든 필터를 거치게 한다. 

코드를 보자

 

로그인 후 jwt가지고 접근 할 때 필터

// 인가  BasicAuthenticationFilter 권한 인증 들어올때 실행됨
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private UserRepository userRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository = userRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String header = request.getHeader(JwtProperties.HEADER_STRING);
        if(header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        System.out.println("header : "+header);
        String token = request.getHeader(JwtProperties.HEADER_STRING)
                .replace(JwtProperties.TOKEN_PREFIX, "");

        // 토큰 검증 (이게 인증이기 때문에 AuthenticationManager도 필요 없음)
        // 내가 SecurityContext에 집적접근해서 세션을 만들때 자동으로 UserDetailsService에 있는 loadByUsername이 호출됨.
        String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(token)
                .getClaim("username").asString();

        if(username != null) {
            User user = userRepository.findByUsername(username);

            // 인증은 토큰 검증시 끝. 인증을 하기 위해서가 아닌 스프링 시큐리티가 수행해주는 권한 처리를 위해
            // 아래와 같이 토큰을 만들어서 Authentication 객체를 강제로 만들고 그걸 세션에 저장!
            PrincipalDetails principalDetails = new PrincipalDetails(user);
            Authentication authentication =
                    new UsernamePasswordAuthenticationToken(
                            principalDetails, //나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
                            null, // 패스워드는 모르니까 null 처리, 어차피 지금 인증하는게 아니니까!!
                            principalDetails.getAuthorities());

            // 강제로 시큐리티의 세션에 접근하여 값 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

}

위 코드를 보면 헤더에 아무 것도 없으면 이 필터는 거치지 않게 한다-> 로그인 진행

헤더에 jwt가 있으면 로그인이 된 것이므로 적절한 jwt인지 검증 거치게 한다.

 

요약

1 .Security는 우리가 만든 User 객체에 Userdetail을 감싸고 그 위에 authentication을 감싸고 인증이 이루어 진다.

2 .인증은 시큐리티 안에 있는 필터인  UsernamepasswordAuhtenticaitonFilter를 만들고 그 안에서 authenticationManager 을 이용한 인증을 한다. (이 안에서 AuthenticationProvider을 이용한 인증 그리고 그 안에 UserDetailService 안에 정의된 loadUserByUsername를 통한 db값 비교) 이 과정을 통한 로그인 인증이 된다.

'기타' 카테고리의 다른 글

aws CloudFront + S3  (3) 2023.02.14
Swagger + Security 적용  (2) 2023.01.26
R data 연습 다루기  (0) 2022.10.25
docker 안에 있는 mariaDB 접속하기  (0) 2022.09.15
docker mailserver 메일이 발송이 안될때  (0) 2022.09.14