[MSA] JWT 인증 서버 구축하기 - 1. 로그인

박상범·2022년 3월 6일
7

MSA

목록 보기
1/4

본 포스팅은 JWT와 access token, refresh token, Spring Security의 기본 개념을 숙지하고 있다는 가정하에 작성되었습니다.

개발 환경

  • Front end : Vue CLI 3.0
  • API Gateway : Spring Cloud Gateway
  • User Service : Spring Framework 2.6.3

플로우 차트

  1. 로그인 진행
  2. 로그인 성공 시 JWT 토큰인 Access Token 및 Refresh 토큰 발행
  3. Request Header의 Authentication Bear로 Access Token 전달
  4. API Gateway에서 JWT 인증
    • Access Token Decryption
    • Access Toekn 유효기간 판단
  5. 해당 리소스에 접근

장점

  • API Gateway를 사용함에 따라 각 마이크로 서비스에서 토큰의 Validation 로직을 작성할 필요가 없음
  • 토큰 검증에 대한 로직 처리가 용이해짐

로그인

이번 포스팅에서는 인증의 제일 첫번째 관문인 로그인에 대해 알아보겠습니다.

Spring Security을 사용하여 로그인 인증 절차 구현되었습니다.

플로우 차트

  1. 프론트에서 로그인 요청 (POST /login)
  2. 아이디, 패스워드 확인 후 refresh token을 redis 저장소에 저장
  3. access token 및 refresh token 발행
    • access token, expired time 를 response body에 닮아 발행
    • refresh token의 경우 cookie(httpOnly) 에 닮아 발행

User Service

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
  • jjwt : JWT 생성 및 인증을 위한 라이브러리
  • spring-boot-starter-security : 로그인 인증 절차를 위한 라이브러리

WebSecurityConfigurerAdapter

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
  • 시프링 시큐리티의 웹 보안 기능 초기화 및 설정
  • HttpSecurity 라는 세부적인 보안 기능을 설정할 수 있는 API를 제공하는 클래스를 생성
  • 해당 객체를 상속받고 @EnableWebSecurity 어노테이션을 작성할 경우 SpringSecurityFilterChain이 자동으로 포함된다.
  • configure 메소드를 오버라이딩 하여 접근 권한을 작성 할 수 있다.
  • @EnableWebSecurity : web security 용도로 사용하겠다는 어노테이션, 스프링 시큐리티를 사용하기 위해 추가.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserDetailsService userDetailsService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenServiceImpl refreshTokenServiceImpl;
    private final CookieProvider cookieProvider;
		
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
		// Custom Login Authentication 필터를 사용함
		LoginAuthenticationFilter loginAuthenticationFilter =
                new LoginAuthenticationFilter(authenticationManagerBean(), jwtTokenProvider, refreshTokenServiceImpl, cookieProvider);
        loginAuthenticationFilter.setFilterProcessesUrl("/login");

        http.csrf().disable();

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests().anyRequest().permitAll();

        http.addFilter(loginAuthenticationFilter);
    }

    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
  • configure(AuthenticationManagerBuilder auth) : password를 해싱하기 위해 BCrypt 알고리즘을 사용
  • http.authorizeRequests() : 시큐리티 처리에 HttpServletRequest를 사용하겠다는 의미
    • antMatchers() : url 패턴을 지정
    • permitAll() : 모든 사용자가 접근 할 수 있다.
  • configure(HttpSecurity http) : 스프링 시큐리티 규칙을 작성해줍니다.
  • JWT 검증 관련 부분은 API Gateway에서 일괄적으로 적용하므로 모든 url에 대하여 허용해줍니다.
@Configuration
public class AppConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • BCrypt 알고리즘을 사용하기 위해 사용자 Configuration 클래스에 PasswordEncoder를 추가시켜줍니다.

UserDetailService - loadUserByUsername

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class UserServiceImpl implements UserService, UserDetailsService {

    private final CustomerRepository customerRepository;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found in the database"));

        Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(user.getDtype()));
        return new org.springframework.security.core.userdetails.User(user.getId().toString(), user.getPassword(), authorities);
    }
}
  • UserDetailsService의 loadUserByUsername를 재정의합니다.
  • 해당 메소드에 로그인 시 아이디와 패스워드를 비교하는 메소드를 구현해줍니다.

UsernamePasswordAuthenticationfilter

  • 아이디, 패스워드 기반의 인증을 담당하는 필터
  • AuthenticationManager를 통한 인증 실행
  • 인증 성공 시, 얻은 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
  • 인증 실패 시, AuthenticationFailureHandler 실행
  • URL 기본 값: /login
@RequiredArgsConstructor
@Slf4j
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenServiceImpl refreshTokenServiceImpl;
    private final CookieProvider cookieProvider;

    // login 리퀘스트 패스로 오는 요청을 판단
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        Authentication authentication;

        try {
		    // POST 요청으로 들어오는 email과 password
            LoginRequest credential = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
						
		    // UsernamePasswordAuthenticationToken을 통해 
			// loadUserByUsername 메소드에서 로그인 판별
            authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(credential.getEmail(), credential.getPassword())
            );
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }

        return authentication;
    }

    // 로그인 성공 이후 토큰 생성
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
        org.springframework.security.core.userdetails.User user = (User) authResult.getPrincipal();

        List<String> roles = user.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        String userId = user.getUsername();
				
		// response body에 넣어줄 access token 및 expired time 생성
        String accessToken = jwtTokenProvider.createJwtAccessToken(userId, request.getRequestURI(), roles);
        Date expiredTime = jwtTokenProvider.getExpiredTime(accessToken);
		// 쿠키에 넣어줄 refresh token 생성
        String refreshToken = jwtTokenProvider.createJwtRefreshToken();
				
		// redis에 새로 발행된 refresh token 값 갱신
        refreshTokenServiceImpl.updateRefreshToken(Long.valueOf(userId), jwtTokenProvider.getRefreshTokenId(refreshToken));

        // 쿠키 설정
        ResponseCookie refreshTokenCookie = cookieProvider.createRefreshTokenCookie(refreshToken);

        Cookie cookie = cookieProvider.of(refreshTokenCookie);

        response.setContentType(APPLICATION_JSON_VALUE);
        response.addCookie(cookie);

        // body 설정
        Map<String, Object> tokens = Map.of(
                "accessToken", accessToken,
                "expiredTime", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(expiredTime)
        );

        new ObjectMapper().writeValue(response.getOutputStream(), Result.createSuccessResult(tokens));
    }

refreshTokenServiceImpl

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class RefreshTokenServiceImpl implements RefreshTokenService {
    private final UserRepository userRepository;
    private final RefreshTokenRedisRepository refreshTokenRedisRepository;

    @Transactional
    @Override
    public void updateRefreshToken(Long id, String uuid) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new NotExistUserException("사용자 고유번호 : " + id + "는 없는 사용자입니다."));

        refreshTokenRedisRepository.save(RefreshToken.of(user.getId().toString(), uuid));
    }
}
  • refresh token 이 탈취되었을 때를 대비 하여 로그인 시에 해당 토큰의 값을 갱신하여줍니다.

유틸리티 클래스

  • 중복 코드를 막고 재사용성을 높히기 위해 커스텀 유틸리티 클래스를 생성하였습니다.

JwtTokenProvider

@Component
@Slf4j
public class JwtTokenProvider {

    @Value("${token.access-expired-time}")
    private long ACCESS_EXPIRED_TIME;

    @Value("${token.refresh-expired-time}")
    private long REFRESH_EXPIRED_TIME;

    @Value("${token.secret}")
    private String SECRET;

    public String createJwtAccessToken(String userId, String uri, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userId);
        claims.put("roles", roles);

        return Jwts.builder()
                .addClaims(claims)
                .setExpiration(
                        new Date(System.currentTimeMillis() + ACCESS_EXPIRED_TIME)
                )
                .setIssuedAt(new Date())
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .setIssuer(uri)
                .compact();
    }

    public String createJwtRefreshToken() {
        Claims claims = Jwts.claims();
        claims.put("value", UUID.randomUUID());

        return Jwts.builder()
                .addClaims(claims)
                .setExpiration(
                        new Date(System.currentTimeMillis() + REFRESH_EXPIRED_TIME)
                )
                .setIssuedAt(new Date())
                .signWith(SignatureAlgorithm.HS512, SECRET)
                .compact();
    }

    public String getUserId(String token) {
        return getClaimsFromJwtToken(token).getSubject();
    }

    public String getRefreshTokenId(String token) {
        return getClaimsFromJwtToken(token).get("value").toString();
    }

    public List<String> getRoles(String token) {
        return (List<String>) getClaimsFromJwtToken(token).get("roles");
    }

    public void validateJwtToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token);
        } catch (SignatureException  | MalformedJwtException |
                UnsupportedJwtException | IllegalArgumentException | ExpiredJwtException jwtException) {
            throw jwtException;
        }
    }

		private Claims getClaimsFromJwtToken(String token) {
        try {
            return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

}

CookieProvider

@Component
public class CookieProvider {

    @Value("${token.refresh-expired-time}")
    private String refreshTokenExpiredTime;

    public ResponseCookie createRefreshTokenCookie(String refreshToken) {
        return ResponseCookie.from("refresh-token", refreshToken)
                .httpOnly(true)
                .secure(true)
                .path("/")
                .maxAge(Long.parseLong(refreshTokenExpiredTime)).build();
    }

    public ResponseCookie removeRefreshTokenCookie() {
        return ResponseCookie.from("refresh-token", null)
                .build();
    }

    public Cookie of(ResponseCookie responseCookie) {
        Cookie cookie = new Cookie(responseCookie.getName(), responseCookie.getValue());
        cookie.setPath(responseCookie.getPath());
        cookie.setSecure(responseCookie.isSecure());
        cookie.setHttpOnly(responseCookie.isHttpOnly());
        cookie.setMaxAge((int) responseCookie.getMaxAge().getSeconds());
        return cookie;
    }
}

Github

https://github.com/Development-team-1/just-pickup

  • 모든 코드는 위 깃허브에서 확인하실 수 있습니다.
profile
배는 항구에 있을 때 가장 안전하다. 그러나 그것이 배의 존재의 이유는 아니다.

2개의 댓글

comment-user-thumbnail
2022년 11월 14일

감사합니다!

답글 달기
comment-user-thumbnail
2023년 1월 4일

개인 프로젝트에 많은 참고가 되고 있습니다. 출처는 명시해놓겠습니다.

답글 달기