안녕하세요.
이번 포스팅에서는 개인 프로젝트를 진행하면서 React와 Spring Security를 적용시킨 방법에 대해서 포스팅하겠습니다.
개인 프로젝트이고, 공부하고 있다보니 참고용으로 봐주시면 감사하겠습니다.
(소스는 맨 아래에 있습니다.)
참고로, 필자는 username, password가 아닌 userId와 userPassword로 파라미터를 받도록 하였습니다.
1. 로그인 시, JSON 요청으로 로그인하는 방법(JSON 응답 포함)
https://ajdahrdl.tistory.com/260?category=1007900
참고) JSON 로그인을 요청하는 데, JSON 데이터를 가져오지도 못한다면 CORS를 확인해보자.
필자도 설정 후 postman을 이용해 테스트를 하는데 JSON 데이터를 가져오지 못하길래 CORS를 설정하였더니 정상 작동하였다.
2. JWT토큰을 이용하기
필자는 스프링시큐리티에서 기본적으로 제공하는 세션 방식이 아닌, JWT 토큰을 이용해보기로 했다.
1) JWT 의존성 추가하기
<!-- JWT -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.19.2</version>
</dependency>
2) LoginSuccessHadler 커스텀하기 (일반 로그인 성공 처리 핸들러)
아래의 소스를 보면, 먼저
1- 인증정보에서 사용자의 정보를 가져오고
2- JWT 토큰을 생성한 후
3- 쿠키에 JWT 토큰을 발급해서
4- JSON 형식으로 로그인 성공 메시지를 보내준다.
필자가 JWT토큰을 쿠키에다 준 이유는 스토리지와 쿠키 두 가지 방법 모두 각각 장단점이 존재하는데, 쿠키의 경우 ajax와 같이 서버에 요청 시에 쿠키의 값들이 자동으로 같이 요청된다는 점과 쿠키로도 많이 저장한다는 얘기를 듣고 쿠키로 저장하였다.
CustomSuccessHandler.java
3) Oauth2LoginSuccessHadler 커스텀하기 (Oauth2 로그인)
필자는, Oauth2 로그인 방식도 이용할 것이기 때문에 이 경우에 대한 성공 처리 핸들러를 이용해 JWT 토큰을 발급한다.
일반 로그인의 성공 처리 핸들러와 마찬가지로
1- 인증 정보에서 사용자 정보를 가져오고
2- JWT 토큰을 생성하고
3- 쿠키에 JWT 토큰을 넣어준다.
4- 하지만, 추가로 쿠키에서 JSESSIONID 를 삭제한다. (세션을 사용하지 않기 때문에 불필요함)
CustomOauth2AuthenticationSuccessHandler.java
4) JWT 토큰으로 인증 필터 만들기
일반 로그인이든 Oauth2 로그인이든 로그인 성공 시, JWT 토큰을 발급해주었으니 로그인 정보를 확인할 때 JWT토큰을 확인하도록 해야한다.
아래와 같은 방식으로 진행된다.
1- 쿠키에서 JWT토큰을 가져와서 유효한 토큰인 지 확인하고
2- 유효한 토큰일 경우 해당 토큰에 저장된 사용자정보를 이용해 DB에서 조회 후 인증을 진행한다.
2. react단에서 Oauth2 사용 방법
이 경우에는 react단에서 소셜 로그인 페이지로 이동시키고 로그인 성공 시 백엔드(Spring Boot)에서 프론트(React)로 리다이렉트 시켜주는 방법을 이용했다.
1) react 단에서 oauth2 로그인 페이지로 이동시켜주기
아래처럼 a 태그를 이용해서 링크를 걸어줬다.
ajax를 이용해서 oauth2 로그인 요청을 보내고 다시 데이터를 받는 구조를 상상했는데 ajax를 이용한 방법은 안된다고 한다. (ajax를 이용해서 oauth2 요청을 보내보신 적이 있는 분은 공유부탁드립니다..!)
<div className="LoginSns">
<p>SNS계정으로 간편하게 로그인하기</p>
<a href="http://localhost:8080/oauth2/authorization/naver"><img src="/image/sns_icons/naver_logo.png" className="LoginLogo" /></a>
<a href="http://localhost:8080/oauth2/authorization/google"><img src="/image/sns_icons/google_logo.png" className="LoginLogo" /></a>
<a href="http://localhost:8080/oauth2/authorization/kakao"><img src="/image/sns_icons/kakaotalk_logo.png" className="LoginLogo"/></a>
</div>
2) 인증 성공 시, Spring Boot에서 react로 리다이렉트 시켜주기
이 부분도 로그인 성공 시에 대한 로직이기 때문에 Oauth2LoginSuccessHadler 커스텀한 소스에서 진행한다.
아래 determineTargetUrl에서 token을 넣는 부분은 빼도 무관할 것이다.
CustomOauth2AuthenticationSuccessHandler.java
3) 리다이렉트받은 react단에서 useEffect와 axios를 이용해 JWT토큰으로 서버에 요청해서 사용자정보를 받을 수 있다.
JWT 토큰은 리다이렉트되면서 쿠키에 들어와있으니, axios를 요청할 때 다른 작업을 해줄 필요가 없다.
(쿠키에 있는 값은 알아서 같이 요청되기 때문)
참고) JWT토큰을 이용해서 인증이 완료되었다면 아래와 같이 로그인한 사용자의 정보를 가져올 수 있다.
3. 소스 코드
1) CustomSuccessHandler.java
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
requestCache.removeRequest(request, response);
clearAuthenticationAttributes(request);
}
String accept = request.getHeader("accept");
PrincipalDetails principalDetails = null;
if (SecurityContextHolder.getContext().getAuthentication() != null) {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal != null && principal instanceof UserDetails) {
principalDetails = (PrincipalDetails) principal;
}
}
// 로그인 시, JSON 으로 반환
MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter();
MediaType jsonMimeType = MediaType.APPLICATION_JSON;
// JWT 토큰 만들기
// RSA 방식이 아닌, Hash암호방식 (최근 이 방식을 더 많이 사용한다고 함)
String jwtToken = JWT.create()
.withSubject("tokenTitle") // 토큰 제목
.withExpiresAt(new Date(System.currentTimeMillis()+(60000*10))) // 토큰 만료시간 ms 기준 60초 * 10 (10분)
.withClaim("userId", principalDetails.getUsers().getUserId())
.withClaim("userNickname", principalDetails.getUsers().getUserNickname())
.sign(Algorithm.HMAC512("tokenAlgorithm"));
// 쿠키에 JWT 토큰 발급
Cookie jwtCookie = new Cookie("Jwt", "[tokenTitle]" + jwtToken );
jwtCookie.setPath("/");
response.addCookie(jwtCookie);
System.out.println("JWT 발급 완료 : "+jwtToken);
String message = "로그인 성공";
JsonDto jsonDto = JsonDto.success(principalDetails, message);
if (jsonConverter.canWrite(jsonDto.getClass(), jsonMimeType)) {
jsonConverter.write(jsonDto, jsonMimeType, new ServletServerHttpResponse(response));
}
}
public void setRequestCache(RequestCache requestCache) {
this.requestCache = requestCache;
}
}
2) CustomSuccessHandler.java
@NoArgsConstructor
public class CustomOauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String targetUri = determineTargetUrl(request, response, authentication);
PrincipalDetails principalDetails = null;
if (SecurityContextHolder.getContext().getAuthentication() != null) {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal != null && principal instanceof UserDetails) {
principalDetails = (PrincipalDetails) principal;
}
}
String jwtToken = JWT.create()
.withSubject("tokenTitle") // 토큰 제목
.withExpiresAt(new Date(System.currentTimeMillis()+(60000*10))) // 토큰 만료시간 ms 기준 60초 * 10 (10분)
.withClaim("userId", principalDetails.getUsers().getUserId())
.withClaim("userNickname", principalDetails.getUsers().getUserNickname())
.sign(Algorithm.HMAC512("tokenAlgorithm"));
System.out.println("JWT 토큰 발급(Oauth2) : " + jwtToken);
// 쿠키에 JWT 토큰 발급
Cookie jwtCookie = new Cookie("Jwt", "[tokenTitle]" + jwtToken );
jwtCookie.setPath("/");
response.addCookie(jwtCookie);
// Oauth2에서 기본적으로 제공하는 JSessionId의 쿠키를 삭제
Cookie jSessionCookie = new Cookie("JSESSIONID", null );
jSessionCookie.setPath("/");
jSessionCookie.setMaxAge(0);
response.addCookie(jSessionCookie);
getRedirectStrategy().sendRedirect(request, response, targetUri);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
//String targetUri = "http://localhost:3000/oauth2/redirect?";
String targetUri = "http://localhost:3000/loginSuccess";
String token = "hi";//tokenProvider.createToken(authentication);
return UriComponentsBuilder.fromUriString(targetUri).queryParam("token", token).build().toUriString();
}
}
3) JwtAuthorizationFilter.java
// 시큐리티가 filter를 가지는데, 필터중에 BasicAuthenticationFilter 가 있음.
// 권한이나 인증이 필요한 특정 주소를 요청했을 때, 위의 필터를 무조건 탄다.
// 만약 권한이나 인증이 필요한 주소가 아니라면 이 필터를 타지 않는다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private UserRepository userRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
// 인증이나 권한이 필요한 주소 요청 시, 해당 필터를 탄다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
String jwtCookie = "";
// 쿠키에서 Jwt 토큰 가져옴
Cookie[] list = request.getCookies();
if(list != null){
for(Cookie cookie:list) {
if(cookie.getName().equals("Jwt")) {
jwtCookie = cookie.getValue();
}
}
}
System.out.println("인증이나 권한이 필요한 주소. jwtCookie : " + jwtCookie);
// JWT 토큰을 검증해서 정상적인 사용자인지 확인
if(jwtCookie == null || !jwtCookie.startsWith("[tokenTitle]")){
chain.doFilter(request, response);
return;
}
String jwtToken = jwtCookie.replace("[tokenTitle]", "");
// jwt토큰 확인 작업
String userId =
JWT.require(Algorithm.HMAC512("tokenAlgorithm")).build().verify(jwtToken).getClaim("userId").asString();
// 서명이 정상적으로 됨
if(userId != null){
Users userEntity = userRepository.findByUserIdAndStatus(userId, "Y");
PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
// JWT 토큰 서명을 통해서 서명이 정상적이라면 Authentication 객체를 만들어준다.
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
// 강제로 시큐리티의 세션에 접근하여 Authentication 저장
SecurityContextHolder.getContext().setAuthentication((authentication));
}
chain.doFilter(request, response);
}
}
4) SecurityConfig.java
@Configuration
@EnableWebSecurity // Spring Security Filter 가 Spring Filter Chain 에 등록되도록
// securedEnabled: @Secured 어노테이션 활성화. @Secured? 특정 URL에 대해서만 간단하게 권한 처리를 할 수 있는 어노테이션
// prePostEnabled: @PreAuthorize, @PostAuthorize 어노테이션 활성화.
// @PreAuthorize: 해당 메소드 진입 전 처리. @PostAuthorize: 해당 메소드 진입 후 처리
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private PrincipalOauth2UserService principalOauth2UserService;
@Autowired
private PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
/**
* Spring Security Setting
* @param http
* @Author Seong-Mok Kim
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception{
http.cors().configurationSource(corsConfigurationSource()).and().csrf().disable();
// CSRF 비활성화(API서버임)
//http.addFilterBefore(new TestFilter(), BasicAuthenticationFilter.class); // BasicAuthenticationFilter 이전에 TestFilter 추가
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 세션 사용 안함
http.httpBasic().disable()
// URL에 따른 접근 제한 처리
.authorizeRequests()
// 권한 설정 시, 주의 점 !!! : hasRole('ROLE_USER')가 아닌 hasRole('USER')로 설정해야함. (알아서 ROLE_이 붙음)
.antMatchers("/api/user/**").authenticated() // URL user : 인증이 되어야 함
.antMatchers("/api/admin/**").access("hasRole('USER')") // URL admin : 권한 'ROLE_ADMIN'이 있어야함
//.antMatchers("/api/admin/**").hasRole("USER") // URL admin : 권한 'ROLE_ADMIN'이 있어야함
.anyRequest().permitAll() // 위에 명시되지 않은 URL 은 로그인 및 권한 검사 X
.and()
// 로그인 설정
.formLogin().disable() // 기존의 로그인 비활성화 후
.addFilterAt(getAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class) // 새로 만들어준 CUSTOM 필터를 넣어줌
.addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository))
.logout()
.logoutUrl("/api/logout")
.logoutSuccessHandler(logoutSuccessHandler())
// OAuth2 로그인 설정(시작)
.and()
.oauth2Login()
.loginPage("/api/login") // OAuth2의 로그인 페이지 URL.
.userInfoEndpoint()
.userService(principalOauth2UserService)// 구글 로그인이 완료된 이후에 후 처리가 필요. (코드가 아닌 액세스토큰+사용자프로필의정보)를 가져옴*/
.and()
.successHandler(oauth2SuccessHandler())
.failureHandler(oauth2FailureHandler())
// OAuth2 로그인 설정(끝)
;
}
// JSON 방식으로도 로그인 가능하도록 설정한 Filter
protected CustomUsernamePasswordAuthenticationFilter getAuthenticationFilter(AuthenticationManager am) {
CustomUsernamePasswordAuthenticationFilter authFilter = new CustomUsernamePasswordAuthenticationFilter(am);
try {
authFilter.setFilterProcessesUrl("/api/login");
authFilter.setAuthenticationManager(this.authenticationManagerBean());
authFilter.setUsernameParameter("userId");
authFilter.setPasswordParameter("userPassword");
authFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
authFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
} catch (Exception e) {
e.printStackTrace();
}
return authFilter;
}
@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler(){
return new CustomSuccessHandler();
}
@Bean
public AuthenticationFailureHandler authenticationFailureHandler(){
return new CustomFailureHandler();
}
@Bean
public LogoutSuccessHandler logoutSuccessHandler(){
return new CustomLogoutSuccessHandler();
}
@Bean
public AuthenticationSuccessHandler oauth2SuccessHandler(){
return new CustomOauth2AuthenticationSuccessHandler();
}
@Bean
public AuthenticationFailureHandler oauth2FailureHandler(){
return new CustomOauth2AuthenticationFailureHandler();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("http://localhost:3000");
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
다음에는 JWT 토큰을 확인할 때마다 DB에서 조회하는 로직을 DB에서 조회하지 않고도 사용할 수 있도록 해볼 예정입니다.
감사합니다.
'BE > Spring' 카테고리의 다른 글
[Spring Security] JSON으로 로그인 및 응답하기 (0) | 2023.12.10 |
---|---|
[Spring] Inteceptor prehandle 에서 response 넘기는 방법 (0) | 2023.04.06 |
[Spring Security] JWT - 4. JWT 로그인 및 권한 처리 (0) | 2022.06.19 |
[Spring Security] JWT - 3. JWT 임시 토큰 테스트 및 로그인 시 토큰 생성 (0) | 2022.06.18 |
[Spring Security] JWT - 2. JWT 필터 설정 및 필터 우선순위 적용 방법 (0) | 2022.06.11 |