프로젝트에서 같은 코드가 반복되면 유지보수성이 떨어지며, 혹시나 수정이 반영되지 않은 반복 코드가 있다면 문제가 발생한다.
SessionUser user = (SessionUser) httpSession.getAttribute("user");
지금은 index 메소드에서만 사용되지만, 다른 컨트롤러와 메소드에서 세션값이 필요하면 그때마다 직접 세션에서 값을 가져와야 한다.
이 부분을 메소드 인자로 세션값을 바로 받을 수 있도록 변경해보자 !
먼저 config.auth 패키지에 @LoginUser 어노테이션을 생성한다.
package com.example.demo.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를 생성한다.
LoginUserArgumentResolver는 HandlerMethodArgumentResolver 인터페이스를 구현한 클래스이다.
HandlerMethodArgumentResolver는 조건에 맞는 경우 메소드가 있다면 구현체가 지정한 값으로 해당 메소드의 파라미터를 넘길 수 있다.
자세한 사용법은 만들면서 배워보자 ㅎ.ㅎ
package com.example.demo.config.auth;
import com.example.demo.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");
}
}
여기까지가 @LoginUser를 사용하기 위한 환경 구성이다.
이제 생성된 LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가하자.
config 패키지에 WebConfig 클래스를 생성하여 다음과 같이 설정을 추가한다.
package com.example.demo.config;
import com.example.demo.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);
}
}
HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()를 통해 추가하여야 한다.
다른 HandlerMethodArgumentResolver가 필요하다면 같은 방식으로 추가해주면 된다.
최종적인 패키지 구조는 아래와 같다.
이제 모든 설정이 완료되었으니 IndexController의 코드에서 반복되는 부분을 @LoginUser로 개선해보자!
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if (user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
기존에 (User) httpSessoin.getAttribute("user")
로 가져오던 세션 정보 값을 파라미터로 가져올 수 있다.
이제 어느 컨트롤러든 @LoginUser만 사용하면 세션 정보를 가져올 수 있게 되었다 !
우리가 만든 서비스는 애플리케이션을 재실행하면 로그인이 풀린다 :(
이는 세션이 내장 톰캣의 메모리에 저장되기 때문이다.
내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화가 된다.
또 만약 2대 이상의 서버에서 서비스를 하고 있다면 톰캣마다 세션 동기화 설정을 해야 한다.
실제 현업에서는 다음 3가지 중 한 가지를 세션 저장소로 이용한다
여기서는 2번째 방식인 데이터베이스를 세션 저장소로 사용하여 진행해본다.
레디스와 같은 서비스는 엘라스틱 캐시를 사용해야 하기 때문에 추후 AWS를 이용한 배포를 할 때 별도로 사용료를 지불해야 한다.
먼저 build.gradle에 의존성을 추가한다.
implementation 'org.springframework.session:spring-session-jdbc'
그리고 application.properties에 세션 저장소를 jdbc로 선택하도록 코드를 추가한다.
spring.session.store-type=jdbc
h2-console을 보면 세션을 위한 테이블 2개(SPRING_SESSION, SPRING_SESSION_ATTRIBUTES)가 생성된 것을 볼 수 있다.
로그인을 하면 다음과 같이 세션이 등록되는 것을 확인할 수 있다.
지금은 H2를 사용하고 있기 때문에 스프링을 재시작하면 세션이 풀린다. (H2도 재시작되기 때문)
하지만 나중에 RDS를 사용하면 세션이 풀리지 않게 될 것이다!
https://developers.naver.com/apps/#/register?api=nvlogin
네이버 서비스 등록을 하면 구글 로그인 때처럼 ClientIDdhk Client Secret을 발급 받는다.
해당 키 값들을 application-oauth.properties에 추가해주자.
네이버는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력하여야 한다.
# registration
spring.security.oauth2.client.registration.naver.client-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"
}
}
스프링 시큐리티에선 하위 필드를 명시할 수 없다.
최상위 필드들만 user_name으로 지정 가능하다.
하지만 네이버의 응답값 최상위 필드는 resultcode, message, response이다.
그러므로 response를 user_name으로 지정하고 추후 response의 id를 user_name으로 지정한다.
구글 로그인을 등록하면서 대부분의 코드가 확장성 있게 작성되었다보니 네이버는 쉽게 등록이 가능하다.
먼저 OAuthAttributes에 네이버인지 판단하는 코드와 네이버 생성자를 추가해준다.
package com.example.demo.config.auth.dto;
import com.example.demo.domain.user.Role;
import com.example.demo.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();
}
}
그 다음 index.mustache에서 구글 로그인 버튼 아래 네이버 로그인 버튼을 추가한다.
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
짜잔 ^ㅅ^
굿이애옹 ~
기존에는 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성하였다.
하지만, 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있다.
기존 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정하자.
전체 테스트를 수행해보자.
책에서는 Tasks > verification > test를 눌러 수행하라 했지만, 그렇게 하면 DemoApplicationTests만 실행되어
좌측 디렉토리에서 test를 우클릭하여 'Run All Test'를 눌러주었다.
테스트 실패 원인을 하나씩 살펴보며, 수정해보자!
No qualifying bean of type 'com.example.demo.config.auth.CustomOAuth2UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
CustomOAuth2UserService를 생성하는데 필요한 소셜 로그인 관련 설정값들이 없어 발생한 오류이다.
test에 application.properties가 없으면 main의 설정을 그대로 가져오지만, application-oauth.properties는 test에 없다고 가져오는 파일이 아니다.
그러므로 문제를 해결하기 위해 테스트 환경을 위한 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
책에는 spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
를 사용하도록 나와있는데, 버전 차이 때문에 아래 코드를 대신 사용하였다. (이래야지 오류 안 남)
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
참고: https://github.com/jojoldu/freelec-springboot2-webservice/issues/67
이는 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문이다.
이러한 API 요청은 임의로 인증된 사용차를 추가하여 API만 테스트해볼 수 있도록 한다.
먼저, build.gradle에 spring-security-test를 추가한다.
testImplementation('org.springframework.security:spring-security-test')
그리고 PostsControllerTest의 2개 테스트 메소드에 임의 사용자 인증을 추가한다.
@Test
@WithMockUser(roles = "USER")
public void Posts_등록() throws Exception {
// 내부 코드는 생략
}
@Test
@WithMockUser(roles = "USER")
public void Posts_수정() throws Exception {
// 내부 코드는 생략
}
하지만 이것만 해서 테스트가 작동하지는 않는다 ㅠ,ㅠ
@WithMockUser가 MockMvc에서만 작동하기 때문이다.
현재 테스트코드는 @SpringBootTest로만 되어 있는데, @SpringBootTest에서 MockMvc를 사용하는 방법을 알아보자.
PostsControllerTest.java를 아래와 같이 수정한다.
package com.example.demo.web;
import com.example.demo.domain.posts.Posts;
import com.example.demo.domain.posts.PostsRepository;
import com.example.demo.web.dto.PostsSaveReqDto;
import com.example.demo.web.dto.PostsUpdateReqDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
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.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MockMvcBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@Before
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@After
public void tearDown() throws Exception {
postsRepository.deleteAll();
}
@Test
@WithMockUser(roles = "USER")
public void Posts_등록() throws Exception {
// given
String title = "title";
String content = "content";
PostsSaveReqDto reqDto = PostsSaveReqDto.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)
.content(new ObjectMapper().writeValueAsString(reqDto)))
.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 Posts_수정() 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";
PostsUpdateReqDto reqDto = PostsUpdateReqDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
HttpEntity<PostsUpdateReqDto> requestEntity = new HttpEntity<>(reqDto);
// when
mvc.perform(put(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(reqDto)))
.andExpect(status().isOk());
// then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
이로써 Post 관련 테스트 오류들을 해결할 수 있었다 :)
마지막 남은 테스트들을 해결해보자
No qualifying bean of type 'com.example.demo.config.auth.CustomOAuth2UserService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
가장 처음 다룬 테스트 오류와 동일한 에러 메시지이다.
HelloControllerTest는 PostsControllerTest와 다르게 @SpringBootTest가 아닌 @WebMvcTest를 사용한다.
스프링 시큐리티 설정은 해결했지만, @WebMvcTest가 CustomOAuth2UserService를 스캔하지 못해 문제가 발생한 것이다.
@WebMvcTest는 @ControllerAdvice, @Controller를 읽지만,
@Repository, @Service, @Component는 스캔 대상이 아니다.
즉, SecurityConfig는 읽었지만, SecurityConfig를 생성하기 위해 필요한 CustomOAuth2UserService를 읽을 수 없어 에러가 발생한 것이다.
이를 해결하기 위해 스캔 대상에서 SecurityConfig를 제거하자.
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE,
classes = SecurityConfig.class)
}
)
더불어, @WithMockUser를 사용하여 가짜로 인증된 사용자를 생성한다.
@WithMockUser(roles = "USER")를 각 테스트 코드 위에 추가한다
JPA metamodel must not be empty!
마지막으로 이 오류를 해결하고 마무리하자!
이 에러는 @EnableJpaAuditing으로 인해 발생한 오류이다.
@EnableJpaAuditing을 사용하기 위해서는 최소 하나의 @Entity가 필요하지만,@WebMvcTest에는 없다 !!
@EnableJpaAuditing이 @SpringBootApplication과 함께 있어 @WebMvcTest에서도 스캔하게 된 것이다.
그러므로 @EnableJpaAuditing과 @SpringBootApplication을 분리하자 !
DemoApplication에서 @EnableJpaAuditing을 지우고,
config 패키지에 JpaConfig를 생성하여 @EnableJpaAuditing를 추가한다.
package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
모든 테스트 성공 ~!