Project MySelectShop - OAuth2

박영준·2022년 12월 9일
0

Java

목록 보기
29/111

OAuth

Open Standard for Authorization

  • 개방형 Authorization 의 표준
  • API 허가(Authorize)를 목적으로 JSON 형식으로 개발된 HTTP 기반의 보안 프로토콜
  • 사용자들이 사용하고자 하는 웹사이트 및 애플리케이션에 비밀번호를 제공하지 않고 접근 권한을 부여 받을 수 있게 해주는 공통적 수단으로서 사용

다양한 클라이언트 환경에 적합한 인증(Authentication) 및 인가(Authorization) 의 위임 방법을 제공하고 그 결과로 클라이언트에게 접근 토큰 (Access Token) 을 발급하는 것에 대한 구조

소셜 로그인

카카오 로그인 사전작업

카카오 사용자 회원가입 설계

관심 상품 등록을 했을 때 회원 구분이 필요하기 때문에, 카카오서버에서 받은 사용자 정보를 이용해 회원 가입을 합니다.

  • 현재 회원 (User) 테이블 확인

  • 카카오로 부터 받은 사용자 정보

  1. kakaoId
  2. nickname
  3. email
  • 카카오 사용자 정보 JSON 의 예
    {
      "id": 1632335751,
      "properties": {
        "nickname": "르탄이",
        "profile_image": "http://k.kakaocdn.net/...jpg",
        "thumbnail_image": "http://k.kakaocdn.net/...jpg"
      },
      "kakao_account": {
        "profile_needs_agreement": false,
        "profile": {
          "nickname": "르탄이",
          "thumbnail_image_url": "http://k.kakaocdn.net/...jpg",
          "profile_image_url": "http://k.kakaocdn.net/...jpg"
        },
        "has_email": true,
        "email_needs_agreement": false,
        "is_email_valid": true,
        "is_email_verified": true,
        "email": "letan@sparta.com"
      }
    }
  • 테이블 설계 옵션
  1. 카카오 User 를 위한 테이블 (ex. KakaoUser) 을 하나 더 만든다.
    1. 장점: 결합도가 낮아짐
      1. 성격이 다른 유저 별로 분리 → 차후 각 테이블의 변화에 서로 영향을 주지 않음
      2. 예) 카카오 사용자들만 profile_image 컬럼 추가해서 사용 가능
    2. 단점: 구현 난이도가 올라감
      1. 예) 관심상품 등록 시, 회원별로 다른 테이블을 참조해야 함
        1. 일반 회원: User - Product
        2. 카카오 회원: KakaoUser - Product
  2. 기존 회원 (User) 테이블에 카카오 User 추가
    1. 장점: 구현이 단순해짐
    2. 단점: 결합도가 높아짐
      1. 폼 로그인을 통해 카카오 로그인 사용자의 username, password 를 입력해서 로그인한다면??
  • 회원 (User) 테이블에 적용하기로 결정

  • 패스워드를 UUID 로 설정한 이유
    : 폼 로그인을 통해서 로그인되지 않도록!!

카카오 서버에서 인가코드 받기

카카오 서버에서 인가코드 받기

인가코드 요청 방법

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

"/templates/login.html" 파일 수정: "client_id=" 뒷부분에 REST API 키 추가

https://kauth.kakao.com/oauth/authorize?client_id=본인의 REST API키&redirect_uri=http://localhost:8080/api/user/kakao/callback&response_type=code

카카오 디벨로퍼스 사이트에서 REST API 키 확인 가능
https://developers.kakao.com/console/app

카카오에서 보내주는 '인가코드' 처리

  1. 카카오에서 보내주는 '인가코드'를 받음 ⇒ Controller
  2. '인가코드'를 가지고 카카오 로그인 처리 ⇒ Service
  3. 로그인 성공 시 생성한 JWT 반환

카카오 사용자 정보 가져오기

카카오 사용자 정보 JSON 의 예

{
  "id": 1632335751,
  "properties": {
    "nickname": "르탄이",
    "profile_image": "http://k.kakaocdn.net/...jpg",
    "thumbnail_image": "http://k.kakaocdn.net/...jpg"
  },
  "kakao_account": {
    "profile_needs_agreement": false,
    "profile": {
      "nickname": "르탄이",
      "thumbnail_image_url": "http://k.kakaocdn.net/...jpg",
      "profile_image_url": "http://k.kakaocdn.net/...jpg"
    },
    "has_email": true,
    "email_needs_agreement": false,
    "is_email_valid": true,
    "is_email_verified": true,
    "email": "letan@sparta.com"
  }
}

User

package com.sparta.myselectshop.entity;

import lombok.Getter;
import lombok.NoArgsConstructor;

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

@Getter
@NoArgsConstructor
@Entity(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // nullable: null 허용 여부
    // unique: 중복 허용 여부 (false 일때 중복 허용)
    @Column(nullable = false, unique = true)
    private String username;

    private Long kakaoId;   //추가

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;

    @OneToMany
    List<Folder> folders = new ArrayList<>();

    public User(String username, String password, String email, UserRoleEnum role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }

    public User(String username, Long kakaoId, String password, String email, UserRoleEnum role) {
        this.username = username;
        this.kakaoId = kakaoId;     //kakao 사용자 등록 시, kakaoId 필요
        this.password = password;
        this.email = email;
        this.role = role;
    }

    public User kakaoIdUpdate(Long kakaoId) {
        this.kakaoId = kakaoId;
        return this;
    }
}

UserRoleEnum

package com.sparta.myselectshop.entity;

public enum UserRoleEnum {
    USER(Authority.USER),  // 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한

    private final String authority;

    UserRoleEnum(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

UserRepository

package com.sparta.myselectshop.repository;

import com.sparta.myselectshop.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    Optional<User> findByKakaoId(Long id);      //kakaoId 가 기존에 있는지 없는지 확인
    Optional<User> findByEmail(String email);       //email 중복됐는지 안됐는지 확인
}

KakaoUserInfoDto

//Access Token 을 사용해서 kakao 서버에서 사용자 정보를 가져오는데, 이 정보를 담기 위함
package com.sparta.myselectshop.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class KakaoUserInfoDto {
    private Long id;
    private String email;
    private String nicknmae;

    public KakaoUserInfoDto(Long id, String nickname, String email) {
        this.id = id;
        this.nicknmae = nickname;
        this.email = email;
    }
}

KakaoService

package com.sparta.myselectshop.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.myselectshop.dto.KakaoUserInfoDto;
import com.sparta.myselectshop.entity.User;
import com.sparta.myselectshop.entity.UserRoleEnum;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class KakaoService {
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;

    public String kakaoLogin(String code, HttpServletResponse response) throws JsonProcessingException {
        // 1. "인가 코드"로 "액세스 토큰" 요청
        //accessToken 에 발급 받은 액세스 토큰 이 들어갈 예정
        String accessToken = getToken(code);

        // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
        KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);

        // 3. 필요시에 회원가입
        User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);

        // 4. JWT 토큰 반환
        //Username, Role 권한 을 이용해서 토큰을 만든다
        String createToken =  jwtUtil.createToken(kakaoUser.getUsername(), kakaoUser.getRole());
        //HttpServletResponse response 객체의 헤더에 넣어서 반환하면(addHeader)
//        response.addHeader(JwtUtil.AUTHORIZATION_HEADER, createToken);  --> 주석 처리 + return createToken 이렇게 만든 토큰을 반환한 이유?
                                                                                //controller 에서 받아온 token 을 직접 서버에 쿠키를 만들어서 넣기 위해
        return createToken;
    }

    // 1. "인가 코드"로 "액세스 토큰" 요청
    private String getToken(String code) throws JsonProcessingException {
        // HTTP Header 생성
        //이번에는 Header 쪽에는 Content-type 이 어떤 건지만 들어갔음
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP Body 생성
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "authorization_code");
        body.add("client_id", "3b4796900b3da34a641776b8a610cda8");      //body.add("client_id", "본인의 REST API키");
        body.add("redirect_uri", "http://localhost:8080/api/user/kakao/callback");
        body.add("code", code);

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest =
                new HttpEntity<>(body, headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                kakaoTokenRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> ObjectMapper 형태로 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);
        //get("access_token"): 키 값을 넣어줌
        //asText: 타입 반환
        return jsonNode.get("access_token").asText();
    }

    // 2. 토큰으로 카카오 API 호출 : "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
    private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + accessToken);      //Authorization 과 Bearer 에 accessToken 를 붙여서 토큰을 보냄
        headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

        // HTTP 요청 보내기
        HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
        RestTemplate rt = new RestTemplate();
        ResponseEntity<String> response = rt.exchange(
                "https://kapi.kakao.com/v2/user/me",
                HttpMethod.POST,
                kakaoUserInfoRequest,
                String.class
        );

        // HTTP 응답 (JSON) -> ObjectMapper 형태로 액세스 토큰 파싱
        String responseBody = response.getBody();
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode jsonNode = objectMapper.readTree(responseBody);

        //파싱 후, 그 안에 들어있는 nickname, email 을 가지고 옴
        Long id = jsonNode.get("id").asLong();
        String nickname = jsonNode.get("properties")
                .get("nickname").asText();
        String email = jsonNode.get("kakao_account")
                .get("email").asText();

        //잘 가지고 왔는지 log 를 찍어서 확인
        log.info("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);
        return new KakaoUserInfoDto(id, nickname, email);
    }

    // 3. 필요시에 회원가입
    //카카오 사용자의 정보를 KakaoUserInfoDto kakaoUserInfo 에 담는다
    private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
        // 파라미터 kakaoUserInfo 안에서 kakaoId 를 가지고 와서,
        Long kakaoId = kakaoUserInfo.getId();
        // kakaoId 로 DB 에 중복된 Kakao Id(kakaoUser) 가 있는지 확인 --> user 가 있다면 이미 회원가입 한 user, 없다면 새로운 회원이므로 kakaoUser == null
        User kakaoUser = userRepository.findByKakaoId(kakaoId)
                .orElse(null);
        if (kakaoUser == null) {
            // 카카오 사용자 email 과 동일한 email 가진 회원이 있는지 확인
            String kakaoEmail = kakaoUserInfo.getEmail();
            User sameEmailUser = userRepository.findByEmail(kakaoEmail).orElse(null);
            //동일한 email 을 가진 회원이 있다면,
            if (sameEmailUser != null) {
                //같은 이메일이 있다면,
                kakaoUser = sameEmailUser;
                // 기존 회원정보(위에서 받아온 변수 kakaoId)에 카카오 Id 만 추가   --> 기존 회원의 kakaoId 만 Update 하는 이유?
                kakaoUser = kakaoUser.kakaoIdUpdate(kakaoId);                //1. 예전에 회원가입 폼으로 회원가입하고, 이후에 kakao 로 다시 로그인 한 경우에는
                                                                                    //kakao 이메일로만 가입한 사용자이므로, 이 사용자(기존 회원) 정보에 kakaoId 만 추가해준다
            //이메일까지 없는 사용자라면, 정말로 신규 회원이다                         //2. 이메일은 절대 중복될 수 없기 때문
            } else {
                // 신규 회원가입
                // password: random UUID 로 만든다
                String password = UUID.randomUUID().toString();
                //인코딩
                String encodedPassword = passwordEncoder.encode(password);

                // email: kakao 사용자 정보에서 가져온 kakao email 을 넣음
                String email = kakaoUserInfo.getEmail();

                //새로운 사용자를 담는다
                //UserRoleEnum.USER: kakao 사용자는 무조건 권한을 주도록
                kakaoUser = new User(kakaoUserInfo.getNicknmae(), kakaoId, encodedPassword, email, UserRoleEnum.USER);
            }
            //새로운 사용자를 저장
            userRepository.save(kakaoUser);
        }
        return kakaoUser;
    }
}

UserController

package com.sparta.myselectshop.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.sparta.myselectshop.dto.LoginRequestDto;
import com.sparta.myselectshop.dto.SignupRequestDto;
import com.sparta.myselectshop.jwt.JwtUtil;
import com.sparta.myselectshop.service.KakaoService;
import com.sparta.myselectshop.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;

@Controller
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserController {

    //의존성 주입
    private final UserService userService;
    private final KakaoService kakaoService;    

    @GetMapping("/signup")
    public ModelAndView signupPage() {
        return new ModelAndView("signup");
    }

    @GetMapping("/login-page")
    public ModelAndView loginPage() {
        return new ModelAndView("login");
    }

    @PostMapping("/signup")
    public String signup(SignupRequestDto signupRequestDto) {
        userService.signup(signupRequestDto);
        return "redirect:/api/user/login-page";
    }

    @ResponseBody
    @PostMapping("/login")
    public String login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) {
        userService.login(loginRequestDto, response);
        return "success";
    }

    @GetMapping("/forbidden")
    public ModelAndView getForbidden() {
        return new ModelAndView("forbidden");
    }

    @PostMapping("/forbidden")
    public ModelAndView postForbidden() {
        return new ModelAndView("forbidden");
    }

    //인가 코드의 요청을 받아주는 부분
    @GetMapping("/kakao/callback")
    public String kakaoLogin(@RequestParam String code, HttpServletResponse response) throws JsonProcessingException {
        // code: 카카오 서버로부터 받은 인가 코드
        String createToken = kakaoService.kakaoLogin(code, response);

        // Cookie 생성 및 직접 브라우저에 Set
        //(key, value)
        Cookie cookie = new Cookie(JwtUtil.AUTHORIZATION_HEADER, createToken.substring(7));
        cookie.setPath("/");
        response.addCookie(cookie);

        //client 쪽으로 반환
        return "redirect:/api/shop";
    }
}
profile
개발자로 거듭나기!

0개의 댓글