CheerUp_Cheers
스프링 부트 - (5) 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 본문
스프링 부트 - (5) 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기
meorimori 2020. 3. 24. 22:52#스프링 시큐리티
- 막강한 인증
- 인가
5.1 스프링 시큐리티와 스프링 시큐리티 Oauth2 클라이언트
#OAuth 기능 목록
- 로그인 시 보안
- 비밀번호 찾기
- 비밀번호 변경
- 회원정보 변경
- 회원가입 시 이메일 혹은 전화번호 인증.
#스프링부트 2.0방식
다음 라이브리를 사용함으로써 가능.
spring-security-oauth2-autoconfigure
- 1.5에서 쓰던 방식을 그대로 사용 가능.
- url 주소 모두 명시 -> client 인증 정보만 입력 하면 가능.
- 직접 입력 -> enum으로 변경.
5.2 구글 서비스 등록
[1] https://console.cloud.google.com/ 접속
[2] 프로젝트 선택 후, 프로젝트 생성.
[3] API 및 서비스 > 사용자인증 정보 > 사용자 인증 만들기 > OAuth 클라이언트 ID
[4] 동의 화면 구성 > 외부, OAuth 클라이언트 ID 만들기
[5] application-oauth.properties 생성
위치 : src/main/resources/
기본값은 openid, profile, email이기 때문에, scope를 profile, email로 등록.
-> 하나의 OAuth2Service로 사용하기 위함( openid서비스를 제공하거나 제공하지 않는 사이트가 존재)
spring.security.oauth2.client.registration.google.client-id= 638451930553-7gbbg51ghkq4rcomvjfg6lp7ubslnii7.apps.googleusercontent.com
spring.security.oauth2.client.registration.google.client-secret=7tAcP4JzTJLxgtUDB20tPRbc
spring.security.oauth2.client.registration.google.scope=profile,email
[6] application.properties에 내용 추가.
spring.profiles.include=oauth
[7] .gitignore에 등록
중요한 정보들을 가리기 위함.
application-oauth.properties
5.3 구글 로그인 연동
[1] User 클래스 생성
위치 : domain/user/User
- @NoArgsConstructor : 파라미터 없는 기본 생성자 생성.
- @Entity : 객체와 테이블 매핑.
- @Builder : Builder를 자동으로 생성.
- @Enumerated(EnumType.STRING)
JPA로 데이터를 저장할 때, 어떤 형태로 저장할지 선택.
기본적으로 int로 된 숫자 저장.
소스보기
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)
@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();
}
}
[2] Role 이넘 생성.
위치 : domain/user/Role
각 사용자의 권한을 관리하는 Enum클래스.
게스트? 유저?
스프링 시큐리티에서는 권한코드에 항상 ROLE_이 있어야함.
소스보기
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;
}
[3] UserRepository 생성
위치 : domain/user/UserRepository.
- findByEmail() : 소셜 로그인으로 반환되는 값 중, email을 통해 이미 생성된 사용자인지 판별.
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> {
Optional<User> findByEmail(String email);
}
[4] 스프링 시큐리티 설정
위치 : build.gradle
스프링 시큐리티 관련 의존성 추가.
- spring-boot-starter-oauth2-client
클라이언트 입장에서 소셜 기능 구현시 필요한 의존성.
compile('org.springframework.boot:spring-boot-starter-oauth2-client')
[5] config.auth 패키지 생성
[6] CustomOAuth2UserService 클래스 생성 > SecurityConfig 클래스 생성
[7] OAuthAttrivutes 클래스 생성.
위치 : config/auth/dto/OAuthAttrivutes
- of() : OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에, 하나하나 반환.
- toEntity()
User 엔티티 생성.
처음 가입할 때, 기본권환을 GUEST로 줌.
소스보기
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;
}
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();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.build();
}
}
[8] SessionUser 클래스 생성
위치 : config/auth/dto/SessionUser
인증된 사용자 정보만 필요.
왜?) User를 그대로 직렬화안했어요?
User 클래스는 엔티티이기 떄문에, 어떤 객체와 어떤 관계를 가질지 모름.
@OneToMany, @ManyToMany...
자식들까지 직렬화에 포함되니 성능이슈, 부수효과를 줄이기위함.
소스보기
package com.jojoldu.book.springboot.config.auth.dto;
import com.jojoldu.book.springboot.domain.user.User;
import lombok.Getter;
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();
}
}
[9] index.mustache 수정
- {{#userName}}
머스테치는 true/false만 판단 -> 항상 최종값만 원함
user가 있따면 userName을 노출 하도록 구성.
여기서는 로그인이 되어서 아이디 노출 창!
- {{^userName}}
userName이 없다면, 자동으로 보여줌.
여기서는 로그인!
- a href="/logout"
스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL -> 따로 컨트롤러 제작 불필요.
SecurityConfig 클래스에서 URL을 변경할 순 있음.
소스보기
<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>
<br>
[10] indexContorller 수정
index.mustache에서 userName을 사용 할수 있도록 변경.
- (SessiongUser) httpSession.getAttribute("user")
CustomOAuth2UserService에서 로그인 성공시 세션에 SessionUser을 저장하도록 구성.
즉, 로그인 성공시 값을 가져올 수 있음.
- if(user != null)
세션에 저장된 값이 있느냐 없느냐..
소스보기
@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");
if(user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
}
[ 결과 확인 ]
5.4 어노테이션 기반으로 개선하기
목표 : 같은 코드가 반복되는 것을 피하기 위함.
-> 메소드 인자로 세션값을 바로 받을수 있게 하자
예 : User user = (User) httpSession.getAtrribute("user");
[1] LoginUser 인터페이스 (어노테이션) 생성
위치 : cofing/auth/LoginUser
- @Targer
이 어노테이션이 올수있는 위치를 지정.
파라미터로 정의 했으니, 여기서는 메소드의 파라미터로 선언된 객체에서만.
- @interface
이파일을 어노테이션 클래스로 지정(생성)
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 {
}
[2] LoginUserArgumentResolver
목표 : LoginUserArgumentResolver가 지정한 구현체의 값으로 해당 메소드 파라미터 보낼수 있음.
위치 : config/auth/LoginUserArgumentResolver
- supportsParameter()
파라미터에 @LoginUser 어노테이션이 붙어있고 (&&)
파라미터 클래스 타입이 SessionUser일 경우 true반환.
- resolveArgument()
파라터에 전달한 객체를 생성(여기선 세션에서 객체를 가져옴)
소스보기
@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");
}
}
[3]WebConfig 생성
목표 : 스프링에서 인식할 수 있도록 WebMvcConfigure 추가.
HandlerMethodArguemntResolver는 항상 WebMvcConfigure의 addArguementResovlers()를 통해 추가.
소스보기
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginUserArgumentResolver loginUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(loginUserArgumentResolver);
}
}
[4] IndexController 개선
@LoginUser 어노테이션을 이용하여 개선.
소스보기
@RequiredArgsConstructor
@Controller
public class IndexController {
private final PostsService postsService;
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if(user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
}
5.5 세션 저장소로 데이터베이스 사용하기
현재 : 로그인 정보를 내장 톰캣의 메모리에 저장, 배포할 때마다 톰캣이 재시작(초기화)
개선 : MySQL와 같은 데이터베이스 사용
[1] build.gradle 의존성 추가
compile('org.springframework.session:spring-session-jdbc')
[2] application.properties 설정 추가
세션 저장소를 jdbc로 선택하도록 추가.
spring.session.store-type=jdbc
JPA로 세션테이블이 자동으로 생성 됨.
5.6 네이버 로그인
[1] https://developers.naver.com/apps/#/register?api=nvlogin 접속
[2] 서식 등록
[3] 발급받은 ID와 PASS application-oauth.properties에 등록.
일일이 수동으로 입력 -> 네이버는 Spring Security를 지원 안함.
스프링 시큐리트에서는 하위필드를 명시 불가.
->response의 하위 필드에 이름, 이메일 등등 존재.
- user_name_attribute=response
기준이 되는 user_name의 이름을 네이버에서는 reponse로해야함.
회원 조회시 반환되는 값이 JSON이기 떄문,
소스보기
# registration
spring.security.oauth2.client.registration.naver.client-id=WIxfOSY3tBgKf8tiIl6Q
spring.security.oauth2.client.registration.naver.client-secret=RBWPulSdqj
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
[4] OAuthAttributes 추가
위치 : config/auth/dto/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) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.picture((String) attributes.get("profileImage"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
[5] index.mustache에 네이버 버튼 추가.
- /oauth2/authorization/naver
/naver 전까지는 고정, 마지막만 소셜 로그인 코드를 사용.
네이버 로그인 URL은 application-oauth.properties에 등록한 redirect-uri_template에 맞춰 등록.
{{^userName}}
<a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
{{/userName}}
5.7 기존 테스트에 시큐리티 적용하기
Gradle > Tasks > Verification > Test
테스트하면, 다음과 같이 실패한 것을 볼수 있음.
# hello_will_be_return
No qualifying bean of type 'com.jojoldu.book.springboot.config.auth.CustomOAuth2UserService' 라는 오류.
이는, 소셜 로그인 관련 설정값들이 없기 때문에 발생.
!)application-oauth.properties에 설정했는데 왜 없는가??
- src/main환경과 src/test환경이 다름.
- 테스트 환경을 위한 가짜 설정값을 통해 실험 -> application.properties를 새로 만듬.
위치 : src/test/resoures/application.properties
# Post_등록된다
스프링 시큐리티 설정으로 인증되지 않은 사용자의 요청은 이동.
-> 임의로 인증된 사용자를 추가 -> API만 테스트.
[1] 테스트를 위한 spring-security-test를 build.gradle에 추가.
testCompile("org.springframework.security:spring-security-test")
[2] PostApiControllerTest의 2개 메소드 추가.
위치 : src/test/web
- @WithMockUser(roles = "USER")
인증된 모의(가짜) 사용자를 만들어 사용.
이 어노테이션으로 권한을 가진 사용자가 API를 이용하는것과 같아짐.
- @Before
매번 테스트가 시작되기전에, MockMvc 인스턴트 생성.
- @mvc.perform
생성된 MockMvc를 통해 api를 테스트.
본문 영역은 문자열로 표현하기위해 ObjectMapper를 통해 문자열 JSON으로 변환.
소스보기
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.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.MediaType;
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.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;
// For mockMvc
@RunWith(SpringRunner.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;
@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";
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 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";
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를 찾을 수 없음.
첫번쨰와는 조금 다름 -> WebMvcTest를 사용 하기 때문.
@WebMvcTest는 CustomOAUth2UserService를 스캔하지않음.
@Service, @Component는 스캔 대상이 아님.
SecurityConfig를 생성하기 위한 CustomOAuth2UserService를 읽을수 없기 떄문에, 발생.
[1] 스캔대상에서 SecurityConfig를 제거.
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
}
)
[2] @WithMockUser를 이용한 가짜 인증된 사용자 생성.
기존의 코드에 @WithMockUser 어노테이션을 선택.
소스보기
@WithMockUser(roles="USER")
@Test
public void hello가_리턴된다() throws Exception {
String hello = "hello";
mvc.perform(get("/hello"))
.andExpect(status().isOk())
.andExpect(content().string(hello));
}
@WithMockUser(roles="USER")
@Test
public void helloDto가_리턴된다() throws Exception {
String name = "hello";
int amount = 1000;
mvc.perform(
get("/hello/dto")
.param("name", name)
.param("amount", String.valueOf(amount)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is(name)))
.andExpect(jsonPath("$.amount", is(amount)));
}
}
[2-1] 추가 에러 발생
java.lang.IllegalArgumentException : At least One JPA
metamodeal must be present.
1)@EnableJpaAuditing으로 인해 발생(= 최소 하나의 @Entity클래스 필요)
-> @WebMvcTest니까 당연히 없음.
2)@EnableJpaAuditing과 @SpringBootApplication이 함께 있어서 @WebMvcTest도 스캔.
-> 둘을 분리.
3) Application의 @EnalbeJpaAuditing을 지우고, config/auth/JpaConfig을 추가.
package com.jojoldu.book.springboot.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
[ 결과 확인 ]
테스트하는게 더 힘들다..
'서적 공부 > 스프링부트 - [스프링부트와 AWS로 혼자 구현하는 웹서비스]' 카테고리의 다른 글
스프링 부트 - (7) AWS RDS (0) | 2020.04.08 |
---|---|
스프링 부트 - (6) AWS 서버 환경을 만들어 보자 (0) | 2020.04.07 |
스프링 부트 - (4) 머스테치로 화면 구성하기 (0) | 2020.03.23 |
스프링 부트 - (3) 스프링부트에서 JPA로 데이터베이스 다뤄보자 (0) | 2020.03.13 |
스프링 부트 - (2) 스프링 부투에서 테스트 코드를 작성하자. (0) | 2020.03.11 |