[스프링부트]5.3 구글 로그인 연동하기

Bummy·2022년 8월 29일
0

springboot

목록 보기
11/15

이번 구글 로그인 연동하기는 보안, 로그인 관련 파트라서 어려움을 많이 느꼈다. 여러번 사용해보고 토이 프로젝트에도 소셜 로그인 기능을 녹여봐야겠다.

User Entity

먼저 사용자 정보를 담당할 도메인인 User 클래스를 생성해주고 다음 코드를 작성해준다.
User

@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

    /*
    @Enumerated(EnumType.STRING)
    JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정함
    기본적으로는 int로 된 숫자가 저장된다.
    숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 가 없다.
    그래서 문자열로 저장될 수 있도록 선언
     */
    @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();
    }

}
  • @Eunmerated(EnumType.STRING) : JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정
    기본적으로는 int로 된 숫자가 저장된다.

각 사용자의 권한을 관리할 Enum 클래스 Role을 생성해준다.
Role

@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}

/*
스프링 시큐리티에서는 권한 코드에 항상 ROLE_이 앞에 있어야함
 */
  • 스프링 시큐리티에서는 권한 코드에 항상 Role_이 앞에 있어야만 함

User의 CRUD를 책임질 UserRepository도 생성해준다.
UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}
  • findByEmail : 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드

스프링 시큐리티

스프링 시큐리티를 설정해주기 위해 관련 의존성을 build.gradle에 추가해준다.

build.gradle

implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
  • spring-boot-starter-oauth2-client : 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
    spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.

OAuth 라이브러리를 이용한 소셜 로그인 설정 코드를 작성한다.
config.auth 패키지를 생성하고 해당 패키지에 시큐리티 관련 클래스를 모두 담아준다.

SecurityConfig

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http.csrf().disable()
                .headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/image/**", "/js/**", "/h2-console/**").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService);
    }
}

처음에 이 코드를 작성하질 않아서 정상적으로 작동하지 않았다. 만약 처음 로컬 url로 접속 시 index 페이지가 아닌 로그인 페이지로 이동하고 로그인 버튼을 눌러도 반응하지 않는다면 이 코드를 작성했는지 확인하면 된다.

  • @EnableWebSecurity : Spring Security 설정들을 활성화시켜 준다.
  • csrf().disable().headers().frameOptions().disable() : h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
  • authorizeRequests : URL별 권한 관리를 설정하는 옵션의 시작점
    authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있다.
  • antMatchers : 권한 관리 대상을 지정하는 옵션
    URL, HTTP 메소드별로 관리가 가능
    "/" 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 부여
    "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 설정
  • anyRequest : 설정된 값들 이외 나머지 URL들을 나타냄
    여기서는 authenticated()을 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용
    -logout().logoutSucessUrl("/") : 로그아웃 기능에 대한 여러 설정의 진입점
    로그아웃 성공 시 / 주소로 이동한다.
  • oauth2Login : OAuth2 로그인 기능에 대한 여러 설정의 진입점
  • userInfoEndpoint : OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
  • userService : 소셜 로그인 성공시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
    리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있음
    -> 즉 각 페이지별로 어떤 권한을 가진 사용자에게 보이게 할 것인지 설정하는 페이지인 것 같다.

CustomOAuth2UserService 클래스를 생성해서 구글 로그인 이후 가져온 사용자 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원
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 registratrionId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        OAuthAttributes attributes = OAuthAttributes.of(registratrionId, 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);
        }
    }
  • registrationid : 현재 로그인 진행 중인 서비스를 구분하는 코드
    네이버 로그인인지 구글 로그인인지 구분하기 위해 사용
  • userNameAttributeName : OAuth2 로그인 진행 시 키가 되는 필드값을 의미 Primary Key와 같은 의미
    구글의 경우 코드를 지원하지만 네이버, 카카오 등은 지원하지 않음
    네이버 로그인과 구글 로그인을 동시 지원할 때 사용된다.
  • OAuthAttributes : OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
  • SessionUser : 세션에 사용자의 정보를 저장하기 위한 Dto 클래스

OAuthAtrribute

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

    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes){
        if("naver".equals(registrationId)){
            return ofNaver("id", 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();
    }

    private static OAuthAttributes ofNaver(String userNameAtrributeName, 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(userNameAtrributeName)
                .build();
    }

    public User toEntity(){
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}
  • of() : OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 반환해야함
  • toEntity() : User 엔티티를 생성
    OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때 사용한다.

SessionUser

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

로그인 테스트

테스트를 위해 index.mustache에 코드를 수정해준다.

index.mustache

<h1>스프링부트로 시작하는 웹 서비스 Ver.2</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}}
<!--머스테치는 다른 언어와 달리 if문을 제공하지 않기에 최종값을 넘겨줘야함 -->
                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>
            {{/userName}}
        </div>
    </div>
    <br>
  • {{#userName}} : 머스테치는 if 기능을 제공하지 않기에 userName이 있다면 userName을 노출시키도록 구성

  • a href="/logout" : 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 url
    스프링 시큐리티에서 제공하기에 개발자가 해당 url에 해당하는 컨트롤러를 만들 필요가 없다.

  • {{^userName}} : 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용한다.

  • a href="/oauth2/authorization/google" : 스프링 시큐리티에서 기본적으로 제공하는 로그인 url, 스프링 시큐리티에서 제공하기에 개발자가 별도의 컨트롤러를 만들 필요 없다.


    index.mustache에서 userName을 사용할 수 있도록 IndexController에 userName을 model에 저장하는 코드를 추가한다.
    IndexController

    @GetMapping("/")
       public String index(Model model){ //Model: 서버 템플릿 엔진에서 사용할 수 있는 객체를 저장할 수 있다.
           //여기서는 postsService.findAllDesc()로 가져온 결과를 posts로 index.mustache에 전달한다.
           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"): 로그인 성공 시 세션에 SessionUser를 저장하도록 구성, 로그인 성공시 httpSession.getAtrribute("user")에서 값을 가져올 수 있도록 함

  • if(user != null) : 세션에 저장된 값이 있을 때만 model에 userName으로 등록

0개의 댓글