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

이멀젼씨

·

2021. 6. 25. 19:04

목적

세션을 사용하지 않고 로그인을 구현하기 위함

목차

  1. 의존성 추가
  2. UserDetails 생성
  3. UserRepository 추가
  4. UserDetailsService 생성
  5. JWT유틸 클래스 추가
  6. JWT필터 추가
  7. 스프링 시큐리티 설정
  8. 스프링 설정
  9. 컨트롤러 설정

 

Refresh Token을 활용한 JWT로그인의 구현은 아래 링크를 참조하면 되겠다.

https://emgc.tistory.com/148

 

1. 의존성 추가

jwt 의존성을 추가해준다

아래는 gradle 기준이다.

implementation 'io.jsonwebtoken:jjwt:0.9.1'

2. UserDetails 생성

스프링 시큐리티에서 관리하는 UserDetails타입의 객체를 생성해야한다.

기본 필드 및 메서드 외 필요한 것들은 추가해서 사용하면 된다.

public class User implements UserDetails{

    @Id
    @GeneratedValue
    private Long id;

    private String email;
    private String password;
    private String phoneNumber;
    private String name;

    @Enumerated(EnumType.STRING)
    private Gender gender;

    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Thumbnail> thumbnailList = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return email;
    }

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

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

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

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

    public User updateInfo(UserDto userDto){
        this.email = userDto.getEmail();
        this.password = userDto.getPassword();
        this.phoneNumber = userDto.getPhoneNumber();
        this.name = userDto.getName();
        this.gender = userDto.getGender();
        return this;
    }
}

3. UserRepository 추가

스프링 시큐티리에서 JPA를 사용하기 때문에 User타입을 다루는 레포지토리를 추가해야한다.

findByEmail은 이메일로 유저를 찾기 위해 선언하였다.
기본 ID로 찾을거면 굳이 선언하지 않아도 된다.

public interface UserRepository extends JpaRepository<User, Long> {  
    Optional<User> findByEmail(String email);  
}

4. UserDetailsService 생성

스프링시큐리티에서 유저를 찾는 메소드를 제공하는 UserDetailsService를 추가한다.

UserDetailsService의 loadUserByUsername메소드를 오버라이딩 하여 유저를 찾는 방법을 직접 지정한다.

반환타입은 UserDetails타입이어야한다.

public class CustomUserDetailService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    }
}

5. JWT유틸 클래스 추가

JWT을 생성, 검증, 정보추출 해주는 클래스이다.

JWT필터 안에서만 사용할 수 있으면 된다.

public class JwtAuthenticationProvider {

    private String secretKey = "secret";

    private long tokenValidTime = 1000L * 60 * 60;

    @Autowired
    private UserDetailsService userDetailsService;

    // JWT 토큰 생성
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        String token = null;
        Cookie cookie = WebUtils.getCookie(request, "X-AUTH-TOKEN");
        if(cookie != null) token = cookie.getValue();
        return token;
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

6. JWT필터 추가

인증에 성공하면 authentication객체를 Context안에 넣는다.

인증에 실패하면 아무런 과정 없이 다음 필터로 넘어간다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtAuthenticationProvider jwtAuthenticationProvider;

    public JwtAuthenticationFilter(JwtAuthenticationProvider provider) {
        jwtAuthenticationProvider = provider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtAuthenticationProvider.resolveToken(request);

        if(token != null && jwtAuthenticationProvider.validateToken(token)){
            Authentication authentication = jwtAuthenticationProvider.getAuthentication(token);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

7. 스프링 시큐리티 설정

JWT필터를 스프링 시큐리티가 관리하려면 설정에서 추가해줘야한다.

필터는 new를 사용해서 객체를 생성하여 등록해주어야 한다.

필터에 @Component나 @Bean을 붙이게 되면 new로 선언해준것 외에도 한번 더 필터로 등록되기 때문이다.

쿠키에 있는 토큰을 전달할 것이기 때문에 CORS설정 또한 해주어야한다.

public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationProvider jwtAuthenticationProvider;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .cors().and()
                .csrf().disable()
                .formLogin().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtAuthenticationProvider), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

8. 스프링 설정

스프링 시큐리티에서 스프링의 CORS설정을 가져다 사용할 수 있도록 설정해준다.

클라이언트의 쿠키를 전달하고 받을 것이기 때문에 allowCredentials를 true로 설정한다.

클라이언트의 쿠키에 전달한 헤더를 exposedHeader안에 넣어주어야 한다.

public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .exposedHeaders("X-AUTH-TOKEN")
                .allowCredentials(true)
                .allowedOrigins("http://localhost:3000");
    }
}

9. 컨트롤러 설정

로그인 시 사용자의 정보가 일치하면 토큰을 만들어 쿠키에 추가해준다.

로그아웃 요청시엔 토큰정보를 갖고 있는 헤더를 지워준다.

/info 는 클라이언트에서 새로고침 시 요청이 들어오는 경로다. 클라이언트의 토큰이 유효하면 정보를 꺼내서 주고, 아니면 아무것도 주지 않는다.

public class UserApi {

    @Autowired private UserRepository userRepository;
    @Autowired private PasswordEncoder passwordEncoder;
    @Autowired private JwtAuthenticationProvider jwtAuthenticationProvider;

    @PostMapping("/join")
    public void join(@RequestBody UserDto user){
        userRepository.save(User.builder()
                .email(user.getEmail())
                .password(passwordEncoder.encode(user.getPassword()))
                .name(user.getName())
                .phoneNumber(user.getPhoneNumber())
                .gender(user.getGender())
                .roles(Collections.singletonList("ROLE_USER"))
                .build());

    }

    @PostMapping("/login")
    public UserDto login(@RequestBody UserDto user, HttpServletResponse response) {
        User member = userRepository.findByEmail(user.getEmail())
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
        if (!passwordEncoder.matches(user.getPassword(), member.getPassword())) {
            throw new IllegalArgumentException("잘못된 비밀번호입니다.");
        }

        String token = jwtAuthenticationProvider.createToken(member.getUsername(), member.getRoles());
        response.setHeader("X-AUTH-TOKEN", token);

        Cookie cookie = new Cookie("X-AUTH-TOKEN", token);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setSecure(true);
        response.addCookie(cookie);

        return new UserDto(member);
    }

    @PostMapping("/logout")
    public void logout(HttpServletResponse response){
        Cookie cookie = new Cookie("X-AUTH-TOKEN", null);
        cookie.setHttpOnly(true);
        cookie.setSecure(false);
        cookie.setMaxAge(0);
        cookie.setPath("/");
        response.addCookie(cookie);
    }

    @GetMapping("/info")
    public UserDto getInfo(){
        Object details = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if(details != null && !(details instanceof  String)) return new UserDto((User) details);
        return null;
    }

}