본문 바로가기
회고

[이상청] OAuth2를 활용한 로그인, 세션 유지 방식의 실패 원인과 해결방안

by 선의 2022. 8. 7.

개관

  쉽게 요약하면, OAuth2는 외부 기업에 로그인 및 회원가입을 위탁하는 서비스다. 스프링에서는 Oauth2 Client와 Spring-Security 두 하위 프레임워크를 통해 OAuth2 로그인을 쉽게 구현할 수 있다.

  결론적으로 아래 방식은 실제 서비스에 사용되지는 못했다. 로그인에는 크게 두 가지 방식이 있다. 첫번째는 세션을 사용하는 방법, 두번째는 jwt 토큰을 사용하는 방법이다. 각각 장단점이 있지만, 나는 첫번째 방식을 사용했다. 이전에 구현해 본 경험이 있고 백엔드에서 거의 모든 로직을 처리해주기 때문이었는데 밑에 나올 여러가지 문제가 발생하여 jwt 토큰을 사용한 방식으로 로그인 방식을 바꾸게 되었다.

  또한 코드는 전반적인 로직이 어떻게 돌아가는지 이해하고 복습하기 위해 가공하여 게시하므로, 실제 프로젝트에 사용된 것과는 차이가 있다. 구글 클라이언트 설정 관련은 생략하도록 하겠다.

 

*** 구글 클라이언트 설정 관련: 배포 후 ec2 도메인 등록 시 amazon.com으로 끝나는 최상위 도메인만 등록이 된다. 보안상 그다지 좋지 않으므로 실서비스일 경우 도메인을 싸게 구입해서 연결해주자.

 

 

구현

SecurityConfig.java

@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()
                .and()
                .authorizeRequests()
                .antMatchers("/")
                .hasAnyRole()
                .anyRequest().authenticated()
                .and()
                .logout()
                .logoutSuccessUrl("/")
                .and()
                .oauth2Login()
                .defaultSuccessUrl("/").successHandler(oAuth2SuccessHandler)
                .userInfoEndpoint()
                .userService(customOAuth2UserService);
    }
}

1) .authorizeRequests()부터 authenticated()까지는 유저에게 부여된 권한에 따라 API 허용 범위를 설정한다.

2) oauth2Login() 부분은 oauth2를 활용하여 로그인을 진행함을 명시한다

3) .defaultSuccessUrl("/"): 말그대로 로그인 성공시 이동하는 url

- 이후 프론트와 연결 중에 알게 된 문제인데, 로그인 성공시 백엔드 주소의 url로 이동한다

- 백엔드가 abc.net이면 로그인 성공 시 abc.net/ 이렇게 이동하는 식

- 이 문제는 successHandler에서 redirect url를 설정해주면 해결할 수 있다

 

OAuth2SuccessHandler.java

@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        System.out.println(oAuth2User.getAttributes());
        Map<String, Object> google = (Map<String, Object>) oAuth2User.getAttributes();
        getRedirectStrategy().sendRedirect(request, response, makeRedirectUrl());
    }

    private String makeRedirectUrl(){
        return UriComponentsBuilder.fromUriString("http://localhost:3000/").build().toUriString();
    }

}

getRedirectStrategy().sendRedirect(request, response, "URL")을 활용하면 로그인 성공 후 프론트엔드 페이지로 redirect url을 보내준다.

 

CustomOauth2UserService.java

@RequiredArgsConstructor
@Service
public class CustomOauth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        System.out.println(oAuth2User.getAttributes());
        System.out.println();

        String hd = (String) oAuth2User.getAttribute("hd");

        if(!Objects.equals(hd, "ewhain.net")){
            return null;
        }

        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        OAuthAttributes attributes = OAuthAttributes.of(userNameAttributeName, oAuth2User.getAttributes());
        String userInfoUri = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri();

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

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                attributes.getAttributes(),
                attributes.getNameAttributeKey()
        );

    }

    public User saveOrUpdate(OAuthAttributes attributes){
        User user = userRepository.findByEmail(attributes.getEmail());

        if(user != null){
            return user;
        } else {
            userRepository.save(attributes.toEntity());
            user = userRepository.findByEmail(attributes.getEmail());
            return user;
        }
    }
    
}

1) SessionUser Dto를 생성하여 유저 조회 및 업데이트 후 필요한 정보(id, 이름, 이메일)만 HttpSession에 저장하였고, 세션 유지가 되는 걸 확인 했으나... 프론트엔드에 연결할 때 유저 조회가 제대로 되지 않았다.

: 이후 redirect 수정 후 오류를 보니 JSESSIONID가 없다는 에러가 발생했다. 프론트엔드에서 SESSIONID를 따로 조회할 수 있는 방법이 있는지, 혹은 백엔드에서 해당 ID를 헤더에 담아서 보내주었어야 하는지는 직접 해 보고 더 공부해봐야 알 것 같다.

2) JSESSIONID, 즉 세션 ID를 헤더에 담아 SuccessHandler에 같이 보내면 되지 않을까? 라는 생각을 했지만, jwt 토큰 기반으로 변경하는 게 되었다.

3) 다른 방식으로 구현하게 되면서 위의 코드는 반영이 되지 않았지만, OAuth2UserService에서 어떻게 구글에서 정보를 받고 해당 정보를 처리할 수 있는지는 공부해 볼 수 있다.

 

우선, Oauth2UserRequest에서 Oauth2User를 받고 getAttribute()로 값을 보면(자료형은 Map이지만 json으로 바꿔 놨다)

{
    "sub":"*", 
    "name":"이선의", 
    "given_name":"선의", 
    "family_name":"이", 
    "picture":"https://lh3.googleusercontent.com/a-/AFdZucqZNuPz0TnQ43ubTIBQ2mAxE-XDCK17Qatqnmn95g=s96-c",
    "email":"ilsa1115111500@gmail.com", 
    "email_verified":true, 
    "locale":"ko"
}

sub 키로 로그인을 한다. 보안상 지워놨는데 긴 숫자 형태로 온다. 구글은 sub키로 로그인 정보를 식별하고, 카카오나 네이버는 또 달랐던 것으로 기억한다(위의 Map에 담겨오는 key 자체가 달랐다).

 

4) 전체 클래스를 보면 "hd" 키가 있는데, 대학 인증을 위해서 넣어두었다. 소속 대학이 있는 경우, gmail.com이 아닌 다른 주소를 사용하는 이메일의 경우 "hd" 값이 함께 온다. 이화여자대학교의 경우 "hd":"ewhain.net" 이런 식으로 받아서, 이 값이 조회되지 않는 경우 로그인에 실패하도록 서비스를 작성했다.

 

 

에러 및 이후 방향

  프론트와 연결 당시 유저 조회가 필요한 경우 에러가 발생했고, 권한만 필요한 API의 경우 접근이 가능했다. 로그인에 완전히 성공하고 redirect하기 이전에 유저 권한을 부여하기 때문에, 로그인 자체는 성공했으나 JSESSIONID를 헤더에 넣어주어야 로그인 유저 관련 API를 사용할 수 있기 때문에 관련 기능은 작동하지 않은 것으로 보인다.

  위의 코드가 어떤 로직으로 돌아가는지는 함수 이름만 읽어도 유추할 수 있고, 도서와 여러 블로그 글 및 공식문서를 참조했기에 서비스 자체는 이해할 수 있었다. 하지만 시큐리티 프레임워크 자체에 대한 이해가 부족했고, 세션/JWT 등 로그인을 구현할 때 필요한 CS 관련 지식도 분명하게 알고 있지 못해 개념상 헤메는 일이 있었다.

  따라서 이후에 시큐리티 프레임워크 및 Oauth2 자체에 대한 공부와, 세션과 JWT를 둘 다 사용해보고 로그인을 구현해보는 개인 실습이 필요하다고 느꼈다.

 

1) 세션 ID가 로그인 성공 시 헤더로 전달되는지, 헤더로 전달할 수 있는 방법이 있는지, 프론트엔드(리액트)에서 세션ID를 조회하고 해당 JSESSIONID로 유저 조회가 가능한지.

2) 세션과 JWT 토큰 발급 방식의 장단점과 차이

3) 세션 저장 방식 중 Redis를 사용한 방식

4) JWT 토큰 발급을 통한 로그인 구현

5) 스프링 시큐리티 상세하게 공부하고 정리해보기(오픈소스 코드를 뜯어보자)

 

정도...를 해 보고 싶다. 그리고 이전에 해커톤에서 개발했던 프로젝트 역시 프론트와 백엔드를 분리하여 개발하고 독립적으로 배포하기로 했는데, 이에 따라 어느정도 수정이 필요하게 되어 세션으로 구현하는 방식을 보완하거나, JWT 토큰을 발급해서 로그인 방식을 조금 수정하게 될 것 같다. 소중대 해커톤 플젝도 여름 안에 완성해서 회고록을 올려보도록 하겠다.

 

 

참고 자료

https://velog.io/@gotaek/%EC%84%B8%EC%85%98Session%EA%B3%BC-JWT

 

세션(Session)과 JWT

🔎 Authentication vs Authorization 두 개의 단어는 비슷해보이지만 엄연히 다른 프로세스이다. Authentication은 인증이고, Authorization은 권한 부여인데 로그인 시스템에서 중요한 역할을 한다. 웹사이트에

velog.io

http://www.yes24.com/Product/Goods/83849117

 

스프링 부트와 AWS로 혼자 구현하는 웹 서비스 - YES24

가장 빠르고 쉽게 웹 서비스의 모든 과정을 경험한다. 경험이 실력이 되는 순간!이 책은 제목 그대로 스프링 부트와 AWS로 웹 서비스를 구현한다. JPA와 JUnit 테스트, 그레이들, 머스테치, 스프링

www.yes24.com

https://github.com/independent-base/dokripgiji-backend/tree/main/backend/web/web/src/main/java/com/dokripgiji/web/config

 

GitHub - independent-base/dokripgiji-backend: 🎵 독립기지 백엔드 레포

🎵 독립기지 백엔드 레포. Contribute to independent-base/dokripgiji-backend development by creating an account on GitHub.

github.com