구현해야할 부분이 대폭 감소함
OAuth2 연동 방법이 1.5와 2.0이 많이 다름
그러나 본 실습에서는 Spring Security Oauth2 Client 라이브러리 사용
스프링 부트 1.5 방식에서는 url 주소를 모두 명시해야 하지만 2.0 방식에서는 client 인증 정보만 입력하면 됨
/src/main/resources에 application-oauth.properties 생성
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
application.properties에 아래 코드 추가
spring.profiles.include=oauth
ID와 비밀 노출을 방지하기 위해 .gitignore에 application-oauth.properties 등록
domain에 user 패키지를 생성하고, User 클래스와 Role 클래스, UserRepository 클래스 생성
User.java
package com.jojoldu.book.springboot.domain.user;
import com.jojoldu.book.springboot.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@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) //JPA로 DB를 저장할 때 Enum값을 어떤 형태로 저장할지를 결정(기본은 int)
@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();
}
}
Role.java
package com.jojoldu.book.springboot.domain.user;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role
{
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
UserRepository.java
package com.jojoldu.book.springboot.domain.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long>
{
// 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 판단
Optional<User> findByEmail(String email);
}
build.gradle에 시큐리티 관련 의존성 추가
implementation('org.springframework.boot:spring-boot-starter-oauth2-client'
springboot 내부에 config.auth 패키지를 만들고 SecurityConfig 클래스와 CustomOAuth2UserService 클래스 생성
SecurityConfig.java
package com.jojoldu.book.springboot.config.auth;
import com.jojoldu.book.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;
@RequiredArgsConstructor
@EnableWebSecurity // Spring Security 설정 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
private final CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception
{
http.csrf().disable()
.headers().frameOptions().disable() // h2-console화면 사용을 위해 해당 옵션들 disable
.and()
.authorizeRequests() // URL별 권한 관리 설정 옵션의 시작점으로, antMatchers 옵션 사용 전에 선언돼야 함
.antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll() // 전체 열람 권한 부여
.antMatchers("/api/v1/**").hasRole(Role.USER.name()) // antMatchers는 권한 관리 대상을 지정하는 옵션으로 URL, HTTP 메소드별로 관리 가능 (USER 권한만 열람 가능)
.anyRequest().authenticated() // 설정된 값들 이외 나머지 URL (인증된 사용자, 즉 로그인한 사용자들에게 권한 부여)
.and()
.logout().logoutSuccessUrl("/") // 로그아웃 기능 설정의 진입점(로그아웃 성공시 /로 이동)
.and()
.oauth2Login() // OAuth2 로그인 기능에 대한 설정 진입점
.userInfoEndpoint() // 로그인 성공 후 사용자 정보를 가져올 때의 설정 담당
.userService(customOAuth2UserService); // 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스 구현체 등록
}
}
CustomOAuth2UserService.java
package com.jojoldu.book.springboot.config.auth;
import com.jojoldu.book.springboot.config.auth.dto.OAuthAttributes;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.domain.user.User;
import com.jojoldu.book.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;
@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(); // OAuth2 로그인 진행 시 키가 되는 필드값(Primary key)
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); // OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
User user = saveOrUpdate(attributes);
httpSession.setAttribute("user", new SessionUser(user)); // 세션에 사용자 정보를 저장하기 위한 Dto 클래스
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);
}
}
OAuthAttributes 클래스 생성
package com.jojoldu.book.springboot.config.auth.dto;
import com.jojoldu.book.springboot.domain.user.Role;
import com.jojoldu.book.springboot.domain.user.User;
import lombok.Builder;
import lombok.Getter;
import java.util.Map;
@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;
}
// OAuth2User에서 반환하는 사용자 정보는 Map이므로 값 하나하나를 변환해야 함
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();
}
// User 엔티티 생성(OAuthAttribute에서 엔티티 생성 시점은 처음 가입할 때)
// 기본 권한은 GUEST이고 클래스 생성이 끝나면 같은 패키지에 SessionUser 클래스 생성
public User toEntity()
{
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
config.auth.dto 패키지에 SessionUser 클래스 추가
package com.jojoldu.book.springboot.config.auth.dto;
import lombok.Getter;
import com.jojoldu.book.springboot.domain.user.User;
import java.io.Serializable;
@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();
}
}
User 클래스를 사용하지 않고 SessionUser 클래스를 새로 만든 이유
index.mustache를 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주도록 수정
<h1>스프링 부트로 시작하는 웹 서비스</h1>
<div class = "col-md-12">
<!-- 로그인 기능 영역 -->
<div class = "row">
<div class = "col-md-6">
<a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
{{#userName}}
Logged in as: <span id="user">{{userName}}</span>
<a href="/logout" class="btn btn-info active" role="button">Logout</a>
{{/userName}}
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
{{/userName}}
</div>
</div>
IndexController에 userName을 model에 저장하는 코드를 추가해 index.mustache에서 userName을 사용할 수 있게 함.
...
import javax.servlet.http.HttpSession;
...
@RequiredArgsConstructor
@Controller
public class IndexController
{
private final PostsService postsService;
private final HttpSession httpSession;
@GetMapping("/")
public String index(Model model)
{
model.addAttribute("posts", postsService.findAllDesc());
SessionUser user = (SessionUser) httpSession.getAttribute("user"); // CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser 저장
if(user != null) // 세선에 저장된 값이 있을 때만 model에 userName으로 등록
{
model.addAttribute("userName", user.getName());
}
return "index";
}
...
}
이후 구글 로그인을 시도하면 로그인이 되는 것을 확인할 수 있음
그러나 게시글 등록을 하면 403 에러가 나옴
같은 코드가 반복되는 경우 개선이 필요함
앞의 코드에서는 IndexController에서 세션값을 가져오는 부분을 개선할 수 있다
config.auth 패키지에 @LoginUser 어노테이션 생성
package com.jojoldu.book.springboot.config.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER) // 어노테이션이 생성될 수 있는 위치 지정
@Retention(RetentionPolicy.RUNTIME) // 이 파일을 어노테이션 클래스로 지정
public @interface LoginUser { }
같은 위치에 LoginUserArgumentResolver를 생성
package com.jojoldu.book.springboot.config.auth;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import javax.servlet.http.HttpSession;
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver
{
private final HttpSession httpSession;
// 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
// @LoginUser 어노테이션이 붙어 있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true
@Override
public boolean supportsParameter(MethodParameter parameter)
{
boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
return isLoginUserAnnotation && isUserClass;
}
// 파라미터에 전달할 객체 생성(세션에서 객체를 가져옴)
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception
{
return httpSession.getAttribute("user");
}
}
LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfig 클래스를 config 패키지에 생성
package com.jojoldu.book.springboot.config.auth;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer
{
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers)
{
argumentResolvers.add(loginUserArgumentResolver);
}
}
IndexController의 코드에서 반복되는 부분을 @LoginUser로 개선
...
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) // 이제는 어느 컨트롤러든지 @LoginUser만 사용하면 세선 정보를 가져올 수 있음
{
model.addAttribute("posts", postsService.findAllDesc());
if(user != null)
{
model.addAttribute("userName", user.getName());
}
return "index";
}
...
현재 서비스는 재실행을 하면 로그인이 풀림
그리고 2대 이상의 서버에서 서비스하면 톰캣마다 세션 동기화 설정을 해야 함
세션 저장소 문제를 해결하기 위한 방법은 크게 3가지
build.gradle에 spring-session-jdbc를 위한 의존성 등록
implementation('org.springframework.session:spring-session-jdbc')
application.properties에 세션 저장소를 jdbc로 선택하도록 코드 추가
이렇게 수정해도 스프링을 재시작하면 세션이 풀림
네이버 오픈 API으로 이동
애플리케이션 등록(API 이용신고) 설정
Client ID와 Secret을 application-oauth.properties에 등록. 스프링 시큐리티가 지원하지 않기 때문에 CommonOAuth2Provider가 해주던 값을 전부 입력해야 함
# registration
spring.security.oauth2.client.registration.naver.client-id=네이버클라이언트ID
spring.security.oauth2.client.registration.naver.client-secret=네이버클라이언트비밀
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver
# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
네이버 오픈 API의 로그인 회원 결과는 다음과 같음
{
"resultcode": "00",
"message": "success",
"response": {
"email": "openapi@naver.com",
"nickname": "OpenAPI",
"profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
"age": "40-49",
"gender": "F",
"id": "32742776",
"name": "오픈 API",
"birthday": "10-01"
}
}
OAuthAttributes에 네이버인지 판단하는 코드와 네이버 생성자 추가
...
public class OAuthAttributes
{
...
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes)
{
if("naver".equals(registrationId)) return ofNaver("id", attributes);
return ofGoogle(userNameAttributeName, attributes);
}
...
private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes)
{
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuthAttributes.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.picture((String) response.get("profile_image"))
.attributes(reponse)
.nameAttributeKey(userNameAttributeName)
.build();
}
}
index.mustache에 네이버 로그인 버튼 추가
<!-- 구글 로그인 버튼 아래에 추가 -->
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
기존 테스트에 시큐리티 적용으로 문제가 되는 부분들을 해결해야 함
인텔리제이 오른쪽 상단에 Gradle-Tasks-verification-test를 선택해 전체 테스트 수행
CustomOAuth2UserService을 찾을 수 없음
returnHello() 메시지를 보면 **No qualifying bean of type 'com.jojoldu.book.springboot.config.auth.CustomOAuth2UserService'**라는 메시지 등장
src/main과 src/test의 환경이 다르기 때문
이를 해결하기 위해 src/test/resources에 application.properties 생성
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
spring.jpa.properties.hibernate.dialect.storage_engine=innodb
spring.datasource.hikari.jdbc-url=jdbc:h2:mem://localhost/~/testdb;MODE=MYSQL
spring.h2.console.enabled=true
spring.session.store-type=jdbc
# Test OAuth
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=profile,email
302 Status Code
PostsRegister() 테스트 로그를 보면 Status Code가 200이 아닌 302가 와서 실패
시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문
임의로 인증된 사용자를 추가하여 API만 테스트함
build.gradle에 아래 코드 추가
testImplementation('org.springframework.security:spring-security-test')
PostsApiControllerTest를 아래와 같이 수정
package com.jojoldu.book.springboot.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jojoldu.book.springboot.domain.posts.Posts;
import com.jojoldu.book.springboot.domain.posts.PostsRepository;
import com.jojoldu.book.springboot.web.dto.PostsSaveRequestDto;
import com.jojoldu.book.springboot.web.dto.PostsUpdateRequestDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.*;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest
{
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@BeforeEach
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@AfterEach
public void tearDown() throws Exception
{
postsRepository.deleteAll();
}
@Test
@WithMockUser(roles = "USER")
public void PostsRegister() throws Exception
{
//given
String title = "title";
String content = "content";
PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
.title(title)
.content(content)
.author("author")
.build();
String url = "http://localhost:" + port + "/api/v1/posts";
//when
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
//then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(title);
assertThat(all.get(0).getContent()).isEqualTo(content);
}
@Test
@WithMockUser(roles = "USER")
public void PostsUpdate() throws Exception
{
//given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savedPosts.getId();
String expectedTitle = "title2";
String expectedContent = "content2";
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
//when
mvc.perform(put(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
//then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
@WebMvcTest에서 CustomOAuth2UserService을 찾을 수 없음
returnHello도 첫 번째와 동일한 오류 메시지
하지만 @WebMvcTest를 사용하므로 CustomOAuth2UserService를 스캔하지 않는 다는 점이 다름
이후 @WithMockUser로 가짜 인증 생성
아래와 같은 오류가 발생하는데, 이는 @EnableJpaAuditing으로 인해 하나 이상의 @Entity 클래스가 필요하기 때문
java.lang.IllegalArgumentException: At least one JPA metmodel must be present!
Application.java에서 @EnableJpaAuditing 제거 후 config 패키지에 JpaConfig 생성
package com.jojoldu.book.springboot.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing // JPA Auditing 활성화
public class JpaConfig {}