본문 바로가기
OAUTH

Spring Boot Security Oauth2Login (SNS Login) 구현

by fouink 2022. 9. 20.

 - application-oauth.properties

#Naver
spring.security.oauth2.client.registration.naver.client-id=
spring.security.oauth2.client.registration.naver.client-secret=
spring.security.oauth2.client.registration.naver.client-name=Naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver

# Naver Provider ??!
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
# ???? ????? json?? ?????, response?? ???? ?????.

# KAKAO
spring.security.oauth2.client.registration.kakao.client-id=
spring.security.oauth2.client.registration.kakao.client-secret=
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email,gender,age_range,birthday
spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST

## kAKAO Provider ??!
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id

application-oauth.properties의 설정을 마치고

 

 - ouath2Login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <center><a href="http://localhost:8080/oauth2/authorization/naver"><img height="50" src="http://static.nid.naver.com/oauth/small_g_in.PNG"/></a></center>
    <center><a href="http://localhost:8080/oauth2/authorization/kakao">카카오 아이디로 로그인</a></center>
</body>
</html>

로그인 버튼을 누르게 되면

 

 - SecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserRepository userRepository;
    private final CorsFilter corsFilter;
    private final OAuth2SuccessHandler successHandler;
    private final CustomOAuth2UserService oAuth2UserService;
    private final TokenServiceImpl tokenService;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilter(corsFilter)
                .formLogin().disable()
                .httpBasic().disable()
                .addFilter(new JwtAuthenticationFilter(authenticationManager(),tokenService))
                .authorizeRequests()
//                .antMatchers("/oauth2/**").permitAll()      //이거 빼고는 authenticationManager
                .antMatchers("/api/v1/user/**")
                .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll();
//                .anyRequest().authenticated()                           //토큰을 필요로 한
        http.oauth2Login()
                .loginPage("/oauth2LoginPage")
                .successHandler(successHandler)
                .userInfoEndpoint().userService(oAuth2UserService);
        http.addFilterBefore(new JwtAuthorizationFilter(authenticationManager(), userRepository,tokenService), UsernamePasswordAuthenticationFilter.class);

    }
}

 

사용자가 이용약관에 동의 하고 oauth2의 redirect도 문제가 없다면 oauth2login의 successHandler 부분으로 이동

(네이버나 카카오에서 제공하는 JSON을 파싱 하는 객체는 스압될 것 같아서 올리지 않겠다..)

 

 - CustomOAuth2UserService.java

@Slf4j
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    private final BCryptPasswordEncoder passwordEncoder;

    public CustomOAuth2UserService(BCryptPasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {            //OAuth2를 통해 회원정보 불러오기
        OAuth2User oAuth2User = super.loadUser(userRequest);

        OAuth2UserInfo oAuth2UserInfo = null;

        if (userRequest.getClientRegistration().getRegistrationId().equals("naver")) {
            oAuth2UserInfo = new NaverUserInfo((Map<String, Object>) oAuth2User.getAttributes().get("response"));
            log.info("네이버 오스 전체 값 확인 " + oAuth2UserInfo.toString());
            log.info("네이버 로그인 중");
        } else if (userRequest.getClientRegistration().getRegistrationId().equals("kakao")) {
            oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
            log.info("카카오 오스 전체 값 확인 " + oAuth2UserInfo.toString());
            log.info("카카오 로그인 중");
        } else {
            log.info("이게 작동 ?");
        }

        log.info("겟 젠더 확인 "+oAuth2UserInfo.getGender());

        //소셜 로그인에서 제공하는 회원정보 파싱
        String nameValidation = oAuth2UserInfo.getProviderId();
        String socialName = oAuth2UserInfo.getProvider();
        log.info("authentication principal getName() 값 확인 : " + nameValidation);
        String username = socialName+"_"+nameValidation;
        char gender = oAuth2UserInfo.getGender().charAt(0);                                            //젠더 문자형으로 변환
        String birth = oAuth2UserInfo.getBirthyear()+"-"+oAuth2UserInfo.getBirthday();                  //태어난 년과 생일을 합침
        String nickname = oAuth2UserInfo.getNickname();
        String email = oAuth2UserInfo.getEmail();
        String name = oAuth2UserInfo.getName();
        String phone = oAuth2UserInfo.getPhone();
        Role role = Role.USER;                                                                          //유저 역할 enum

        SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");                        //날짜 날짜 형식 지정
        Date BirthYearAddBirthDay = null;
        try {
            BirthYearAddBirthDay = formatter.parse(birth);                       //날짜 형식으로 변환
        } catch (ParseException e) {
            e.printStackTrace();
        }


        UserInfo userInfo = UserInfo.builder()
                .username(username)
                .password(passwordEncoder.encode(randomString()))               //패스워드 랜덤 문자 저장
                .gender(gender)
                .nickname(nickname)
                .email(email)
                .name(name)
                .phone(phone)
                .birthday(BirthYearAddBirthDay)
                .role(role)
                .build();

        return PrincipalDetails.builder()
                .userInfo(userInfo)
                .build();
    }

    public String randomString() {
        int leftLimit = 48; // numeral '0'
        int rightLimit = 122; // letter 'z'
        int targetStringLength = 10;
        Random random = new Random();
        return random.ints(leftLimit, rightLimit + 1)
                .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97))
                .limit(targetStringLength)
                .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
                .toString();
    }

소셜 로그인으로 받은 회원 정보들을 파싱 해주고 PrincipalDetails에 회원정보를 담아준다. (설명은 밑에)

패스워드는 랜덤 값을 만들고 그 랜덤 값을 다시 암호화해서 객체로 만든다.

 


@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
    private final TokenServiceImpl tokenService;
    private final UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException {
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        String username = principalDetails.getUserInfo().getUsername();

        UserInfo userInfo = userRepository.findByUsername(username);
        log.info("유저인포 값 확인 : " + userInfo);

        if (userInfo == null) {
            userRepository.save(principalDetails.getUserInfo());                            // 최초 로그인이라면 회원가입 처리를 한다.
            userInfo = userRepository.findByUsername(username);
        }

        principalDetails = PrincipalDetails
                .builder()
                .userInfo(userInfo)
                .build();

        Authentication auth = getAuthentication(principalDetails);
        SecurityContextHolder.getContext().setAuthentication(auth);                     //@Authentication에 저장

        log.info("유저인포 겟 아이디 확인 :" + principalDetails.getUserInfo().getUsername());
        Token token = tokenService.generateToken(principalDetails.getUserInfo().getId(), "ROLE_USER");                   //토큰 생성
        log.info("{}", token);                                                                       //토큰 값 확인
        userInfo.setRefreshtoken(token.getRefreshToken());                                          //리프레쉬 토큰 유저정보 저장
        log.info("업데이트 전 유저인포 값 확인 : " + userInfo);
        userRepository.save(userInfo);                                                              //유저정보 리프레쉬 토큰 포함 업데이트
            writeTokenResponse(response, token);                                                         //토큰 전달(?)
    }

    private void writeTokenResponse(HttpServletResponse response, Token token) throws IOException {                     //의미 확인 필요{
        response.setContentType("text/html;charset=UTF-8");
        System.out.println(token.getToken());
        String authorizationToken = "Bearer "+token.getToken();
        String refreshToken = "Bearer "+token.getRefreshToken();
//        response.addHeader("Authorization", authorizationToken);                            //토큰약속 문자열 Bearer 포함 해야함
//        response.addHeader("Refresh", refreshToken);
        response.setContentType("application/json;charset=UTF-8");
        response.sendRedirect("/sns?authorizationToken="+authorizationToken);
    }

    public Authentication getAuthentication(PrincipalDetails member) {    //Authentication 객체에 담아서 자체적으로 전역으로 불러올 수 있도록 담음
        return new UsernamePasswordAuthenticationToken(member, "",
                Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
    }
}

이제 다 성공하면 PrincipalDetail을 SecurityContextHolder에 담아준다. 여기에 담아주는 이유는 Controller에서 @AuthenticationPrincipal 어노테이션으로 로그인 처리를 굉장히 쉽게 할 수 있기 때문이다.

 

그리고 writeTokenResponse를 보면 파라미터로 직접 생성한 JWT 엑세스 토큰을 redirect로 보내준다 (이것도 보내주는 이유가 있음)

 

 - UserController.java

/**
 * SNS 로그인 성공 시 View단에 토큰 값 뿌려주기 위한 매핑 함수
 * @return
 */
@GetMapping("/sns")
public String snsLoginComplete(
        @RequestParam(value = "authorizationToken") String authorizationToken,
        HttpServletResponse response) throws UnsupportedEncodingException {
    System.out.println("SNS LOGIN COMPLETE PRINT : "+authorizationToken);
    authorizationToken = URLEncoder.encode(authorizationToken, "utf-8");
    Cookie cookie = new Cookie("Authorization", authorizationToken);
    response.addCookie(cookie);
    return "Login/SnsAuthorization";
}

그럼 받은 토큰을 바로 쿠키에 담아서 html로 리턴한다.

 

 - SnsAuthorization.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SNS로그인 인증중..</title>
</head>
<body>
  <script src="//code.jquery.com/jquery-1.11.0.min.js"></script>
  <script type="text/javascript">
      let req = new XMLHttpRequest();
      console.log(req.getAllResponseHeaders());
      var name = "Authorization";
    var answer;
    var nameOfCookie = name + "="; //쿠키는 "쿠키=값" 형태로 가지고 있어서 뒤에 있는 값을 가져오기 위해 = 포함
    var x = 0;
      while (x <= document.cookie.length)  { //  --현재 세션에 가지고 있는 쿠키의 총 길이를 가지고 반복
        var y = (x + nameOfCookie.length); //substring으로 찾아낼 쿠키의 이름 길이 저장
        if (document.cookie.substring(x, y) == nameOfCookie) { //잘라낸 쿠키와 쿠키의 이름이 같다면
          if ((endOfCookie = document.cookie.indexOf(";", y)) == -1) //y의 위치로부터 ;값까지 값이 있으면
          endOfCookie = document.cookie.length; //쿠키의 길이로 적용하고
          answer = unescape(document.cookie.substring(y, endOfCookie)); //쿠키의 시작점과 끝점을 찾아서 값을 반환
        }
        x = document.cookie.indexOf(" ", x) + 1; //다음 쿠키를 찾기 위해 시작점을 반환
        if (x == 0){ //쿠키 마지막이면
        break; //반복문 빠져나오기
      }
    }
    answer = answer.replace("Bearer+","Bearer ")
    console.log(answer);
    window.sessionStorage.setItem("token",answer);
    window.location.replace("/");
  </script>
</body>
</html>

그럼 view단에서 쿠키를 해석해서 해당 엑세스 토큰으로 "/" 메인페이지로 이동한다

근데 여기서 위에 컨트롤러에서 바로 쿠키에 값을 담으면 "Bearer " 의 뒷부분 공백이 +로 바뀌어서 replace를 해줬다.

 

 - index.html

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Team UP!</title>
  <style>
    pre{
      overflow: scroll;
    }
  </style>
</head>
</html>
<body>
  <script src="//code.jquery.com/jquery-1.11.0.min.js"></script>
  <script type="text/javascript">
        $.ajax({
        contentType: "application/json",
        type: "GET",
        url: "/thymeleaf/getUsername",
        headers: {'Authorization':sessionStorage.getItem("token")},
        dataType: "json",
      }).done(function (res) {
          console.log("응답 성공");
          console.log(res.username);
          if (res.username !== null) {
            sessionStorage.setItem("username",res.username);
            const loginMypage = document.getElementById("login/mypage");
            loginMypage.innerHTML = `<a href="/myPage" id="login/mypage">마이페이지</a>`;
            const joinLogout = document.getElementById("join/logout");
            joinLogout.innerHTML = `<a href="/logout" id="join/logout">로그아웃</a>`;
          }
        })
                .fail(function (res) {
                  console.log(res);
                  alert("알수없는 에러가 발생하였습니다");
                });
  </script>

  <center><h2>TeamUP!</h2></center>
  <p><a href="/board">게시판</a></p> <hr/>
  <p><a href="/oauth2LoginPage">SNS 로그인</a></p> <hr/>
  <p><a id="login/mypage" href="/loginPage">일반 로그인</a></p> <hr/>
  <p><a id="join/logout" href="/joinPage">회원가입</a></p> <hr/>
</body>

 

그럼 이제 발급받은 엑세스 토큰으로 유저 아이디 요청을 해서 최종적으로 로그인 상태로 만든다.

 

 - UserController.java

/**
 * 인덱스 페이지 로그인 여부 랜더링 매핑 함수
 *
 * @param principalDetails
 * @return
 */
@GetMapping("/thymeleaf/getUsername")
public ResponseEntity<?> getUsrename(@AuthenticationPrincipal PrincipalDetails principalDetails) {
    Map<String, String> map = new HashMap<>();
    if (principalDetails != null) {
        map.put("username", principalDetails.getUserInfo().getUsername());
        System.out.println("로그인 상태");
        return ResponseEntity.ok(map);
    } else {
        map.put("username", null);
        System.out.println("비로그인 상태");
        return ResponseEntity.ok(map);
    }
}

댓글