우선, 구글 클라우드 플랫폼에서 발급받은 OAuth 클라이언트 ID, 클라이언트 보안 비밀, 그리고 scope를 src/main/resources/application-oauth.properties에 등록합니다
spring.security.oauth2.client.registration.google.client-id=클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀
spring.security.oauth2.client.registration.google.scope=profile,email
잠깐, “클라이언트 ID”란 무엇일까요?
결국, 우리가 만든 서비스(클라이언트)의 ID를 통해 구글(리소스 서버)에 접속하여 정보를 받아오는 것입니다.
application-xxx.properties로 properties의 이름을 만들었기에, xxx에 해당하는 oauth로 profile이 생성됩니다.
이를 application.properties에서spring.profiles.include=oauth
로 설정값을 가져옵니다.
클라이언트ID와 보안 비밀은 보안에 있어 중요한 정보들이기 때문에, application-oauth.properties 파일은 .gitignore에 추가해주어서 깃허브에 공유되지 않게 합니다.
사용자 정보에 대한 도메인인 User를 생성해줍니다.
@Getter
@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) // Enum값을 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;
}
public String getRoleKey() {
return this.role.getKey();
}
}
다음으로 Enum클래스 Role을 생성합니다
@Getter
@RequiredArgsConstructor
public enum Role { / 사용자의 권한을 관리 /
GUEST("ROLEGUEST", "손님"), // 스프링 시큐리티에서는 권한코드에 항상 ROLE 이 앞에 있어야 함
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
@Getter
@RequiredArgsConstructor
public enum Role { /* 사용자의 권한을 관리 */
GUEST("ROLE_GUEST", "손님"), // 스프링 시큐리티에서는 권한코드에 항상 ROLE_ 이 앞에 있어야 함
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
User의 CRUD를 담당하는 UserRepository도 생성합니다.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
// 소셜로그인으로 반환되는 email을 통해 이미 있는 사용자인지, 처음 가입하는 사용자인지 판단하기 위한 메소드
}
JpaRepository<엔티티클래스, pk클래스> 를 상속받았기에 CRUD메서드가 자동으로 생성됩니다.
Optional 은 null혹은 null아닌 값을 저장할 수 있는 컨테이너 입니다. 제네릭을 통해 들어올 수 있는 객체의 타입을 User로 제한하고 있습니다.
build.gradle에 스프링 시큐리티 의존성을 추가합니다.
implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
다음으로, 시큐리티 관련 클래스를 담을 config.auth 패키지를 생성합니다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception{
http
.authorizeRequests()
.antMatchers("/","/css/**","/images/**","/js/**","/profile").permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.oauth2Login()
.userInfoEndpoint()
.userService(customOAuth2UserService);
}
}
@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<OAuth2UserRequest, OAuth2User> 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);
}
}
인터페이스의 loadUser를 오버라이드 → loadUser : UserInfo Endpoint에서 최종 사용자의 속성을 가져온 후 OAuth2User를 반환합니다.
로그인 인증이 실패되어도 동작하도록 로그인 실패 Exception을 throw합니다.
delegate는 “대리인”이라는 뜻입니다.
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));
마지막으로, DefaultOAuth2User를 만들어 반환할 것입니다.
- public DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes, String nameAttributeKey)
- 객체를 생성하기 위하여 GrantedAutority를 상속한 컬렉션과, attributes Map과 , nameAttributeKey가 필요합니다.
- role을 인자로 받은 SimpleGrantedAuthority를 ,Set객체 하나만 저장 가능한 싱글톤 컬렉션을 통해 첫번째 DefaultOAuth2User에 의 첫번째 인자로 넘깁니다.
- 나머지 인자들은 기존에 만들어 두었던 속성들을 활용합니다
조금 복잡해서 정리해보았습니다.
왜 httpSession에 사용자 정보를 바인딩할때 User 클래스를 만들지 않고 SessionUser 이라는 dto를 만들어서 쓸까요??
@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();
}
}
org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.Object] to type [byte[]] for value 'springboot.domain.user.User@58939314'; nested exception is org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer; nested exception is java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [springboot.domain.user.User]
이런 에러가 뜹니다.별다른 설정이 없다면 애플리케이션이 재시작 되면 로그인이 풀립니다.
세션이 WAS의 메모리에 저장되고 호출되어서, 내장 톰캣이 재시작할때마다 세션 정보가 날아가기 때문입니다.
이러한 일을 방지하기 위해 MySql을 세션 저장소로 사용합니다.
implementation('org.springframework.session:spring-session-jdbc')
build.gradle에 의존성을 추가해줍니다.spring.session.store-type =jdbc
application.properties에서 세션 저장소를 jdbc로 사용하도록 합니다.
JPA를 통하여 Mysql에 세션 테이블이 자동 생성됩니다.
마지막으로, 테스트를 스프링 시큐리티에 맞게 수정해보겠습니다.
테스트 환경을 위한 application.properties 생성
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/board?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root
spring.profiles.include=oauth
spring.session.store-type =jdbc
spring.session.jdbc.initialize-schema = always
spring.jpa.show-sql=true
spring.jpa.database=mysql
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=false
spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=test
테스트를 실행할 시, 별도의 application.properties가 없다면 main의 것을 가져오지만, application-oauth.properties까지는 가져오지 않기에, 테스트 환경을 위한 application.properties를 따로 생성합니다.
임의로 인증된 사용자를 추가합니다.
build.gradle에 스프링 시큐리티 테스트를 위한 도구를 지원해주는 testImplementation('org.springframework.boot:spring-boot-starter-test')
를 추가합니다.
PostsApiControllerTest의 테스트 메소드들에 모의 사용자를 추가해줍니다
@Test
@WithMockUser(roles="USER")
public void posts_등록() throws Exception { ...
@Before
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.apply(sharedHttpSession())
.build();
}
.
.
.
.
@Test
@WithMockUser(roles="USER") // MockMVC에서만 작동
public void posts_등록() throws Exception {
.
.
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(requestDto))) // 문자열 JSON으로 변환
.andExpect(status().isOk());
.
.
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
@ComponentScan.Filter(type= FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
})
마찬가지로 @WithMockUser로 인증된 사용자를 만들어줍니다
여전히 에러가 발생합니다.
java.lang.illegalargumentexception at least one jpa metamodel must be present
@EnableJpaAuditing는 엔티티들의 생성 및 수정 시간을 자동으로 관리해줍니다.
이를 Application.java 클래스에 애노테이션으로 등록해두면 모든 테스트들이 항상 JPA관련 Bean을 필요로 하게됩니다.
즉, @EnableJpaAuditing 을 사용하기 위해 최소 하나의 @Entity 클래스가 필요하게 됩니다
WebMvcTest에는 이것이 없습니다,
따라서 이를 Application.java에서 분리해 config 패키지에 별도로 생성합니다.
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}