Open Standard for Authorization
다양한 클라이언트 환경에 적합한 인증(Authentication) 및 인가(Authorization) 의 위임 방법을 제공하고 그 결과로 클라이언트에게 접근 토큰 (Access Token) 을 발급하는 것에 대한 구조
관심 상품 등록을 했을 때 회원 구분이 필요하기 때문에, 카카오서버에서 받은 사용자 정보를 이용해 회원 가입을 합니다.
현재 회원 (User) 테이블 확인
카카오로 부터 받은 사용자 정보
{
"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) 테이블에 적용하기로 결정
패스워드를 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
카카오 사용자 정보 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"
}
}
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;
}
}
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";
}
}
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 중복됐는지 안됐는지 확인
}
//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;
}
}
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;
}
}
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";
}
}