네 오늘은 지난번에 OAuth란 무엇인지에 대해서 간략하게 알아보았으니, 오늘은 이를 직접 구현해보도록 하겠습니다.
간단하게 구글, 카카오, 네이버 로그인 기능을 구현해보고, 동작하는 것까지 확인하는 시간을 가져보도록 하겠습니다.
다음처럼 세팅을하고, 프로젝트를 생성해주었습니다.
- gradle
- Spring Boot 2.7.9.
- Java 17
- Dependency
- Spring Web
- Lombok
- Spring Data Jpa
- OAuth2 Client
- MySQL Driver
일단 데이터베이스와 연동을 한 뒤, 프로젝트가 정상적으로 동작하는지 확인해줍니다.
OAuth를 라이브러리에 넣어주어서 그런지, 로그인창이 바로 뜨네요.
로그인을 실행해주면,
다음처럼 잘 작동하는 것을 확인할 수 있습니다.
일단 그럼 1차적으로 프로젝트와 관련된 설정은 완료한겁니다.
그럼 이제 본격적으로 소셜 로그인 기능을 사용하기 위한 설정을 시작해보겠습니다.
구글 계정을 통하여 로그인 기능을 구현하기 위해서는 설정을 통하여 키와 비밀 번호를 입력받아야합니다.
https://console.cloud.google.com/home/dashboard
해당 사이트로 접속하여 구글 계정으로 로그인해줍니다.
그리고 좌측 상단에 보시면 프로젝트 생성이라는 버튼이 있을텐데, 해당 버튼을 통하여 새 프로젝트를 만들어줍니다.
일단 먼저 프로젝트의 이름을 설정해줍니다.
위치는 따로 설정하지않고 넘어가도록 하겠습니다.
그럼 조금 시간이 지난 뒤에 프로젝트가 만들어집니다.
그리고 메뉴 - API 및 서비스 - 사용자 인증 정보로 들어가줍니다.
여기서 사용자 인증 정보 만들기 - OAuth 클라이언트 ID를 선택해줍니다.
동의화면 구성 - 외부를 선택해줍니다.
다음처럼 필요한 정보들만 입력해줍니다.
그럼 다음처럼 범위를 지정하는 화면이 뜰텐데,
범위 추가 또는 삭제를 누른뒤, 다음처럼 우리가 필요한 정보만을 선택해줍니다.
일단 저는 간단하게 로그인 기능만을 구현할 예정이기 때문에, 이메일 주소와 프로필 정보만을 가져오도록 하겠습니다.
테스트 사용자는 설정하지않고 그냥 넘어가겠습니다.
그리고 다시 사용자 인증 정보 - 사용자 인증 정보 만들기 - OAuth 클라이언트 ID를 선택해줍니다.
다음처럼 어플리케이션 유형, 이름, 리다이렉션 URL을 다음처럼 입력한 뒤, 만들기를 클릭할 경우 클라이언트 아이디와 비밀번호를 얻을 수 있습니다.
여기서 얻은 클라이언트 아이디와 비밀 번호를 나중에 사용하니, 꼭 기억해주셔야합니다.
네이버도 구글과 마찬가지로 다음 URL로 이동하여 설정해줄 필요가 있습니다.
https://developers.naver.com/apps/#/register?api=nvlogin
상단 Application 메뉴 - 어플리케이션 등록으로 이동해줍니다.
그리고 다음처럼 필요한 정보들을 입력해줍니다.
등록하면 아까 구글에서 얻었던 것처럼 Client-id와 비밀번호를 얻을 수 있습니다.
이 정보도 구글과 마찬가지로, 기억해야할 정보입니다.
마지막으로 카카오 로그인 서비스 구현에 필요한 설정입니다.
다음 사이트로 이동하여 내 어플리케이션 - 어플리케이션 추가하기를 통하여 새로운 어플리케이션을 만들도록합니다.
어플리케이션을 추가하면 다음처럼
우리가 만든 어플리케이션을 확인할 수 있습니다.
이걸 선택한 뒤, 설정화면으로 들어가줍니다.
여기서 플랫폼 설정 화면으로 넘어가서,
WEB 플랫폼을 등록해줍니다.
저장버튼을 눌러주면, 다음처럼
Redirect URL을 등록해주어야합니다.
그리고 다음처럼 모든 기능 활성화를 해주고, Redirect URL을 등록해줍니다.
그리고 좌측 메뉴에 보시면 동의항목이 있을텐데 해당 메뉴로 들어가서
다음처럼 수집할 개인 정보 범위를 설정해줍니다.
그리고 좌측 메뉴에서 요약 정보로 들어가시면, 다음처럼 키값이 있을텐데
우리는 여기서 REST API 키만을 사용할 예정입니다.
마찬가지로 기억해주시면 됩니다.
그러면 이제 준비는 다 되었습니다.
아까 입력받은 값을 토대로 다음처럼 설정을해줍니다.
spring:
security:
oauth2:
client:
registration:
google:
client-id: 구글 클라이언트 키
client-secret: 구글 클라이언트 비밀번호
scope: email, profile
naver:
client-id: 네이버 클라이언트 키
client-secret: 네이버 클라이언트 비밀번호
redirect-uri: http://localhost:8080/login/oauth2/code/naver
authorization-grant-type: authorization_code
client-name: Naver
scope: name, email
kakao:
client-id: 카카오 클라이언트 아이디
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
client-authentication-method: POST
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email
client-name: Kakao
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
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
구글의 경우, OAuth2가 지원하기 때문에 설정이 간편하지만, 네이버나 카카오의경우에는 지원하지 않기 때문에 설정해야할 부분들이 좀 많습니다.
그리고 홈 화면에 띄워주기 위한 index.html 파일을 만들어줍니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OAuth 2.0 로그인</title>
</head>
<body>
<a href="/oauth2/authorization/google" class="btn btn-sm btn-success active" role="button">Google Login</a><br>
<a href="/oauth2/authorization/naver" class="btn btn-sm btn-success active" role="button">Naver Login</a><br>
<a href="/oauth2/authorization/kakao" class="btn btn-third active" role="button">Kakao Login</a>
</body>
</html>
그리고 간단하게 Security와 관련된 설정들을 해줍니다.
import OAuth.practice.oauth.service.OAuth2Service;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2Service oAuth2Service;
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
return http.csrf().disable() // csrf 보안 설정 사용 X
.logout().disable() // 로그아웃 사용 X
.formLogin().disable() // 폼 로그인 사용 X
.authorizeRequests() // 사용자가 보내는 요청에 인증 절차 수행 필요
.antMatchers("/").permitAll() // 해당 URL은 인증 절차 수행 생략 가능
.anyRequest().authenticated() // 나머지 요청들은 모두 인증 절차 수행해야함
.and()
.oauth2Login() // OAuth2를 통한 로그인 사용
.defaultSuccessUrl("/oauth/loginInfo", true) // 로그인 성공시 이동할 URL
.userInfoEndpoint() // 사용자가 로그인에 성공하였을 경우,
.userService(oAuth2Service) // 해당 서비스 로직을 타도록 설정
.and()
.and().build();
}
}
먼저, Security에 관한 설정을 담당하는 SecurityConfig 클래스를 생성하였습니다.
보통 다른 사람들은 WebSecurityConfigurerAdapter를 사용하는 방식을 많이 사용하지만, 해당 방식은 이제 스프링에서 지원하지 않는 방식이라고 하였기때문에 해당 인터페이스를 사용하지 않는 방식으로 코드를 작성하였습니다.
로그인 성공시, userService()에 매개변수로 입력해준 oAuthService의 경우, 일단은 다음처럼 틀만 만들어두었습니다.
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@Service
public class OAuth2Service implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
return null;
}
}
그럼, 이제 동작을 확인해보면
다음처럼 로그인 링크로 연결하는 하이퍼링크가 보이는 것을 확인할 수 있습니다.
먼저, 구글로그인부터 시도해봅시다.
오....
로그인을 하게되면 다음 페이지로 이동하는데, 이는 로그인 성공시 이동하도록 만든 페이지에 접근하기에는 권한을 가지고 있지 않기 때문에 이렇게 차단되는 것입니다.
네이버랑 카카오의 로그인 페이지도 정상적을로 출력되는 것을 확인하였으며,
다음처럼 로그인할 경우, 권한 관련하여 동의하는 페이지 역시 잘 동작하는 것을 확인하였습니다.
그럼 이제부터, 사용자와 관련된 정보를 받아서 데이터베이스에 저장하는 것까지 해보겠습니다.
먼저, 사용자 정보를 저장하기 위한 User Entity를 생성해줍니다.
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.DynamicUpdate;
import javax.persistence.*;
@Entity
@Getter
@DynamicUpdate // Entity update시, 원하는 데이터만 update하기 위함
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Column(name = "username", nullable = false)
private String username; // 로그인한 사용자의 이름
@Column(name = "email", nullable = false)
private String email; // 로그인한 사용자의 이메일
@Column(name = "provider", nullable = false)
private String provider; // 사용자가 로그인한 서비스(ex) google, naver..)
// 사용자의 이름이나 이메일을 업데이트하는 메소드
public User updateUser(String username, String email) {
this.username = username;
this.email = email;
return this;
}
}
다음처럼 사용자의 정보를 저장하기위한 User Entity를 만들어주었습니다.
import OAuth.practice.oauth.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findUserByEmailAndProvider(String email, String provider);
}
User Entity를 위한 Repository를 만들어줍니다.
또한, 이메일과 로그인 시 활용하였던 서비스명으로 사용자에 관한 정보를 찾을 findUserByEmailAndProvider() 메소드를 만들어줍니다.
다음은 사용자의 정보를 담을 DTO 파일인 UserProfile 이라는 클래스를 하나 만들어줍니다.
import OAuth.practice.oauth.domain.User;
import lombok.Getter;
@Getter
public class UserProfile {
private String username; // 사용자 이름
private String provider; // 로그인한 서비스
private String email; // 사용자의 이메일
public void setUserName(String userName) {
this.username = userName;
}
public void setProvider(String provider) {
this.provider = provider;
}
public void setEmail(String email) {
this.email = email;
}
// DTO 파일을 통하여 Entity를 생성하는 메소드
public User toEntity() {
return User.builder()
.username(this.username)
.email(this.email)
.provider(this.provider)
.build();
}
}
다음으로는 열거형 클래스인 OAuthAttributes라는 파일을 만들어줍니다.
import OAuth.practice.oauth.dto.UserProfile;
import java.util.Arrays;
import java.util.Map;
import java.util.function.Function;
public enum OAuthAttributes {
GOOGLE("google", (attribute) -> {
UserProfile userProfile = new UserProfile();
userProfile.setUserName((String)attribute.get("name"));
userProfile.setEmail((String)attribute.get("email"));
return userProfile;
}),
NAVER("naver", (attribute) -> {
UserProfile userProfile = new UserProfile();
Map<String, String> responseValue = (Map)attribute.get("response");
userProfile.setUserName(responseValue.get("name"));
userProfile.setEmail(responseValue.get("email"));
return userProfile;
}),
KAKAO("kakao", (attribute) -> {
Map<String, Object> account = (Map)attribute.get("kakao_account");
Map<String, String> profile = (Map)account.get("profile");
UserProfile userProfile = new UserProfile();
userProfile.setUserName(profile.get("nickname"));
userProfile.setEmail((String)account.get("email"));
return userProfile;
});
private final String registrationId; // 로그인한 서비스(ex) google, naver..)
private final Function<Map<String, Object>, UserProfile> of; // 로그인한 사용자의 정보를 통하여 UserProfile을 가져옴
OAuthAttributes(String registrationId, Function<Map<String, Object>, UserProfile> of) {
this.registrationId = registrationId;
this.of = of;
}
public static UserProfile extract(String registrationId, Map<String, Object> attributes) {
return Arrays.stream(values())
.filter(value -> registrationId.equals(value.registrationId))
.findFirst()
.orElseThrow(IllegalArgumentException::new)
.of.apply(attributes);
}
}
해당 클래스의 경우, 현재 로그인하고 있는 계정이 어느 서비스인지를 나타내는 registrationId,
현재 로그인하고 있는 사용자의 정보를 저장하고있는 attirbute을 제시하면, 사용자의 정보를 담고 있는 UserProfile를 반환하는 Function 인터페이스로 구성되어 있습니다.
extract() 메소드가 바로 그 예시인데, 일단 values()를 통하여 로그인한 사용자가 가지고 있는 권한들을 스트림으로 변화시키는데, 입력받은 매개변수 registrationId와 일치하는 사용자 권한을 찾을 수 없는 경우, 해당 서비스는 올바르지 않은 서비스라고 판단하여 오류를 반환하도록 설정하였습니다.
만약 정보가 일치한다면, apply() 메소드를 통하여 attibute를 사용하는데, 이 경우 attribute의 값에 해당하는 UserProfile이 반환됩니다.
값에 해당하는 UserProfile의 경우, 각 서비스들마다 반환되는 값의 형태가 다르기때문에 코드의 형태가 다른 것을 확인할 수 있었습니다.
구글의 경우에는 다음처럼 attribute가 구성되어 있기에, 간단하게 map을 통하여 값을 가져왔습니다.
네이버의 경우에는 다음처럼 response 하위에 정보가 담겨있기 때문에 일단 response로 map을 가져와서 해결하였습니다.
카카오의 경우에는 kakao_account 속 profile 내부에 이름이 있고, email은 kakao_account 내부에 있어서 2개의 map을 통하여 정보를 가져왔습니다.
import OAuth.practice.oauth.domain.OAuthAttributes;
import OAuth.practice.oauth.domain.User;
import OAuth.practice.oauth.dto.UserProfile;
import OAuth.practice.oauth.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
public class OAuth2Service implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 로그인을 수행한 서비스의 이름
String userNameAttributeName = userRequest
.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName(); // PK가 되는 정보
Map<String, Object> attributes = oAuth2User.getAttributes(); // 사용자가 가지고 있는 정보
UserProfile userProfile = OAuthAttributes.extract(registrationId, attributes);
userProfile.setProvider(registrationId);
updateOrSaveUser(userProfile);
Map<String, Object> customAttribute =
getCustomAttribute(registrationId, userNameAttributeName, attributes, userProfile);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("USER")),
customAttribute,
userNameAttributeName);
}
public Map getCustomAttribute(String registrationId,
String userNameAttributeName,
Map<String, Object> attributes,
UserProfile userProfile) {
Map<String, Object> customAttribute = new ConcurrentHashMap<>();
customAttribute.put(userNameAttributeName, attributes.get(userNameAttributeName));
customAttribute.put("provider", registrationId);
customAttribute.put("name", userProfile.getUsername());
customAttribute.put("email", userProfile.getEmail());
return customAttribute;
}
public User updateOrSaveUser(UserProfile userProfile) {
User user = userRepository
.findUserByEmailAndProvider(userProfile.getEmail(), userProfile.getProvider())
.map(value -> value.updateUser(userProfile.getUsername(), userProfile.getEmail()))
.orElse(userProfile.toEntity());
return userRepository.save(user);
}
}
마지막으로 사용자의 로그인이 성공하였을경우 실행되는 OAuth2Service 로직입니다.
아까는 틀만 만들어주었으나, 이번에는 로직까지 모두 구현해주었습니다.
2개의 메소드를 생성하여 적용하였는데, 먼저 getCustomAttribute() 메소드의 경우에는 사용자의 정보를 담고 있는 attribute의 경우 수정이 불가능하다는 특성을 가지고 있기 때문에, 사용자 임의로 변화시키고 싶은 부분을 수정하기 위하여 별도로 메소드를 만들어주었습니다.
updateOrSaveUser() 메소드의 경우, 데이터베이스에 User 객체가 존재하지 않는 경우에는 UserProfile을 토대로 User Entity를 생성해주고, 그렇지 않은 경우에는 이름과 이메일을 업데이트해주는 메소드입니다.
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/oauth")
public class UserController {
@GetMapping("/loginInfo")
public String getJson(Authentication authentication) {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = oAuth2User.getAttributes();
return attributes.toString();
}
}
간단하게 사용자가 가지고 있는 권한인 attribute만을 확인하기 위하여 다음과 같이 코드를 작성하였습니다.
실행하였을때, 다음처럼 데이터베이스에 잘 저장되는 모습을 확인할 수 있었습니다.
전에 해봤던 JWT와 비교하였을 때, 서비스를 하나하나 구현하지 않아도 되기 때문에 매우 편리하고, 사용자 입장에서도 소셜 계정을 통하여 인증이 완료되므로 상호간 편리하다는 장점이 있었습니다.
물론 JWT와 비교하였을 때의 장단점이 있을테고, 아직 OAuth를 어떻게 활용해야할지는 잘 모르기 때문에 좀 더 공부해야할것 같습니다.
그래도 구글이나 네이버에서 제공하는 API를 사용해보는 좋은 경험이었던 것 같습니다.
https://junuuu.tistory.com/415?category=1014988
https://velog.io/@beenz/Spring-Security-OAuth2.0-에서-Google-Login만-OAuth2UserService의-커스텀-구현체-로직-타지-않는-문제