스프링 부트와 AWS로 혼자 구현하는 웹 서비스(5)
📌 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기
✔ 스프링 시큐리티 : 막강한 인증과 인가 기능을 가진 프레임워크
스프링 기반의 애플리케이션에서는 보안을 위한 표준
⚡ 구글 서비스 등록
✔ 구글 서비스에 신규 서비스 생성
- 여기서 발급된 인증 정보(client와 clientSecret)를 통해서 로그인 기능과 소셜 서비스 기능 사용
✔ 구글 클라우드 플랫폼 주소 : https://console.cloud.google.com
- 접속 후 프로젝트 선택 클릭
- 새 프로젝트 클릭
- 등록될 서비스의 이름 입력(원하는 이름) : ex) yeroni-springboot2-webservice
✔ 프로젝트 선택 후 API 및 서비스 클릭
- 사용자 인증 정보
- 사용자 인증 정보 만들기
- OAuth 클라이언트 ID 만들기
- 동의 화면 구성
- 애플리케이션 이름 : 구글 로그인 시 사용자에게 노출될 애플리케이션 이름을 이야기 함
- 지원 이메일 : 사용자 동의 화면에서 노출될 이메일 주소
- Google API 범위 : 이번에 등록할 구글 서비스에서 사용할 범위 목록
- 기본값은 email/profile/openid
✔ 사용자 인증 정보
로 들어감
- 사용자 인증 정보 만들기
- OAuth 클라이언트 ID 만들기
- 유형 : 웹 애플리케이션, 이름은 똑같이
- 승인된 리다이렉션 URI
- URL 추가 : http://localhost:8080/login/oauth2/code/google
승인된 리다이렉션 URI
- 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL
- 스프링부트 2버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL지원
- 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없음
- 시큐리티에서 이미 구현해놓은 상태
- 현재 개발 단계이므로 http://localhost:8080/login/oauth2/code/google로만 등록
- AWS 서버에 배포하게 되면 localhost 외에 추가로 주소를 추가해야 하며, 이건 이후 단계에서 진행
✔ 클라이언트 ID와 보안 비밀번호 설정
applilcation-oauth
등록
main/resources
아래에application-oauth.yml
생성
spring:
security:
oauth2:
client:
registration:
google:
client-secret: #secretKey
client-id: #client-id
scope: profile,email
🔶 scope: profile,email`
- 많은 예제에서는 scope을 등록하지 않음
- 기본값이 openid, profile, email이기 때문
- 강제로 profile, email을 등록한 이유는 openid라는 scope가 있으면 Open Id Provider라고 인식하기 때문
- Open Provider인 서비스(구글)과 그렇지 않은 서비스(카카오, 네이버 등)로 나눠서 OAuth2Service를 만들어야 함
- 하나의 OAuth2Service로 사용하기 위해 openid scope 빼고 등록
spring:
profiles:
include: oauth
✔ .gitignore
등록
- 구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들임
application-oauth.yml
이 깃허브에 올라가는 것을 방지해야 함!- .gitignore 파일에 코드 추가
application-oauth.yml
- 커밋 파일 목록에 뜨지 않아야 함
- .gitignore 파일에 코드 추가
⚡ 구글 로그인 연동하기
🔶 main/.../domain/user/User
package com.yerin.book.springbootwebservice.domain;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import static jakarta.persistence.GenerationType.IDENTITY;
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity{
@Id
@GeneratedValue(strategy = 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();
}
}
✔ @Enumerated(EnumType.STRING)
- JPA로 데이터베이스로 지정할 때 Enum 값을 어떤 형태로 저장할지 결정
- 기본적으로는 int로 된 숫자 지정
- 숫자로 지정되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수가 없음
EnumType.STRING
선언
🔶 main/.../user
아래에 Role
enum 생성
package com.yerin.book.springbootwebservice.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;
}
✔ 스프링 시큐리티에서는 권한 코드에 항상 ROLE_
이 앞에 있어야 함
🔶 main/.../user
아래에 UserRepository
class 생성
package com.yerin.book.springbootwebservice.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);
}
✔ findByEmail
- 소셜로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드
⚡ 스프링 시큐리티 설정
🔶 build.gradle
에 스프링 시큐리티 관련 의존성 하나 추가
implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
- 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
SecurityConfig.java
🔶 java/com.yerin.book.springbootwebservice/config/auth/SecurityConfig
@EnableWebSecurity
@EnableMethodSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf((csrfConfig) ->
csrfConfig.disable()
)
.headers((headerConfigurer) ->
headerConfigurer.frameOptions(frameOptionsConfig ->
frameOptionsConfig.disable()
)
)
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers(AntPathRequestMatcher.antMatcher("/"), AntPathRequestMatcher.antMatcher("/css/**"),
AntPathRequestMatcher.antMatcher("/images/**"),
AntPathRequestMatcher.antMatcher("/js/**"),
AntPathRequestMatcher.antMatcher("/h2-console/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/api/v1/**")).hasRole(Role.USER.name())
.anyRequest().authenticated()
)
.logout((logoutConfig) ->
logoutConfig.logoutSuccessUrl("/")
)
.oauth2Login(oauth2LoginConfigurer ->
oauth2LoginConfigurer
.userInfoEndpoint(userInfoEndpointConfigurer ->
userInfoEndpointConfigurer
.userService(customOAuth2UserService)
)
);
return http.build();
}
}
- 스프링 시큐리티 5.7.0-M2 부터 WebSecurityConfigurerAdapter는 deprecated 됨
- 컴포넌트 기반 설정으로 변경할것을 권항
✔ @EnableWebSecurity
- Spring Security 설정들을 활성화시켜줌
✔ csrf().disable()
-> .csrf((csrfConfig)-> csrfConfig.disable())
✔ headers().frameOption().disable()
->
.headers((headerConfig)->headerConfig.frameOptions(frameOptionsConfig -> frameOptionsConfig.disable()))
- 함수형으로 사용
- h2.console 화면을 사용하기 위해 해당 옵션들 disable
✔ authorizeRequests
- URL별 권한 관리를 설정하는 옵션의 시작점
- authorizeRequests가 선언되어야만 antMarchers옵션 사용 가능
✔ antMarchers
-> requestMarchers
사용
- 설정된 값들 이외 나머지 URL들을 나타냄
- 여기선 authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 함
- 인증된 사용자 즉, 로그인한 사용자들
✔ `logout().logoutSuccessUrl("/")
- 로그아웃 기능에 대한 여러 설정의 진입점
- 로그아웃 성공 시 "/"주소로 이동
✔ oauth2Login
- OAuth2 로그인 기능에 대한 여러 설정의 진입점
✔ userInfoEndpoint
- OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
✔ userService
- 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록함
- 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있음
CustomOAuth2UserService.java
🔶 java/.../config.auth
아래에CustomOAuth2UserService
이름의 클래스 생성
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
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();
OAuthAttributes attriubutes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
Member member = saveOrUpdate(attriubutes);
httpSession.setAttribute("user", new SessionUser(member));
return new DefaultOAuth2User(
Collections.singleton(new
SimpleGrantedAuthority(member.getRoleKey())),
attriubutes.getAttributes(),
attriubutes.getNameAttributeKey());
}
private Member saveOrUpdate(OAuthAttributes attributes){
Member member = memberRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return memberRepository.save(member);
}
}
✔ registrationId
- 현재 로그인 진행 중인 서비스를 구분하는 코드
- 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용
✔ userNameAttributeName
- OAuth2 로그인 진행 시 키가 되는 필드값
- Primary Key와 같은 의미
- 구글의 경우 기본적으로 코드를 지원하지만, 네이버 카카오 등은 기본 지원하지 않음
- 구글의 기본 코드는 "sub"임
- 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용됨
✔ OAurhAttributes
- OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담은 클래스
- 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용
✔ SessionUser
- 세션에 사용자 정보를 저장하기 위한 Dto 클래스
- 왜 User 클래스를 쓰지 않고 새로 만들어서 쓰는지 뒤이어서 상세하게 설명
✔ 구글 사용자 정보가 업데이트 되었을 때를 대비하여 update
기능도 같이 구현
-> 이름, 프로필 사진이 변경되면 User엔티티에도 반영
OAuthAttributes.java
🔶 java/.../config.auth/dto/OAuthAttributes
package com.yerin.book.springbootwebservice.config.auth.dto;
import com.yerin.book.springbootwebservice.domain.user.Role;
import com.yerin.book.springbootwebservice.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.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();
}
}
🔶 of()
- OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 함
🔶 toEntity()
- User엔티티 생성
- OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때임
- 가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST사용
- OAuthAttributes 클래스 생성이 끝났으면 같은 패키지에 SessionUser 클래스를 생성
SessionUser.java
🔶 java/.../config.auth/dto
아래에SessionUser
이름의 클래스 생성
package com.yerin.book.springbootwebservice.config.auth.dto;
import com.yerin.book.springbootwebservice.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 클래스를 사용하면 안되는가?
Failed to convert from type [java.lan.Object] to type [byte[]] for value 'com.prac.webservice.springboot.domain.user.User@4a43d6'
error 발생
- User 클래스를 세션에 저장하려고 하니, User 클래스에 직렬화를 구현하지 않았다라는 의미의 에러
- User 클래스가 엔티티이기 때문에 엔티티 클래스에는 언제 다른 엔티티와 관계가 형성될 지 모름
- ex). @OneToMany, @ManyToMany 등 자식 엔티티를 가지고 있다면 직렬화 대상에 자식들까지 포함되니 성능 이슈, 부수 효과가 발생할 확률이 높음
- 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 유지보수 때 많은 도움이 됨
⚡ 로그인 테스트
✔ 화면에 로그인 버튼 추가
index.mustache
🔶 java/.../resources/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>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
{{/userName}}
</div>
</div>
<br>
...
🔶 {{#userName}}
- 머스테치는 다른 언어와 같은 if문(if userName != null 등)을 제공하지 않음
- true/false 여부만 판단할 뿐임
- 그래서 머스테치에서는 항상 최종값을 넘겨줘야 함
- 여기서도 역시 userName이 있다면 userName을 노출시키도록 구성함
🔶 a href="/logout"
- 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL
- 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없음
- SecurityConfig 클래스에서 URL을 변경할 순 있지만 기본 URL을 사용해도 충분하니 여기서는 그대로 사용함
🔶 `{{^userName}}
- 머스테치에서 해당 값이 존재하지 않는 경우에는 ^ 사용
- 여기서는 userName이 없다면 로그인 버튼을 노출시키도록 구성
🔶 a href="/oauth2/authorization/google"
- 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL
- 로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없음
IndexController.java
🔶 java/.../web/IndexController.java
코드 수정
@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";
}
...
🔶 (SessionUser) httpSession.getAttribute("user")
- 앞서 작성된
CustomOAuth2UserService
에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성 - 즉, 로그인 성공 시
httpSession.getAttribute("user")
에서 값을 가져올 수 있음
🔶 if(user != null)
- 세션에 저장된 값이 있을 때만 model에 userName으로 등록
- 세션에 저장된 값이 없으면 model에 아무런 값이 없는 상태이니 로그인 버튼이 보이게 됨
❗ User
이름이 예약어로 인식돼서 오류가 발생 -> Member
로 변경
✔ http://localhost:8081/h2-console 에서 Member 테이블 확인
✔ 권한 관리
- 로그인된 사용자의 권한은 GUEST임
- posts 기능을 사용불가(403 권한 거부 에러 발생)
- h2-console에서 update member set role = 'USER'; 입력
- 로그아웃 후 다시 로그인하면 됨
⚡ 어노테이션 기반으로 개선하기
✔ 같은 코드가 반복 -> 유지보수성 하락
✔ 개선해야 할 부분
IndexController
에서 세션값을 가져오는 부분
SessionUser user = (SessionUser) httpSession.getAttribute("user");
- index 메소드 외에 다른 컨트롤러와 메소드에서 세션값이 필요하면 그때마다 직접 세션에서 값을 가져와야 함
- 메소드 인자로 세션값을 바로 받을 수 있도록 변경
@LoginUser
🔶 config.auth/@LoginUser
package com.yerin.book.springbootwebservice.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 {
}
🔶 @Target(ElementType.PARAMETER)
- 이 어노테이션이 생성될 수 있는 위치 지정
- PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용 가능
- 이 외에도 클래스 선언문에 쓸 수 있는 TYPE 등이 있음
🔶 @interface
- 이 파일을 어노테이션 클래스로 지정
LoginUser
라는 이름을 가진 어노테이션이 생성된 것임
LoginUserArgumentResolver.java
🔶 config.auth/LoginUserArgumentResolver
package com.yerin.book.springbootwebservice.config.auth;
import com.yerin.book.springbootwebservice.config.auth.dto.SessionUser;
import jakarta.servlet.http.HttpSession;
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;
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
@Override
public boolean supportsParameter(MethodParameter parameter){
boolean isLoginUserAnnotaion = parameter.getParameterAnnotation(LoginUser.class) != null;
boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());
return isLoginUserAnnotaion && isUserClass;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception{
return httpSession.getAttribute("user");
}
}
🔶 supportsParameter()
- 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단
- 여기서는 파라미터에
@LoginUser
어노테이션이 붙어있고, 파라미터 클래스 타입이SessionUser.class
인 경우 true 반환
🔶 resolveArgument()
- 파라미터에 전달할 객체 생성
- 여기서는 세션에서 객체를 가져옴
WebConfig.java
✔ LoginUserArgumentResolver
가 스프링에서 인식될 수 있도록 WebMvcConfigurer
에 추가
🔶 config/WebConfig
package com.yerin.book.springbootwebservice.config;
import com.yerin.book.springbootwebservice.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()
를 통해 추가해야 함
IndexController에 코드 추가
✔ 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";
}
}
⚡ 세션 저장소로 데이터베이스 사용하기 (개선하기 2)
✔ 지금까지의 코드로는 애플리케이션을 재실행하면 로그인이 풀림
- 내장 톰캣의 메모리에 저장되기 때문
✔ 기본적으로 세션은 실행되는 WAS(Web Application Server)의 메모리에서 저장되고 호출됨
- 메모리에서 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화됨
💡 즉, 배포할 때마다 톰캣이 재시작
- 2대 이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야 함
- 실제 현업에서는 세션 저장소에 다음 3가지 중 하나를 선택해야 함
✔ 톰캣 세션을 사용함
- 일반적으로 별다른 설정을 하지 않을 때 기본적으로 선택되는 방식
- 톰캣(WAS)에 세션이 저장되기 때문에 2대 이상의 WAS가 구동되는 환경에서는 톰캣들 간의 세션 공유를 위한 추가 설정이 필요함
✔ MySQL과 같은 데이터베이스를 세션 저장소로 사용
- 여러 WAS 간의 공용 세션을 사용할 수 있는 가장 쉬운 방법임
- 많은 설정이 필요 없지만, 결국 로그인 요청마다 DB IO가 발생하여 성능상 이슈가 발생할 수 있음
- 보통 로그인 요청이 많ㅇ ㅣ없는 백오피스, 사내 시스템 용도에서 사용함
✔ Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용
- B2C 서비스에서 가장 많이 사용하는 방식
- 실제 서비스로 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요함
❗2번째 방식인 데이터베이스를 세션저장소로 사용하는 방식을 선택
- 설정이 간단하고 사용자가 많은 서비스가 아니며 비용절감을 위해서 사용
- 나중에 AWS에서 이 서비스를 배포하고 운영할 때를 생각하면 Redis와 같은 메모리 DB를 사용하기는 부담스러움
- 서비스(엘라스틱 캐시)에 별도로 사용료를 지불해야 하기 때문
이 부분이 해도해도 마구마구 오류가 났다ㅜㅜ
@LoginUser를 따로 안 만들면 오류가 안났는데 이 과정만 거치면 NullpointerException 오류가 났다ㅜㅜ
➡️ build.gradle에 version '1.0.4-SNAPSHOT-'+new Date().format("yyyyMMddHHmmss")
를 넣어주니까 된 거 같은데.. 확실하진 않음 ㅠ 나중에 공부해버고 다시 수정하게따..
spring-session-jdbc 등록
- build.gradle에 implementation('org.springframework.session:spring-session-jdbc') 등록
application.yml
에 세션 저장소를 jdbc로 선택하도록 코드 추가spring.session.store-type=jdbc
애플리케이션을 다시 실행한 후 로그인을 하고 h2-console 접속
-> 세션을 위한 테이블 2개(SPRING_SESSION, SPRING_SESSION_ATTRIBUTES)가 생성된 것을 확인가능
- JPA로 인해 세션 테이블이 자동 생성되었기 때문에 별도로 해야 할 일은 없음
- 방금 로그인했기 때문에 한 개의 세션이 등록돼있는 것을 볼 수 있음
- 세션 저장소를 데이터베이스로 교체함
- 스프링을 재시작하면 세션이 풀림
- 스프링이 재시작될 때 H2도 재시작되기 때문
- AWS로 배포하면 AWS의 데이터베이스 서비스인 RDS를 사용하게 돼서 세션이 풀리지 않게 됨
⚡ 네이버 로그인
✔ https://developers.naver.com/apps/#/register?api=nvlogin
- 네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 common-OAuth2Provider에서 해주던 값들을 수동으로 입력해야 함
spring:
security:
oauth2:
client:
registration:
naver:
client-id: 클라이언트 ID
client-secret: 클라이언트 비밀
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
scope:
- name
- email
- profile_image
client-name: Naver
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: response
🔶 user_name_attribute: response
- 기준이 되는 user_name의 이름을 네이버에서는 response로 해야 함
- 네이버의 회원 조회 시 반환되는 JSON 형태 때문
스프링 시큐리티 설정 등록
🔶 OAuthAttributes
에 네이버인지 판단하는 코드와 네이버 생성자 코드 추가
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
...
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
if (registrationId.equals("naver")) {
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(response)
.nameAttributeKey(userNameAttributeName)
.build();
}
...
}
index.mustache에 네이버 로그인 버튼 추가
{{^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}}
- 네이버 로그인 URL은 application-oauth.yml에 등록한 redirect-uri 값에 맞춰 자동으로 등록됨
- /oauth2/authorization/까지는 고정이고 마지막 Path만 각 소셜 로그인 코드를 사용하면 됨
- 여기서는 naver가 마지막 Path가 됨
⚡ 기존 테스트에 시큐리티 적용하기
✔ 기존 테스트에 시큐리티 적용으로 문제가 되는 부분들을 해결해보겠다
- 기존에는 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출하도록 구성함
- 시큐리티 옵션이 활성화되면 인증된 사용자만 API를 호출할 수 있음
- 기존의 API 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정
- 오른쪽 위에 Gradle 탭 클릭
- Tasks -> verification -> test 선택
- 전체 테스트 수행
-> 롬복을 이용한 테스트 외에 스프링을 이용한 테스트는 모두 실패함
❗ 문제 1 : CustomOAuth2UserService를 찾을 수 없음
✔ 첫 번째 실패 테스트인 "hello가_리턴된다"의 메시지를 보면 "No qualifying bean of type 'com. ... config.auth.CustomOAuth2UserService'" 라는 메시지가 등장한다는데 난 안뜬다.. 왜지..
- 이 에러는 소셜 로그인 관련 설정값들이 없기 때문에 발생한다고 함
src/main
환경과src/test
환경의 차이때문- test에
application.yml
이 없으면 main의 설정을 그대로 가져오기 때문! - 자동으로 가져오는 옵션의 범위는 application.yml까지임 (application-oauth.yml은 가져오지 않음)
- 테스트 환겨을 위해서 test 안에도 application.yml을 만들어줌 (가짜 설정값)
- test에
spring:
jpa:
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
h2:
console:
enabled: true
security:
oauth2:
client:
registration:
google:
client-id: test
client-secret: test
scope:
- profile
- email
❗문제2 : 302 Status Code
✔ "Post가_등록된다" 테스트 로그
org.junit.ComparisonFailure:
Expected :200 OK
Actual :300 FOUND
<Click to see difference>
- 응답의 결과로 200(정상 응답) Status Code를 원했는데 결과는 302(리다이렉션 응답) Status Code가 와서 실패함
- 스프링 시큐리티 설정 때문에 인증되지 않은 사용자의 요청은 이동시키기 때문
- 임의로 인증된 사용자를 추가하여 API만 테스트해 볼 수 있게 함
- 스프링 시큐리티에서 이미 공식적으로 방법을 지원하고 있음
- 스프링 시큐리티 테스트를 위한 여러 도구를 지원하는 spring-security-test를 build.gradle에 추가
testImplementation("org.springframework.security:spring-security-test") // 권한 관련
- PostsApiControllerTest의 2개 테스트 메소드에 다음과 같이 임의 사용자 인증을 추가
@Test
@WithMockUser(roles = "USER")
public void Posts_등록된다() throws Exception{
...
}
@Test
@WithMockUser(roles = "USER")
public void Posts_수정된다() throws Exception{
...
}
🔶 @WithMockUser(roles = "USER")
- 인증된 모의(가짜) 사용자를 만들어서 사용
- roles에 권한을 추가할 수 있음
- 즉, 이 어노테이션으로 인해 ROLE_USER 권한을 가진 사용자가 API를 요청하는 것과 동일한 효과를 가지게 됨
✔ 이 정도만 하면 테스트가 될 것 같지만, 실제로 작동하지 않음
- @WithMockUser가 MockMvc에서만 작동하기 때문
- 현재 PostsApiControllerTest는
@SpringBootTest
로만 되어있으며 MockMvc를 전혀 사용하지 않음
@SpringBootTest
에서 MockMvc를 사용하는 방법
🔶 PostsApiControllerTest
코드 수정
package com.yerin.book.springbootwebservice.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yerin.book.springbootwebservice.domain.posts.Posts;
import com.yerin.book.springbootwebservice.domain.posts.PostsRepository;
import com.yerin.book.springbootwebservice.web.dto.PostsSaveRequestDto;
import com.yerin.book.springbootwebservice.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.test.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 java.util.List;
import static org.assertj.core.api.AssertionsForClassTypes.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;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate testTemplate;
@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 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
//ResponseEntity<Long> responseEntity = testTemplate.postForEntity(url, requestDto, Long.class);
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
//then
// Assertions.assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
// Assertions.assertThat(responseEntity.getBody()).isGreaterThan(0L);
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;
HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);
//when
//ResponseEntity<Long> responseEntity = testTemplate.exchange(url, HttpMethod.PUT, requestEntity, Long.class);
mvc.perform(put(url)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
//then
// assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
// assertThat(responseEntity.getBody()).isGreaterThan(0L);
List<Posts>all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
❗ 문제 3 : @WebMvcTest
에서 CustomOAuth2UserService를 찾을 수 없음
- 첫 번째로 해결한 것과 동일한 메시지인 "No qualifying bean of type 'com. ... config.auth.CustomOAuth2UserService'" 메시지가 나타남
- HelloControllerTest는 1번과 조금 다름
@WebMvcTest
를 사용- 1번을 통해 스프링 시큐리티 설정은 잘 작동했지만,
@WebMvcTest
는 CustomOAuth2UserService를 스캔하지 않기 때문
@WebMvcTest
WebSecurityConfigurerAdapter
,WebMvcConfigurer
를 비롯한@ControllerAdvice
,@Controller
를 읽음- 즉,
@Repository
,@Service
,@Component
는 스캔 대상이 아님
SecurityConfig
를 제거하면 해결됨
- HelloControllerTest는 1번과 조금 다름
@ExtendWith(SpringExtension.class)
@WebMvcTest(controllers = HelloController.class, excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
})
public class HelloControllerTest {
...
@WithMockUser(roles = "USER")
@Test
public void hello가_리턴된다() throws Exception{
...
}
@WithMockUser(roles = "USER")
@Test
public void helloDto가_리턴된다() throws Exception{
...
}
...
}
- 이렇게 실행을 하면
java.lang.IllegalArgumentException: JPA metamodel must not be present!
에러 발생 @EnableJpaAuditing
때문에 발생@EnableJpaAuditing
을 사용하기 위해선 최소 하나의@Entity
클래스가 필요함@WebMvcTest
이다보니 없음@SpringBootApplication
과 함께 있다보니@WebMvcTest
에서도 스캔하게 됨@EnableJpaAuditing
과@SpringBootApplication
둘을 분리해야 함
✔ SpringbootWebserviceApplication.java
에서 @EnableJpaAuditing
제거
✔ config
에 JpaConfig
클래스 생성해서 @EnableJpaAuditing
추가
스프링 부트와 AWS로 혼자 구현하는 웹 서비스_이동욱 지음 책을 읽고 정리하는 글입니다
![](https://t1.daumcdn.net/keditor/emoticon/friends1/large/006.gif)
PREV
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(4)
📌 머스테치로 화면 구성하기 ⚡ 서버 템플릿 엔진과 머스테치 소개 ✔ 템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐서 HTML 문서를 출력하는 소프트웨어 클라이언트 템플릿 엔진 : 리액트
nyeroni.tistory.com
NEXT
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(6)
📌 AWS 서버 환경 - AWS EC2 ⚡ AWS란? ✔ AWS : Amazon Web Service 이번 장에서는 AWS 클라우드 서비스를 이용해 서버 배포를 할 것임 외부에서 본인이 만든 서비스에 접근하려면 24시간 작동하는 서버가 필
nyeroni.tistory.com
'프로젝트 > 스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 카테고리의 다른 글
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(7) (1) | 2024.01.22 |
---|---|
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(6) (0) | 2024.01.22 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(4) (0) | 2024.01.22 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(3) (1) | 2024.01.22 |
스프링 부트와 AWS로 혼자 구현하는 웹 서비스(2) (0) | 2024.01.22 |