이번 시간에는 이전 포스팅들에서 공부한 spring security와 카카오 소셜로그인에서 나아가 여러 소셜 로그인들을 통합해서 처리할 수 있는 코드를 구현하려고 한다.
build.gradle
SecurityConfig
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록됨
public class SecurityConfig {
// 해당 메서드의 리턴되는 오브젝트를 IOC로 등록해준다.
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.disable());
http.authorizeHttpRequests(authorize -> {
authorize
.requestMatchers("/user/**").authenticated()
.requestMatchers("/manager/**").hasAnyRole("MANAGER", "ADMIN")
.requestMatchers("/admin/**").hasAnyRole("ADMIN")
.anyRequest().permitAll();
});
http.formLogin(form -> {
form.loginPage("/loginForm");
});
return http.build();
}
}
현재 url에 따른 필요한 권한들을 다음과 같이 설정하였고,로그인 페이지는 "/loginForm" 경로로 설정하였다.
실습용 컨트롤러
IndexController
@Controller
@RequiredArgsConstructor
public class IndexController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
@GetMapping({"", "/"})
public String index() {
return "index";
}
@GetMapping("/user")
public @ResponseBody String user() {
return "user";
}
@GetMapping("/admin")
public @ResponseBody String admin() {
return "admin";
}
@GetMapping("/manager")
public @ResponseBody String manager() {
return "manager";
}
@GetMapping("/loginForm")
public String loginForm() {
return "loginForm";
}
@GetMapping("/joinForm")
public String joinForm() {
return "joinForm";
}
@PostMapping("/join")
public String join(User user) {
return "redirect:/loginForm";
}
}
먼저 로그인을 시도할 User 객체를 만들어준다.
User
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String email;
private String role;
@CreationTimestamp
private Timestamp timestamp;
}
이후 회원가입을 진행하는데, 이전 시간에 배운 것처럼 spring security를 이용하면 비밀번호를 암호화해서 저장해야하기 때문에 passwordEncoder를 이용해 비밀번호를 암호화해 저장화주었다.
IndexController
@PostMapping("/join")
public String join(User user) {
System.out.println("user = " + user);
user.setRole("ROLE_USER");
String rawPassword = user.getPassword();
String encPassword = passwordEncoder.encode(rawPassword);
user.setPassword(encPassword);
userRepository.save(user); // 회원가입 잘됨. 비밀번호:1234 => 시큐리티로 로그인을 할 수 없음. 이유는 패스워드가 암호화가 안되었기 때문
return "redirect:/loginForm";
}
스프링 시큐리티를 통한 로그인 과정은 다음과 같다.
시큐리티가 /login을 낚아채서 로그인을 진행시킨다.
로그인 진행이 완료되면 시큐리티 session을 만들어준다. (Security ContextHolder)
오브젝트 => Authentication 타입 객체
Authentication 안에 User정보가 있어야 됨
User오브젝트 타입 => UserDetails 타입 객체
Security Session => Authentication => UserDetails(PrincipalDetails)
시큐리티 세션 안에는 Authentication(UserDetails) 타입만 저장 될 수 있기 때문에 User 객체를 UserDetails 객체로 바꿔줘야한다.
PrincipalDetails
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {
private final User user; // 콤포지션
// 해당 유저의 권한을 리턴하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Security Config 변경
/login 요청이 오면 자동으로 UserDetailsService 타입으로 IoC되어 있는 loadUserByUsername 함수가 실행되어 로그인 처리를 한다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// 시큐리티 session(내부 Authentication(내부 UserDetails))
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("username = " + username);
User userEntity = userRepository.findByUsername(username);
if (userEntity != null) {
return new PrincipalDetails(userEntity);
}
return null;
}
}
먼저 구글 api 콘솔(https://console.cloud.google.com/apis/library?hl=ko)로 이동한다.
새 프로젝트 생성
OAuth 동의화면 선택
사용자 인증 정보 만들기
OAuth2 라이브러리를 사용하면 해당 주소를 고정으로 사용해야한다.
이후 생성되는 클라이언트 ID와 클라이언트 보안 비밀번호를 통해 사용자 정보를 받아올 수 있다.
이전에 얻은 클라이언트 ID와 클라이언트 보안 비밀번호를 다음과 같이 적어준다.
security:
oauth2:
client:
registration:
google:
client-id: 147521920274-9vtvlo25jcel8ua0etd6bib2kipgvo99.apps.googleusercontent.com
client-secret: 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
scope:
- email
- profile
loginForm.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<form action="/login" method="post">
<input type="text" name="username", placeholder="Username"/><br/>
<input type="password", name="password", placeholder="Password"/><br/>
<button>로그인</button>
</form>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/joinForm">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
google 소셜 로그인을 통해 사용자 정보를 받으려면 "/oauth2/authorization/google"라는 주소를 통해 인증을 받아야한다.
구글 로그인을 통해 로그인이 완료되면 다음의 과정이 필요하다.
구글 로그인 완료된 뒤의 후처리가 필요함.
1. 코드받기(인증됨)
2. 엑세스 토큰(사용자 정보에 접근할 권한받음)
3. 사용자 프로필 정보를 가져오고
4-1. 그 정보를 토대로 회원가입을 자동으로 진행시키기도 함
4-2. (이메일, 전화번호, 이름, 아이디) 쇼핑몰 -> (집주소), 백화점몰 -> (vip등급, 일반등급)
우선 일반 로그인 사용자와 소셜 로그인 사용자를 구분하기 위해 User 객체에 다음을 추가해준다.
userInfoEndpoint를 설정하면 소셜 로그인이 성공했을때 사용자 정보에 대한 후처리를 할 수 있다.
PrincipalOauth2UserService
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
private UserRepository userRepository;
// 구글로부터 받은 userRequest 데이터에 대한 후처리되는 함수
// 함수 종료시 @AuthenticationPrincipal 어노테이션이 만들어진다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("getClientRegistration = " + userRequest.getClientRegistration()); // registrationId로 어떤 OAuth로 로그인 했는지 확인 가능
System.out.println("getAccessToken = " + userRequest.getAccessToken().getTokenValue());
OAuth2User oAuth2User = super.loadUser(userRequest);
// 구글로그인 버튼 클릭 -> 구글로그인창 -> 로그인을 완료 -> code를 리턴(OAuth2-Client 라이브러리) -> AccessToken 요청
// userRequest 정보 -> 회원 프로필 받아야함(loadUser함수 호출) -> 구글로부터 회원프로필 받아준다.
System.out.println("getAttributes = " + oAuth2User.getAttributes());
String provider = userRequest.getClientRegistration().getRegistrationId(); // google
String providerId = oAuth2User.getAttribute("sub");
String username = provider + "_" + providerId; // google_10021320120
String password = bCryptPasswordEncoder.encode("겟인데어");
String email = oAuth2User.getAttribute("email");
String role = "ROLE_USER";
User userEntity = userRepository.findByUsername(username);
if (userEntity == null) {
System.out.println("구글 로그인이 최초입니다.");
userEntity = User.builder()
.username(username)
.password(password)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(userEntity);
} else {
System.out.println("구글 로그인을 이미 한적이 있습니다. 당신은 자동회원가입이 되어 있습니다.");
}
// 회원 가입을 강제로 진행해볼 예정
return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
}
}
현재 시큐리티 세션에 들어 갈 수 있는 객체는 다음과 같다.
타입이 다른 두 객체가 들어갈 수 있기 때문에 컨트롤러에서는 사용자를 조회할 때 일반 로그인과 소셜 로그인을 구분하여 컨트롤러를 2개 만들어줘야하는 번거로움이 있다.
이를 해결하기 이해 PrincipalDetails 객체가 UserDetails, OAuth2User를 둘 다 implementation하게 만들면 불편함을 해결할 수 있다.
PrincipalDetails
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user; // 콤포지션
private Map<String, Object> attributes;
// 일반 로그인
public PrincipalDetails(User user) {
this.user = user;
}
// OAuth 로그인
public PrincipalDetails(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
// 해당 유저의 권한을 리턴하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getName() {
return null;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
}
다음과 같이 일반 로그인은 provider와 providerId 값이 비어있지만, 소셜 로그인은 어떤 provider를 통해 어떤 소셜 로그인 환경에서 로그인 했는지 확인할 수 있다.
현재는 구글 소셜 로그인에 맞춰 코드가 설계되어있다. 이를 카카오, 네이버 등에도 적용할 수 있게 바꾸어보겠다.
먼저 공통적인 정보를 저장할 인터페이스를 만들어준다.
이후 OAuth2UserInfo를 구현한 GoogleUserInfo 클래스를 만들어준다.
public class GoogleUserInfo implements OAuth2UserInfo{
private Map<String, Object> attributes; // getAttributes()
public GoogleUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return (String) attributes.get("sub");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
각각의 소셜 로그인 값에 약간의 차이들이 있기 때문에 이렇게 공통 인터페이스를 만들어두고 이를 각각의 환경에 맞게 설정하면 유지보수가 편리해진다.
loadUser
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("getClientRegistration = " + userRequest.getClientRegistration()); // registrationId로 어떤 OAuth로 로그인 했는지 확인 가능
System.out.println("getAccessToken = " + userRequest.getAccessToken().getTokenValue());
OAuth2User oAuth2User = super.loadUser(userRequest);
// 구글로그인 버튼 클릭 -> 구글로그인창 -> 로그인을 완료 -> code를 리턴(OAuth2-Client 라이브러리) -> AccessToken 요청
// userRequest 정보 -> 회원 프로필 받아야함(loadUser함수 호출) -> 구글로부터 회원프로필 받아준다.
System.out.println("getAttributes = " + oAuth2User.getAttributes());
String provider = userRequest.getClientRegistration().getRegistrationId(); // google
String providerId = oAuth2User.getAttribute("sub");
String username = provider + "_" + providerId; // google_10021320120
String password = bCryptPasswordEncoder.encode("겟인데어");
String email = oAuth2User.getAttribute("email");
String role = "ROLE_USER";
User userEntity = userRepository.findByUsername(username);
if (userEntity == null) {
System.out.println("구글 로그인이 최초입니다.");
userEntity = User.builder()
.username(username)
.password(password)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(userEntity);
} else {
System.out.println("구글 로그인을 이미 한적이 있습니다. 당신은 자동회원가입이 되어 있습니다.");
}
// 회원 가입을 강제로 진행해볼 예정
return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
}
현재 loadUser() 함수는 다음과 같이 구글 로그인에 대해서만 적용되도록 코드가 적혀있다. 이를 통합 소셜 로그인이 가능하도록 바꾸어보자.
loadUser
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
System.out.println("getClientRegistration = " + userRequest.getClientRegistration()); // registrationId로 어떤 OAuth로 로그인 했는지 확인 가능
System.out.println("getAccessToken = " + userRequest.getAccessToken().getTokenValue());
OAuth2User oAuth2User = super.loadUser(userRequest);
// 구글로그인 버튼 클릭 -> 구글로그인창 -> 로그인을 완료 -> code를 리턴(OAuth2-Client 라이브러리) -> AccessToken 요청
// userRequest 정보 -> 회원 프로필 받아야함(loadUser함수 호출) -> 구글로부터 회원프로필 받아준다.
System.out.println("getAttributes = " + oAuth2User.getAttributes());
OAuth2UserInfo oAuth2UserInfo = null;
if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
System.out.println("구글 로그인 요청");
oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
} else {
System.out.println("우리는 구글만 지원합니다.");
}
String provider = oAuth2UserInfo.getProvider(); // google
String providerId = oAuth2UserInfo.getProviderId();
String username = provider + "_" + providerId; // google_10021320120
String password = bCryptPasswordEncoder.encode("겟인데어");
String email = oAuth2UserInfo.getEmail();
String role = "ROLE_USER";
User userEntity = userRepository.findByUsername(username);
if (userEntity == null) {
System.out.println("구글 로그인이 최초입니다.");
userEntity = User.builder()
.username(username)
.password(password)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(userEntity);
} else {
System.out.println("구글 로그인을 이미 한적이 있습니다. 당신은 자동회원가입이 되어 있습니다.");
}
// 회원 가입을 강제로 진행해볼 예정
return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
}
제대로 적용된 것을 확인할 수 있다.
다음의 절차대로 애플리케이션을 만들어준다.
application.yml
naver:
client-id: EaVXktplGUi9J_pJ0Tpt
client-secret: WZoYctWcum
scope:
- name
- email
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/login/oauth2/code/naver
다음과 같이 설정하고 프로그램을 실행하면 오류가 나는데, 그 이유는 네이버는 OAuth2 라이브러리가 관리하는 provider가 아니기 때문이다.
따라서 다음 정보를 추가로 적어준다.
여기서 맨 마지막줄의 response가 있는 이유는 네이버 로그인시 getAttributes에서 id와 email이 response 안에 있기 때문이다.
이후 이전에 만들어둔 OAuth2UserInfo를 구현하여 NaverUserInfo 클래스를 작성해주면 된다.
NaverUserInfo
public class NaverUserInfo implements OAuth2UserInfo{
private Map<String, Object> attributes; // getAttributes()
public NaverUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getProviderId() {
return (String) attributes.get("id");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
loadUser() 함수도 다음과 같이 간단하게 바꿔주면 된다.
정상적으로 작동하는 것을 확인할 수 있다.
이로서 스프링부트 기본 로그인 + OAuth2.0 통합 로그인 환경을 만들어보았다.
다음의 절차대로 애플리케이션을 만들어준다.
REST API 키를 통해 카카오 로그인을 진행할 것이다.
Redirect URL은 카카오 로그인이 정상적으로 완료된 후 사용자 정보를 받을 주소이다. 이 주소는 고정적으로 위의 주소를 사용해야한다.
다음은 로그인 처리를 할 주소이다.
secert_key 발급
application.yml 설정
server:
port: 8080
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
username: security
password: 3865
jpa:
hibernate:
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
security:
oauth2:
client:
registration:
google:
client-id: 147521920274-9vtvlo25jcel8ua0etd6bib2kipgvo99.apps.googleusercontent.com
client-secret: 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
scope:
- email
- profile
naver:
client-id: EaVXktplGUi9J_pJ0Tpt
client-secret: WZoYctWcum
scope:
- name
- email
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/login/oauth2/code/naver
kakao:
client-name: kakao
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
client-id: 9fcee9537206e292e3b5b5c734a2c0dd
client-secret: 2MqjkqExcBIFgKKMVDIoxFWntwB5HXSX
client-authentication-method: client_secret_post
scope:
- profile_nickname
- account_email
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response # 회원정보를 json으로 받는데 response라는 키값으로 네이버가 리턴해줌
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
user-name-attribute: id
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
네이버와 마찬가지로 카카오 유저정보 클래스도 OAuth2UserInfo를 구현해 작성해준다.
KakaoUserInfo
public class KakaoUserInfo implements OAuth2UserInfo{
private Map<String, Object> attributes; // getAttributes()
public KakaoUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProvider() {
return "kakao";
}
@Override
public String getProviderId() {
return String.valueOf(attributes.get("id"));
}
@Override
public String getEmail() {
Object object = attributes.get("kakao_account");
LinkedHashMap accountMap = (LinkedHashMap) object;
return (String) accountMap.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
정상적으로 동작하는 것을 확인할 수 있다.
이로서 카카오, 구글, 네이버를 통합한 소셜 로그인 환경을 만들어보았다.