<OAuth 2.0>
: 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준.
사용자가 애플리케이션에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜.
OAuth를 사용하는 서비스 제공자는 대표적으로 구글, 페이스북 등이 있습니다. 국내에는 대표적으로 네이버와 카카오가 있죠.
: 로그인, 개인정보 관리 책임을 서드파티 애플리케이션 (Google, Facebook, Kakao 등)에게 위임할 수 있다. (단, 사용자가 기존에 서드파티 서비스에 회원가입이 되어있어야 함)
- 내가 만든 애플리케이션에서 사용자가 Kakao 로그인을 통해 로그인했다면 사용자가 넘겨준 토큰으로 Kakao Resource 서버로부터 해당 사용자의 프로필 정보 등을 조회할 수 있다.
- 일반화된것은 아니고 상황에 따라 유연하게 변경될 수 있는것 같다
❗❗ JWT와 OAuth 2.0 의 차이!!
JWT : 쿠키, 세션을 대신하여 의미있는 문자열 토큰으로써 권한을 행사할 수 있는 토큰의 한 형식
OAuth : 하나의 플랫폼의 권한(아무 의미없는 무작위 문자열 토큰)으로 다양한 플랫폼에서 권한을 행사할 수 있게 해줌으로 써 리소스 접근이 가능하게 하는데 목적
=>OAuth 2.0 에서 의미없는 정보를 가지는 토큰이 의미있는 정보를 가져야한다면 두 기술을 혼합하여 access token 을 JWT 형식 으로 구현할 수도 있다
<카카오 로그인 사용 해보기>
1. 카카오 로그인 사용 승인 받기 - kakao developers
2. 카카오 서버에서 인가코드 받기
3.카카오 사용정보 가져오기
1) 인가코드로 액세스토큰 요청
[액세스토큰 요청]
// 1. "인가 코드"로 "액세스 토큰" 요청
// HTTP Header 생성
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", "본인의 REST API키");
body.add("redirect_uri", "http://localhost:8080/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) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
String accessToken = jsonNode.get("access_token").asText();
[KakaoOAuth2 에 발급받은 본인의 REST API 키 입력]
body.add("client_id", "본인의 REST API키");
2) 액세스토큰으로 카카오 사용자 정보 가져오기
[사용자 정보 요청]
// 2. 토큰으로 카카오 API 호출
// HTTP Header 생성
headers.add("Authorization", "Bearer " + accessToken);
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> kakaoUserInfoRequest = new HttpEntity<>(headers);
response = rt.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.POST,
kakaoUserInfoRequest,
String.class
);
responseBody = response.getBody();
jsonNode = objectMapper.readTree(responseBody);
Long id = jsonNode.get("id").asLong();
String nickname = jsonNode.get("properties")
.get("nickname").asText();
String email = jsonNode.get("kakao_account")
.get("email").asText();
System.out.println("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);
3) 리팩토링
[dto>KakaoUserDto]
import lombok.AllArgsConstructor;
@AllArgsConstructor
public class KakaoUserInfoDto {
private Long id;
private String nickname;
private String email;
}
[service>UserService]
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private static final String ADMIN_TOKEN = "AAABnv/xRVklrnYxKZ0aHgTBcXukeZygoC";
@Autowired
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public void registerUser(SignupRequestDto requestDto) {
// 회원 ID 중복 확인
String username = requestDto.getUsername();
Optional<User> found = userRepository.findByUsername(username);
if (found.isPresent()) {
throw new IllegalArgumentException("중복된 사용자 ID 가 존재합니다.");
}
// 패스워드 암호화
String password = passwordEncoder.encode(requestDto.getPassword());
String email = requestDto.getEmail();
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (requestDto.isAdmin()) {
if (!requestDto.getAdminToken().equals(ADMIN_TOKEN)) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
User user = new User(username, password, email, role);
userRepository.save(user);
}
public void kakaoLogin(String code) throws JsonProcessingException {
// 1. "인가 코드"로 "액세스 토큰" 요청
String accessToken = getAccessToken(code);
// 2. "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);
}
private String getAccessToken(String code) throws JsonProcessingException {
// HTTP Header 생성
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", "본인의 REST API키");
body.add("redirect_uri", "http://localhost:8080/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) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
return jsonNode.get("access_token").asText();
}
private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
// HTTP Header 생성
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>> 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
);
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
Long id = jsonNode.get("id").asLong();
String nickname = jsonNode.get("properties")
.get("nickname").asText();
String email = jsonNode.get("kakao_account")
.get("email").asText();
System.out.println("카카오 사용자 정보: " + id + ", " + nickname + ", " + email);
return new KakaoUserInfoDto(id, nickname, email);
}
}
4. 카카오 사용자 정보로 회원가입 설계
❓ 관심 상품 등록을 했을 때 회원 구분이 필요하기 때문에, 카카오서버에서 받은 사용자 정보를 이용해 회원 가입
- 카카오로 부터 받은 사용자 정보
- kakaoId
- nickname
- 테이블 설계 옵션
- 카카오 User 를 위한 테이블 (ex. KakaoUser) 을 하나 더 만든다.
- 장점: 결합도가 낮아짐
- 성격이 다른 유저 별로 분리 → 차후 각 테이블의 변화에 서로 영향을 주지 않음
- 예) 카카오 사용자들만 profile_image 컬럼 추가해서 사용 가능
- 단점: 구현 난이도가 올라감
- 예) 관심상품 등록 시, 회원별로 다른 테이블을 참조해야 함
- 일반 회원: User - Product
- 카카오 회원: KakaoUser - Product
- 예) 관심상품 등록 시, 회원별로 다른 테이블을 참조해야 함
- 장점: 결합도가 낮아짐
- 기존 회원 (User) 테이블에 카카오 User 추가
- 장점: 구현이 단순해짐
- 단점: 결합도가 높아짐
- 폼 로그인을 통해 카카오 로그인 사용자의 username, password 를 입력해서 로그인한다면??
- 카카오 User 를 위한 테이블 (ex. KakaoUser) 을 하나 더 만든다.
5. 카카오 사용자 정보로 회원가입 구현
- 카카오 사용자 정보로 회원가입
- User table에 kakaoId 추가
@Setter
@Getter // get 함수를 일괄적으로 만들어줍니다.
@NoArgsConstructor // 기본 생성자를 만들어줍니다.
@Entity(name = "users") // DB 테이블 역할을 합니다.
public class User {
// ID가 자동으로 생성 및 증가합니다.
@GeneratedValue(strategy = GenerationType.AUTO)
@Id
private Long id;
// nullable: null 허용 여부
// unique: 중복 허용 여부 (false 일때 중복 허용)
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
@Column(unique = true)
private Long kakaoId;
public User(String username, String password, String email, UserRoleEnum role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
this.kakaoId = null;
}
public User(String username, String password, String email, UserRoleEnum role, Long kakaoId) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
this.kakaoId = kakaoId;
}
}
- 회원가입 : kakao ID 를 가진 회원이 없는 경우에만 회원가입 처리
// DB 에 중복된 Kakao Id 가 있는지 확인
Long kakaoId = kakaoUserInfo.getId();
User kakaoUser = userRepository.findByKakaoId(kakaoId)
.orElse(null);
if (kakaoUser == null) {
// 회원가입
// username: kakao nickname
String nickname = kakaoUserInfo.getNickname();
// password: random UUID
String password = UUID.randomUUID().toString();
String encodedPassword = passwordEncoder.encode(password);
// email: kakao email
String email = kakaoUserInfo.getEmail();
// role: 일반 사용자
UserRoleEnum role = UserRoleEnum.USER;
kakaoUser = new User(nickname, encodedPassword, email, role, kakaoId);
userRepository.save(kakaoUser);
}
- repository > UserRepository
Optional<User> findByKakaoId(Long kakaoId);
- dto>KakapUserDto
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class KakaoUserInfoDto {
private Long id;
private String nickname;
private String email;
}
<강제 로그인 처리>
- 로그인 성공 시
- "로그인 성공 사용자 정보" (UserDetails) 는 SecurityContext 에 저장됨
- 강제 로그인 처리 방법
- SecurityContextHolder 를 통해 SecurityContext 에 "로그인 성공 사용자 정보" 직접 추가
// 4. 강제 로그인 처리
UserDetails userDetails = new UserDetailsImpl(kakaoUser);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
★리팩토링★
[service>KakaoUserService]
@Service
public class KakaoUserService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
@Autowired
public KakaoUserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public void kakaoLogin(String code) throws JsonProcessingException {
// 1. "인가 코드"로 "액세스 토큰" 요청
String accessToken = getAccessToken(code);
// 2. "액세스 토큰"으로 "카카오 사용자 정보" 가져오기
KakaoUserInfoDto kakaoUserInfo = getKakaoUserInfo(accessToken);
// 3. "카카오 사용자 정보"로 필요시 회원가입
User kakaoUser = registerKakaoUserIfNeeded(kakaoUserInfo);
// 4. 강제 로그인 처리
forceLogin(kakaoUser);
}
private String getAccessToken(String code) throws JsonProcessingException {
// HTTP Header 생성
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", "본인의 REST API키");
body.add("redirect_uri", "http://localhost:8080/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) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
return jsonNode.get("access_token").asText();
}
private KakaoUserInfoDto getKakaoUserInfo(String accessToken) throws JsonProcessingException {
// HTTP Header 생성
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>> 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
);
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
Long id = jsonNode.get("id").asLong();
String nickname = jsonNode.get("properties")
.get("nickname").asText();
String email = jsonNode.get("kakao_account")
.get("email").asText();
return new KakaoUserInfoDto(id, nickname, email);
}
private User registerKakaoUserIfNeeded(KakaoUserInfoDto kakaoUserInfo) {
// DB 에 중복된 Kakao Id 가 있는지 확인
Long kakaoId = kakaoUserInfo.getId();
User kakaoUser = userRepository.findByKakaoId(kakaoId)
.orElse(null);
if (kakaoUser == null) {
// 회원가입
// username: kakao nickname
String nickname = kakaoUserInfo.getNickname();
// password: random UUID
String password = UUID.randomUUID().toString();
String encodedPassword = passwordEncoder.encode(password);
// email: kakao email
String email = kakaoUserInfo.getEmail();
// role: 일반 사용자
UserRoleEnum role = UserRoleEnum.USER;
kakaoUser = new User(nickname, encodedPassword, email, role, kakaoId);
userRepository.save(kakaoUser);
}
return kakaoUser;
}
private void forceLogin(User kakaoUser) {
UserDetails userDetails = new UserDetailsImpl(kakaoUser);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
[service>UserService]
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private static final String ADMIN_TOKEN = "AAABnv/xRVklrnYxKZ0aHgTBcXukeZygoC";
@Autowired
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public void registerUser(SignupRequestDto requestDto) {
// 회원 ID 중복 확인
String username = requestDto.getUsername();
Optional<User> found = userRepository.findByUsername(username);
if (found.isPresent()) {
throw new IllegalArgumentException("중복된 사용자 ID 가 존재합니다.");
}
// 패스워드 암호화
String password = passwordEncoder.encode(requestDto.getPassword());
String email = requestDto.getEmail();
// 사용자 ROLE 확인
UserRoleEnum role = UserRoleEnum.USER;
if (requestDto.isAdmin()) {
if (!requestDto.getAdminToken().equals(ADMIN_TOKEN)) {
throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
}
role = UserRoleEnum.ADMIN;
}
User user = new User(username, password, email, role);
userRepository.save(user);
}
}
[controller>UserController]
@Controller
public class UserController {
private final UserService userService;
private final KakaoUserService kakaoUserService;
@Autowired
public UserController(UserService userService, KakaoUserService kakaoUserService) {
this.userService = userService;
this.kakaoUserService = kakaoUserService;
}
// 회원 로그인 페이지
@GetMapping("/user/login")
public String login() {
return "login";
}
// 회원 가입 페이지
@GetMapping("/user/signup")
public String signup() {
return "signup";
}
// 회원 가입 요청 처리
@PostMapping("/user/signup")
public String registerUser(SignupRequestDto requestDto) {
userService.registerUser(requestDto);
return "redirect:/user/login";
}
@GetMapping("/user/kakao/callback")
public String kakaoLogin(@RequestParam String code) throws JsonProcessingException {
kakaoUserService.kakaoLogin(code);
return "redirect:/";
}
}
'🌿SPRING > 🍀공부 [SPRING]' 카테고리의 다른 글
[SPRING] AOP 개념 (0) | 2022.09.01 |
---|---|
[SPRING] [JPA] 기본 키 매핑 @GeneratedValue (0) | 2022.08.31 |
[SPRING] Spring Security - API 접근 권한 제어 (0) | 2022.08.23 |
[SPRING]Spring Security - 패스워드 암호화 (0) | 2022.08.23 |
[SPRING] Spring Security framework (0) | 2022.08.23 |