1. 스프링 시큐리티
스프링 시큐리티를 사용하여 인증(Authentication), 인가(Authorization)을 손쉽게 구현할 수 있는 스프링 기반 애플리케이션의 보안 프레임워크이다.
A. OAuth2 스프링부트 버전 별 특징
1.5: application.properties에 client인증 정보 외 accessTokenUrl, userAuthorizationUrl, clientAuthenticationScheme를 입력해야 한다.
1.5: spring-security-oauth2-autoconfigure 라이브러리를 사용하여 호환되도록 설정할 수 있다.
2.0: application.properties에 client인증 정보(clientId, clientSecret)만 입력하면 된다. client 인증 정보 외 항목은 CommonOAuth2Provider라는 enum으로 제공된다. 구글, 깃허브, 페이스북, 옥타 지원.
2. 소셜 로그인 서비스 등록
소셜 로그인 기능을 활용하면 계정 보안 관련된 기능 구현 없이 사용자를 관리할 수 있다.
A. 구글 서비스 등록
https://console.cloud.google.com 에서 프로젝트 생성 후 아래 항목을 세팅한다.
i) API 및 서비스> OAuth 동의 화면 > 외부
아래 항목 선택 후 저장
- 애플리케이션 이름: 구글 로그인 시 사용자에게 노출 될 애플리케이션 이름.
- 지원 이메일: 사용자 동의 화면에서 노출 될 이메일 주소. 서비스 help 이메일을 입력한다.
- Google API의 범위: 구글에서 받아 쓸 정보 범위
ii) API 및 서비스 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID
웹 애플리케이션 선택 후 아래 항목 입력
- 이름: 앱의 표시 이름이 아닌 OAuth 클라이언트 ID의 이름.
- 승인 된 리디렉션 URI: 인증 성공 후 구글에서 리다이렉트 할 URL.
→ http://localhost:8080/login/oauth2/code/google을 등록한다(스프링 시큐리티에서 지원하는 URL. 별도의 컨트롤러를 구현이 필요 없음).
B. 네이버 서비스 등록
i) API 서비스 신청
https://developers.naver.com/apps/#/register?api=nvlogin 에 접속해서 다음 항목을 입력한다.
- 애플리케이션 이름: 로그인 시 사용자에게 표시되는 이름
- 사용 API: 사용을 원하는 API. 네아로(네이버 아이디로 로그인) / 회원이름, 이메일, 프로필 사진 체크
ii) 로그인 오픈 API 서비스 환경
환경추가 > PC 웹 선택 후 아래 항목 입력
- 서비스 URL: http://localhost:8080/
- 네이버아이디로로그인 Callback URL: 인증 성공 후 네이버에서 리다이렉트 할 URL.
→ http://localhost:8080/login/oauth2/code/naver를 등록한다(application-oauth.properties / naver.redirect-uri설정에 의해 자동 생성).
3. 애플리케이션 개발
A. 인증정보 세팅
[application-oauth.properties - google]
##google
#registration
spring.security.oauth2.client.registration.google.client-id=클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트SECRET
spring.security.oauth2.client.registration.google.scope=profile,email
scope를 지정하지 않으면 scope에 openid 항목도 default로 설정되고 Open Id Provider로 인식된다. 이렇게 되면 Open Id Provider가 아닌(네이버, 카카오) 서비스용 OAuth2Service를 따로 만들어야 한다. 하나로 관리하기 위해 scope를 지정해준다.
[application-oauth.properties - naver]
##naver
#registration
spring.security.oauth2.client.registration.naver.client-id=클라이언트ID
spring.security.oauth2.client.registration.naver.client-secret=클라이언트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
user-name-attribute=response: 네이버 응답값의 최상위 필드를 response로 설정한다.
네이버 API는 회원 조회 시 resultcode, message, response가 응답되며, response에 회원 정보가 담겨있다. 스프링 시큐리티에서는 하위 필드를 명시할 수 없으므로 response를 최상위 필드(user_name)로 지정한다.
[application-oauth.properties - google]
[application.properties]
...
spring.profiles.include=oauth
application-xxx.properties를 가져올 수 있다.
[.gitignore]
...
application-oauth.properties
중요한 인증정보(clientId, clientSecret)가 올라가지 않도록 .gitignore에 등록하여 제외시킨다.
B. 권한 관리용 Enum 생성
[Role.java]
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;
}
스프링 시큐리티에서는 권한 코드에 항상 ROLE_ 접두어가 붙어야 한다.
C. 사용자 엔티티에 권한 항목 생성
[User.java]
@Entity
public class User extends BaseTimeEntity {
...
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
...
}
@Enumerated(EnumType.STRING): JPA로 데이터베이스 저장 시 Enum 값의 저장 타입 지정. 기본적으로 int로 저장되기 때문에 데이터를 알아볼 수 있도록 문자열로 지정해준다.
D. OAuth2 설정
[build.gradle]
...
dependencies {
...
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
}
[/config/auth/SecurityConfig.java]
import com.wrallee.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;
import org.springframework.security.oauth2.client.userinfo.CustomUserTypesOAuth2UserService;
@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/**", "/js/**", "/h2-console/**").permitAll() // 빼먹으면 리다이렉트 되네??
.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: 스프링 시큐리티 설정 활성화
authorizeRequests(): URL 별 권한 관리 설정(시작)
antMatchers(): 권한 관리 대상 지정 옵션
anyRequest(): 설정된 값 이외 나머지 URL
userInfoEndpoint(): OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정(시작)
userService(): 로그인 성공 시 수행 할 UserService 구현체 지정
[/config/auth/CustomOAuth2UserService.java]
import com.wrallee.book.springboot.config.auth.dto.OAuthAttributes;
import com.wrallee.book.springboot.config.auth.dto.SessionUser;
import com.wrallee.book.springboot.domain.user.User;
import com.wrallee.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 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);
}
}
OAuthAttributes, SessionUser → Dto 클래스
[/config/auth/dto/OAuthAttributes.java]
import com.wrallee.book.springboot.domain.user.Role;
import com.wrallee.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;
}
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 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();
}
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(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
E. 메소드 인자로 세션값 받기
세션 값을 불러오는 기능을 어노테이션 기반으로 만들어 쉽게 활용할 수 있다.
[/config/auth/LoginUser.java]
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 {
}
@Target: 어노테이션이 생성될 수 있는 위치 지정. PARAMETER로 지정하면 메소드 파라미터로 받을 수 있다.
@interface: 어노테이션 클래스임을 정의
[/config/auth/LoginUserArgumentResolver.java]
import com.wrallee.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;
@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");
}
}
supportsParameter(): 특정 파라미터를 지원하는지 판단하는 메서드. Annotation과 클래스 타입을 체크하도록 구현했다.
resolveArgument(): 파라미터에 객체를 전달.
[/config/WebConfig.java]
import com.wrallee.book.springboot.config.auth.LoginUserArgumentResolver;
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);
}
}
LoginUserArgumentResolver를 등록해준다.
아래와 같이 @LoginUser 형태로 사용하면 된다.
...
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if (user != null) {
model.addAttribute("sessionUserName", user.getName());
}
return "index";
}
...
F. 세션 저장소로 데이터베이스 사용
[build.gradle]
dependencies {
...
compile('org.springframework.session:spring-session-jdbc')
}
[application.properties]
...
spring.session.store-type=jdbc
이후 h2-console에 접속하여 SPRING_SESSION, SPRING_SESSION_ATTRIBUTE 테이블을 확인한다.
4. 스프링 시큐리티 테스트
스프링 시큐리티 테스트를 위해서는 properties 설정과 테스트 라이브러리가 필요하다.
A. 가짜 설정값 생성
테스트 환경에서는 application.properties만 읽기 때문에 스프링 시큐리티 인증정보를 불러올 수 없다. 따라서 No qualifying bean of type 'xxx.config.auth.CustomOAuth2UserService' 라는 메시지가 표시된다.
src/test/resources에 아래 가짜 설정값을 생성해준다.
spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
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
B. MockMvc 세팅
테스트 시 사용자를 지정하지 않았기 때문에 302(리다이렉션) 응답이 온다. 임의로 인증된 사용자를 추가한다.
[build.gradle]
dependencies {
...
testCompile('org.springframework.security:spring-security-test')
}
[PostsApiControllerTest.java]
...
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
...
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.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
...
@Before
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
...
@Test
@WithMockUser(roles = "USER")
public void postsFindById() throws Exception {
// given
Posts posts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long generatedId = posts.getId();
String url = "http://localhost:" + port + "/api/v1/posts/" + generatedId;
// when
MvcResult result = mvc.perform(get(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(generatedId)))
.andExpect(status().isOk())
.andReturn();
// then
ObjectMapper mapper = new ObjectMapper();
PostsResponseDto dto = mapper.readValue(result.getResponse().getContentAsString(), PostsResponseDto.class);
assertThat(dto.getId()).isEqualTo(generatedId);
}
...
}
@WithMockUser(roles = "USER"): ROLE_USER 권한을 가진 인증된 모의 사용자를 만들어서 사용한다.
setup(): 테스트 시작 전 MockMvc 인스턴스 생성.
mvc.perform(): 생성 된 MockMvc를 통해 API를 테스트.
mapper.readValue(...): 응답 값을 PostsResponseDto 객체로 변환
'Backend' 카테고리의 다른 글
Springboot (7) 배포 자동화 - 2 (0) | 2020.05.28 |
---|---|
Springboot (6) 배포 자동화 - 1 (0) | 2020.05.27 |
iBatis 쿼리 가져오기 (0) | 2020.05.07 |
Springboot (4) 웹 애플리케이션 - 2 (0) | 2020.04.29 |
Springboot (3) 웹 애플리케이션 - 1 (0) | 2020.04.29 |