코딩하는 문과생
[Spring Boot] Spring Security 본문
※ 해당 글은 튜토리얼이 아닌 헷갈리거나 중요한 개념 위주로 정리한 글입니다.
[스프링 시큐리티 흐름]
: 스프링 시큐리티는 단순히 코드 작성이 아니라 흐름이 정말 중요한 것 같다. 흐름을 짚고 넘어가자.
[Google Login]
구글 로그인을 위해서는 구글 클라우드 플랫폼에서 동의화면과 클라이언트ID를 생성해야 한다.
- OAuth동의화면: 구글로그인 시 사용자에게 필요한 정보에 대해 동의를 요청하는 화면이다.
- 클라이언트 ID: 구글 로그인, 동의화면 관리 및 어플 개발시 어플에서 호출할 클라이언트 ID와 secret key를 생성한다. 이를 코드에 삽입해 사용하면 어플에서 구글로그인 호출이 가능하다.
* 승인된 리다이렉트 uri: 어플에서 파라미터로 인증 정보를 주었을 때, 인증에 성공하면 구글이 다이렉트할 url이다.
스프링 부트2버전은 기본적으로 도메인/login/oauth2/code/소셜서비스코드 로 리다이렉트를 지원한다.
- application-oauth.properties
- 클라이언트 ID와 secret key는 application-oauth.properties에 작성 후 gitignore에 등록한다. 민감한 정보이기에 깃헙에는 따로 push하지 않는다.
- 스프링 부트는 properties의 이름을 application-oauth.properties로 설정하고 이를 profile=oauth로 호출할 수 있다. 따라서 application.properties에 아래와 같은 코드를 추가하면, 스프링 부트가 이를 application-oauth.properties로 인식해 구글연동 시 필요한 민감정보를 불러올 수 있다.
# 스프링 시큐리티 : application-oauth.properties를 포함시키겠다.
spring.profiles.include=oauth
- 권한코드(ROLE)
: 권한코드에 항상 ROLE_가 있어야만 한다. 이렇게 해야 권한 호출이 가능하다.
package com.sijune.project.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;
}
-NPE처리를 위한 Optional<T>
: Option<T>를 이용해 NullPointException시 에러를 발생시키지 않을 수 있다.
public interface UserRepository extends JpaRepository<User, Long> { //Entity Id
Optional<User> findByEmail(String email); //이메일을 통해 이미가입된 사용자인지 판단
//NPE를 안전하게 받을 수 있다.
}
[파일 구성]
: 스프링 시큐리티 파일 구성은 아래와 같고, 중요한 파일 몇가지만 살펴보겠다.
- SecurityConfig
: 컨트롤러 역할을 한다. 권한부여, 로그아웃, 로그인 처리를 담당한다.
@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);
//userInfoEndpoint: 로그인 성공시 사용자 정보를 가져올 때 설정 담당
//userService: 이후 후속초치 즉, 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시(ex. 가입, 정보수정, 세션 저장 등등)
}
}
-CustomOAuth2UserService
: 로그인 이후 사후처리를 담당한다. 가령 세션 저장, 가입 등이 있다.
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final HttpSession httpSession;
@Override //userRequest에 따른 User반환
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest); //userRequest에 따른 OAuthUser를 가져온다.
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //서비스 구분코드
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // PK와 동일
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
//OAuth2User의 attribute를 담을 클래스
//attribute내에 name, email, picture정보가 있다.
User user = saveOrUpdate(attributes); //받아온 유저 정보 저장
httpSession.setAttribute("user", new SessionUser(user)); //세션 저장
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())), attributes.getAttributes(), attributes.getNameAttributeKey());
//반환 값으로 ROLE, 유저의 Attributes, PK반환
}
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
: Dto이다. id에 따라(구글 또는 네이버) 받아온 값을 OAuthAttributes객체에 저장해 리턴한다. 왜냐하면 소셜로그인마다 리턴되는 형식과 값이 다르기 때문에 이렇게 설정해주어야 한다.
@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){ //서비스 구분, PK, 유저 정보
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();
}
}
[어노테이션 기반으로 개선]
: 중복된 코드를 개선하기 위해 사용한다.
- 어노테이션 생성
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser { //@LoginUser 생성
}
- HandlerMethodArgumentResolver
: 조건에 맞는 경우 구현체가 지정한 값으로 값을 넘길 수 있다.
@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");
}
}
- WenConfig
: WebMvcConfigurer를 상속받아 설정을 추가한다.
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(loginUserArgumentResolver);
}
}
-IndexController
: 파라미터에 어노테이션과 클래스를 체크한 후, 파라미터 객체를 생성한다.
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc()); //Model: 객체 저장 가능, service에 받은 값은 화면에 전달
//SessionUser user = (SessionUser) httpSession.getAttribute("user"); //로그인 성공 시 세션에 user를 저장해놓음
if(user != null){
model.addAttribute("userName", user.getName());
}
return "index"; //index.mustache 로 변환되어 view Resolver(이름과 객체를 맵핑한다)가 처리한다.
}
[세션 관리]
세션을 관리하는 방법은 여러가지가 있다. 톰캣과 같은 WAS를 이용하거나, 엘라스틱 캐시 등이 있지만, 소규모 서비스에서는 db로 관리하는 것이 가장 저렴하면서 운용이 쉽다.
-의존성 추가
compile('org.springframework.session:spring-session-jdbc')
- application.properties에 추가
# 세션 저장
spring.session.store-type=jdbc
이후 내장WAS 실행 시 SPRING_SESSION 테이블이 생성되며 세션 관리가 가능하다.
[네이버 로그인]
: 네이버는 구글 로그인 등록과 일부 프로세스가 상이하다.
1. application-oauth.properties에 registration이외 provider도 작성해야한다.
특히 아래와 같은 코드를 추가해야 로그인 url도 자동으로 등록된다.
2. 반환되는 값이 response내 name, email과 같은 값을 담아오므로, 이에 관해 꺼내는 과정도 필요하다.(OAuthAttributes)
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
# 리다이렉트 uri 등록시 로그인 url도 자동으로 등록된다
[주의사항]
- test환경 내 properties파일
test 폴더에는 자동으로 삽입되는 properties는 application.properties가 끝이다. application-oauth.properties는 자동으로 삽입되지 않으므로, 이에 대한 설정이 필요하다.
* 이 때, 가짜 설정값(application.properties)내 spring.profiles.include=oauth이 있으면 안된다. 해당 설정은 운영버전에서만 설정이 가능한 부분이다.
-SpringBootTest환경과 MockMvc환경
임의로 사용자를 추가해야한다. 이 때 @WithMockUser(roles="USER")를 사용해야 하지만 Test 컨트롤러는 SpringBootTest기반으로 작성되어 있으므로 해당 어노테이션을 사용하기 위해서는 MockMvc 이라는 추가설정이 필요하다.
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) //JPA까지 테스트할 수 있도록 해준다. WebMvcTest는 단지 컨트롤러 테스트할 때 사용
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate; //webEnvironment설정시 이용 가능하다. Rest Api를 호출 후 응답을 기다리는 동기 방식
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@Autowired
private PostsRepository postsRepository;
@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_Insert() 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))) //JSON변환
.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_Update() throws Exception {
//given
//1. 데이터 저장
Posts savedPosts = postsRepository.save(Posts.builder().title("title").content("content").author("author").build());
//2. id값 얻고,
Long updateId = savedPosts.getId();
//3. 업데이트할 데이터 생성
String expectedTitle = "title2";
String expectedContent = "content2";
//4. 업데이트 객체 생성
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder().title(expectedTitle).content(expectedContent).build();
//5. 테스트할 url 생성
String url = "http://localhost:"+port+"/api/v1/posts/" + updateId;
//6. 업데이트할 객체를 http객체안에 넣는다.
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
//when
//7. 테스트 시작, url에 post방식으로 http객체를 던지고, 반환값으로 long을 얻는다.
mvc.perform(put(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
-@WebMvcTest환경과 Service
: @WebMvcTest는 컨트롤러 환경에 해당되는 설정을 읽는다. 따라서 @Service나 @Component는 스캔 대상이 아니다. 따라서 스캔대상에서 SecurityConfig(SecurityConfig 내 service가 있으므로)를 스캔대상에서 제거한다. excludeFilter를 이용해 스캔대상에서 SpringSecurity를 제거한다.
@RunWith(SpringRunner.class) //Junit 프레임워크의 SpringRunner라는 클래스를 실행시켜라
@WebMvcTest(controllers = HelloController.class, excludeFilters = {@ComponentScan.Filter(type= FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)})
public class HelloControllerTest {...}
- @EnableJpaAuditing
: 이를 사용하기 위해서 @Entity클래스가 필요하지만 @WebMvcTest는 컨트롤러 측만 테스트하다보니 해당 클래스가 존재하지 않는다. 따라서, @SpringBootApplication과 분리가 필요하다.
- Application 파일 내 @EnableJpaAuditing를 제거하고,
- config폴더 내 JpaConfig파일을 생성해 @EnableJpaAuditing을 추가해준다.
@Configuration
@EnableJpaAuditing //Jpa Auditing 활성화
public class JpaConfig {
}
※ 해당 글은 "스프링 부트와 AWS로 혼자 구현하는 웹 서비스(이동욱 저)"를 참고해 작성하였습니다.
'웹 프로그래밍 > Spring Boot' 카테고리의 다른 글
[Spring boot] 프로젝트 AWS에 올리기(step1) (0) | 2020.12.10 |
---|---|
[Spring Boot] AWS 아키텍처(+ HotSpot으로 연결 시 여러 이슈들) (0) | 2020.12.06 |
[Spring Boot] Mustache, 화면 구성 (0) | 2020.12.06 |
[Spring Boot] JPA, 데이터베이스 연동 (0) | 2020.11.30 |
[Spring Boot] TDD & 단위테스트 (0) | 2020.11.30 |