슬기로운 개발생활

Spring Boot 게시판 OAuth 2.0 구글 로그인 구현

by coco3o
반응형

Spring Boot에 Spring Security와 OAuth2.0을 사용해 소셜 로그인을 구현해보도록 하자.


1. 구글 OAuth 서비스 등록

필자는 이미 만들어놓은 프로젝트가 있어 예시 프로젝트를 하나 생성해보도록 하겠다.

1-1. 구글 OAuth 서비스를 등록하기 위해 다음 링크를 통해 콘솔로 접속한다.
https://console.cloud.google.com/apis

Google Cloud Platform

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

accounts.google.com

1-2. 콘솔 대시보드화면에서 프로젝트 만들기를 클릭해 프로젝트 생성화면으로 이동한다.

1-2. 프로젝트 이름을 작성하고 생성한다.

1-3. 구글 OAuth 클라이언트를 사용하기 위해 OAuth 동의화면이 먼저 구성되어 있어야 한다.

1-4. 외부 선택 후 만들기

1-5. 앱 이름, 이메일을 입력 후 저장 후 계속

1-6. 범위 추가 또는 삭제 클릭 > 기본값 email, profile, openid 선택 > 저장 후 계속 > 테스트 사용자는 패스 > 완료

1-7. 사용자 인증 정보 이동 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID 클릭

1-8. 유형, 이름, 승인된 리다이렉션 URI(http://localhost:8080/login/oauth2/code/google) 설정 후 만들기

1-9. 클라이언트 아이디, 클라이언트 비밀번호 GET 완료.


2. build.gradle

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

3. resources 폴더 하위 application-oauth.properties 추가

# 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

scope 기본값은 email, profile, openid 인데 profile, email만 입력한 이유는

※ openid가 있으면 OpenId Provider로 인식하기 때문인데, 이렇게 되면 OpenId Provider인 서비스와 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 OAuth2Service를 만들어야 한다.

3-1.  application.properties에 등록
application-xxx.properties로 만들면 xxx라는 profile이 생성되어 관리할 수 있다.
해당 정보를 가져오기 위해서 application.properties에 profiles.include=xxx를 입력한다.

# OAUTH
spring.profiles.include=oauth


3-2. .gitignore에 application-oauth.properties 등록
깃허브에 application-oauth.properties파일 안의 클라이언트id와 pw를 보여주지 않기 위해
.gitignore에 다음과 같이 등록해준다.

application-oauth.properties

4. User, TimeEntity, UserRepository, Role

User

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Entity
public class User extends TimeEntity {

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

    @Column(nullable = false, length = 30, unique = true)
    private String username; // 아이디

    @Column(nullable = false, unique = true)
    private String nickname;

    @Column(length = 100)
    private String password;

    @Column(nullable = false, length = 50)
    private String email;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    /* 회원정보 수정을 위한 set method*/
    public void modify(String nickname, String password) {
        this.nickname = nickname;
        this.password = password;
    }

    /* 소셜로그인시 이미 등록된 회원이라면 수정날짜만 업데이트하고
     * 기존 데이터는 그대로 보존하도록 예외처리 */
    public User updateModifiedDate() {
        this.onPreUpdate();
        return this;
    }

    public String getRoleValue() {
        return this.role.getValue();
    }
}

TimeEntity

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {

    @Column(name = "created_date")
    @CreatedDate
    private String createdDate;

    @Column(name = "modified_date")
    @LastModifiedDate
    private String modifiedDate;

    /* 해당 엔티티를 저장하기 이전에 실행 */
    @PrePersist
    public void onPrePersist(){
        this.createdDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"));
        this.modifiedDate = this.createdDate;
    }

    /* 해당 엔티티를 업데이트 하기 이전에 실행*/
    @PreUpdate
    public void onPreUpdate(){
        this.modifiedDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"));
    }
}

UserRepository

/* OAuth */
Optional<User> findByEmail(String email);

Role

@Getter
@RequiredArgsConstructor
public enum Role {
    USER("ROLE_USER"),
    ADMIN("ROLE_ADMIN"),
    SOCIAL("ROLE_SOCIAL"); // OAuth

    private final String value;
}

5. SecurityConfig , CustomOAuth2UserService, OAuthAttributes

SecurityConfig

@RequiredArgsConstructor
@Configuration 
@EnableWebSecurity 
@EnableGlobalMethodSecurity(prePostEnabled = true) // 특정 주소로 접근하면 권한 및 인증을 미리 체크
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomUserDetailsService myUserDetailsService;

    private final AuthenticationFailureHandler customFailureHandler;
    /* OAuth */
    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public BCryptPasswordEncoder Encoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
     return super.authenticationManagerBean();
    }

    /* 시큐리티가 로그인 과정에서 password를 가로챌때 어떤 해쉬로 암호화 했는지 확인 */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserDetailsService).passwordEncoder(Encoder());
    }

    /* static 관련설정은 무시 */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web
                .ignoring().antMatchers( "/css/**", "/js/**", "/img/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().ignoringAntMatchers("/api/**") /* REST API 사용 예외처리 */
                .and()
                .authorizeRequests()
                .antMatchers("/", "/auth/**", "/posts/read/**", "/posts/search/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/auth/login")
                .loginProcessingUrl("/auth/loginProc")
                .failureHandler(customFailureHandler)
                .defaultSuccessUrl("/")
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .invalidateHttpSession(true).deleteCookies("JSESSIONID")
                .logoutSuccessUrl("/")
                .and() /* OAuth */
                .oauth2Login()
                .userInfoEndpoint() // OAuth2 로그인 성공 후 가져올 설정들
                .userService(customOAuth2UserService); // 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
    }
}

CustomOAuth2UserService

/**
 * Security UserDetailsService == OAuth OAuth2UserService
 * */
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession session;

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

        /* OAuth2 서비스 id 구분코드 ( 구글, 카카오, 네이버 ) */
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        /* OAuth2 로그인 진행시 키가 되는 필드 값 (PK) (구글의 기본 코드는 "sub") */
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();

        /* OAuth2UserService */
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes);
        
        /* 세션 정보를 저장하는 직렬화된 dto 클래스*/
        session.setAttribute("user", new UserSessionDto(user)); 

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

    /* 소셜로그인시 기존 회원이 존재하면 수정날짜 정보만 업데이트해 기존의 데이터는 그대로 보존 */
    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
                .map(User::updateModifiedDate)
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}
  • 각 필드인 registrationId, userNameAttributeName, attributes를 로그로 확인하면 나오는 값은 다음과 같다.
    • registrationId : google
    • userNameAttributeName : sub
    • attributes.getAttributes() : {sub=XXX, name=XXX, given_name=XX(이름), family_name=X(성), picture=XXX, email=XXXX@gmail.com, email_verified=true, locale=ko}


OAuthAttributes

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String username;
    private String nickname;
    private String email;
    private Role role;
    
    public static OAuthAttributes of(String registrationId,
                                     String userNameAttributeName,
                                     Map<String, Object> attributes) {
        /* 구글인지 네이버인지 카카오인지 구분하기 위한 메소드 (ofNaver, ofKaKao) */

        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName,
                                            Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .username((String) attributes.get("email"))
                .email((String) attributes.get("email"))
                .nickname((String) attributes.get("name"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
    
    public User toEntity() {
        return User.builder()
                .username(email)
                .email(email)
                .nickname(nickname)
                .role(Role.SOCIAL)
                .build();
    }
}

username(로그인 아이디)는 이메일로 저장하도록 설정했다.


6. Mustache

header.mustache

<!DOCTYPE HTML>
<html>
<head>
    <title>Board Service</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.6.0/font/bootstrap-icons.css">
    <link rel="stylesheet" href="/css/app.css">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<div id="header"class="d-flex bd-highlight">
    <a href="/" class="p-2 flex-grow-1 bd-highlight">Board Service</a>
    <form action="/posts/search" method="GET" class="form-inline p-2 bd-highlight" role="search">
        <input type="text" name="keyword" class="form-control" id="search" placeholder="검색">
        <button class="btn btn-success bi bi-search"></button>
    </form>
</div>

<nav id ="nav">
    <div class="text-right">
        {{#user}}
            <span class="mx-3">{{user}}님 안녕하세요!</span>
            <a href="/logout" class="btn btn-outline-dark">로그아웃</a>
            <a href="/modify" class="btn btn-outline-dark bi bi-gear"></a>
        {{/user}}
        {{^user}}
            <a href="/oauth2/authorization/google" role="button" class="btn btn-outline-danger bi bi-google"> 로그인</a>
            <a href="/auth/login" role="button" class="btn btn-outline-dark bi bi-lock-fill"> 로그인</a>
            <a href="/auth/join" role="button" class="btn btn-outline-dark bi bi-person-circle"> 회원가입</a>
        {{/user}}
    </div>
</nav>

로그인 url은 /oauth2/authorization/google 으로 연결한다.


7. 결과 확인

7-1. 구글 로그인 버튼 클릭

7-2. 구글 계정 선택 혹은 새로운 로그인 화면

7-3. 로그인 성공

반응형

블로그의 정보

슬기로운 개발생활

coco3o

활동하기