Spring Security + jwt로 로그인/로그아웃 구현하기

개발자·2023년 6월 2일
7

Spring Security와 jwt를 이용해 로그인/로그아웃을 구현한 과정을 정리해보려고 한다.

로그인 구현 방법을 검색해보니 2가지 방법이 나왔다.

  1. Spring Security 로직에서 사용자 정보 인증 및 토큰 발급을 한다.
    Interceptor에서 토큰 인증을 처리한다.

  2. 로그인 컨트롤러를 만들어 사용자 정보 인증 및 토큰 발급을 한다.
    Spring Security에 토큰을 인증하는 Custom Filter를 구현한다.

나는 두 방법중 2번 방법을 선택해 로그인을 구현했다.
(1번 방법은 여기 참고)


로그인

우선 필요한 라이브러리를 추가해준다.(Gradle 프로젝트 기준)

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

로그인 및 토큰 발급

LoginController

@PostMapping(value = "/login")
@ApiOperation(value="로그인")
public ResponseEntity<String> login(@RequestBody @Valid WebLoginRequest request) {

        return ResponseEntity.ok(loginService.login(request));
}

사용자가 로그인할 때 WebLoginRequest에 입력한 loginId와 password를 받아온다.

LoginService

@Transactional
public String login(WebLoginRequest request) {
        //ID 체크
        Admin admin = adminReposiroty.findByLoginId(request.getLoginId())
                .orElseThrow(() -> new LoginException(LoginErrorCode.ID_NOT_FOUND));
        //PW 체크
        if (!passwordEncoder.matches(request.getPassword(), admin.getPassword())) {
            throw new LoginException(LoginErrorCode.PASSWORD_INVALID);
        }
        
        //로그인 성공시 jwt 토큰 생성
        String token = jwtTokenProvider.createToken(admin.getLoginId());

        return token;
}
  1. 입력받은 loginId로 DB에서 User 데이터를 조회한다. Id와 일치하는 사용자가 없으면 LoginException을 던진다.
  2. 입력받은 password와 조회해온 User 데이터에 있는 password를 복호화한 값이 일치하는지 체크한다. 비밀번호가 틀렸다면 LoginException을 던진다.
  3. Id와 password 모두 일치한다면 jwt를 생성한다.
    => 토큰을 생성하는 createToken 부분은 아래 토큰 인증의 JwtTokenProvider 에서 확인할 수 있다.

토큰 인증

클라이언트는 api를 호출할 때 마다 Header에 jwt를 담아서 보내야 한다.
/login과 같이 인증이 필요없는 일부 url을 제외하고는 모두 jwt 인증을 거쳐야 한다.
클라이언트가 보낸 jwt를 인증하는 Custom Filter를 구현해보자.

SecurityConfig

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

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

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/static/css/**, /static/js/**, *.ico");

        // swagger
        web.ignoring().antMatchers(
                "/v2/api-docs",  "/configuration/ui",
                "/swagger-resources", "/configuration/security",
                "/swagger-ui.html", "/webjars/**","/swagger/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .authorizeRequests()
            .antMatchers("/login", "/swagger-ui/**", "/swagger-resources/**").permitAll() //인증 필요 없는 url
            .anyRequest().authenticated()
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 사용 안함
            .and()
            .formLogin()
                .disable()
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
    }
}

토큰 기반 인증을 할 것이므로 세션 사용을 하지 않도록 설정해준다.
토큰 인증이 필요없는 /login이나 Swagger관련 url들을 위의 코드와 같이 설정해준다.
addFilterBefore에 우리가 만들 CustomFilter인 JwtAuthenticationFilter를 추가해준다.

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, String> redisTemplate;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //헤더에서 토큰 받아오기
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        //토큰이 유효하다면
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        //다음 Filter 실행
        chain.doFilter(request, response);
    }
}

위에서 제외한 url을 제외하고 전부 JwtAuthenticationFilter를 통과하게 된다.
클라이언트가 Header에 담은 jwt를 꺼내 유효한지 체크하고, 유효하다면 SecurityContextHolder에 인증 객체를 담는다.

JwtTokenProvider

@RequiredArgsConstructor
@Component @Slf4j
public class JwtTokenProvider {

    @Value("${security.jwt.secret-key}")
    private String SECRET_KEY;
    private final long tokenValidTime = 30 * 60 * 1000L; //토큰 유효시간 -> 30분

    private final UserService userService;

    //객체 초기화, secretKey를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    }

    //토큰 생성
    public String createToken(String adminPk) {
        //adminPk => loginId
        Claims claims = Jwts.claims().setSubject(adminPk); //JWT payload 에 저장되는 정보단위
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now) //토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) //토큰 유효시각 설정
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY) //암호화 알고리즘, secret 값 설정
                .compact();
    }

    //인증 정보 조회
    public Authentication getAuthentication(String token) {
        //Spring Security에서 제공하는 메서드 override해서 사용해야 함
        AdminLoginDto adminLoginDto = userService.loadUserByUsername(this.getAdminPk(token));
        return new UsernamePasswordAuthenticationToken(adminLoginDto.getAdmin(), "", adminLoginDto.getAuthorities());
    }

    //토큰에서 Admin 정보 추출
    public String getAdminPk(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody().getSubject();
    }

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

    //Request의 Header에서 token 값 가져오기
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

}

토큰 생성, 인증, 인증정보 조회 등 토큰 관련 로직이 구현되어 있다.
secret key는 원하는 값으로 지정해 application.properties 파일에 설정해주면 된다.
createToken 메서드는 토큰을 생성하는 부분이다. claims에 권한 등 원하는 정보를 추가로 put할 수 있다.

getAuthentication 메서드를 보면 userService에서 사용자 정보를 조회해 UsernamePasswordAuthenticationToken을 생성한다.
이 부분을 자세히 살펴보자.

UserService

UserDetailsService는 UserDetails라는 유저의 정보를 가져올 수 있는 Spring Security가 제공하는 인터페이스이다.
UserService는 UserDetailsService 인터페이스를 구현해 UserDetails 정보를 return해 준다. 이 때, UserDetails를 return해줘야 하는 이유는 UsernamePasswordAuthenticationToken 생성하는데 필요하기 때문이다.

여기서 AdminLoginDto은 UserDetails 인터페이스를 구현한 객체이므로 UserDetails 대신 return 가능하다. 내가 개발중인 시스템은 Admin 엔티티를 User로 사용중이므로 AdminLoginDto 속성으로 Admin 엔티티를 추가해 return해줬다.

@RequiredArgsConstructor
@Service
public class UserService implements UserDetailsService {

    private final AdminRepository adminRepository;

    @Override
    public AdminLoginDto loadUserByUsername(String loginId) throws UsernameNotFoundException {
        Admin admin = adminRepository.findByLoginId(loginId)
                .orElseThrow(() -> new LoginException(LoginErrorCode.ID_NOT_FOUND));
        return Admin.toAdminLoginDto(admin);
    }
}

AdminLoginDto

UserDetails를 그대로 사용해도 되지만, 위에 말한것 처럼 내가 구현중인 시스템에 맞게 수정하고자 Admin 속성을 추가해 AdminLoginDto를 구현했다.

@RequiredArgsConstructor
@Getter @Setter
@Builder
public class AdminLoginDto implements UserDetails {

    private final Admin admin;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

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

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

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

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

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

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

즉, 토큰 인증 과정을 요약해보자면
1. SecurityConfig에서 CustomFilter인 JwtAuthenticationFilter를 실행한다.
2. JwtAuthenticationFilter에서 Header에서 token을 꺼낸 후 유효성을 체크한다.
3. 유효하다면 토큰으로부터 인증 정보를 조회해 Authentication 객체에 담아준다.
이 과정을 자세히 보면 token에 들어있는 loginId를 꺼내 사용자 정보를 조회한 후 사용자 정보(UserDetails)로 UsernamePasswordAuthenticationToken을 만들어 return한 값이 Authentication에 담기게 되는 것이다.
4. 마지막으로 인증 객체인 Authentication를 SecurityContextHolder의 Context에 저장한다.

이후에 SecurityContextHolder에서 로그인한 사용자의 정보를 꺼내쓸 수 있다.(로그아웃 할 때 사용 가능)


로그아웃

jwt는 쿠키나 세션과 달리 강제로 만료시킬 수 없다. 따라서 로그아웃을 위해 다른 방법을 사용해야한다.
로그인시 Redis 같은 인메모리DB에 token 정보를 저장하고, 로그아웃시 Redis에서 token 정보를 지움으로써 로그아웃 여부를 확인할 수 있다.
(로그아웃 여부만 구분하면 되므로 로그아웃시 Redis에 token 정보를 저장하는 방식을 사용해도 된다.)
(Redis 설정 부분은 생략)

LoginController

@PostMapping(value = "/logout")
@ApiOperation(value="로그아웃")
public ResponseEntity<Void> logout(HttpServletRequest servletRequest) {

        loginService.logout();
        return ResponseEntity.ok().build();
}

LoginService

  • Login
@Transactional
public String login(WebLoginRequest request) {
        Admin admin = adminReposiroty.findByLoginId(request.getLoginId())
                .orElseThrow(() -> new LoginException(LoginErrorCode.ID_NOT_FOUND));
        if (!passwordEncoder.matches(request.getPassword(), admin.getPassword())) {
            throw new LoginException(LoginErrorCode.PASSWORD_INVALID);
        }

        String token = jwtTokenProvider.createToken(admin.getLoginId());
        
        //**로그아웃 구분하기 위해 redis에 저장**
        redisTemplate.opsForValue().set("JWT_TOKEN:" + request.getLoginId(), token, jwtTokenProvider.getExpiration(token));

        return token;
}

로그인할 때, Redis에 loginId를 key, token을 value로 저장한다.

  • Logout
@Transactional
public void logout() {
        //Token에서 로그인한 사용자 정보 get해 로그아웃 처리
        Admin admin = (Admin) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if (redisTemplate.opsForValue().get("JWT_TOKEN:" + admin.getLoginId()) != null) {
            redisTemplate.delete("JWT_TOKEN:" + admin.getLoginId()); //Token 삭제
        }
}

SecurityContextHolder에 저장해둔 사용자 정보(Admin)를 꺼낸다.
Admin의 loginId를 redis의 key로 사용해 value를 조회한 후 값이 존재하면 삭제한다.
=> 값이 존재하면 아직 로그아웃 안 한 사용자라는 의미

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, String> redisTemplate;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            String key = "JWT_TOKEN:" + jwtTokenProvider.getAdminPk(token);
            String storedToken = redisTemplate.opsForValue().get(key);
            
            //**로그인 여부 체크**
            if(redisTemplate.hasKey(key) && storedToken != null) {
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        chain.doFilter(request, response);
    }
}

Filter에 token 인증 후 로그인 여부를 체크하는 부분이 추가된다.
redis가 loginId를 key로 가지고 있는지 & redis에 loginId를 key로 하는 데이터가 존재하는지 확인한다.
만약 로그아웃 된 상태라면 이 부분을 통과하지 못한다.


로그인/로그아웃, 인증 구현이 끝났다.
추후에 refresh token을 추가하는 방향으로 개선해봐야겠다.
🤓


Ref.

https://imbf.github.io/spring/2020/06/29/Spring-Security-with-JWT.html
https://dev-chw.tistory.com/30
https://velog.io/@ydppwljg/Spring-boot%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EB%A1%9C%EA%B7%B8%EC%95%84%EC%9B%83-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Spring-security-JWT-1

profile
log.info("공부 기록 블로9")

2개의 댓글

comment-user-thumbnail
2023년 8월 27일

제가 원하던 글!

답글 달기
comment-user-thumbnail
2023년 9월 30일

좋은 포스트 감사합니다

답글 달기