Spring Security session방식 Oauth2로그인 - 카카오로그인처리(4)
현재 일반회원가입까지 구현한 상태다.
카카오로그인사용자는 회원가입이 없다.
( 실제론, 첫 카카오 로그인 시 우리서버에 회원가입이 자동으로 이루어진다.)
일반회원 FORM 로그인처리와 카카오로그인처리를 해보자.
로그인사용자정보
로그인 성공 후 사용자정보객체를 만들어야 한다.
기본 security방식에서 session에 저장되는 일반FORM로그인사용자를 UserDetials를 구현한
CustomUserDetails를 만들었었다.
카카오 로그인 시 session에 저장되는 객체는 Oauth2User 타입이다.
카카오 로그인 객체를 Oauth2User를 구현한 CustomOauth2User 를 만들어 저장하면 된다.
즉 CustomUserDetails, CustomOauth2User 2가지 타입의 객체들이 사용자정보로 저장된다.
이 사용자정보는 Security 저장공간에서 Principal(사용자정보)에 해당된다.
Principal이 CustomUserDetails, CustomOauth2User 2가지타입이 될 수 있는 것이다.
Security는 개발자가 UserDetails로 저장하든, Oauth2User로 저장하든 전부 저장할 수 있게 하기 위해
Principal을 Object 타입으로 설정했다.
그래서 Oauth2User, UserDetails를 구현한 객체를 따로따로 만들게 되면
사용자정보를 Controller 등에서 사용할 때 instanceof 등을 사용해 타입이 CustomUserDetails(UserDetails)인지, CustomOauth2User(Oauth2User)인지를 확인해야된다.
카카오로그인한 유저를 일반FORM로그인 유저와 명확하게 구별해서 사용할 것이라면
따로따로 구현하고 instanceof등을 통해 타입검사를 해주는게 맞다.
카카오로그인한 유저는 단순히 회원가입을 하지않을 뿐이고
일반FORM로그인한 유저랑 사이트 이용에 차이가 없다면
userDetails,Oauth2User를 한번에 구현해 타입검사를 하지 않고 사용하는게 좋다.
CustomUserAccount
// Oauth2 + 일반 로그인에서는 통합 UserDetatials( UserDetails, Oauth2User)를 사용. 따로따로 하는건 controller단에서 instance of 처리해야함.
public class CustomUserAccount implements UserDetails, OAuth2User {
private UserEntity userEntity;
private final Map<String, Object> attributes; // OAuth2 로그인
public CustomUserAccount(UserEntity userEntity) { //일반 사용자로 로그인 한 경우
this.userEntity = userEntity;
this.attributes=null;
}
public CustomUserAccount(UserEntity userEntity, Map<String, Object> attributes) { //Oauth2로 로그인한 경우
this.userEntity = userEntity;
this.attributes=attributes;
}
// userDetails
@Override
public String getUsername() {
return userEntity.getUsername();
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
//이 메소드는 oauth2User와 UserDetails의 getAuthroties()를 전부 override
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return userEntity.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.toList();
}
// Oauth2User
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
// 카카오로그인이든 폼로그인이든 똑같은 서비스를 제공하기 위해 DB 저장했던 userEntity 사용
/**
* oauth2User는 사용자를 getName()으로 식별하지만, 통합User객체를 사용하기때문에
* userDetails와 맞춰줌.
*/
@Override
public String getName() { //Spring SecurityContext에서 OAuth2 로그인한 사용자의 식별자로 사용됨.
return getUsername();
}
public String getEmail() {
return userEntity.getEmail();
}
public String getNickname() {
return userEntity.getNickname();
}
public String getProvider(){
return userEntity.getProvider();
}
}
통합 사용자정보 클래스 이름은 여기서 사용자 정보라는 의미를 위해 CustomUserAccount를 사용했지만
CustomUserPrincipal, CustomOAuth2UserDetails 등이 사용되기도 한다.
일반FORM로그인 처리
// 일반 로그인 처리
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userData = userRepository.findByUsername(username);
if (userData != null) {
return new CustomUserAccount(userData);
}
return null;
}
}
통합사용자객체인 CustomUserAccount로 바뀐걸 제외하면
기본 Security 방식코드랑 똑같다.
카카오로그인 처리
전체적인 로그인처리과정은 https://brilliantdevelop.tistory.com/218참고.
여기서는 카카오서버에서 로그인성공 후, 우리서버에서의 로그인처리과정이다.
우리는 DefaultOauth2Userservice를 상속받아 loadUser메소드를 구현하면 된다.
CustomOAuth2UserService
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Autowired
private UserRepository userRepository;
@Transactional(readOnly = false)
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 카카오로그인이든 폼로그인이든 똑같은 서비스를 제공하기 위해 DB 저장
OAuth2User oAuth2User = super.loadUser(userRequest);// user-info-uri : https://kapi.kakao.com/v2/user/me 에서 유저정보를 가져옴.
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //"kakao"
// OAuth2 제공자(Kakao)에서 가져온 사용자 정보
Map<String, Object> attributes = oAuth2User.getAttributes();
/**
{
"id": 1234567890, 문자아님 Long
"kakao_account": {
"email": "user@example.com",
"profile": {
"nickname": "홍길동"
}
}
}
*/
Long id= (Long) attributes.get("id"); //카카오는 id가 Long.
Map<String,Object> kakaoAccount=(Map<String,Object>)attributes.get("kakao_account");
String email= (String) kakaoAccount.get("email");
Map<String,Object> profile=(Map<String,Object>)kakaoAccount.get("profile");
String nickname=(String)profile.get("nickname");
// DB에서 사용자 조회 (없으면 생성)
UserEntity user = userRepository.findByUsername("kakao"+id);
if (user == null) { //카카오로 처음 로그인하는 분
user = new UserEntity();
user.setUsername("kakao"+id);
user.setPassword("{noop}oauth2user"); // OAuth2 로그인은 비밀번호 없음. {noop}은 security가 암호화 안된 비밀번호임을 암시.
user.setEmail(email);
user.setNickname(nickname);
user.getRoles().add("USER");
user.setProvider(registrationId);
userRepository.save(user);
}else{ //처음 로그인은 아님. 카카오에서 nickname변경됐다면 우리 DB에도 반영해야지
//username password는 안 바뀜.
user.setEmail(email);
user.setNickname(nickname);
}
//CustomUserAccount로 반환 (OAuth2User + UserDetails 통합)
return new CustomUserAccount(user, attributes);
}
}
여기서 만든 CustomUserAccount(user, attributes)가 사용자정보로 저장된다.
※참고사항들
"kakao" 변수이름 registrationId vs provider
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //"kakao"
...
userEntity.setProvider(registarationId);
registration:
kakao: # 우리서버에서 내 맘대로 사용하기 위한 값. security코드에서는 이 값을 return
provider:
kakao: #kakao에서 설정한 이름으로 변경불가.
security 내부적으로는 등록된 서비스 이름을 구별하는거라 registrationId이고,
개발자가 DB에서 사용하는건 공급자를 구별하고싶은거라 userEntity필드 이름이 provider.
사실 특별한 경우 아니면 registration이름을 provider이름이랑 같게설정.
id 외부유출
Long id= (Long) attributes.get("id"); //카카오는 id가 Long.
id가 일반적으로 외부에 유출해도 되는 정보지만
카카오 id같은 경우는 노출되면 프로필,친구관계 등이 노출될 수 있습니다.
서버내부적으로 사용하는건 문제없습니다.
그 외
LoginController
@Controller
public class LoginController {
@GetMapping("/login")
public String login( ){
return "login";
}
}
login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
로그인페이지입니다.
<form action="/loginProc" method="post" name="loginForm"> <!-- securityConfig의 loginProcessingUrl("/loginProc") -->
<input id="username" type="text" name="username" placeholder="id(username)"/> <!-- name이 반드시 username이어야한다. -->
<input id="password" type="password" name="password" placeholder="password"/> <!-- name이 반드시 password이어야한다. -->
<input id="nickname" type="text" name="username" placeholder="nickname"/>
<input id="email" type="password" name="password" placeholder="email"/>
<button type="submit" >로그인</button>
</form>
<br>
<!-- 카카오 로그인 버튼 -->
<a href="/oauth2/authorization/kakao">
<img src="/images/kakao_login.png" alt="카카오 로그인">
</a>
</body>
</html>
참고로 이미지는 https://developers.kakao.com/tool/resource/login에서 다운받을 수 있다.
MainController
@Controller
public class MainController {
//로그인 후
@RequestMapping("/my/info")
@ResponseBody
public String myInfo(@AuthenticationPrincipal CustomUserDetails userDetails
//개발자가 직접 만든 로그인 정보. 이거 사용하세요!
){
StringBuilder sb=new StringBuilder();
sb.append("username : " + userDetails.getUsername() +"<br>");
sb.append("nickname : " + userDetails.getNickname() +"<br>");
sb.append("email : " + userDetails.getEmail() +"<br>");
sb.append( "권한 : "+ userDetails.getAuthorities().iterator().next().getAuthority() +"<br>" );
return sb.toString();
}
@RequestMapping("/admin")
@ResponseBody
public String admin(){
return "권한이없어서 여기에 도달 할 수 없다.";
}
}
결과
로그인화면
카카오로그인화면
카카오로그인성공 후