본문 바로가기
공부 및 정리/점프 투 스프링부트

점프 투 스프링부트 추가 기능 구현 - 소셜 로그인

by 스파이펭귄 2024. 1. 19.
728x90

https://wikidocs.net/162814의 추가 기능 구현 - 소셜 로그인

 

3-14 SBB 추가 기능 구현하기

이 책에서 구현할 SBB의 기능은 아쉽지만 여기까지이다. 함께 더 많은 기능을 추가하고 싶지만 이 책은 SBB의 완성이 아니라 SBB를 성장시키는 경험을 전달하는 데 목표를 두고…

wikidocs.net

이 글은 점프 투 스프링 부트의 3-13까지 구현과 이 글 이전의 구현을 마친 것을 전제로 한다.


스프링 부트 OAuth2 소셜 로그인 구현하기

 

스프링 부트 OAuth2 소셜 로그인 구현하기

이 포스트에서는 스프링 부트로 소셜 로그인을 구현해보겠습니다. 스프링 시큐리티와 스프링 OAuth2 클라이언트 라이브러리를 사용하여 구글, 네이버, 카카오 서비스와 연동하여 로그인, 회원 탈

velog.io

구글, 네이버, 카카오의 OAuth2 생성 방법은 위 글을 참고하였다. 카카오는 이메일을 불러오기가 되지 않아서 제외하였다….

[Spring] 스프링 시큐리티 - 구글 로그인

 

[Spring] 스프링 시큐리티 - 구글 로그인

스프링 시큐리티 - 구글 로그인을 적용해보자.

velog.io

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현

 

스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현

스프링 시큐리티(Spring Security)는 막강한 인증과 인과(혹인 권한 부여) 기능을 가진 프레임워크입니다.사실상 스프링 기반의 애플리케이션에서는 보안을 위한 표준이라고 보면 됩니다. 필터 기반

velog.io

기타 dependency와 application.properties 설정은 위 블로그들을 참고하였다.

Dependency와 application.properties 설정

dependency에 아래 라이브러리를 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

application-oauth.properties 파일을 생성 후 아래 프로퍼티를 작성해준다.

spring boot 에서 profile 사용한 application.properties 로딩 (gradle build)

 

spring boot 에서 profile 사용한 application.properties 로딩 (gradle build)

환경(dev, qa, stage, prod) 에 따른 설정 로딩 요구개발환경에 따라 설정값을 달리 로딩해야할 필요가 있습니다. Eg) dev, qa, stage, prod spring boot 에서는 이들을 profile 로 취급하며,application-<profile>.propertie

lejewk.github.io

자세한 내용은 위 블로그 참조.

# GOOGLE
spring.security.oauth2.client.registration.google.client-id = [클라이언트 id]
spring.security.oauth2.client.registration.google.client-secret = [클라이언트 pw]
spring.security.oauth2.client.registration.google.scope = profile, email

# registration
spring.security.oauth2.client.registration.naver.client-id=7XTAZLiz8HWtxMYapHII
spring.security.oauth2.client.registration.naver.client-secret=iHBQ3av6nK
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email
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 #json으로 리턴
# 출처: https://iseunghan.tistory.com/300 [iseunghan:티스토리]

application-oauth.properties를 사용하기 위해 아래 코드를 application.properties에 추가한다.

# application-oauth.properties
spring.profiles.include = oauth

그 후 gitignore에 application-oauth.properties를 추가한다. (정보 유출 방지)

 

 

스프링 시큐리티 변경

OAuth2를 사용하기 위해서는 SecurityConfig를 변경해야한다.

다른 블로그들을 살펴보면 method chaining을 통해서 설정하였으나, 스프링 시큐리티 6.x 버전부터는 이 방법은 deprecated되었고, 람다 방식을 사용해야 한다.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests.requestMatchers(new AntPathRequestMatcher("/**"))
                .permitAll())
                .csrf((csrf) ->
                        csrf.ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
                .headers((headers) ->
                        headers.addHeaderWriter(new XFrameOptionsHeaderWriter(XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
                .formLogin((formLogin) -> formLogin
                        .loginPage("/user/login").defaultSuccessUrl("/"))
                .logout((logout) -> logout
                        .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                        .logoutSuccessUrl("/")
                        .invalidateHttpSession(true))
                .oauth2Login((Customizer.withDefaults()));

        return http.build();
    }
        (...생략...)

위 방식 말고 .oauth2Login((oauth) -> oauth.userInfoEndpoint((endpoint) -> endpoint.userService(customOAuth2UserService))); 처럼 lambda를 사용하는 방식도 있으나 이 방식을 사용하면 내 구현상 순환 참조가 발생할 수 있어 위 방식을 사용하였다. 이 방식은 위 코드의 방식과 동일하다. (아래 블로그 참조하였음)

[Spring] 스프링 시큐리티 - 구글 로그인

 

[Spring] 스프링 시큐리티 - 구글 로그인

스프링 시큐리티 - 구글 로그인을 적용해보자.

velog.io

이제 구글 로그인 이후 가져온 정보를 받아줄 OAuth2UserService<OAuth2UserRequest, OAuth2User> 클래스를 만들어주어야 한다.

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

    private final UserService userService;
    private final HttpSession httpSession;

    @Autowired
    public CustomOAuth2UserService(UserService userService, HttpSession httpSession) {
        this.userService = userService;
        this.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();

        Map<String, Object> attributes = oAuth2User.getAttributes();
        SiteUser user = this.userService.socialLogin(registrationId,
                (String) attributes.get("name"), (String) attributes.get("email"));

        httpSession.setAttribute("user", new SessionUser(user));

        return new CustomOAuth2User(user.getUsername(), user.getEmail());
    }

    class CustomOAuth2User extends SiteUser implements OAuth2User {
        public CustomOAuth2User(String username, String email) {
            super(username, email);
        }
        @Override
        public Map<String, Object> getAttributes() {
            return null;
        }

        @Override
        public String getName() {
            return this.getUsername();
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            List<GrantedAuthority> authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
            return authorities;
        }
    }
}

이 클래스는 구글 로그인시 loadUser 함수를 통해 로그인하고, 우리가 Control에서 로그인한 계정 정보를 얻기 위해 사용하던 Principal을 생성해준다

loadUser 함수에서는 로그인한 구글 계정 정보로부터 이메일과 이름을 가져오고 이를 통해 UserService를 통해 socialLogin 함수를 불러 소셜 로그인을 수행한다. (이 부분은 조금있다가 구현을 봐보자.)

그 후 httpSession에 SessionUser라는 직접 구현한 직렬화 가능한 user 정보를 가진 객체를 “user”라는 키로 넣어 유저의 로그인 세션 정보를 만들어 준다. 사용자가 SBB와 상호 작용할 때, 이 세션은 사용자별로 상태 정보(예: 로그인 상태 등)를 유지하는데 사용하게 된다.

그리고 마지막으로 CustomOAuth2User라는 객체를 생성해 리턴한다. 이 클래스는 SiteUser라는 클래스를 상속받는데 다른 블로그들의 예제를 보면 그냥 DefaultOAuth2User 클래스를 사용한다.

하지만 여기서는 우리가 정의한 SiteUser를 상속해야 우리가 이전에 작성한 프론트 엔드 파일들을 수정하지 않을 수 있다. (getUsername같은 함수를 사용하는데 DefaultOAuth2User에는 위와 같은 함수가 없기 때문)

먼저 간단한 SessionUser의 구현부터 살펴보자.

@Getter
public class SessionUser implements Serializable {

    private String username;
    private String email;
    private String password;

    public SessionUser(SiteUser user) {
        this.username = user.getUsername();
        this.email = user.getEmail();
        this.password = user.getPassword();
    }
}

정말 단순히 User 정보를 가지고 직렬화(Serialize) 할 수 있도록 만든 클래스이다.

이번엔 SiteUser 엔티티 클래스를 보도록 하자.

@Entity
@Getter
@Setter
public class SiteUser {
        (...생략...)
    @Column(unique=true)
    private String username;
        (...생략...)

    private String registerId;
    @Builder
    public SiteUser() {
    }
    @Builder
    public SiteUser(String username, String email) {
        this.username = username;
        this.email = email;
    }
}

소셜 로그인을 하는 방식을 담을 registerId를 추가해주었고, 생성자 방식을 2개 추가해주었다.

이제 UserService의 변경사항에 대해서 보자.

@Service
public class UserService {

        (...생략...)
        public SiteUser create(String userName, String email, String password) {
        SiteUser user = new SiteUser();
        user.setUsername(userName);
        user.setEmail(email);
        user.setPassword(this.passwordEncoder.encode(password));
        this.userRepository.save(user);
        return user;
    }
        // overloading
        public SiteUser create(String registrationId, String userName,
                           String email, String password) {
        SiteUser user = new SiteUser();
        user.setUsername(userName);
        user.setEmail(email);
        user.setPassword(password);
        user.setRegisterId(registrationId);
        this.userRepository.save(user);
        return user;
    }
        (...생략...)

        public SiteUser socialLogin(String registrationId, String username, String email) {
        Optional<SiteUser> user = this.userRepository.findByEmail(email);
        if (user.isPresent()) {
            return user.get();
        } else {
            return this.create(registrationId, username, email, "");
        }
    }
}

위에서 사용한 socialLogin 함수는 registerId, username, email을 받고, 같은 username이 있는지 확인하고, 아닌 경우 생성해준다.

이제 login_form 파일을 수정해주자.

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <form th:action="@{/user/login}" method="post">
        <div th:if="${param.error}">
            <div class="alert alert-danger">
                사용자 ID 또는 비밀번호를 확인해주세요.
            </div>
        </div>
        <div class="mb-3">
            <label for="username" class="form-label">사용자 ID</label>
            <input type="text" name="username" id="username" class="form-control">
        </div>
        <div class="mb-3">
            <label for="password" class="form-label">비밀번호</label>
            <input type="password" name="password" id="password" class="form-control">
        </div>
        <button type="submit" class="btn btn-primary">로그인</button>
        <a href="/user/find-account">비밀번호가 기억나지 않아요.</a>

                <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">구글 로그인</a>
        <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">네이버 로그인</a>
    </form>
</div>
</html>

그냥 간단히 네이버, 구글 로그인 버튼을 만들어 주었다.

구글 로그인으로 로그인을 해보도록 하자.

로그인시 사용할 수 있는 기능들이 잘 동작하는 것을 확인할 수 있었다.

네이버 로그인을 하기 위해서는 네이버 로그인시 google에서 넘겨주는 것과는 또 다른 처리가 필요하다.

oAuth2User.getAttributes()를 통해 얻은 Map의 response 값을 확인해보면, {id=코드, [email=](mailto:email=bestech82@naver.com)naver@naver.com, name=이름}형태로 오게 된다.

이 json 결과를 파싱해 이름과 이메일을 가져와주자.

@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

//        String userNameAttributeName = userRequest.getClientRegistration()
//                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = oAuth2User.getAttributes();
        SiteUser user;
        try {
            user = this.login(registrationId, attributes);
        } catch (ExecutionControl.NotImplementedException e) {
            throw new RuntimeException(e);
        }

        httpSession.setAttribute("user", new SessionUser(user));
        return new CustomOAuth2User(user.getUsername(), user.getEmail());
    }
    private SiteUser login(String registrationId, Map<String, Object> attributes) throws ExecutionControl.NotImplementedException {
        if (registrationId.startsWith("google")) {
            String name = (String) attributes.get("name");
            String email = (String) attributes.get("email");

            return this.userService.socialLogin(registrationId, name, email);
        } else if(registrationId.startsWith("naver")) {
            Map<String, Object> response = (Map<String, Object>) attributes.get("response");
            String name = (String) response.get("name");
            String email = (String) response.get("email");

            return this.userService.socialLogin(registrationId, name, email);
        }
        throw new ExecutionControl.NotImplementedException("Implemented Google and Naver login only");
    }

로그인을 해보면 잘 되는 것을 확인할 수 있다.

하지만 내 정보를 보면 gmail로 되어있는 괴기한 상황을 맞닥들이게 된다.

이러한 상황은 아까 말한 문제(하지만 이 경우 동일한 이름의 계정을 이미 누가 만든 경우 그 사람의 계정을 리턴하는 문제)에 대한 예시이다.

이를 해결하는 간단한 방법은 email 속성을 username으로 바꾸고, nickname을 따로 설정해 email로 로그인을 하게 하는 방법이다. 이는 상당 부분의 코드를 바꿔야 하므로 미래의 일로 두도록 하자.

전체 구현은 깃허브 참조.

 

Reference

728x90