Super Kawaii Cute Cat Kaoani 스프링 부트와 AWS로 혼자 구현하는 웹 서비스(5)

스프링 부트와 AWS로 혼자 구현하는 웹 서비스(5)

2024. 1. 22. 01:38
728x90
SMALL

📌 스프링 시큐리티와 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
    • 커밋 파일 목록에 뜨지 않아야 함
  •  

 

 

⚡ 구글 로그인 연동하기

🔶 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 부터 WebSecurityConfigurerAdapterdeprecated
    • 컴포넌트 기반 설정으로 변경할것을 권항

✔ @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는 항상 WebMvcConfigureraddArgumentResolvers()를 통해 추가해야 함

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 테스트 코드들이 모두 인증에 대한 권한을 받지 못하였으므로, 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정
  1. 오른쪽 위에 Gradle 탭 클릭
  2. Tasks -> verification -> test 선택
  3. 전체 테스트 수행

-> 롬복을 이용한 테스트 외에 스프링을 이용한 테스트는 모두 실패함

 

❗ 문제 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을 만들어줌 (가짜 설정값)
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를 제거하면 해결됨

 

@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 제거
configJpaConfig 클래스 생성해서 @EnableJpaAuditing 추가

 

 

 

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스_이동욱 지음 책을 읽고 정리하는 글입니다

 

 

 

 

 

 

 

 


PREV

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스(4)

📌 머스테치로 화면 구성하기 ⚡ 서버 템플릿 엔진과 머스테치 소개 ✔ 템플릿 엔진 : 지정된 템플릿 양식과 데이터가 합쳐서 HTML 문서를 출력하는 소프트웨어 클라이언트 템플릿 엔진 : 리액트

nyeroni.tistory.com

 

NEXT

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스(6)

📌 AWS 서버 환경 - AWS EC2 ⚡ AWS란? ✔ AWS : Amazon Web Service 이번 장에서는 AWS 클라우드 서비스를 이용해 서버 배포를 할 것임 외부에서 본인이 만든 서비스에 접근하려면 24시간 작동하는 서버가 필

nyeroni.tistory.com

 

728x90
LIST

BELATED ARTICLES

more