Spring Boot + Spring Security + OAuth2 네이버 로그인 해보기

초보개발·2022년 7월 3일
1

Spring

목록 보기
35/37

외부 로그인은 기존 프로젝트에 적용해본적이 없어서 OAuth2에 대해 알아보고 정리해보았다.

1. 네이버 로그인 애플리케이션 등록

네아로 등록

  • 애플리케이션 이름: 사용할 애플리케이션의 이름을 작성해주면 된다.
  • 사용 API: 네이버 로그인 선택
  • 제공 정보 선택: 서비스에 활용할 정보들을 필수로 추가하거나 추가하면 된다. 여기에서는 별명(nickname), 이메일주소(email), 프로필 사진(profile_image), 성별(gender), 연령대(age) 정보를 가져오도록 했다.
  • 환경 추가: Web 환경에서 테스트할 것이므로 웹 추가
  • 서비스 URL: http://localhost 추가
  • 네이버 로그인 Callback URL: http://localhost:8080/login/oauth2/code/naver 추가

2. 생성후 개요 페이지에서 Client ID와 Client Secret 확인

이제 네이버 로그인 서비스를 적용할 Spring boot 애플리케이션을 작성해보자.

3. Spring boot App

  • Java11, gradle, Spring boot 2.7.1, Thymeleaf

Dependency

  • Spring Data JPA(MySQL), OAuth2-client, Spring security
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

생성 완료했다면, 메인 애플리케이션 클래스에 @EnableJpaRepositories 추가

application.properties 정보 추가하기

# registration
spring.security.oauth2.client.registration.naver.client-id=<client id>
spring.security.oauth2.client.registration.naver.client-secret=<client secret>
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=nickname,email,gender,age,profile_image
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

spring.mvc.pathmatch.matching-strategy=ant_path_matcher

spring.jpa.database=mysql
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=true

#spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect

spring.jpa.hibernate.use-new-id-generator-mappings=true

spring.jackson.serialization.fail-on-empty-beans=false

# database
spring.profiles.include=db
spring.datasource.url=jdbc:mysql://localhost:3306/oauthTest?characterEncoding=UTF-8
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=1234

spring.security.oauth2.client.registration.naver.scope에는 네이버에서 가져올 정보를 추가하면 되는것 같다.

4. SpringSeuciry Configuration 생성하기

기존에 사용했던 코드를 재사용하려 했으나 WebSecurityConfigurerAdapter는 deprecated되었다고 한다. 따라서 새롭게 권장되는 방식인 filterchain을 사용하여 작성해보았다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfig {

    private final UserService userService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/", "/css/**", "/images/**", "/js/**", "/profile").permitAll()
                .antMatchers("/api/**").hasRole(Role.USER.name())
                .anyRequest().authenticated().and()
                .logout().logoutSuccessUrl("/").and()
                .oauth2Login()
                .userInfoEndpoint()
                .userService(userService);

        return http.build();
    }
}
  • @EnableWebSecurity: Spring security 설정 활성화
  • authorizeRequests(): URL별 권환 관리를 설정하는 시작 지점
  • antMatchers(..): URL, Http method 별로 관리 가능
    • antMatchers("/", "/css/**", "/images/**", "/js/**", "/profile").permitAll(): 전체에서 열람 가능

    • antMatchers("/api/**").hasRole(Role.USER.name()): USER 권한을 가진 사람만 접근 가능

  • anyRequest(): 설정된 값 외에 나머지 URL
    • anyRequest().authenticated(): 나머지 URL은 인증된 사용자에게만 허용(로그인한 사용자만)
  • logout().logoutSuccessUrl("/"): 로그아웃 후 이동 페이지 지정
  • oauth2Login(): OAuth2 로그인 설정 시작 지점
  • userInfoEndpoint(): OAuth2 로그인 후 사용자 정보를 가져올 때의 설정 담당
  • userService(..): 로그인 성공 후 조치를 진행할 UserService 인터페이스 구현체 등록, 로그인 서버에서 정보를 가져오고 나서 추가로 진행하고자 하는 기능 명시 가능

5. 로그인 및 정보 저장할 User Entity, Dto 만들기

User Entity

@Builder
@AllArgsConstructor
@Getter @Entity
public class User {

    public User() {}

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

    @Column(nullable = false)
    private String nickname;

    @Column(nullable = false)
    private String email;
	
    @Column(name = "profile_picture")
    private String picture;

    @Column(nullable = false)
    private String gender;

    @Column(nullable = false)
    private String age; // 연령대

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

    public User update(String name, String picture) {
        this.name = name;
        this.picture = picture;

        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}
  • 네이버 인증후 가져올 정보들: nickname, email, picture, gender, age

다른 정보도 추가하고 싶다면 아래 참조 -> 개발 가이드

나는 헷갈리지 않기 위해 response에 담겨있는 이름과 똑같이 작성해서 사용했다.

사용자 권한 관리를 위한 Role enum 클래스 작성하기

@Getter
@RequiredArgsConstructor
public enum Role {

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

    private final String key;
    private final String value;
}

‼️ 주의할 점

  • 스프링 시큐리티에서 권한 코드에 항상 맨 앞에 ROLE_ 이 있어야 한다.
    • ROLE_GUEST, ROLE_UESR

인증된 사용자 정보만 필요한 SessionUser

@Getter
public class SessionUser implements Serializable {

    SessionUser() {}

    public SessionUser(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }

    private String nickname;

    private String email;

    private String picture;
    
    private String gender;
    
    private String age;
}
  • 이 클래스는 인증된 사용자 정보들만 담을 수 있는 클래스이다. 엔티티를 주고 받을 때 직접적으로 사용하는 것 보다 이렇게 Dto 클래스를 만들어 사용하는 것이 성능상 이점이 있다.
  • Serializable를 추가해 직렬화 기능을 가짐

6. UserService, Controller, Repository 만들기

@Service
@RequiredArgsConstructor
public class UserService 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();
		// naver, kakao 로그인 구분
        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.getNickname(), attributes.getPicture()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}
  • OAuth2UserService<OAuth2UserRequest, OAuth2User>의 구현체인 UserService
  • userNameAttributeName: OAuth2 로그인할 때 키가 되는 pk, 구글은 지원하나 네이버, 카카오는 기본적으로 지원하지 않는다고 한다.
  • OAuthAttributes: OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
  • SessionUser: 세션에 사용자 정보를 저장하기 위한 DTO 클래스

UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}

JPA에서 기본적으로 제공해주는 CRUD 메서드 말고 이메일로 사용자 객체를 찾는 메서드를 사용하기 때문에 작성해 주었다.

UserController

@RequiredArgsConstructor
@Controller
public class UserController {

    private final HttpSession httpSession;

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

7. OAuthAttributes 클래스 만들기

@Getter
@Builder
@RequiredArgsConstructor
public class OAuthAttributes {
    private final Map<String, Object> attributes;
    private final String nameAttributeKey;
    private final String name;
    private final String email;
    private final String picture;
    private final String gender;
    private final String age;

    public static OAuthAttributes of(String registrationId, Map<String, Object> attributes) {
        if("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }
        else {
            return ofKakao("id", attributes);
        }
    }


    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("nickname"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .gender((String) response.get("gender"))
                .age((String) response.get("age"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .gender(gender)
                .age(age)
                .role(Role.GUEST)
                .build();
    }
}

8. 간단한 Thymeleaf 뷰 만들기

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <title>Main Page</title>
</head>
<body>
<div sec:authorize="isAuthenticated()">
    Logged in as: <span id="user" th:text="${username}">None</span>
    <a href="/logout" class="btn btn-info active" role="button">Logout</a>
</div>
<a sec:authorize="isAnonymous()" href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
</body>

9. 로그인 확인하기

메인 페이지

네이버 로그인 페이지


그다음 기본 정보 제공 동의 후

네이버 로그인 완료

Reference


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

0개의 댓글