[토이프로젝트]당근마켓 구현하기(7) - kakao API 적용하기

gamja·2022년 11월 16일
0
post-thumbnail

지난 글에서는 kakao api를 구현해보는 시간을 가졌다. 이번 시간에는 카카오 oauth와 스프링 시큐리티를 함께 사용하기 위한 발버둥이 담겨있다. 그럼에도 결국 완전한 해결은 못 하였지만...

spring security를 적용해 놓은 상태라서 카카오 API로 로그인을 한 경우 spring security 설정 때문에 다른 페이지로 이동하려 하면 다시 login 페이지로 이동한다. 이것 때문에 진짜 며칠동안 밤새 오류에 시달리고 골머리를 앓았다.


위와 같은 문제점을 해결하기 위해서 온갖 서치를 해본 결과 아래 블로그를 참고하기로 했다.

https://thalals.tistory.com/237

위 글에서는 카카오 Client Secret 대신 spring security 설정을 사용한 듯하다. 나는 spring security를 그대로 사용하되, spring security 설정을 변경시키려 한다.


WebSecurityConfig

@Configuration
@EnableWebSecurity
public class WebSecurityConfig  {
    @Autowired
    private DataSource dataSource; // application.properties에 기재된 데이터 정보를 사용할 수 있도록 Autowired 한다.
    public WebSecurityConfig(DataSource dataSource) { // 필드 주입은 권장 하지 않는다. 생성자 주입을 권장
        this.dataSource = dataSource;
    }
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeHttpRequests((requests) -> requests
                        .antMatchers("/", "/account/signup",
                                "/css/**", "/images/**",
                                "/js/**", "/api/**", "/oauth/**").permitAll() // 해당 페이지는 모든 사람들이 접속할 수 있는 권한을 부여한다.
                        .anyRequest().authenticated()   // 만약 그냥 "/"만 해주면 css 적용이 안 됨. 따라서 "/css/**"를 해줌으로써 css폴더 안에 있는 하위 파일들을 다 가져오도록 한다.
                )
                .formLogin((form) -> form
                        .loginPage("/account/login")    // 위에서 설정할 페이지("/", "/css/**")를 제외하고 다른 페이지로 이동하려고 하면 login페이지로 이동한다.
                        .usernameParameter("username")   // 이거 user_name으로 변경해서 로그인 인증에 계속 실패했었다... 되도록이면 변경하지 말기
                        .passwordParameter("password")
                        .permitAll()                    // loginPage()에 설정한 페이지로 바로 이동된다.
                )
                .logout((logout) -> logout.permitAll());
        return http.build();
    }

    @Bean
    public static PasswordEncoder passwordEncoder() {  // 처음에 public 으로만 되어 있었는데 BeanCurrentlyInCreationException 오류가 뜸
        return new BCryptPasswordEncoder();            // public static으로 바꿔줘야 오류 해결 됨
    }


    // jdbc authentication 방식 (db로 인증하는 방법)
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) // autowired를 통해서 autenticationManagerBuilder를 생성한다.
            throws Exception {      // AuthenticationManagerBuilder 인스턴스를 가지고 스프링 내부에서 인증 처리를 한다.
        auth.jdbcAuthentication()
                .dataSource(dataSource) // datasource에 있는 정보를 사용해서 인증 처리를 한다.
                .passwordEncoder(passwordEncoder()) // 아래 있는 PasswordEncoder를 여기에 넣어주고, 비밀번호 복호화 자동으로 해준다.
                .usersByUsernameQuery("select user_id,user_password, enabled " // 인증 처리
                        + "from tb_users "
                        + "where user_id = ?")
                .authoritiesByUsernameQuery("select u.user_id,r.role_name "   // 권한 처리
                        + "from tb_user_role ur inner join tb_users u on ur.user_id = u.user_id "
                        + "inner join tb_role r on ur.role_id = r.role_id "
                        + "where u.user_id = ?");
        System.out.println("confiureGlobal 실행 완료");
    }
}

Repository 생성

public interface UserRepository extends JpaRepository<User, String> {
    Optional<User> findByUserIdOrEmail(String userId);

}

이때 Optional을 사용한 이유는 NullPointerException 을 방지하기 위해서다. 위의 Repository는 회원 중복 확인을 위해 사용될 예정이다.


Service

KakaoOAuthService

@Service
public class KakaoOAuthService {
    public String getToken(String code) throws IOException {
        // 인가코드로 토큰받기
        /*
            구현 과정
        1. connection 생성
        2. POST로 보낼 Body 작성
        3. 받아온 결과 JSON 파싱 (Gson)
         */

        // 1. connection 생성
        String host = "https://kauth.kakao.com/oauth/token";
        URL url = new URL(host);
        HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
        String token = "";
        try {
            // POST 요청을 위해 기본값이 false인 setDoOutput을 true로 해준다.
            urlConnection.setRequestMethod("POST");
            urlConnection.setDoOutput(true);        // 데이터 기록 알려주기

            // 2. post로 보낼 바디 작성(POST 요청에 필요로 요구하는 파라미터 스트림을 통해 전송)
            BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(urlConnection.getOutputStream()));
            StringBuilder sb = new StringBuilder();
            sb.append("grant_type=authorization_code");
            sb.append("&client_id=3b9ab37490257b10f89f741a7912ceb7");       // TODO REST_API_KEY 입력
            sb.append("&redirect_uri=http://localhost:8080/oauth/kakao");   // TODO 인가코드 받은 redirect_uri 입력
            sb.append("&code=" + code);

            bw.write(sb.toString());
            bw.flush();

            // 결과 code가 200이면 성공
            int responseCode = urlConnection.getResponseCode();
            System.out.println("responseCode = " + responseCode);

            // 3. 받아온 결과 JSON으로 파싱
            BufferedReader br = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
            String line = "";
            String result = "";
            while ((line = br.readLine()) != null) {
                result += line;
            }
            System.out.println("result = " + result);

            // Json 라이브러리에 포함된 클래스로 json parsing 객체 생성
            JSONObject elem = (JSONObject) new JSONParser().parse(result);

            String access_token = elem.get("access_token").toString();
            String refresh_token = elem.get("refresh_token").toString();

            System.out.println("refresh_token = " + refresh_token);
            System.out.println("access_token = " + access_token);

            token = access_token;

            br.close();
            bw.close();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ParseException e) {
            e.printStackTrace();
        }


        return token;
    }


    // 사용자 정보 가져오기
    public Map<String, Object> getUserInfo(String access_token) throws IOException {
        String host = "https://kapi.kakao.com/v2/user/me";
        Map<String, Object> result = new HashMap<>();

        // Access_token을 이용하여 사용자 정보 조회
        try {
            URL url = new URL(host);
            HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();

            urlConnection.setRequestProperty("Authorization", "Bearer " + access_token);
            urlConnection.setRequestMethod("GET");

            // 받아온 결과가 200이면 성공
            int responseCode = urlConnection.getResponseCode();
            System.out.println("responseCode = " + responseCode);


            //요청을 통해 얻은 JSON타입의 Response 메세지 읽어오기
            BufferedReader br = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
            String line = "";
            String res = "";

            while((line=br.readLine())!=null) {
                res+=line;
            }

            System.out.println("res = " + res);

            // JSON 라이브러리로 JSON 파싱
            JSONParser parser = new JSONParser();
            JSONObject obj = (JSONObject) parser.parse(res);
            JSONObject kakao_account = (JSONObject) obj.get("kakao_account");
            JSONObject properties = (JSONObject) obj.get("properties");


            String id = obj.get("id").toString();
            String email = kakao_account.get("email").toString();
            String nickname = properties.get("nickname").toString();
            String profile_image = properties.get("profile_image").toString();


            result.put("id", id);
            result.put("email", email);
            result.put("nickname", nickname);
            result.put("profile_image", profile_image);

            br.close();


        } catch (IOException | ParseException e) {
            e.printStackTrace();
        }

        return result;
    }
}

AccountService

@Service
public class AccountService {
    private KakaoOAuthService kakaoService;
    private UserRepository userRepository;
    private PasswordEncoder passwordEncoder;
    private AuthenticationManager authenticationManager;

    @Autowired
    public AccountService(KakaoOAuthService kakaoService, UserRepository userRepository
                    , PasswordEncoder passwordEncoder, AuthenticationManager authenticationManager){
        this.kakaoService = kakaoService;
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.authenticationManager = authenticationManager;
    }

    public void kakaoLogin(String code) throws IOException {
        String access_token = kakaoService.getToken(code);
        Map<String, Object> userInfo = kakaoService.getUserInfo(access_token);

        Long kakaoId = (Long) userInfo.get("id");
        String email = (String) userInfo.get("email");
        String nickname = (String) userInfo.get("nickname");
        String image = (String) userInfo.get("image");
        String password = kakaoId + access_token;


        // DB에서 중복 확인하기
        User kakaoUser =  userRepository.findByKakaoId(kakaoId).orElse(null);   // Optional로 하면 자꾸 에러나서 일단 Optional사용 x
        if(kakaoUser == null){
            // 패스워드 인코딩
            String encodedPassword = passwordEncoder.encode(password);
            // ROLE = 사용자
            kakaoUser = new User(kakaoId, encodedPassword, nickname, email, image);
            userRepository.save(kakaoUser);
        }

        // 로그인 처리
        Authentication kakaoUsernamePassword = new UsernamePasswordAuthenticationToken(kakaoId, password);
        Authentication authentication = authenticationManager.authenticate(kakaoUsernamePassword);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

로그인을 생각하기 전에 회원가입을 먼저 생각해봐야 한다.
회원가입의 경우 일반 회원가입과, 카카오 간편 로그인을 통한 회원가입이 존재한다.

  • 일반 회원가입 : 회원가입 버튼 클릭 -> [post]/account/signup -> user_name과 email 중복 확인 -> 중복된 정보가 없다면 데이터 저장 -> 로그인 처리
  • 카카오 회원가입 : 카카오 회원가입 버튼 클릭 -> [get]/oauth/kakao -> code로 토큰 얻고 token으로 userInfo 얻기 -> 사용자 중복 확인 -> 중복된 정보 없다면 데이터 저장 -> 로그인 처리

과정은 비슷하지만 url이 완전히 다르기 때문에 따로 구현해줘야 한다.
따라서 Service는 KakaoOAuthService와 AccountService 두 개를 만들어줬다.


DTO

@Getter
@Setter
@ToString
@NoArgsConstructor
public class UserDto {
    private String userId;
    private String userPassword;
    private String nickname;
    private String email;
    private String imgUrl;
    private boolean enabled;


    public UserDto(String userId, String userPassword, String nickname
                , String imgUrl, String email) {
        this.userId = userId;
        this.userPassword = userPassword;
        this.nickname = nickname;
        this.imgUrl = imgUrl;
        this.email = email;
    }

}

Entity

package daangnmarket.daangntoyproject.user.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@NoArgsConstructor
@Table(name = "tb_users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private int idx;

    @Column(nullable = true)
    private String userId;

    @Column(name = "kakao_id", nullable = true)
    private long kakaoId;

    @Column(name = "user_password")
    private String userPassword;

    private String nickname;

    private String email;

    @Column(name = "img_url")
    private String imgUrl;

    @Column(name = "manner_temp")
    private double mannerTemp;

    @Column(name = "region_cnt")
    private int regionCnt;

    @ManyToMany
    @JoinTable(
            name = "tb_user_role",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private List<Role> roles = new ArrayList<>();

    public User(long kakaoId, String userPassword, String nickname, String email, String image){
        this.kakaoId = kakaoId;
        this.userPassword = userPassword;
        this.nickname = nickname;
        this.email = email;
        this.imgUrl = image;
    }
    public User(String userId, String userPassword, String nickname, String email, String image){
        this.userId = userId;
        this.userPassword = userPassword;
        this.nickname = nickname;
        this.email = email;
        this.imgUrl = image;
    }

    @Builder
    public User(String userId, long kakaoId, String userPassword,
                String nickname, String email, String imgUrl, double mannerTemp,
                int regionCnt){
        this.userId = userId;
        this.kakaoId = kakaoId;
        this.userPassword = userPassword;
        this.nickname = nickname;
        this.email = email;
        this.imgUrl = imgUrl;
        this.mannerTemp = mannerTemp;
        this.regionCnt = regionCnt;
    }

}

카카오 로그인 회원 정보를 저장해둘 DTO를 하나 만들었다.
나는 email, nickname, image_url까지 받을 생각이라서 위와 같이 생성해줬다.


이렇게 다 설정하고 나서 실행해봤는데 response code 401이 떴다.

401오류를 찾아보니 리소스를 접근할 자격이 없다는 뜻이라는 거다. 바로 드는 생각이 kakao developers 에서 보안 설정을 해뒀던 것 때문인가..?

그래서 바로 보안을 풀어보니 아래처럼 200이 바로 떴다.


문제 발생


카카오 간편 로그인을 한 뒤 게시글로 이동을 하려 하면 spring security 때문에 이렇게 403에러가 뜬다. 진짜 이거 때문에 며칠을 밤낮으로 알아봤는데 결국 해결하지 못 했다. 우선 갈 길이 멀기 때문에 다른 기능을 먼저 구현한 뒤 로그인을 좀더 보완하기로 했다.

다른 사람들도 spring security와 카카오 간편 로그인을 함께 쓰는 것 같은데 왜 나는 안 되는 걸까..

참고 블로그
https://juu-code.tistory.com/33
https://thalals.tistory.com/237

profile
눈도 1mm씩 쌓인다.

0개의 댓글