Framework/Spring

[3주차] Spring Security 구글 소셜 로그인 구현, 세션 관리

MINGYUM 2022. 5. 22. 14:27

소셜 로그인을 구현하기에 앞서, 구글 로그인을 연동하기 위해 아래 링크에서 Oauth Client id를 생성한다. 

https://console.cloud.google.com/home/dashboard?project=long-sonar-350806 

 

Google Cloud Platform

하나의 계정으로 모든 Google 서비스를 Google Cloud Platform을 사용하려면 로그인하세요.

accounts.google.com

resources 디렉토리에 application-oauth.properties를 추가해서 아래와 같이 클라이언트 ID, Secret 값을 넣어 초기화해준다. (.gitignore에 해당 설정파일 추가해주기 필수!)

spring.security.oauth2.client.registration.google.client-id=클라이언트ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트SECRET
spring.security.oauth2.client.registration.google.scope=profile,email

사용자 정보를 담당할 User 엔티티 생성

@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue
    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 getRolKey(){
        return this.role.getKey();
    }
}

Role의 경우 Enum클래스로, 필요한 정보를 GUEST, USER의 이름으로 담았다. 

이 때 스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야하므로 코드별 키 값을 아래와 같이 설정한다. 

@Getter
@RequiredArgsConstructor
public enum Role {
    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}

config/auth 디렉토리를 생성하고 SecurityConfig 클래스를 작성한다. 

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOauth2UserService customOauth2UserService;

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception{
        httpSecurity.csrf().disable()
                .headers().frameOptions().disable() // h2-console 화면 사용을 위해 해당 옵션 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);
    }
}
1. @EnableWebSecurity : 스프링 시큐리티 사용을 활성화시켜줌
2. csrf().disable().headers().frameOptions().disable() : CSRF 비활성화, X-Frame Options를 비활성화
3. authorizeRequest() : URL별 권한 관리를 설정하는 옵션, antMatcher를 사용하려면 필수적이다. 
4. antMatchers() : 권한 관리 대상을 지정, URL과 HTTP 메소드별로 관리가 가능
5. anyRequest().authenticated() : 지정한 URL 이외의 나머지 URL들은 모두 인증된 사용자에 의해서 접근이 가능하도록 한다.
6. logout().logout()SuccessUrl : 로그아웃 기능에 대한 여러 설정의 진입점이다. 성공 시 "/"로 이동하도록 설정
7. oauth2Login : OAuth2 로그인 기능한 여러 설정의 진입점이다.
8. userInfoEndPoint() : OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당한다.
9. userService : 

CustomOauth2UserService 클래스를 생성한다.

이 클래스는 사용자들의 정보를 기반으로 가입 및 정보 수정, 세션 저장 등의 기능을 수행한다.

@RequiredArgsConstructor
@Service
public class CustomOauth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final UserRepository userRepository;
    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 attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes);
        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    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);
    }
}

1. loadUser 함수

1) OAuth2UserRequest 객체를 매개변수로 받아서 OAuth2UserService 클래스의 객체를 생성

2) loadUser을 호출해 OAuth2User 객체인 oAuth2User를 생성한다. 

3)  userRequest로부터 registrationId와 userNameAttributeName의 값을 추출한다. 각각 로그인 진행 중인 서비스를 구분 (구글 Or 네이버 등) 하는 코드, 그리고 OAuth 로그인 진행 시 키가 되는 필드값을 나타낸다. 

4) OAuthAttributes 객체 생성. OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스이다.

5) SessionUser : 세션에 사용자 정보를 저장하기 위한 DTO 클래스. 아래와 같이 따로 Entity를 구현해준다. 

@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.getEmail();
    }
}

OAuthAttributes의 경우 처음 가입했을 때 엔티티가 생성되며, 생성이 끝나면 같은 패키지에 SessionUser 클래스를 생성한다. 

@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;
    }

    // OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 반환해야한다.
    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();
    }

    // Convert OAuthAttributes to User
    public User toEntity(){
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

SessionUser에는 인증된 사용자 정보만 필요하다. 그 외에 필요한 정보들은 없으니 name, email, picture만 필드로 선언한다. 

attributes에서 findByEmail을 이용해 조회를 한 후 attributes에 지정된 name과 picture로 수정을 한 객체를 map형식으로 반환받는다. 이 User 객체를 SessionUser 객체로 변환한 다음 Attribute에 담도록 한다. 

여기서 User 클래스가 아닌 SessionUser로 변환하는 이유는 뭘까? 

setAttribute를 보면 이렇게 Object  자료형으로 되어있다. User 클래스의 경우 세션에 저장할 때 직렬화가 구현되어있지 않아 에러가 뜨는 것을 알 수 있다.

https://devlog-wjdrbs96.tistory.com/268

 

[Java] 직렬화(Serialization)란 무엇일까?

Serializable에 대해서 알아보기 직렬화라는 용어에 대해서 들어만 보고 공부해본 적은 없는데 이번 기회에 정리를 하게 되었습니다,, 이번 글에서는 직렬화 에 대해서 알아보겠습니다. public interfac

devlog-wjdrbs96.tistory.com

자바 직렬화란, 객체가 외부 자바 시스템에서도 쓰일 수 있도록 byte[]의 형식으로 변환하는 기술이다. 직렬화 기능을 가진 엔티티를 만들게 되면 다른 엔티티나 종속적인 위치에 있는 엔티티들이 직렬화 대상에 포함되게 된다. 

그래서 직렬화 기능을 가진 Dto를 하나 추가적으로 만들어서 유지보수에 도움이 되도록 하는 것이다. 


로그인 테스트

머스테치는 다른 언어와 같은 if문을 제공하지 않는다. {{ #userName }}과 같이 true/false로 구분해 userName이 있다면 노출시키도록 구현하였음. {{ ^userName }} 의 문법은 해당 값이 존재하지 않는 경우 사용한다. 여기서는 userName이 존재하지 않을 떄 Login버튼이 생성되도록 하였다. 

a href = "/logout">은 Spring Security에서 기본적으로 제공하는 URL이다.  <a href = "/oauth2/authorization/google">와 같은 URL도 마찬가지이다.

 


코드 개선하기 

 

이 코드에서 SessionUser를 가져오는 부분에서 httpSession으로부터 세션값을 가져오고 있다.

index함수가 아닌 다른 컨트롤러 메소드에 접근하는 경우 일일이 세션값을 가져와야하는 문제가 있었는데, 메소드 인자로 값을 받아오는 것으로 이를 해결할 수 있다. 

 

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

LoginUser라는 어노테이션을 생성하고, 파라미터 앞에 쓸 수 있도록 지정. 

@Retention을 이용해 어느 범위까지 어노테이션을 가져갈 지 정할 수 있고, 여기서는 RUNTIME 환경까지 가져가도록 설정한다. 

@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");
    }
}

LoginUserArgument를 생성한다. 

1) supportsParameter : 컨트롤러 메서드의 특정 파라미터를 지원하는 지 판단한다. 파라미터에 인가된 어노테이션이 LoginUser.class인지 확인하고 인자로 들어온 파라미터의 타입이 SessionUser인지 확인하여 boolean 타입으로 반환함

2) resolveArgument : 파라미터에 전달할 세션 객체를 생성한다. 

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers){
        argumentResolvers.add(loginUserArgumentResolver);
    }
}

이제 이 LoginUserArgument를 스프링에서 인식하도록 WebConfig를 작성해준다. 

Custom한 HandlerArgumentResolver를 추가해주기 위해서 꼭 다음과 같이 addArgumentResolvers 함수에서 추가해주어야 한다. 

    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

이렇게 IndexController를 수정해 파라미터에서 바로 세션 정보를 가져올 수 있게 되었다. 

세션 저장소로 데이터베이스를 사용할 수 있는데, 스프링의 WAS(Web Applicatoin Server) 내장 톰캣 메모리에 세션이 저장되어 애플리케이션이 실행될 때마다 세션 초기화가 되는 문제를 해결하기 위해서이다. 

데이터베이스를 사용하는 방법 이외에 톰캣 세션을 사용해 톰캣 세션 동기화를 하거나, Redis 혹은 Memcached와 같은 메모리 DB를 세션 저장소로 사용하는 방법이 있다. 

compileOnly('org.springframework.session:spring-session-jdbc')

다음 코드를 build.gradle에 설치해 spring-session-jdbc를 추가한다. 

애플리케이션을 실행하면 SPRING_SESSION, SPRING_SESSION_ATTRIBUTES가 생성된 것을 확인할 수 있다.

 

이후에 AWS의 DB 서비스인 RDS를 사용하면 세션이 풀리지 않고 서비스를 사용할 수 있다