프로젝트/SUFY

[도착 알리미 SUFY] 2. 카카오 로그인 구현 및 jwt토큰 (1)

kim-dev 2024. 2. 6. 19:01
반응형

 

SUFY의 기본적인 흐름이 지하철이 종착역에 다다르면 카카오톡으로 알림을 보내는 서비스이기 때문에, 카카오 로그인을 사용하고 굳이 다른 로그인 로직을 구현하지 않기로 했다.

 

사실 카카오 로그인 자체는 OAuth2 라이브러리를 사용하면 크게 어렵지 않게 구현할 수 있다.
문제는 Jwt토큰을 발급하고 클라이언트에 전달하는 과정이 좀 복잡했었는데... 저번에 페이스북 로그인을 구현하면서 한 번 겪어봤던 문제이기 때문에 이번에는 아마 나름대로 쉽게 구현할 수 있을 듯?

 

여튼 일단 OAuth2와 Spring Security, JWT를 build.gradle에 추가해 주었다.

< build.gradle >

// Spring Security, JWT
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation "com.auth0:java-jwt:3.19.2"

// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

시큐리티를 적용했기 때문에, 이제 로그인을 시도하면 시큐리티의 기본 로그인 페이지로 리다이렉팅된다.

ㅋㅋ... 여기에 로그인 시도하면 솔직히 멋도 안 나고, 내가 만든 로그인 폼이 있는데 굳이 이 로그인 폼을 사용?
그런 일은 있을 수가 없다... 바로 SecurityConfig를 만들어서 WebSecurityConfigurerAdapter를 상속해주자.

< SecurityConfig.java >

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 특정 주소로 접근하면 권한 및 인증을 먼저 체크하겠다는 것.
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // csrf 토큰 비활성화
        http.csrf().disable();

        // 세션을 Stateless로 변경, 기존의 폼 로그인 방식 비활성화
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http.formLogin().disable().httpBasic().disable();
    }
}

나는 세션을 사용하지 않고, 토큰으로 세션을 관리할 거라서 stateless로 변경해준 후 폼 로그인을 비활성화 해 주었다.
이렇게 폼 로그인을 비활성화 해주기만 해도, 시큐리티에서 기본적으로 제공하는 로그인 폼은 나타나지 않는다.


이제 로그인 폼은 다 만들었다!
이제 카카오 로그인 버튼을 만들면 카카오에서 로그인을 진행할 수 있도록 로직을 구현해야 한다.

 

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

해당 사이트에 로그인한 후, 애플리케이션을 추가해 주고 웹페이지 주소(http://localhost:8080)를 입력해 주면,
해당 애플리케이션에 사용할 수 있는 REST API 키를 발급받을 수 있다.

여튼 카카오 로그인 API 관련 부분은 구글링하면 많이 나오니까 그거 보시고.. 저는 작성하기 귀찮으니 대충 쓰겠습니다.

 

암튼 이렇게 카카오 로그인 API를 성공적으로 구했다. 이제 서버에 등록해 주어야 한다!
구글과 페이스북은 application.properties에 클라이언트id와 시크릿 코드만 써주면 됐었다. 그런데 카카오는 구글이나 페이스북과 달리 OAuth2에 Provider가 기본적으로 등록되어있지 않아서 내가 직접 작성해 주어야 했다.

< application.properties >

# 카카오 로그인을 위한 OAuth2 설정
spring.security.oauth2.client.registration.kakao.client-id={REST API키}
spring.security.oauth2.client.registration.kakao.scope=profile_nickname, talk_message
spring.security.oauth2.client.registration.kakao.client-name=Kakao
spring.security.oauth2.client.registration.kakao.client-authentication-method= POST
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:9000/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code

# 카카오 로그인 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

 

이제 아래의 주소로 GET 요청을 보내면, 카카오 로그인을 실행할 수 있다.

https://kauth.kakao.com/oauth/authorize?client_id={REST_API_KEY}&redirect_uri={REDIRECT_URI}&response_type=code

 

여기서 동의하고 계속하기를 눌러도, 로그인이 완료되지 않는다.


왜? 난 아까 로그인 완료 후 콜백 주소를 "/login/kakaoCallback"로 설정해 두었는데, 해당 uri의 컨트롤러를 만들지 않아서 그런가?
물론 그것도 맞지만... 카카오 로그인은 로그인이 완료되면 해당 콜백 주소로 'code'라는 쿼리스트링으로 인가 코드를 전송해준다.
이 코드를 가지고 카카오 api에 다시 요청하여 액세스 토큰과 리프레시 토큰을 인가받아야 로그인이 완료되는 것이다.

즉 이제 우리가 해야할 일은, 콜백 주소의 컨트롤러에서 인가 코드를 받은 후,
해당 코드로 다시 한 번 카카오 uri에 POST 요청을 보내 토큰들을 받아야 하는 것이다.

 

"https://kauth.kakao.com/oauth/token" uri로 POST 요청을 보내면 그 응답으로 토큰들을 받을 수 있다.

아래는 응답 메시지의 양식이다.

<200,{"access_token":"6e5RLvLgbpR7SeN_lrcmKLEVUnLg2VxsTX0KPXSYAAABjX1TS1W2xj-RG-1vuA",
     "token_type":"bearer",
     "refresh_token":"sIcC8dAPfsUI1PIkTYQo7oesYV7-toJVtV8KPXSYAAABjX1TS1K2xj-RG-1vuA",
     "expires_in":21599,"scope":"talk_message profile_nickname",
     "refresh_token_expires_in":5183999},
     [Date:"Tue, 06 Feb 2024 07:30:27 GMT", Content-Type:"application/json;charset=utf-8", Transfer-Encoding:"chunked", Connection:"keep-alive", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-XSS-Protection:"1; mode=block", X-Frame-Options:"DENY", X-Content-Type-Options:"nosniff", Kakao:"Talk", Access-Control-Allow-Origin:"*", Access-Control-Allow-Methods:"GET, POST, OPTIONS", Access-Control-Allow-Headers:"Authorization, KA, Origin, X-Requested-With, Content-Type, Accept"]>

우리가 필요한 건 access_token과 refresh_token이다.
이 액세스 토큰과 리프레시 토큰을 클라이언트에 전달해주면 된다.

 

<LoginController.java>

@GetMapping("/login/kakaoCallback")
public String kakaoCallback(Model model,
                            @RequestParam(required = false) String code,
                            @RequestParam(required = false) String error) throws JsonProcessingException {

    if (error != null) { // 에러가 발생했을 경우
        model.addAttribute("restApiKey", HiddenData.kakaoRestApi);
        model.addAttribute("callBack", HiddenData.kakaoCallBack);

        return "login/login"; // 로그인 창으로 돌려 보낸다.

    } else { // 로그인 인가 완료됐을 경우 (에러가 뜨지 않았을 경우)
        loginService.kakaoCallback(model, code);
        return "login/tokenSave";
    }
}

 

(코드는 아래에 적어 놓은 분의 블로그에서 가져왔다... 서버에서도 http 요청을 보낼 수 있구나...)

이렇게 하면 액세스 토큰과 리프레시 토큰을 추출해서, Model에 담아 tokenSave.jsp로 보낸다.
이 tokenSave.jsp에서 토큰을 LocalStorage에 저장하면 되는 것...

 

페이스북 때와는 달리 리프레시 토큰을 DB에 저장하지 않아도 될 것 같아서, 저장하는 로직은 만들지 않았다.
왜냐?? 기존에 리프레시 토큰을 DB에 저장했던 이유가, 해당 리프레시 토큰에 해당하는 유저의 정보를 찾기 위해서였는데 이번에는 굳이 우리가 DB에 저장해두지 않아도, 리프레시 토큰으로 카카오 쪽에 요청을 보내면 그 유저에 해당하는 유저의 액세스 토큰을 재발급 해주기 때문... (아닐 수도 ㅋㅋ)

< LoginService.java >

@Service
public class LoginService {

    @Autowired
    private BCryptPasswordEncoder encoder;

    @Autowired
    AccountRepository accountRepository;

    @Transactional
    public void kakaoCallback(Model model, String code) throws JsonProcessingException {
        // POST 방식으로 key=value 데이터를 요청 (카카오쪽으로)
        // 이 때 필요한 라이브러리가 RestTemplate, 얘를 쓰면 http 요청을 편하게 할 수 있다.
        RestTemplate rt = new RestTemplate();

        // HTTP POST를 요청할 때 보내는 데이터(body)를 설명해주는 헤더도 만들어 같이 보내줘야 한다.
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");

        // body 데이터를 담을 오브젝트인 MultiValueMap를 만들어보자
        // body는 보통 key, value의 쌍으로 이루어지기 때문에 자바에서 제공해주는 MultiValueMap 타입을 사용한다.
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", HiddenData.kakaoRestApi);
        params.add("redirect_uri", HiddenData.kakaoCallBack);
        params.add("code", code);

        // 요청하기 위해 헤더(Header)와 데이터(Body)를 합친다.
        // kakaoTokenRequest는 데이터(Body)와 헤더(Header)를 Entity가 된다.
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(params, headers);

        // POST 방식으로 Http 요청한다. 그리고 response 변수의 응답 받는다.
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token", // https://{요청할 서버 주소}
                HttpMethod.POST, // 요청할 방식
                kakaoTokenRequest, // 요청할 때 보낼 데이터
                String.class // 요청 시 반환되는 데이터 타입
        );

        String parsedRespString = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode parsedResp = objectMapper.readTree(parsedRespString);

        // 액세스 토큰으로 유저를 찾는다.
        String accessToken = parsedResp.get("access_token").asText();
        Account account = findUser(accessToken);

        String refreshToken = parsedResp.get("refresh_token").asText();

        // Model에 토큰 2개를 담아서 응답한다.
        model.addAttribute("accessToken", "Bearer " + accessToken);
        model.addAttribute("refreshToken", "Bearer " + refreshToken);

        // 만료 시간을 밀리초로 변경
        int accessExpire = parsedResp.get("expires_in").asInt() * 1000;
        int refreshExpire = parsedResp.get("refresh_token_expires_in").asInt() * 1000;

        // 토큰의 만료 시간도 담는다.
        model.addAttribute("accessExpire", new Date().getTime() + accessExpire); // 6시간
        model.addAttribute("refreshExpire", new Date().getTime() + refreshExpire); // 2달
    }

    @Transactional
    public Account findUser(String accessToken) throws JsonProcessingException {
        RestTemplate rt = new RestTemplate();

        // 토큰을 헤더에 담는다.
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);
        headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");

        // 헤더를 가지고 Http 요청 객체를 만든다.
        HttpEntity<MultiValueMap<String, String>> kakaoProfileRequest = new HttpEntity<>(headers);

        // POST 요청을 보낸 후 응답을 받는다.
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoProfileRequest,
                String.class
        );

        String parsedRespString = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode parsedResp = objectMapper.readTree(parsedRespString);

        String id = parsedResp.get("id").asText();
        String username = "K" + id;
        String nickname = parsedResp.get("properties").get("nickname").asText();

        Account account = accountRepository.findByUsername(username);
        if (account == null) { // 가입
            String salt = encoder.encode(UUID.randomUUID().toString());
            String ori_password = salt + UUID.randomUUID().toString();
            String hashed_password = encoder.encode(ori_password);

            account = new Account();
            account.setUsername(username);
            account.setSalt(salt);
            account.setPassword(hashed_password);
            account.setNickname(nickname);
            account.setRole(RoleType.ROLE_USER);

            accountRepository.save(account);
        }

        return account;
    }
<tokenSave.jsp>

...
<div id="SUFY_label">
    잠시만 기다려 주세요...
    <script>
        localStorage.setItem('Authorization', '${accessToken}')
        localStorage.setItem('Refresh-Token', '${refreshToken}')
        localStorage.setItem('accessExpire', '${accessExpire}')
        localStorage.setItem('refreshExpire', '${refreshExpire}')

        setTimeout(function() {
            window.location.href = "/index";
        }, 1500);
    </script>
</div>
...

응답받은 토큰을 클라이언트에 보내서 LocalStorage에 토큰들을 저장한다.
이 로직을 로그인 할 때마다 실행하는 것!


이렇게 하면, 카카오에 로그인 하여 토큰을 발급받는 과정까지는 끝난다.
다음에는 해당 토큰을 검증하여, 만료되었을 경우 재발급 받거나 튕기게 하는 로직을 구현해 볼 것이다!
한 번에 다 하려고 했는데... 생각보다 글이 길어져서 다음 편에 계속...