728x90

개요

시큐리티를 사용해 OAuth2 로그인을 진행하고자 한다. 나는 그중 카카오와 네이버에 대해 해보겠다.

앞에서 진행한 코드를 그대로 사용해서 이어서 진행한다. 

Spring Security + Session 를 사용한 회원가입, 로그인, 로그아웃 구현 :: 미정 (tistory.com)

 

OAuth2 에 대한 설명은 

Spring : OAuth2 로그인 방식 :: 미정 (tistory.com) 

 

Spring : OAuth2 로그인 방식

OAuth 2.0 이란?서비스에서 사용자 개인정보와 인증에 대한 책임을 지지 않고 신뢰할 만한 타사 플랫폼에 위임개인정보 관련 데이터를 직접 보관하는 것은 리스크가 크며 보안적으로 문제되지 않

eesko.tistory.com

이걸 보면 된다.

 

 

 

네이버, 카카오 소셜 로그인 신청

나는 네이버와 카카오에 대해서 신청을 했고

이 분의 영상을 보고 네이버에 대한 신청은 했다. 

https://youtu.be/L8yAtjjOhDo?feature=shared

 

카카오 신청은 이 분의 글을 보고 신청 했다.

[Spring Boot] 로그인 기능 구현 (6) - 카카오 로그인 (OAuth 2.0) — 공대생의 코딩 일기 (tistory.com)

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6

oauth2

 

 

프로젝트 파일 구조

 

 

 

의존성 설치

  • Build.gradle
	// OAuth2 Client
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

    // security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    runtimeOnly 'com.mysql:mysql-connector-j'
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //타임리프에서 스프링시큐리티를 쓸수있게.
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    implementation 'org.jsoup:jsoup:1.14.3'

    //validation사용
    implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
  • Build.gradle 에 security와 OAuth2 의존성을 추가해준다.

 

  • Application.yml
spring:
  application:
    name: [projectname]

  datasource:
    url: jdbc:mysql://localhost:3306/[db]
    username: [usernmae]
    password: [password]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: [설정]
            client-secret: [설정]
            scope:
              - account_email
              - profile_nickname
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            client-name: Kakao
            client-authentication-method: client_secret_post

          naver:
            client-id: [설정]
            client-secret: [설정]
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            client-name: Naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email

        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          naver:
            authorization_uri: https://nid.naver.com/oauth2.0/authorize
            token_uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user_name_attribute: response

server:
  port: 8080
  • application.yml 파일에 OAuth2 설정을 해준다. 
  • 위에서 신청한 네이버와 카카오 설정들을 맞게 설정해준다.

 


1. 엔티티 수정

  • User.java
@Entity
@Table(name = "users")
@Getter @Setter
public class User {

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

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

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String usernick;

    @Column(name = "registrationDate", nullable = false)
    private LocalDateTime registrationDate  = LocalDateTime.now();

    private String filename;
    private String filepath;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
    
    /**
     * Oauth2 관련
     */
    // providerId : 구굴,카카오,네이버 로그인 한 유저의 고유 ID가 들어감
    private String loginId; // Unique identifier for OAuth users

    // provider : google, kakao, naver 이 들어감
    private String provider;
}
  • 아래 2개의 칼럼을 추가해준다.
  • 그리고 위의 엔티티는 최종이고 아래 부분은 이전 엔티티에서 닉네임 컬럼 부분을 보면 usernick이 중복 불가능하게 했지만 그 부분을 수정해줬다.
  • 하지만 현재 디비에 값들이 저장된 상태라서 엔티티에서 변경을 한후 sql 구문을 통해 수정해줬다.
// 변경 전
@Column(nullable = false, unique = true)
private String usernick;

// 변경 후 
@Column(nullable = false)
private String usernick;
# 1 
SHOW INDEX FROM users WHERE Column_name = 'usernick';

# 2 Key_name 의 값을 확인

# 3
ALTER TABLE users DROP INDEX UK5rqu0cqcxxar2go1qlddeia02;

 

2. Oauth2 응답 객체

  • OAuth2Response.java
public interface OAuth2Response {

    //제공자 (Ex. naver, google, ...)
    String getProvider();

    //제공자에서 발급해주는 아이디(번호)
    String getProviderId();

    //이메일
    String getEmail();

    //사용자 실명 (설정한 이름)
    String getName();
}
  • 각 사이트마다 인증 데이터 규격이 다르기 때문에 OAuth2Response 인터페이스를 만들어 구별한다.
  • 카카오, 구글, 네이버, 깃허브 등등 이 인터페이스를 상속받아 각각의 규격에 맞게 구현한다.
  • 현재 나는 사용자 이메일, 설정 이름만 가져왔고 프로필 이미지, 생일 등등 가져올 수 있다.

 

Q. 여기서 잠깐!

  • 네이버와 카카오, 구글의 사용자 정보 response 를 보자.
getAttributes : 
{
	id=12345678, 
	connected_at=2024-07-26T09:56:54Z, 
	properties={
    	nickname=엉이
    }, 
	kakao_account={
    	profile_nickname_needs_agreement=false, 
                             profile={nickname=엉이, is_default_nickname=false},
                             has_email=true, 
                             email_needs_agreement=false, 
                             is_email_valid=true, 
                             is_email_verified=true, 
                             email=엉이@kakao.com
    }
}

 provider : kakao
getAttributes :
{
	resultcode=00,
    	message=success,
    	response={
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : naver
getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google
  • 각 사이트마다 출력 값들이 다르다.
  • 그러기 때문에 기본적인 형태가 다르기 때문에 위에서 만든 OAuth2Response 인터페이스를 만들어서 네이버, 카카오 응답 객체에 맞게 고칠 수 있도록 기본적인 틀을 만들어 둔다.
  • 그럼 카카오, 네이버, 구글 등 OAuth2Response 인터페이스를 상속받아서 구현하면 된다.

 

 

  • KakaoResponse.java
getAttributes : 
{
	id=12345678, 
	connected_at=2024-07-26T09:56:54Z, 
	properties={
    	nickname=엉이
    }, 
	kakao_account={
    	profile_nickname_needs_agreement=false, 
                             profile={nickname=엉이, is_default_nickname=false},
                             has_email=true, 
                             email_needs_agreement=false, 
                             is_email_valid=true, 
                             is_email_verified=true, 
                             email=엉이@kakao.com
    }
}

 provider : kakao

위의 출력값을 보면서 아래 코드를 이해하자.

public class KakaoResponse implements OAuth2Response{

    private Map<String, Object> attribute;

    public KakaoResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("Kakao attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");
        return kakaoAccount.get("email").toString();
    }

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");
        return properties.get("nickname").toString();
    }
}
  • 카카오는 출력 데이터 내부 안에 kakao_account 라는 데이터가 존재하고 그 안에 사용자 이메일, 이름 등에 대한 값이 있기 때문에 get()으로 값을 꺼내온다.

 

 

 

  • NaverResponse.java
getAttributes :
{
	resultcode=00,
    	message=success,
    	response={
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : naver

위의 출력값을 보면서 아래 코드를 이해하자.

public class NaverResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public NaverResponse(Map<String, Object> attribute) {
        this.attribute = (Map<String, Object>) attribute.get("response");
        System.out.println("naver attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }
}
  • 네이버는 데이터 내부 안에  response 라는 데이터가 또 있고 그 안에 사용자의 정보에 대한 값이 저장되어 있기 때문에 get으로 가져와준다.

 

 

  • googleResponse.java
getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google
public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("google attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }
}

구글에 대해서는 카드 인증이 필요해서 귀찮기 때문에.. 하지는 않았지만 정리를 위해 작성했다. 실제 구글 oauth2는 신청하지 않았기에 사용하지 않았다.

 

 

 

3. OAuth2User

  • CustomOAuth2User.java
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2Response oAuth2Response;
    private final String role;

    public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {
        this.oAuth2Response = oAuth2Response;
        this.role = role;
    }

	// 현재는 null 했지만 이 값을 지정하면 
    // 카카오,네이버 등 로그인했으면 거기에 맞는 프로필 사진이나 가져올 수 있음
    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

	// role에 해당하는 값
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new SimpleGrantedAuthority(role));
        return collection;
    }
    
    @Override
    public String getName() {
        return oAuth2Response.getName();
    }

	// 사용안함
    public String getUsername() {
        return oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
    }
}
  • 카카오, 네이버, 구글 서비스로부터 받은 특정 사이트의 응답 값과 롤에 대한 값을 받는 클래스이다. 
  • 이 클래스를 통해 특정 값과 롤에 대해 정의한다.

 

4. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService; // 기본 세션 로그인
    private final CustomOauth2UserService customOAuth2UserService; // OAuth2 등록

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login").permitAll()
                        .requestMatchers("/oauth-login/admin").hasRole("ADMIN")
                        .requestMatchers("/oauth-login/info").authenticated()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login") // 로그인 페이지의 경로
                        .loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable())
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(configurationSource()))
                // OAuth 등록
               .oauth2Login(oauth2 -> oauth2
                        .loginPage("/api/users/login") // 커스텀 로그인 방식 지정
                        // customOAuth2UserService 등록
                        .userInfoEndpoint(userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2UserService))
                        .failureUrl("/loginFailure")
                        .defaultSuccessUrl("/api/users/info")
                        .authorizationEndpoint(authorization -> authorization
                                .baseUri("/oauth2/authorization")
                        )
                        .permitAll()
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET","POST","DELETE"));
        source.registerCorsConfiguration("/**",config);
        return source;
    }
}
  • Oauth2 설정을 해준다.

 

 

5. OAuth2UserService 구현

  • OAuth2UserService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {
    // DefaultOAuth2UserService는 OAuth2UserService의 구현체라서 
    // DefaultOAuth2UserService 또는 OAuth2UserService 아무거나 상속해도 된다.

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
	
    // loadUser --> 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드
    // userRequest 를 통해 카카오, 네이버, 구글 등등 인증 데이터가 넘어 올 것이다.
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        System.out.println("OAuth2User attributes: " + oAuth2User.getAttributes());
  		
        // 네이버, 구글, 깃허브 등등 어떤 어떤 인증 값인지 구별하기 위해 인증 제공자를 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        
        // 각 사이트마다 인증 데이터 규격이 다르기 때문에 OAuth2Response 를 만들어 구별
        // 인증 제공자에 따라 OAuth2Response를 생성
        OAuth2Response oAuth2Response = null;
        
        // 그 값이 네이버면
        if (registrationId.equals("naver")) {
            log.info("naver 로그인");
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
      	// 그 값이 카카오면
        } else if (registrationId.equals("kakao")) {
            log.info("kakao 로그인");
            oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        // 둘다 아니면
        } else {
            System.out.println("로그인 실패");
            throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
        }

        // 데이터 베이스 저장 관련 구현
        
        String provider = oAuth2Response.getProvider();
        String providerId = oAuth2Response.getProviderId();

        String username = provider + " " + providerId;
        Optional<User> userOptional = userRepository.findByUsername(username);

        String roleName = "USER";
        Optional<Role> roleOptional = roleRepository.findByName(roleName);
        Role role;
        if (roleOptional.isEmpty()) {
            role = new Role(roleName);
            role = roleRepository.save(role);  // Save the new role and get the persisted instance
        } else {
            role = roleOptional.get();
        }

        User user;
        if (userOptional.isEmpty()) {
            user = User.builder()
                    .username(username)
                    .email(oAuth2Response.getEmail())
                    .roles(Set.of(role))
                    .loginId(oAuth2Response.getProviderId())
                    .provider(oAuth2Response.getProvider())
                    .password("defaultPassword")
                    .registrationDate(LocalDateTime.now())
                    .usernick(oAuth2Response.getName())
                    .build();
            userRepository.save(user);
        } else {
            user = userOptional.get();
            user.setUsername(username);
            user.setEmail(oAuth2Response.getEmail());
            user.setLoginId(oAuth2Response.getProviderId());
            user.setProvider(oAuth2Response.getProvider());
            user.getRoles().add(role);
            user.setRegistrationDate(LocalDateTime.now());
            user.setUsernick(oAuth2Response.getName());
            userRepository.save(user);
            roleName = user.getRoles().iterator().next().getName();
        }

        System.out.println("User saved: " + user);

		// 특정 사이트의 응답 값과 역할을 받는 CustomOAuth2User 클래스
        // 인증된 사용자 정보를 반환
        return new CustomOAuth2User(oAuth2Response, roleName);
    }
}
  • Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
  • 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
  • loadUser는 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드이다.
  • 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.

 

 

6. 컨트롤러

  • UserController.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/api/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

    @GetMapping("/login")
    public String showLoginForm() {
        return "/user/loginform";
    }

    // 일반 로그인 회원의 정보 가져오기
    @GetMapping("/home")
    public String index(Model model, Authentication authentication) {

        String username = authentication.getName();
        Optional<User> userOptional = userServiceimpl.findByEmail(username);
        if (userOptional.isPresent()) {
            model.addAttribute("user", userOptional.get());
            return "user/home"; // user/home.html 템플릿으로 이동
        }
        return "redirect:/api/users/login";
    }

    // oauth2 유저의 정보 가져오기
    @GetMapping("/info")
    public String info(Model model, Authentication authentication) {
        CustomOAuth2User userDetails = (CustomOAuth2User) authentication.getPrincipal();

        model.addAttribute("name", userDetails);
        return "/user/info";
    }
}
  • 해당 컨트롤러에서 /api/users/info앤트포인트의 값으로 화면이 보여질 것이다.

 

 

7. 로그인 폼 및 로그인 한 회원의 정보를 보여주기

  • loginform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/oauth2/authorization/kakao">카카오 로그인</a>
<a href="/oauth2/authorization/naver">네이버 로그인</a>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
  • info.html
  • OAuth2 로그인 후 보여지는 화면이다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<h1> 당신은 OAUth2 로그인을 하셨습니다. </h1>
<p>안녕하세요, <span th:text="${name}"></span>님</p>
<p>안녕하세요, 당신이 로그인한 플랫폼 : <span th:text="${name.username}"></span>님</p>
<p>안녕하세요, 플랫폼 이름 : <span th:text="${name.name}"></span>님</p>
<p>안녕하세요, 당신의 등급 :  <span th:text="${name.authorities}"></span>님</p>
</body>
</html>
  • home.html
  • 일반 로그인 후 보여지는 화면이다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<p>안녕하세요, 로그인 : <span th:text="${user.email}"></span>님</p>
<p>이름: <span th:text="${user.username}"></span></p>
</body>
</html>

 

이렇게 하면 기본 Oauth2 세션 로그인은 성공할 것이고 이제 여기에 jwt를 사용해서 구현해볼 것이다.

728x90

+ Recent posts