Just Do IT!

JWT + spring security 로그인 구현 실습 본문

개발 공부/Spring

JWT + spring security 로그인 구현 실습

MOON달 2024. 9. 10. 14:23
728x90
반응형

토큰 기반 인증이란?

  • 토큰 기반 인증은 토큰을 사용하는 방법이다.
    • 토큰 : 서버에 요청을 받을 때, 요청을 보낸 클라이언트를 구분하기 위한 유일한 값
  • 서버가 토큰을 생성해서 클라이언트에게 제공하면, 클라이언트는 해당 토큰을 보관하고 있다가 여러 요청을 토큰과 함께 보내게 된다.
  • 그러면 서버는 토큰을 보고 해당 클라이언트가 유효한 사용자인지 검증하고, 요청을 처리해주게 된다.

 

JWT?

  • 발급받은 JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중에 Authorization 값에 Bearer + JWT 토큰값을 넣어서 보내야 한다.
  • JWT는 .을 기준으로 헤더(Header), 내용(Payload), 서명(Signature)으로 구성되어 있다.

JWT 구성

  • 헤더 : 토큰의 타입과 해싱 알고리즘을 지정하는 정보
  • 내용 : 토큰과 관련된 정보로 내용의 한 덩어리를 클레임(Claim)이라고 부르며 키와 값의 한 쌍으로 이뤄져 있다.
    • 등록된 클레임은 토큰에 대한 정보를 담는데 사용된다.
  • 서명 : 해당 토큰이 조작되었거나 변견되지 않았음을 확인하는 비밀키

 

 

 

 

 

 

직접 실습해보기 [spring security + jwt]

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
	private final UserDetailsService userDetailsService;
	private final JwtProperties jwtProperties;

	// JWT PROVIDER bean 생성
	@Bean
	JwtProvider jwtProvider() {
		return new JwtProvider(jwtProperties, userDetailsService);
	};

	private TokenUtils tokenUtils() {
		return new TokenUtils(jwtProvider());
	}

	private JwtAuthenticationService jwtAuthenticationService() {
		return new JwtAuthenticationService(tokenUtils());
	}

	// 인증 관리자 (Authenticaiton Manager) 설정
	@Bean
	AuthenticationManager authenticationManager() {
		DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
		authProvider.setUserDetailsService(userDetailsService);
		authProvider.setPasswordEncoder(bCryptPasswordEncoder());

		return new ProviderManager(authProvider);
	}

	// 암호화 빈
	@Bean
	BCryptPasswordEncoder bCryptPasswordEncoder() {
		return new BCryptPasswordEncoder();
	}

	// HTTP 요청에 따른 보안 구현
	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		// 경로에 대한 권한 설정
		http.authorizeHttpRequests(auth ->
		// 특정 URL 경로에 대해서는 인증 없이 접근 가능
		auth.requestMatchers(
				// 로그인 관련 로직은 LoginCustomAuthenticationFilter에 존재
				new AntPathRequestMatcher("/img/**"), // image는 전부 다 보이도록 설정
				new AntPathRequestMatcher("/api/auth/signup"), // 회원가입
				new AntPathRequestMatcher("/api/auth/duplicate"), // email 중복 체크,
				new AntPathRequestMatcher("/api/post/**", "GET") // 전체 게시글 리스트는 전부 보이도록
		).permitAll()
				// AuthController 중 나머지는 ADMIN만 접근 가능한 페이지
				.requestMatchers(new AntPathRequestMatcher("/api/auth")).hasRole("ADMIN")
				// 그 밖의 다른 요청들은 인증을 통과한 사용자라면(=로그인한 사용자) 접근 가능
				.anyRequest().authenticated());

		// session 관리는 이제 더 이상 하지 않는다
		// 무상태성 세션 관리
		http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

		// 특정 경로(로그인)에 대한 필터 추가
		http.addFilterBefore(new LoginCustomAuthenticationFilter(authenticationManager(), jwtAuthenticationService()),
				UsernamePasswordAuthenticationFilter.class);

		// (토큰을 통해 검증할 수 있도록) filter 추가
		// http.addFilterBefore(추가할 필터, 다른필터)
		// jwt 인증 필터를 추가
		http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider()), UsernamePasswordAuthenticationFilter.class);

		// HTTP 기본 설정
		http.httpBasic(HttpBasicConfigurer::disable);

		// CSRF 비활성화
		http.csrf(AbstractHttpConfigurer::disable);

		// CORS 비활성화
		http.cors(corsConfig -> corsConfig.configurationSource(corsConfigurationSource()));

		return http.getOrBuild();
	}

	@Bean
	CorsConfigurationSource corsConfigurationSource() {
		return request -> {
			CorsConfiguration config = new CorsConfiguration();
			config.setAllowedHeaders(Collections.singletonList("*"));
			config.setAllowedMethods(Collections.singletonList("*"));
			config.setAllowedOriginPatterns(Collections.singletonList("http://localhost:3000"));
			config.setAllowCredentials(true);
			return config;
		};
	}
}

 

Bean 생성

  • JWT Provider : JWT 토큰을 생성하고 검증
  • TokenUtils : JWT를 처리하는 유틸리티 클래스
  • JwtAuthenticationService : JWT를 이용한 인증 서비스
  • AuthenticationManager ; DaoAuthenticationProvider를 사용하여 사용자 인증을 처리, 비밀번호 암호화
  • BCryptPasswordEncoder : 비밀번호 암호화

 

Security Filter Chain

  • 권한 설정
  • 세션 관리 : STATELESS 로 설정해서 무상태
  • 필터 추가 : LoginCustomAuthentication (로그인 필터), JwtAuthenticationFilter  (JWT를 통한 인증 필터)
  • HTTP 기본 인증 비활성화
  • CRSF 비활성화
  • CORS 설정 : 특정 출처(localhost:3000)에서의 요청을 허용

 

CORS 설정

  • 요청 헤더, 메서드, 출처를 허용

 

 

 

 

 

JwtProvider.java

@Slf4j
@Service
@RequiredArgsConstructor
public class JwtProvider {
	// jwt 설정 정보 객체 주입
	private final JwtProperties jwtProperties;

	private final UserDetailsService userDetailsService;

	// jwt access token 생성
	public String generateAccessToken(User user) {
		log.info("[generateAccessToekn] 토큰을 생성합니다");
		Date now = new Date(); // 현재 날짜
		Date expiredDate = new Date(now.getTime() + jwtProperties.getAccessDuration()); // 만료일
		return makeToken(user, expiredDate);
	}

	// 토큰 생성 공통 메소드 (실제로 jwt 토큰 생성)
	private String makeToken(User user, Date expiredDate) {
		String token = Jwts.builder().header().add("typ", "JWT") // jwt 타입을 명시
				.and().issuer(jwtProperties.getIssuer()) // 발행자 정보 설정
				.issuedAt(new Date()) // 발행일시 설정
				.expiration(expiredDate) // 만료일 설정
				.subject(user.getEmail()) // 토큰의 주제(subject) 설정 >> 사용자 이메일
				.claim("id", user.getId()) // claim 설정
				.claim("role", user.getRole()) // claim 설정
				.claim("role", user.getRole().name()) // user role의 name claim 설정
				.signWith(getSecretKey(), Jwts.SIG.HS256) // 비밀키와 해시 알고리즘 사용하여 토큰 설명값 설정
				.compact(); // 토큰 정보를 최종적으로 압축해서 문자열로 반환

		log.info("[makeToken] 완성된 토큰: {}", token);
		return token;
	}

	// 비밀키 만들기 메소드
	private SecretKey getSecretKey() {
		return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes());
	}

	// 토큰이 유효한지 검증하는 메소드
	public boolean validateToken(String token) {
		log.info("[validateToken] 토큰 검증을 시작합니다.");
		try {
			Jwts.parser().verifyWith(getSecretKey()) // 비밀키로 서명 검증
					.build().parseSignedClaims(token); // 서명된 클레임을 파싱
			return true;
		} catch (Exception e) {

		}
		log.info("토큰 검증 실패");
		return false;
	}

	// 토큰에서 정보(Claim) 추출 메소드
	private Claims getClaims(String token) {
		return Jwts.parser().verifyWith(getSecretKey()) // 비밀키로 서명 검증
				.build().parseSignedClaims(token) // 새로운 클레임을 파싱
				.getPayload(); // 파싱된 클레임에서 페이로드(실제 클레임)을 반환
	}

	// 토큰에서 인증정보 반환하는 메소드
	public Authentication getAuthenticationByToken(String token) {
		log.info("[getAuthenticationByToken] 토큰 인증 정보 조회");
		String userEmail = getUserEmailByToken(token);
		User user = (User) userDetailsService.loadUserByUsername(userEmail);

		Authentication authentication = new UsernamePasswordAuthenticationToken(user, token, user.getAuthorities());

		return authentication;
	}

	// 토큰에서 사용자 email만 추출하는 메소드
	public String getUserEmailByToken(String token) {
		log.info("[getUserEmailByToken] 토큰 기반 회원 정보 추출");
		Claims claims = getClaims(token);
		String email = claims.get("sub", String.class);
		return email;
	}

}

 

generateAccessToken(User usre)

  • 사용자 정보를 바탕으로 JWT accessToken 생성
  • 현재 날짜와 만료 날짜를 설정
  •  makeToken 메소드를 호출하여 실제 JWT 생성

 

makeToken

  • JWT를 생성하는 실제 로직 처리
  • JWT의 헤더, 발행자, 발행일, 만료일, 주제(subject), 클레임(claim)을 설정
  • getSecretKey 메소드를 사용하여 비밀키를 얻고, HS256 알고리즘으로 서명
  • 최종적으로 JWT를 문자열로 압축해서 반환

 

getSecretKey

  • JWT 서명키를 위한 비밀키 생성
  • jwtProperties에서 비밀키를 가져와 SecretKey 객체로 변환
  • key byte array를 기반으로 적절한 HMAC 알고리즘을 적용한 Key(java.security.Key) 객체를 생성

 

validateToken

  • JWT의 유효성 검증
  • Jwts.parser()로 JWT를 파싱하고 비밀키로 서명을 검증
  • 예외가 발생하면 토큰이 유효하지 않다고 판단

 

getClaims

  • JWT에서 클레임을 추출
  • JWT를 파싱하고 클레임(payload)를 반환함

 

getAuthenticationByToken

  • JWT에서 인증 정보를 추출하여 Authentication 객체 생성
  • getUserEmailByToken 메소드로 사용자 이메일을 추출하고, UserDetailsService를 사용하여 사용자 정보 로드
  • UsernamePasswordAuthenticationToken을 생성하여 인증 정보를 반환

 

getuserEmailByToken

  • JWT에서 사용자 이메일 추출

 

 

 

 

JwtAuthenticationFilter

  • HTTP 요청이 들어올 때마다 실행
  • 요청 헤더에서 JWT를 추출하고 이를 검증하여 인증 정보를 설정
  • HEADER_AUTHORIZATION : JWT를 포함한 HTTP 헤더 이름
  • TOKEN_PREFIX : JWT가 포함된 헤더 값의 접두사 (일반적으로 'Bearer '로 시작)
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
	private final JwtProvider jwtProvider;
	private final static String HEADER_AUTHORIZATION = "Authorization";
	private final static String TOKEN_PREFIX = "Bearer ";

	// HTTP 요청이 들어올 때마다 실행되는 필터
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		String header = request.getHeader(HEADER_AUTHORIZATION);
		// header에서 token값 가져오기
		String token = getAccessToken(header);
		if (token != null && jwtProvider.validateToken(token)) {
			// 유효한 토큰인 경우
			Authentication authentication = jwtProvider.getAuthenticationByToken(token);
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
		// 그 다음 요청 처리 체인을 이어서 진행
		filterChain.doFilter(request, response);

	}

	private String getAccessToken(String header) {
		log.info("[getAccessToken] token값 추출, {}", header);
		if (header != null && header.startsWith(TOKEN_PREFIX)) {
			return header.substring(TOKEN_PREFIX.length());
		}
		return null;
	}

}

 

 

 

 

LoginCustomAuthenticationFilter

  • AbstractAuthenticaitonProcessingFilter를 확장하여 로그인 필터를 정의
  • 사용자가 로그인 요청을 할 때 인증을 처리하고, 성공적인 인증 후 JWT를 생성하여 클라이언트에 반환하는 역할
  • LOGIN_PATH : 로그인 요청의 URL 경로('/api/auth/login') 경로의 POST 요청만 처리
@Slf4j
public class LoginCustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	private JwtAuthenticationService jwtAuthenticationService;
	private static final AntPathRequestMatcher LOGIN_PATH = new AntPathRequestMatcher("/api/auth/login", "POST");

	protected LoginCustomAuthenticationFilter(AuthenticationManager authenticationManager,
			JwtAuthenticationService jwtAuthenticationService) {
		super(LOGIN_PATH);
		setAuthenticationManager(authenticationManager);
		this.jwtAuthenticationService = jwtAuthenticationService;
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException, IOException, ServletException {
		// POST '/api/auth/login'에 요청이 들어오면 진행되는 곳

		// 1. Body에 있는 로그인 정보 { email: "", password: "" } 가져오기
		LoginRequest loginRequest = null;
		try {
			log.info("[attemptAuthentication] 로그인 정보 가져오기");
			ObjectMapper objectMapper = new ObjectMapper();
			loginRequest = objectMapper.readValue(request.getInputStream(), LoginRequest.class);
		} catch (IOException e) {
			throw new RuntimeException("로그인 요청 파라미터 이름 확인 필요 (로그인 불가)");
		}

		// 2. email, password를 기반으로 AuthenticationToken 생성
		log.info("[attemptAuthentication] Authentication 생성");
		UsernamePasswordAuthenticationToken uPAToken = new UsernamePasswordAuthenticationToken(loginRequest.getEmail(),
				loginRequest.getPassword());

		// 3. 인증 시작
		// (AuthenticationManager이 authentication 메소드가 동작할 때 >> loadUserByUsername 실행)
		log.info("[attemptAuthentication] 인증 시작");
		Authentication authenticate = getAuthenticationManager().authenticate(uPAToken);

		return authenticate;
	}

	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		log.info("[successfulAuthentication] 로그인 성공 > 토큰 생성");
		jwtAuthenticationService.successAuthentication(response, authResult);
	}

}

 

LoginCustomAuthenticationFilter

  • LoginCustomAuthenticationFilter의 생성자
  • super(LOGIN_PATH): AbstractAuthenticationProcessingFilter의 생성자를 호출하여 로그인 요청 경로를 설정
  • setAuthenticationManager(authenticationManager): AuthenticationManager를 설정하여 인증 작업을 처리할 수 있게
  • JWT 관련 작업을 위한 서비스 객체를 설정

 

attemptAuthentication

  • 로그인 요청을 처리하며 Authentication 객체를 생성하고 인증을 시도
  • request.getInputStream()을 통해 요청 본문에서 로그인 정보 추출
  • ObjectMapper를 사용하여 JSON 형태의 데이터를 LoginRequest로 변환
  • 추출된 이메일과 비밀번호를 사용하여 UsernamePasswordAuthenticationToken 생성
  • AuthenticationManager를 사용하여 인증 시도 > 성공 시 Authentication 반환

 

successfulAuthentication

  • 인증이 성공한 후 호출되며 JWT를 생성하고 응답에 추가

 

 

 

 

JwtAuthenticationService

  • 사용자 인증이 성공한 후 JWT를 생성하고 HTTP 응답에 포함시키는 역할
  • 이 클래스를 통해 인증 성공 시 JWT를 클라이언트에게 반환
// 인증 관련 서비스 진행
@Service
@RequiredArgsConstructor
public class JwtAuthenticationService {
	private final TokenUtils tokenUtils;

	void successAuthentication(HttpServletResponse response, Authentication authResult) throws IOException {
		User user = (User) authResult.getPrincipal(); // authResult의 유저 정보 가져오기

		// tokenUtils에 user 넣어서 토큰 생성
		Map<String, String> tokenMap = tokenUtils.generateToken(user);
		String accessToken = tokenMap.get("accessToken");

		// loginRespnse에 token 담아서 응답
		LoginResponse loginResponse = LoginResponse.builder().accessToken(accessToken).build();

		tokenUtils.writeResponse(response, loginResponse);

	}

}

 

  • 인증 성공 후 JWT를 생성하고 이를 HTTP 응답으로 반환
  • authResult에서 User 객체를 가져오기 >> getPrincipal()은 인증된 사용자 정보 반환
  • TokenUtils 사용하여 사용자 정보를 기반으로 JWT 생성
  • generateToken 메소드는 사용자 정보를 입력으로 받아 JWT를 생성하고 accessToken 등을 포함한 맴븡ㄹ 반환
  • 생성된 JWT 중 accessToken 추출
  • LoginResponse 객체를 생성하여 응답에 포함될 엑세스 토큰을 설정
  • TokenUtils를 사용하여 응답에 JWT를 포함시킨다
  • writeResponse 메소드는 HttpServletResponse 객체와 LoginResponse 객체를 사용하여 클라이언트에게 응답을 작성한다.

 

 

 

 

 

TokenUtils.java

@Component
@RequiredArgsConstructor
public class TokenUtils {
	private final JwtProvider jwtProvider;

	// token 생성
	public Map<String, String> generateToken(User user) {
		String accessToken = jwtProvider.generateAccessToken(user);

		Map<String, String> tokenMap = new HashMap<String, String>();
		tokenMap.put("accessToken", accessToken);
		return tokenMap;
	}

	// JSON 응답 전송
	public void writeResponse(HttpServletResponse response, LoginResponse loginResponse) throws IOException {
		ObjectMapper objectMapper = new ObjectMapper();
		String jsonResponse = objectMapper.writeValueAsString(loginResponse);

		response.setContentType("application/json");
		response.setCharacterEncoding("UTF-8");
		response.getWriter().write(jsonResponse);
	}
}

 

generateToken

  • JwtProvider의 generateAccessToken 메소드를 호출하여 사용자 정보 기반으로 accessToken 생성
  • Map 객체를 생성하여 JWT 추가
  • 액세스 토큰을 토함한 Map 객체 반환

 

writeResponse

  • 로그인 응답을 JSON 형식으로 HTTP 응답에 작성
  • ObjectMapper를 통해 객체를 JSON으로 변환
  • HTTP 응답의 콘텐츠 타입을 application/json으로 설정
  • HTTP 응답의 문자 인코딩을 UTF-8로 설정
  • 변환된 JSON의 문자열을 응답에 작성하여 클라이언트에 전송

 

 

 

 

 

 

로그인 화면 확인해보기

header에 accessToken 추가되는 거 확인
로그인 전 / 로그인 후

 

 

 

 

 

 

 

 



또다시 글이 길어지고 말았다...

jwt 실습하는데 코드 따라치기도 바빠서 꼭 하나씩 생각해보고 싶었는데 정리를 해도 아직 100% 이해되지 않았다(ㅋㅋ)

역시 로그인 로직이 가장 어렵다고 (개인적으로) 생각한다.

우선은 실습 내용을 정리해두고...꼭 이해해야겠다.

728x90