먼저 구글 서비스에 신규 서비스를 생성한다.
여기서 발급된 인증 정보(clientId와 clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능을 사용 할 수 있으니 무조건 발급 받고 시작해야 한다.
구글 클라우드 플랫폼 주소(Google Cloud Platform)로 이동한다.
프로젝트 생성
API 및 서비스 대시보드 이동 / 사용자 인증 정보 > 사용자 인증 정보 만들기
OAuth 동의 화면 입력
OAuth 클라이언트 ID 만들기
application-oauth.properties 등록
구글의 로그인 인증정보를 발급 받았으니 프로젝트 구현을 해보자!
먼저 사용자 정보를 담당할 도메인인 User 클래스를 생성한다.
User
package com.ha0kim.webservice.springboot.domain.user;
import com.ha0kim.webservice.springbootjpa.BaseTimeEntity;
import lombok.Builder;
import lombok.NoArgsConstructor;
import javax.management.relation.Role;
import javax.persistence.*;
/**
* com.ha0kim.webservice.springboot.domain.user
*
* @author Nora(Hayoung Kim)
* @date 2021/04/10
*/
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
}
JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정한다.
기본적으로 int로 숫자가 저장된다.
숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없다.
그래서 문자열(EnumType.STRING)로 지정될 수 있도록 선언한다.
각 사용자의 권한을 관리할 Enum 클래스 Role을 생성한다.
Role
package com.ha0kim.webservice.springboot.domain.user;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* com.ha0kim.webservice.springboot.domain.user
*
* @author Nora(Hayoung Kim)/서버개발팀/DREAMUS(ha0.kim0217@dreamus.io)
* @date 2021/04/10
*/
@Getter
@RequiredArgsConstructor
public enum Role {
GUSET("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 한다.
그래서 코드별 키 값을 ROLE_GUEST, ROLE_USER 등으로 지정한다.
마지막으로 User의 CRUD를 책임질 UserRepository도 생성한다.
UserRepository
package com.ha0kim.webservice.springboot.domain.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* com.ha0kim.webservice.springboot.domain.user
*
* @author Nora(Hayoung Kim)/서버개발팀/DREAMUS(ha0.kim0217@dreamus.io)
* @date 2021/04/10
*/
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드이다.
먼저 build.gradle에 스프링 시큐리티 관련 의존성 하나를 추가한다.
build.gradle
buildscript {
ext {
springBootVersion = '2.1.7.RELEASE'
}
repositories {
mavenCentral()
jcenter()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'war'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'application'
mainClassName = 'com.ha0kim.webservice.IndexController'
version '1.0-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
mavenCentral()
}
dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
testCompile('org.springframework.boot:spring-boot-starter-test')
compile('org.projectlombok:lombok')
compile('org.springframework.boot:spring-boot-starter-data-jpa')
compile('com.h2database:h2')
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
annotationProcessor('org.projectlombok:lombok')
compile('org.springframework.boot:spring-boot-starter-mustache')
}
소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성이다.
spring-boot-starter-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.
build.gradle 설정이 끝났으면 OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성한다.
config.auth 패키지 생성
시큐리티 관련 클래스는 모두 이곳에 생성한다.
SecurityConfig 클래스 생성
package com.ha0kim.webservice.config.auth;
import com.ha0kim.webservice.springboot.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
/**
* com.ha0kim.webservice.config.auth
*
* @author Nora(Hayoung Kim)/서버개발팀/DREAMUS(ha0.kim0217@dreamus.io)
* @date 2021/04/10
*/
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/", "/css/**", "/images/**",
"/js/**", "/h2-console/**").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.
USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
@EnableWebSecurity
Spring Security 설정들을 활성화 시켜준다.
.csrf().disable().headers().frameOptions().disable()
h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
authorizeRequests
URL별 권한 관리를 설정하는 옵션의 시작점이다.
authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있다.
antMatchers
권한 관리 대상을 지정하는 옵션이다.
URL, HTTP 메소드별로 관리가 가능하다.
"/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 부여했다.
"/api/v1/**"주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했다.
anyRequest
설정된 값들 이외 나머지 URL들을 나타낸다.
여기서는 authenticated()을 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 된다.
인증된 사용자 즉, 로그인한 사용자들은 이야기한다.
logout().logoutSuccessUrl("/")
로그아웃 기능에 대한 여러 설정의 진입점이다.
로그아웃 성공 시 / 주소로 이동한다.
oauth2Login
OAuth 2 로그인 기능에 대한 여러 설정의 진입점이다.
userInfoEndpoint
OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
userService
소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록한다.
리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있다.
설정 코드 작성이 끝났다면 CustomOAuth2UserService클래스를 생성 한다.
이 클래스에서는 구글 로그인 이후 가져온 사용자의 정보(email,name,picture등) 들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원한다.
CustomOAuth2UserService
package com.ha0kim.webservice.config.auth;
import com.ha0kim.webservice.config.auth.dto.OAuthAttributes;
import com.ha0kim.webservice.config.auth.dto.SessionUser;
import com.ha0kim.webservice.springboot.domain.user.User;
import com.ha0kim.webservice.springboot.domain.user.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 javax.servlet.http.HttpSession;
import java.util.Collections;
/**
* com.ha0kim.webservice.config.auth
*
* @author Nora(Hayoung Kim)/서버개발팀/DREAMUS(ha0.kim0217@dreamus.io)
* @date 2021/04/10
*/
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
OAuthAttributes attributes = OAuthAttributes.
of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user));
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(),attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}
registrationId
현재 로그인 진행 중인 서비스를 구분하는 코드이다.
서비스가 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용한다.
userNameAttributeName
OAuth2 로그인 진행 시 키가 되는 필드값을 의미한다.
즉, Primary Key와 같은 의미이다.
구글의 경우 기본적으로 코드를 지원하지만 네이버 카카오 등은 기본 지원하지 않는다.
구글의 기본 코드는 "sub"이다.
OAuthAttributes
OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스이다.
다른 소셜 로그인도 이 클래스 사용한다.
SessionUser
세션에 사용자 정보를 저장하기 위한 Dto 클래스이다.
CustomOAuth2UserService 클래스까지 생성되었다면 OAuthAttributes 클래스를 생성한다.
OAuthAttributes
package com.ha0kim.webservice.config.auth.dto;
import com.ha0kim.webservice.springboot.domain.user.Role;
import com.ha0kim.webservice.springboot.domain.user.User;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
/**
* com.ha0kim.webservice.config.auth.dto
*
* @author Nora(Hayoung Kim)/서버개발팀/DREAMUS(ha0.kim0217@dreamus.io)
* @date 2021/04/10
*/
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
@Builder
public OAuthAttributes(Map<String, Object> attributes,
String nameAttributeKey, String name,
String email, String picture) {
this.attributes = attributes;
this.nameAttributeKey= nameAttributeKey;
this.name = name;
this.email = email;
this.picture = picture;
}
public static OAuthAttributes of(String registrationId,
String userNameAttributeName,
Map<String, Object> attributes) {
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttributeName,
Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUSET)
.build();
}
}
of()
OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 한다.
toEntity
User 엔티티를 생성한다.
OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때 이다.
가입할 때의 기본 권할을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용한다.
OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스를 생성한다.
config.auth.dto 패키지에 SessionUser 클래스를 추가한다.
SessionUser
package com.ha0kim.webservice.config.auth.dto;
import com.ha0kim.webservice.springboot.domain.user.User;
import lombok.Getter;
import java.io.Serializable;
/**
* com.ha0kim.webservice.config.auth.dto
*
* @author Nora(Hayoung Kim)/서버개발팀/DREAMUS(ha0.kim0217@dreamus.io)
* @date 2021/04/10
*/
@Getter
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
}
}
SessionUser에는 인증된 사용자 정보만 필요하다.
세션에 저장하기 위해 User클래스를 세션에 저장하려고 하니 User 클래스에 직렬화를 구현하지 않았다는
에러가 난다.
Entity 클래스는 직렬화 코드를 넣지 않는게 좋다.
엔티티 클래스에는 언제 다른 엔티티와 관계가 형성될지 모른다.
@OneToMany, @ManyToMany등 자식 엔티티를 갖고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높다.
그래서 직렬화 기능을 가진 세션 Dto를 하나 추가로 만든 것이 더 좋은 방법이다.
스프링 시큐리티가 잘 적용되었는지 확인하기 위해 화면에 로그인 버튼을 추가해보자!