본문 바로가기
프로젝트/아리(Ari)

[Spring Boot, Spring Security, Data JPA] 로그인/로그아웃 1

by sepang 2022. 9. 10.

  저번에는 회원가입을 해보았으니 이번에는 로그인을 할 차례이다. 여기서는 spring security를 사용하는데, 생각보다 매우 복잡하고 깊게 파기에는 시간이 오래걸릴 것 같아서 대략적인 흐름만 파악하고 구현을 시작해봤다. 그리고 이 기능을 구현을 할 때, 마지막 참고자료에 적힌 분의 포스팅이 매우 큰 도움이 되었다. jwt와 spring security의 흐름을 잡기 좋았다.

Session? JWT?

  이번에 스프링을 배울 때, 인프런에서 김영한 님의 스프링 로드맵 중 일부를 들었는데 이때의 로그인은 쿠키&세션을 이용했었다. 그래서 프로젝트에서도 이걸 응용하면 되겠지라고 생각했었다. 그런데 우리 팀은 자체 로그인과 소셜 로그인 두 개를 모두 사용하려고 하는데, 소셜 로그인은 jwt를 이용하여 로그인이 수행된다고 했다. 통일성을 갖추기 위해서도 있고 요즘에는 jwt로 로그인을 구현한다길래 열심히 구글링을 시작했다. 우선은 jwt에 대해 파악할 필요가 있었다.

세션 로그인

fig 1. 세션 로그인

  세션은 보안적으로 취약한 쿠키의 문제점을 보완한 방법이다. 처음 서버에 요청을 보낼 때 응답으로 탈취해서 뜯어봐도 별 내용이 없는 임의의 토큰을 쿠키로 생성해 브라우저에 응답한다. 그리고 이후 이 토큰을 서버가 받아서 세션 저장소에 이 토큰과 동일한 것이 있는지 찾고 있다면 이 세션키에 해당하는 유저를 찾을 수 있는 것이다. 

  이 방법도 충분히 좋은 방법인 것 같지만 어찌되었든 서버 DB에 세션키를 저장할 공간이 필요하기 때문에 유저가 많아질 수록 확장에 불리하다는 단점이 있다. 만약 세션키를 한 테이블에 담기에는 너무 많아서 두 개 이상으로 나눈다고 생각해보자. 상황에 따라 세션 저장소를 선택하는 과정이 추가적으로 필요해지니 코드가 복잡해질 것 같다. 그럼 jwt는 어떻게 이런 점을 개선하였을까?

JWT(Json Web Token) 로그인

fig 1-2. jwt

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiYXV0aCI6IlJPTEVfT1dORVIiLCJleHAiOjE2NjE5Mzg4MTh9.NHayn8tv3dmHE1R4hWoANCLyzEewXul153M7yi3eUZZWtZzg0TgMWra6oUV-4jqmS0RpDdENkv5vrH1kS6k2Ag

  우선 이 jwt라는 걸 그대로 표현하면 다음과 같다. 랜덤한 토큰 값이 아무렇게 쓰인 것 같지만 자세히 보면 '.'으로 구분되어 3조각으로 나눌 수 있다. 각 부분은 순서대로 'header . payload . signature'로 이루어져 있다. header와 signature를 이용하여 이 jwt를 인코딩 하거나 해싱을 한다. 그리고 payload에서 넣고자하는 값들을 넣을 수 있다.

  이렇게 jwt를 클라이언트에 넣어준 뒤 이후 헤더에 해당 토큰 값을 담아 요청을 보내면 서버에서 이것을 뜯어본 뒤에 요청을 보낸 회원이 누구인지 등을 알 수 있다. 우리는 여기에 유저의 db상 id를 넣어주고 이를 받았을 때 요청한 유저의 정보나 가게 정보 등을 찾을 수 있게끔 할 생각이다.

Access Token & Refresh Token

fig 1-3. jwt 로그인 과정

  fig 1-3를 보면 로그인 요청에 대한 응답으로 access token, refresh token(생성 시 저장소에 저장) 두개를 반환하는 것을 볼 수 있다. access token은 매 요청시 헤더에 담기면서 서버가 유효한 토큰인지 인증한 뒤에 payload를 확인해 어떤 유저인지 등을 확인할 때 사용된다. 이것을 계속 유효하게 쓸 수 있다면 누군가 access token을 탈취하게 되면 보안적으로 취약해질 것이다. 그렇기 때문에 보통 5분 정도로 access token은 유효기간을 설정한다. 하지만 그 말은 5분마다 로그인을 해야한다는 것인데 이것은 사용자 입장에서 여간 불편한게 아니다.

  이러한 문제를 해소하기 위한 방법이 바로 refresh token이다. 이 토큰은 뜯어봤자 아무런 정보를 얻을 수 없고 단지 토큰을 재발급 받기 위해서만 쓰인다.  이 토큰은 보통 유효기간을 2주 정도 잡는다고 한다. 만약 요청을 보냈는데 access token이 만료되거나 또는 만료직전이라면 이때 access/refresh token을 모두 담아 재발급 요청을 보낸다. 서버는 access token의 조작여부를 확인하고 동일한 refresh token이 저장소에 있는지 확인 후 유효기간도 지나지 않았다면 새로운 access/refresh token을 갱신하여 응답한다.

 

JWT 로그인 구현

fig 1. 로그인 화면

  컨트롤러와 서비스를 통해 대략적인 로그인 흐름을 살펴보자.

DTO

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {
    @NotEmpty(message = "이메일은 필수 입력값입니다.")
    @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식에 맞지 않습니다.")
    private String email;

    //비밀번호 형식은 나중에 확정해야 함
    @NotEmpty(message = "비밀번호는 필수 입력값입니다.")
    private String password;

    public UsernamePasswordAuthenticationToken toAuthentication() {
        return new UsernamePasswordAuthenticationToken(email, password);
    }
}

  toAuthentication() 메소드는 로그인 과정에서 필요한 UsernamePasswordAuthenticationToken을 생성하는데 쓰인다.

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {

    private String grantType;
    private String accessToken;
    private String refreshToken;
    private Long accessTokenExpireIn;
    private Long refreshTokenExpiresIn;
    private String authority;
    private String info;

    public void setInfo(String info) {
        this.info = info;
    }
}

  생성된 토근을 다음과 같은 dto에 담아서 응답한다. 프론트에서는 응답받은 토큰 값들을 저장하고 이후 요청에는 Authorization 헤더에 access token을 담아 보내게 된다. 이것을 검증한 뒤 어느 유저가 보낸 토큰인지 알아낸다. 그리고 토큰의 유효 시간인 accessTokenExpireIn을 통해 만료 몇 초전에 reissue를 요청을 보내 새로운 access token, refresh token을 받아 저장한다.

  즉 현재 로그인 기능에서 로그인 상태가 해제 되는 경우는 브라우저 종료 후 refresh token의 유효기간(ex. 2주)만큼 사이트에 접속하지 않거나, 로그아웃 요청을 보내는 경우 두가지 이다.

컨트롤러

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginDto loginDto, Errors errors) {
    //validation check
    if (errors.hasErrors()) {
        return response.invalidFields(Helper.refineErrors(errors));
    }
    return memberService.login(loginDto);
}

  loginDto에는 이메일과 비밀번호가 담겨있다. 이것을 그대로 서비스에 넘긴다.

서비스

public ResponseEntity<?> login(LoginDto loginDto) {

    Optional<Member> member = memberRepository.findByEmail(loginDto.getEmail());

    if (member.orElse(null) == null || !passwordEncoder.matches(loginDto.getPassword(), member.get().getPassword())) {
        return response.fail("ID 또는 패스워드를 확인하세요", HttpStatus.BAD_REQUEST);
    }

    /* Login id/pw를 기반으로 Authentication 객체 생성 
     * 이때 authentication는 인증 여부를 확인하는 authenticated 값이 false 
     */
    UsernamePasswordAuthenticationToken authenticationToken = loginDto.toAuthentication();

    /* 실제 검증(사용자 비밀번호 체크)이 이루어지는 부분
     * authenticate 메서드가 실행될 때 CustomUserDetailService에서 만든 loadUserByUserName 메서드 실행
     */
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

    /* 인증 정보를 기반으로 JWT 토큰 생성 */
    TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

    /* RefreshToken Redis 저장 (expirationTime 설정을 통해 자동 삭제 처리) */
    redisTemplate.opsForValue()
            .set("RT:" + authentication.getName(), tokenDto.getRefreshToken(),
                    tokenDto.getRefreshTokenExpiresIn(), TimeUnit.MILLISECONDS);

    /* user/owner에 따라 닉네임or가게이름 tokenDto에 추가 */
    if (member.get().getAuthority() == Authority.ROLE_USER) {
        tokenDto.setInfo(member.get().getNickname()); // 닉네임
    } else {
        tokenDto.setInfo(member.get().getStores().get(0).getName()); // 가게이름
    }

    return response.success(tokenDto, "로그인에 성공했습니다.", HttpStatus.OK);
}

  회원가입 때보다 과정이 좀 복잡해보인다. 하나씩 살펴보자.

  1. 우선 DB에 입력한 이메일과 패스워드를 가지는 member가 있는지 찾는다. 이때 패스워드는 인코딩된 상태이기 때문에, 비교 시 '=='을 쓰는 것이 아니라 PasswordEncoder'matches()' 메소드를 사용해야 한다.
  2. email / pw를 기반으로 Authentication(UsernamePasswordAuthenticationToken) 객체를 생성한다.
  3. 'authenticate()' 메서드를 통해 사용자에 대한 검증을 진행한다.
  4. 이후 인증정보를 기반으로 jwt 토큰을 생성하여 tokenDto에 담아준다.
  5. refresh token을 토큰 만료시간 설정한 뒤 redis에 저장한다. 이렇게 되면 유효시간이 지나면 자동으로 삭제되므로 refresh token의 만료 여부를 확인할 수 있다.
  6. 그리고 회원이 user인지 owner인지에 따라 별명이나 가게이름을 tokenDto에 추가한다.

authentication() 인증 절차

  2.에서 id/pw 기반으로 Authentication 객체를 생성할 때(loginDto.toAuthenticatoin) UsernamePasswordAuthenticationToken 생성자를 사용한다. 이 클래스는 Authentication 인터페이스를 구현한 추상 클래스인 AbstractAuthenticatonToken을 구현한 클래스이다.

fig 2-1. UsernamePasswordAuthenticationToken 생성자

  fig 1-2에서 확인할 수 있듯이 2개의 생성자가 존재하는데 2.에서는 앞의 생성자로 객체가 만들어진다. 아직 인증 전이기 떄문에 setAuthenticaton(false)임을 확인할 수 있다. 이후 검증 과정을 거치면 두번째 생성자가 사용될 것이다.

  3.에서는 Authentication 인터페이스를 구현한 ProviderManager 클래스의 authenticate() 메서드가 동작한다. 여기서 모든 Provider들을 확인하면서 인증을 할 수 있는 Provider를 찾은 뒤 해당 Provider의 authenticate() 메서드를 통해 인증을 진행한다. 여기서는 AbstractUserDetailsAuthenticationProvider 클래스로 결정이 된다.

fig 2-2. AbstractUserDetailsAuthenticationProvider의 authenticate() 1

  해당 클래스의 retrieveUser() 메서드를 자세히 확인해보자. 이 메서드는 이 클래스를 상속받는 DaoAuthenticationProvider 클래스에 구현되어 있다.

fig 2-3. DaoAuthenticationProvider의 retrieveUser()

  여기서 'this.getUserDetailsService().loadUserByUsername(username)'가 있는데, 이것은 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService 클래스를 통해 직접 구현해야 한다.

@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return memberRepository.findByEmail(username)
            .map(this::createUserDetails)
            .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
}

  CustomUserDetailsService에 다음과 같은 메서드를 추가했다. 이를 통해 DB에서 해당 email을 갖는 회원이 있는지 검증한다.

fig 2-4. AbstractUserDetailsAuthenticationProvider의 authenticate() 2

protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    if (authentication.getCredentials() == null) {
        this.logger.debug("Failed to authenticate since no credentials provided");
        throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    } else {
        String presentedPassword = authentication.getCredentials().toString();
        if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            this.logger.debug("Failed to authenticate since password does not match stored value");
            throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }
}

    retrieveUser() 실행 이후 다시 AbstractUserDetailsAuthenticationProvider의 authenticate()로 넘어가 진행이 된다. fig 1-5를 보면 additionalAuthenticationChecks() 메서드를 확인할 수 있다. 이 메서드 역시 DaoAuthenticationProvider에 구현되어 있다. 이 클래스에서 이메일/비밀번호가 일치하는지 판단한다고 볼 수 있겠다.

fig 2-5. createSuccessAuthentication()

  이후에는 createSuccessAuthentication() 메서드의 결과를 반환하는데 여기서 만들어지는 UsernamePasswordAuthenticationToken의 생성자가 바로 fig 1-2의 두번째 생성자이다. 이때의 authenticated 값은 true가 되며 이 객체는 인증이 완료된 Authentication 객체가 된다.

 

authentication() 이후 로그인 절차

  다시 서비스 코드로 돌아와 4.부터 계속 진행해보자. tokenProvider.generateTokenDto(authentication)를 통해 토큰을 생성한다.

/*
 * TokenProvider Class
 */
public TokenDto generateTokenDto(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 + ACCESS_TOKEN_EXPIRE_TIME);
    String accessToken = Jwts.builder()
            .setSubject(authentication.getName())  // payload "sub": "name"
            .claim(AUTHORITIES_KEY, authorities)   // payload "auth": "ROLE_USER"
            .setExpiration(accessTokenExpiresIn)   // payload "exp": 1516239022 (예시)
            .signWith(private_key, SignatureAlgorithm.HS512)  // header "alg": "HS512" -> HS512: HMAC using SHA-512
            .compact();

    // Refresh Token 생성
    String refreshToken = Jwts.builder()
            .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
            .signWith(private_key, SignatureAlgorithm.HS512)
            .compact();

    log.info("권한: {}", authorities);

    return TokenDto.builder()
            .grantType(BEARER_TYPE)
            .accessToken(accessToken)
            .refreshTokenExpiresIn(REFRESH_TOKEN_EXPIRE_TIME)
            .refreshToken(refreshToken)
            .accessTokenExpireIn(ACCESS_TOKEN_EXPIRE_TIME)
            .authority(authorities)
            .build();
}

  3.을 통해 인증 완료된 authentication 객체를 이용해 access token과 refresh token을 생성하는 메서드이다. access token 생성 시에는 권한(우리 서비스에서는 USER, OWNER), id 정보를 넣어주지만, refresh token 생성 시에는 유효기간, 서명 정도의 정보만 넣어주는데, 이는 단순히 재발급 받기 위해서만 사용이 되기 때문이다. 이후에 해당 토큰이 담긴 요청을 받으면 어떤 권한의 어떤 회원이 요청을 보냈는지 알 수 있기에 그에 따른 처리를 할 수 있다.

fig 3. redis에 저장된 refresh token

  그 다음은(5.) redis에 refresh token을 저장한다. 'RT:{회원id}     refreshToken' 형식으로 저장한다. 그러므로 reissue 요청 시에는 db가 아닌 redis를 탐색해야 한다. 이후에는 사이드 바에 회원이름 or 가게이름을 띄어주기 위해 해당 정보를 담아 응답을 하면 login() 메서드는 끝나는 것이다.

fig 4. login() 결과

  수정 전 결과 사진이라 accessToken 유효기간과 회원의 권한, 이름/가게 내용이 빠져있긴하지만 성공적으로 토큰을 응답받은 것을 확인할 수 있다.

시큐리티 설정

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 메서드 단위로 @PreAuthorize 검증 어노테이션을 사용
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final TokenProvider tokenProvider; // jwt 생성 및 유저 정보 반환
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final RedisTemplate redisTemplate;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // CSRF 설정 Disable
        http.csrf().disable()

                // exception handling시 적용할 클래스를 추가
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
                .accessDeniedHandler(jwtAccessDeniedHandler) // 403

                // 시큐리티는 기본적으로 세션을 사용
                // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
                .and()
                .authorizeRequests()
                .antMatchers("/auth/**").permitAll()
                .antMatchers("/map/**").permitAll()
                // ...

                //권한 테스트
                .antMatchers("/member/userTest").hasRole("USER")
                .antMatchers("/member/ownerTest").hasRole("OWNER")
                .antMatchers("/member/adminTest").hasRole("ADMIN")
                .anyRequest().authenticated()

                // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
                .and()
                .apply(new JwtSecurityConfig(tokenProvider, redisTemplate));
    }
}
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;
    private final RedisTemplate redisTemplate;

    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider, redisTemplate);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); // 직접 만든 JwtFilter 를 Security Filter 앞에 추가
    }
}

  WebSecurityConfigurerAdapter를 상속받은 SecurityConfig 클래스(이후 많은 수정이 필요한 코드)이다. configure()을 통해 권한에 따라 요청을 제한할 수 있다. 마지막 부분에 'new JwtSecurityConfig(tokenProvider, redisTemplate)'를 통해 JwtSecurity 클래스를 적용하는데 이를 통해 JwtFilter를 UsernamePasswordAuthenticationFilter 전에 실행한다.

// JWT 인증 필터는 OncePerRequestFilter를 상속받아 작성. 해당 필터에서 토큰의 유효성 검사가 진행
// 요청 받을 때 단 한번만 실행
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final TokenProvider tokenProvider;
    private final RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        String jwt = resolveToken(request);

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            /*1. Redis 에 해당 accessToken logout 여부 확인 */
            String isLogout = (String)redisTemplate.opsForValue().get(jwt);
            if (ObjectUtils.isEmpty(isLogout)) {
                /*2. 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장 */
                Authentication authentication = tokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }

    public String resolveToken(HttpServletRequest request) {
        /*1. 헤더에서 토큰을 꺼냄*/
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        /*2. 토큰이 담겨 있을 시, 앞의 "Bearer " 제거*/
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }

        return null;
    }
}
/*
 * TokenProvider Class
 */
public Authentication getAuthentication(String accessToken) {

    // 토큰 복호화
    Claims claims = parseClaims(accessToken);

    if (claims.get(AUTHORITIES_KEY) == null) {
        throw new RuntimeException("권한 정보가 없는 토큰입니다.");
    }

    // 클레임에서 권한 정보 가져오기
    Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());

    // UserDetails 객체를 만들어서 Authentication 리턴
    UserDetails principal = new User(claims.getSubject(), "", authorities);

    // SecurityContext 를 사용하기 위한 절차(SecurityContext 가 Authentication 객체를 저장)
    return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}

// 토큰 정보를 검증
public boolean validateToken(String token) {
    try {
        log.info("token: {}", token);
        Jwts.parserBuilder()
                .setSigningKey(private_key)
                .build()
                .parseClaimsJws(token);
        return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
        log.info("잘못된 JWT 서명입니다.");
    } catch (ExpiredJwtException e) {
        log.info("만료된 JWT 토큰입니다.");
    } catch (UnsupportedJwtException e) {
        log.info("지원되지 않는 JWT 토큰입니다.");
    } catch (IllegalArgumentException e) {
        log.info("JWT 토큰이 잘못되었습니다.");
    }
    return false;
}

private Claims parseClaims(String accessToken) {
    try {
        return Jwts.parserBuilder()
                .setSigningKey(private_key)
                .build()
                .parseClaimsJws(accessToken)
                .getBody();
    } catch (ExpiredJwtException e) {
        return e.getClaims();
    }
}

  JwtFilter를 통해 요청이 왔을 때 헤더에 담긴 토큰의 유효시간, 서명, 형식 등을 검증하고 유효할 경우, getAuthentication()을 실행하여 jwt 토큰을 복호화하여 토큰에 들어있는 정보를 가져온다. 그리고 이를 통해 Authentication 객체를 만든 뒤 이를 SecurityContextHolder의 SecurityContext 안에 저장하게 된다. 이후 남아있는 filter들이 동작한 뒤 api에 해당하는 응답을 하게 된다.


참고자료

 

spring security + JWT 로그인 기능 파헤치기 - 1

로그인 기능은 거의 대부분의 애플리케이션에서 기본적으로 사용됩니다. 추가로 요즘은 웹이 아닌 모바일에서도 사용 가능하다는 장점과 Stateless 한 서버 구현을 위해 JWT를 사용하는 경우를 많

wildeveloperetrain.tistory.com

 

댓글