Spring - React : 카카오 소셜 로그인

호수·2023년 9월 23일
0

Spring

목록 보기
4/5
post-thumbnail

OAuth 로그인 전체 프로세스

프론트엔드(클라이언트)가 할 일

백으로 인가 코드를 따서 보내줘야 한다.

JWT를 받아오고, access token과 refresh 토큰을 관리해야 한다.
-> http-only cookie를 이용해 토큰으로 계속 서버에 조회 요청을 할 것인지,
담아온 정보를 localstorage에 저장해서 관리할 것인지 선택해야 한다.
나는 브라우저에 남아있는 쿠키를 이용해 refresh token을 받아오는 방식을 택했다.

로그인 유무에 따라 다른 컴포넌트를 렌더링해야 한다.
이를 위해선 redux를 이용해 전역 상태관리가 필요할 것 같다.

백엔드(서버)가 할 일

프론트에서 넘어온 인가 코드로 카카오 서버에 정보 조회를 요청한다.

응답으로 받은 정보를 DB에 저장한다.

모든 유효한 로그인 요청에 대해 access token 과 refresh token 을 발급한다.

refresh token은 DB에 저장해야 한다.
-> 초기에는 access token만 발급해 백단에 접근하는 방식으로 구현했지만,
이왕 하는 거 제대로 해보고 싶어서 refresh token을 이용하는 전체적인 로직을 구현했다.

프론트에서 넘어온 access token의 유효성을 검증하고, 데이터를 응답한다.

Access Token

Client가 Resource Server에게 사용자 정보를 요청하기 위한 입장권.
이 입장권에는 유효기간이 있다.
유효기간이 지나면 더 이상 이 코튼을 사용할 수 없다.
Refresh Token

위 Access Token이 유효기간이 만료되면, 새로운 Access Token을 발급받기 위해 필요한 토큰이다.
이 토큰에도 유효기간이 있다. 단, Access Token 보다는 유효기간이 훨씬 길다

  1. code (code를 발급받고 code로 access token 발급)
    [1]. code 발행
  • 아래 url은 기본 oauth2.0 코드 발급 URI로 절차 완료시 redirect_uri 로 code 가 발행된다.
  • URL : http://localhost:8080/oauth/authorize
  • Parameter : response_type=code, redirect_uri=코드 전달받을 URL, scope=read

[2]. access token 발행

  • 발급 받은 코드를 가지고 token 을 발행 한다.
  • URL : http://localhost:8080/oauth/token
  • Parameter : grant_type=code, authorization_code=발급받은코드, redirect_uri=코드 전달받은 URL
  1. refresh_token (refresh token으로 access token 발행)
  • 해당 방식은 1, 2번으로 인증후 재 로그인 없이 자동 로그인을 구현하기 위함
  • 1,2 방식으로 access_token 발행과 동시에 refresh_token 도 같이 발행해주기에 얻은 refresh token로 token 발행이 가능하다.
  • URL : http://localhost:8080/oauth/token
  • Parameter : grant_type=refresh_token, refresh_token=이전에 발급받은 refresh token, scope=read

서버에서 자체 발급한 새 토큰(JWT) 보내기

(1) 카카오 엑세스 토큰 발급 받기

  1. User 클래스 만들기
package dwu.swcmop.trippacks.entity;

import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;

import javax.persistence.*;
import java.security.Timestamp;

@Entity//JPA가 데이터 바인딩
@Data
@NoArgsConstructor//기본 생성자 만들어줌, 갑이 비어도 됨
@Table(name = "user_master") //(1)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) //(2)
@Column(name = "user_code") //(3)
private Long userCode;

@Column(name = "kakao_id")
private Long kakaoId;

@Column(name = "kakao_profile_img")
private String kakaoProfileImg;

@Column(name = "kakao_nickname")
private String kakaoNickname;

@Column(name = "kakao_email")
private String kakaoEmail;

@Column(name = "user_role")
private String userRole;

@Column(name = "create_time")
@CreatedDate//DB에서 current_timestamp설정시 사용
private Timestamp createTime;//유저 관리용 시간

@Builder
public User(Long kakaoId, String kakaoProfileImg, String kakaoNickname,
            String kakaoEmail, String userRole) {

    this.kakaoId = kakaoId;
    this.kakaoProfileImg = kakaoProfileImg;
    this.kakaoNickname = kakaoNickname;
    this.kakaoEmail = kakaoEmail;
    this.userRole = userRole;
}

}
2. UserRepository 만들기

package dwu.swcmop.trippacks.repository;

import dwu.swcmop.trippacks.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {

//    User save(User user);
    // JPA findBy 규칙
    // select * from user_master where kakao_email = ?
    public User findByKakaoEmail(String kakaoEmail);

    public User findByUserCode(String userCode);
}

3-1. UserController 만들기

package dwu.swcmop.trippacks.controller;

import dwu.swcmop.trippacks.model.OauthToken;
import dwu.swcmop.trippacks.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController //(1)
@RequestMapping("/api")
public class LoginController {

    @Autowired
    private UserService userService; //(2)

    // 프론트에서 인가코드 받아오는 url
    @GetMapping("/oauth/token") // (3)
    public OauthToken getLogin(@RequestParam("code") String code) { //(4)
        System.out.println("code : " + code);

        // 넘어온 인가 코드를 통해 access_token 발급 //(5)
        OauthToken oauthToken = userService.getAccessToken(code);

        return oauthToken;
    }
}

3-2. UserService 만들기

package dwu.swcmop.trippacks.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dwu.swcmop.trippacks.model.OauthToken;
import dwu.swcmop.trippacks.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

@Service
public class UserService {


    @Autowired
    UserRepository userRepository; //(1)

    public OauthToken getAccessToken(String code) {

        //(2)RsTemplate이용해 URL형식으로 PSOT
        RestTemplate rt = new RestTemplate();

        //(3)헤더만들기
        //(3)
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        //(4)
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", "b791159adc4e18ab175997922e03859a");//{클라이언트 앱 키}
        params.add("redirect_uri", "http://localhost:8080/auth");//{리다이렉트 uri}
        params.add("code", code);
        params.add("client_secret", "SUMeEShOhtICwtY23mlES55AY66d97pP"); // 생략 가능!{시크릿 키}

        //(5)
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(params, headers);

        //(6)
        ResponseEntity<String> accessTokenResponse = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        //(7)
        ObjectMapper objectMapper = new ObjectMapper();
        OauthToken oauthToken = null;
        try {
            oauthToken = objectMapper.readValue(accessTokenResponse.getBody(), OauthToken.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return oauthToken; //(8)
    }
}

3-3. OauthToken 클래스 만들기

package dwu.swcmop.trippacks.model;

import lombok.Data;

@Data
public class OauthToken {
        String token_type;
        String access_token;
        Integer expires_in;
        String refresh_token;
        String id_token;
        Integer refresh_token_expires_in;
        String scope;
}

(2) 카카오에서 회원정보 요청후 DB에 저장하기

  1. 어플리케이션 동의 항목 확인하기

  2. UserController 수정하기

이전 포스팅에서 만들었던 api에 로직을 추가해줄 것이다.
발급받은 엑세스 토큰을 이용해 카카오 서버에서 사용자 정보를 요청하고, 그 사용자 정보를 DB에 저장하는 기능까지 넣어보겠다.

@RestController
@RequestMapping("/api")
public class UserController {

@Autowired
private UserService userService; 

// 프론트에서 인가코드 받아오는 url

@GetMapping("/oauth/token")
public User getLogin(@RequestParam("code") String code) {

   // 넘어온 인가 코드를 통해 access_token 발급
   OauthToken oauthToken = userService.getAccessToken(code);
   
   //(1)
   // 발급 받은 accessToken 으로 카카오 회원 정보 DB 저장
   String User = userService.saveUser(oauthToken.getAccess_token());

   return User;

}

}
UserService에 해당 메소드를 추가해준다.

  1. UserService 수정하기

@Service
public class UserService {

@Autowired
UserRepository userRepository;

. . . (생략)

public User saveUser(String token) {

	//(1)
    KakaoProfile profile = findProfile(token);

	//(2)
    User user = userRepository.findByKakaoEmail(profile.getKakao_account().getEmail());
    
    //(3)
    if(user == null) {
        user = User.builder()
                .kakaoId(profile.getId())
                 //(4)
                .kakaoProfileImg(profile.getKakao_account().getProfile().getProfile_image_url())
                .kakaoNickname(profile.getKakao_account().getProfile().getNickname())
                .kakaoEmail(profile.getKakao_account().getEmail())
                 //(5)
                .userRole("ROLE_USER").build();

        userRepository.save(user);
    }

    return user;
}


//(1-1)
public KakaoProfile findProfile(String token) {
    
    //(1-2)
    RestTemplate rt = new RestTemplate();

	//(1-3)
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Bearer " + token); //(1-4)
    headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

	//(1-5)
    HttpEntity<MultiValueMap<String, String>> kakaoProfileRequest =
            new HttpEntity<>(headers);

	//(1-6)
    // Http 요청 (POST 방식) 후, response 변수에 응답을 받음
    ResponseEntity<String> kakaoProfileResponse = rt.exchange(
            "https://kapi.kakao.com/v2/user/me",
            HttpMethod.POST,
            kakaoProfileRequest,
            String.class
    );

	//(1-7)
    ObjectMapper objectMapper = new ObjectMapper();
    KakaoProfile kakaoProfile = null;
    try {
        kakaoProfile = objectMapper.readValue(kakaoProfileResponse.getBody(), KakaoProfile.class);
    } catch (JsonProcessingException e) {
        e.printStackTrace();
    }

    return kakaoProfile;
}

3-1. saveUser(), findProfile() 메소드 만들기텍스트

3-2. KakaoProfile 클래스 만들기
만들어둔 model/oauth 패키지 안에 KakaoProfile 이라는 클래스를 만든다.

@Data
public class KakaoProfile {

public Long id;
public String connected_at;
public Properties properties;
public KakaoAccount kakao_account;

@Data
public class Properties { //(1)
    public String nickname;
    public String profile_image; // 이미지 경로 필드1
    public String thumbnail_image;
}

@Data
public class KakaoAccount { //(2)
    public Boolean profile_nickname_needs_agreement;
    public Boolean profile_image_needs_agreement;
    public Profile profile;
    public Boolean has_email;
    public Boolean email_needs_agreement;
    public Boolean is_email_valid;
    public Boolean is_email_verified;
    public String email;

    @Data
    public class Profile {
        public String nickname;
        public String thumbnail_image_url;
        public String profile_image_url; // 이미지 경로 필드2
        public Boolean is_default_image;
    }
}

}

(4) JWT를 생성해서 응답 헤더에 넣기

  1. JwtProperties 만들기
public interface JwtProperties { //(1)
    String SECRET = "{}"; //(2)
    int EXPIRATION_TIME =  864000000; //(3)
    String TOKEN_PREFIX = "Bearer "; //(4)
    String HEADER_STRING = "Authorization"; //(5)
}
  1. UserController 수정하기
@RestController 
@RequestMapping("/api")
public class UserController {

    @Autowired
    private UserService userService; 

    // 프론트에서 인가코드 받아오는 url
   @GetMapping("/oauth/token")
   public ResponseEntity getLogin(@RequestParam("code") String code) { //(1)

       // 넘어온 인가 코드를 통해 access_token 발급
       OauthToken oauthToken = userService.getAccessToken(code);
       
       //(2)
       // 발급 받은 accessToken 으로 카카오 회원 정보 DB 저장 후 JWT 를 생성
       String jwtToken = userService.SaveUserAndGetToken(oauthToken.getAccess_token());

	   //(3)
       HttpHeaders headers = new HttpHeaders();
       headers.add(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + jwtToken);

	   //(4)
       return ResponseEntity.ok().headers(headers).body("success");
   }

}

  1. UserService 수정하기

@Service
public class UserService {

    @Autowired
    UserRepository userRepository;
    

. . . (생략)


    public String saveUserAndGetToken(String token) { //(1)
        KakaoProfile profile = findProfile(token);

        User user = userRepository.findByKakaoEmail(profile.getKakao_account().getEmail());
        if(user == null) {
            user = User.builder()
                    .kakaoId(profile.getId())
                    .kakaoProfileImg(profile.getKakao_account().getProfile().getProfile_image_url())
                    .kakaoNickname(profile.getKakao_account().getProfile().getNickname())
                    .kakaoEmail(profile.getKakao_account().getEmail())
                    .userRole("ROLE_USER").build();

            userRepository.save(user);
        }

        return createToken(user); //(2)
    }

    public String createToken(User user) { //(2-1)
        
        //(2-2)
        String jwtToken = JWT.create()
        
        		 //(2-3)
                .withSubject(user.getKakaoEmail())
                .withExpiresAt(new Date(System.currentTimeMillis()+ JwtProperties.EXPIRATION_TIME))
                
                //(2-4)
                .withClaim("id", user.getUserCode())
                .withClaim("nickname", user.getKakaoNickname())
                
                //(2-5)
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));

        return jwtToken; //(2-6)
    }
    
}

(5)JWT 유효성을 검사하고 인증된 사용자 정보 불러오기

  1. corsFilter 설정 확인하기
@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("*");
        config.addAllowedHeader("*"); //(1)
        config.addExposedHeader("*"); //(2)
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}
  1. JWT 유효성 검사하기

2-1. JwtRequestFilter 만들기

//(1)

@RequiredArgsConstructor
@Component
public class JwtRequestFilter extends OncePerRequestFilter {


    @Autowired //(2)
    UserRepository userRepository;

    @Override //(3)
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    
    	//(4)
        String jwtHeader = ((HttpServletRequest)request).getHeader(JwtProperties.HEADER_STRING);

        //(5)
        if(jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        //(6)
        String token = jwtHeader.replace(JwtProperties.TOKEN_PREFIX, "");

        Long userCode = null;

		//(7)
        try {
            userCode = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(token)
                    .getClaim("id").asLong();

        } catch (TokenExpiredException e) {
            e.printStackTrace();
            request.setAttribute(JwtProperties.HEADER_STRING, "토큰이 만료되었습니다.");
        } catch (JWTVerificationException e) {
            e.printStackTrace();
            request.setAttribute(JwtProperties.HEADER_STRING, "유효하지 않은 토큰입니다.");
        }

		//(8)
        request.setAttribute("userCode", userCode);

		//(9)
        filterChain.doFilter(request, response);
    }
}

2-2. CustomAuthenticationEntryPoint 클래스 만들기

.ExceptionHandling() 으로 이 클래스를 등록하면 앞선 인증 과정에서 401(UnAuthorized) 에러가 발생했을 때 이 클래스가 호출된다.

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
   
    	//(1)
        String exception = (String) request.getAttribute(JwtProperties.HEADER_STRING);
        String errorCode;

        if(exception.equals("토큰이 만료되었습니다.")) {
            errorCode = "토큰이 만료되었습니다.";
            setResponse (response, errorCode);
        }

        if(exception.equals("유효하지 않은 토큰입니다.")) {
            errorCode = "유효하지 않은 토큰입니다.";
            setResponse(response, errorCode);
        }
    }

	//(2)
    private void setResponse(HttpServletResponse response, String errorCode) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().println(JwtProperties.HEADER_STRING + " : " + errorCode);
    }
}

JwtRequestFilter 에서 Exception 발생시 request 에 추가한 요소를 불러와 담아준다.

스테이터스, 콘텐트 타입, 오류 메세지를 담아 응답해주는 메소드를 만들어 사용한다.

2-3. SecurityConfig 설정하기

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

            .and()
            .httpBasic().disable()
            .formLogin().disable()
            .addFilter(corsFilter);

    http.authorizeRequests()
            .antMatchers(FRONT_URL+"/main/**")
            .authenticated()
            .anyRequest().permitAll()

            .and()
            //(1)
            .exceptionHandling()
            .authenticationEntryPoint(new CustomAuthenticationEntryPoint());

	//(2)
    http.addFilterBefore(new JwtRequestFilter(), UsernamePasswordAuthenticationFilter.class);
}
  1. 인증된 사용자 정보 가져오기
    3-1. UserController 수정하기

인증된 사용자 정보를 받아 올 수 있는 api를 추가한다.

@RestController 
@RequestMapping("/api")
public class UserController {

    @Autowired
    private UserService userService; 

   @GetMapping("/oauth/token")
   public ResponseEntity getLogin(@RequestParam("code") String code) {

		. . . (생략)

       return ResponseEntity.ok().headers(headers).body("success");
   }

	@GetMapping("/me")
    public ResponseEntity<Object> getCurrentUser(HttpServletRequest request) { //(1)

		//(2)
        User user = userService.getUser(request);
        
		//(3)
        return ResponseEntity.ok().body(user);
    }
}

3-2. UserService 수정하기

@Service
public class UserService {

    @Autowired
    UserRepository userRepository;
    

. . . (생략)

    public User getUser(HttpServletRequest request) { //(1)
    	//(2)
        Long userCode = (Long) request.getAttribute("userCode");

		//(3)
        User user = userRepository.findByUserCode(userCode);

		//(4)
        return user;
    }

    
}

서비스 로그아웃
유효하면 header에 access token을 실어 보낸다.

LoginController

 @ApiOperation(value = "로그아웃", notes = "카카오 로그아웃한다.")
    @GetMapping(value = "/logout")
    public Map<String, Object> logout(@RequestHeader Map<String, Object> requestHeader) {
        System.out.println(requestHeader);
        System.out.println(requestHeader.get("authorization"));
        System.out.println("로그아웃 시도");
        return requestHeader;
    }

issue
Error creating bean with name 'springSecurityFilterChain' defined in class path resource

해결방안: SecurityConfig에서 추가
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)

참고:https://velog.io/@kimujin99/Spring-React-카카오-소셜-로그인-JWT-5

profile
Back-End개발자 입문 과정 블로그🚀

0개의 댓글