ABOUT ME

96년생 컴공

Today
-
Yesterday
-
Total
-
  • [프로젝트 리뷰] Spring Security OAuth2 소셜 로그인(구글, 네이버, 카카오) 구현
    FrameWork/Spring 2023. 2. 6. 12:11

     

    개발환경

    • STS 4.16.1
    • Springboot 3.0.1 (springsecurity6)
    • Thymeleaf
    • Java 17
    • JPA
    • Gradle 7.6

     

     

    전체 레포지토리

     

    GitHub - rkgh17/SeoulWalk: 휴먼 스프링부트 프로젝트

    휴먼 스프링부트 프로젝트. Contribute to rkgh17/SeoulWalk development by creating an account on GitHub.

    github.com

     


     

    사전설정

    네이버 / 구글 / 카카오 OAuth2 API 설정은 따로 기술하지 않습니다.

     

     

    Gradle 의존성 추가

     

    build.gradle

      dependencies{
    	implementation 'org.springframework.boot:spring-boot-starter-web'
    	compileOnly 'org.projectlombok:lombok'
    	annotationProcessor 'org.projectlombok:lombok'
    	testImplementation 'org.springframework.boot:spring-boot-starter-test'
    	developmentOnly 'org.springframework.boot:spring-boot-devtools'
    	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    	implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
    	implementation 'org.springframework.boot:spring-boot-starter-security'
    	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    	implementation 'org.springframework.boot:spring-boot-starter-validation'
        
      	//소셜 로그인 관련
    	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
      	//implementation group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1.1'
      }

     


    application.properties 설정

    인증 api 정보 설정

     

     

    application.properties

    spring.profiles.include=oauth

     

    application-oauth.properties

    # Google
    spring.security.oauth2.client.registration.google.redirect-uri= 본인의redirect-uri
    spring.security.oauth2.client.registration.google.client-id= 본인의client-id
    spring.security.oauth2.client.registration.google.client-secret= 본인의client secret key
    spring.security.oauth2.client.registration.google.scope=profile,email
    
    # Naver
    spring.security.oauth2.client.registration.naver.client-id = 본인의client-id
    spring.security.oauth2.client.registration.naver.client-secret= 본인의client secret key
    spring.security.oauth2.client.registration.naver.client-name=Naver
    spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
    spring.security.oauth2.client.registration.naver.redirect-uri= 본인의redirect-uri
    
    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 
    
    # KaKao
    spring.security.oauth2.client.registration.kakao.client-id = 본인의client-id
    spring.security.oauth2.client.registration.kakao.client-secret = 본인의client secret key
    spring.security.oauth2.client.registration.kakao.redirect-uri= 본인의redirect-uri
    spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
    spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email
    spring.security.oauth2.client.registration.kakao.client-name=kakao
    spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
    
    spring.security.oauth2.client.provider.kakao.authorization-uri= https://kauth.kakao.com/oauth/authorize
    spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
    spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
    spring.security.oauth2.client.provider.kakao.user-name-attribute=id

    .gitignore

    본인의 개인정보가 git에 올라가는게 싫다면 다음과 같이 수정

    ### OAUTH ###
    application-oauth.properties

    View

    static 하위에 로그인 버튼 png 파일을 넣어주고, 로그인화면과 로그아웃 화면을 먼저 만들어 주었다

     

     

    login.html

    "/oauth2/authorization/..." 부분은 Spring Security에서 기본적으로 제공하는 url이다.

     

    <html layout:decorate="~{layout}">
    <div layout:fragment="content" class="logincontainer">
    
    	<!--카카오-->
    	<div class="sociallogin">
        	<a href="/oauth2/authorization/kakao">
        		<img th:src="@{/img/sociallogin/kakao_login.png}"
        			 class="img-responsive"
        			 alt="..."/>
        	</a>
    	</div>
    	
    	<!--네이버-->
    	<div class="sociallogin">
        	<a href="/oauth2/authorization/naver">
        		<img th:src="@{/img/sociallogin/naver_login.png}"
        			 class="img-responsive"
        			 alt="..."/>
        	</a>
    	</div>
    	
    	<!--구글-->
    	<div class="sociallogin">
        	<a href="/oauth2/authorization/google">
        		<img th:src="@{/img/sociallogin/google_login.png}"
        			 class="img-responsive"
        			 alt="..."/>
        	</a>
    	</div>
    </div>
    </html>
     ▼로그인 화면보기

     

     

    logout.html

    <html layout:decorate="~{layout}">
    <div layout:fragment="content">
    	
    	<div class = "logincontainer">
    
    
    	<h2>로그아웃 하시겠습니까?</h2>
    	
    	<button style="border:0; border-radius:12px; margin-top:50px;
    			   width: 200px; height:60px;
    			   background-color:#1ABC9C">
    		<a style="font-weight:bold;
    				  font-size:23px;
    				  color:white;
    				  text-decoration : none;"
    				  
    				  
    		 th:href="@{/user/logout/do}">로그아웃</a>	
    	</button>
    
    	
    	</div>
    </div>
    </html>
     ▼로그아웃 화면보기

     


     

    Model

     

     

    SiteUser.java

    소셜 로그인 사용자 정보를 담을 Entity 생성

    import jakarta.persistence.Column;
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    
    @Getter
    @Entity
    @NoArgsConstructor
    public class SiteUser{
    	
    	// user 고유번호
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    	
    	// user name
    	@Column(nullable = false)
    	private String name;
    	
    	// user email
    	@Column(unique = true)
    	private String email;
        
    	// user nickname
    	@Column
    	private String nickname;
        
    	// user role
    	@Column(nullable = false)
    	private String role = "ROLE_USER";
    	
    	@Builder
    	public SiteUser(String name, String email, String nickname, String role){
    		this.name = name;
    		this.email = email;
    		this.nickname = nickname;
    		this.role = role;
    	}
    	
    	public SiteUser update(String name) {
    		this.name = name;
    		
    		return this;
    	}	
    
    }

     

     

    UserRepository.java

    unique key인 email을 이용하여 사용자를 찾기 위해 Repository 생성

    import java.util.Optional;
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.stereotype.Repository;
    
    @Repository
    public interface UserRepository extends JpaRepository<SiteUser, Long>{
    	Optional findByEmail(String email); // 사용자 조회
    }

     

     

    OAuthAttribute.java

    OAuth2 로그인을 통해 가져온 사용자 정보의 전달을 담당한다.

    import java.util.Map;
    import lombok.Getter;
    import lombok.Setter;
    
    @Getter
    @Setter
    public class OAuthAttributes {
        private Map<String, Object> attributes;
        private String nameAttributeKey;
        private String name;
        private String email;
        
        public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email) {
        	this.attributes = attributes;
        	this.nameAttributeKey = nameAttributeKey;
        	this.name = name;
        	this.email = email;
        }
        
        public OAuthAttributes() {
        }
        
        public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        
        	// 카카오-네이버-구글
            if(registrationId.equals("kakao")) {
        		return ofKakao(userNameAttributeName, attributes);
        	} else if(registrationId.equals("naver")) {
        		return ofNaver(userNameAttributeName, attributes);
        	}
        	return ofGoogle(userNameAttributeName, attributes);
        }
        
        // 카카오일때
        private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        	Map<String, Object> kakao_account = (Map<String, Object>) attributes.get("kakao_account");
        	System.out.println(kakao_account);
        	Map<String, Object> profile = (Map<String, Object>) kakao_account.get("profile");
        	
        	return new OAuthAttributes(attributes,
        			userNameAttributeName,
        			(String) profile.get("nickname"),
        			(String) kakao_account.get("email"));
        }
        
        // 네이버일때
        private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        	Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        	
        	return new OAuthAttributes(attributes,
        			userNameAttributeName,
        			(String) response.get("name"),
        			(String) response.get("email"));
        }
        
        // 구글일때
        private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        	return new OAuthAttributes(
        			attributes,
        			userNameAttributeName,
        			(String) attributes.get("name"),
        			(String) attributes.get("email"));
        }
        
        
        // 닉네임 설정 메서드
        public SiteUser toEntity() {
        	int idx = email.indexOf("@");
        	String nickname = email.substring(0,idx-4) + "****" + email.substring(idx+1, email.length()-4);
        	
        	return new SiteUser(name, email, nickname, "ROLE_USER");
        }
    }

    로그인하는 서비스가 kakao인지, google인지, naver인지 구분하여 매핑을 시켜준다.

    게시판 서비스를 생각했을때, 사용자 닉네임을 표시해주고 싶어서 toEntity메서드를 수정해 주었다.

     

     

    SessionUserDTO.java

    인증된 사용자 정보를 세션에 저장하는 DTO 클래스.

    import java.io.Serializable;
    import lombok.Getter;
    
    @Getter
    public class SessionUserDTO implements Serializable{
    	private String name;
    	private String email;
    	private String nickname;
    	private String role;
    	
    	public SessionUserDTO(SiteUser siteuser) {
    		this.name = siteuser.getName();
    		this.email = siteuser.getEmail();
    		this.nickname = siteuser.getNickname();
    		this.role = siteuser.getRole();
    	}
    
    }

     

    DTO 클래스를 따로 만드는 이유?

     

    Entity 클래스를 그대로 사용할 수도 있지만, 따로 만들어 주는 이유는 다음과 같다.

    1. 엔티티는 다른 엔티티와 관계를 가질 가능성이 있다.
    2. 엔티티 클래스에 직렬화(Serializable) 코드를 넣으면 직렬화 대상에 관계를 가진 자식들까지 포함되어 추가적인 문제를 발생할 확률이 높아진다.

     

    직렬화란..

     

    Java의 직렬화(Serialize)란?

    Java의 직렬화(Serialize)란? Java를 공부하고 Spring을 쓰다보면 계속해서 Serialize를 상속받은 클래스들을 볼 수 있었다. 도대체 직렬화란 무엇일까? 공부를 해보자 직렬화(Serialize) 자바 시스템 내부에

    go-coding.tistory.com

     

    자바 직렬화, 그것이 알고싶다. 실무편 | 우아한형제들 기술블로그

    {{item.name}} 자바의 직렬화 기술에 대한 대한 두 번째 이야기입니다. 실제 자바 직렬화를 실무에 적용해보면서 주의해야 할 부분에 대해 이야기해보려고합니다. 자바 직렬화 실제 업무에서 사용

    techblog.woowahan.com

     

     

     

    CustomOAuth2UserService.java

    로그인한 사용자의 정보를 활용하는 Service

    import java.util.Collections;
    import java.util.Optional;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
    import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
    import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
    import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
    import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
    import org.springframework.security.oauth2.core.user.OAuth2User;
    import org.springframework.stereotype.Service;
    import jakarta.servlet.http.HttpSession;
    
    @Service
    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User>{
    	
    	@Autowired
    	private UserRepository userRepository;
    	
    	@Autowired
    	private HttpSession httpSession;
    	
    	@Override
    	public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
    		
    		OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
    		OAuth2User oAuth2User = delegate.loadUser(oAuth2UserRequest);
    				
    		String registrationId = oAuth2UserRequest.getClientRegistration().getRegistrationId();
    		String userNameAttributeName = oAuth2UserRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
    		
    		OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
    		
    		SiteUser siteuser = saveOrUpdate(attributes);
    		
    		httpSession.setAttribute("user", new SessionUserDTO(siteuser));
    
    		return new DefaultOAuth2User(
    				Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"))
    				, attributes.getAttributes()
    				, attributes.getNameAttributeKey());
    	}
    	
    	// 저장된 사용자 정보면 Update
    	private SiteUser saveOrUpdate(OAuthAttributes attributes) {
        
    		SiteUser siteuser = userRepository.findByEmail(attributes.getEmail())
    				.map(entity -> entity.update(attributes.getName()))
    				.orElse(attributes.toEntity());
    		
    		return userRepository.save(siteuser);
    	}
    	
    	
    	// httpsession (사용자 검증)
    	public SessionUserDTO getSession() {
    		
    		SessionUserDTO user = (SessionUserDTO) httpSession.getAttribute("user");
    		
    		return user;
    	}
    	
    	
        // 사용자 조회 메서드 - 이메일
        public SiteUser getUser(String email) {
        	
        	// UserRepository - findByEmail
            Optional<SiteUser> siteUser = this.userRepository.findByEmail(email);
            
            // 조회 성공
            if (siteUser.isPresent()) {
            	System.out.println("조회 성공");
                return siteUser.get();
            }
    
            // 조회 실패
            else {
                return null;
            }
        }
    
    
    }

     

     

    SecurityConfig.java

    스프링 시큐리티 설정

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.web.SecurityFilterChain;
    import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
    import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
    import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
    import com.human.seoulroad.user.CustomOAuth2UserService;
    import com.human.seoulroad.user.CustomAuthSuccessHandler;
    import lombok.RequiredArgsConstructor;
    
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig{
    	
    	@Autowired
    	private CustomOAuth2UserService customOAuth2UserService;
    
    	@Bean
    	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    			http.csrf().disable();
    		
    			http.authorizeHttpRequests().requestMatchers(
    				new AntPathRequestMatcher("/**")).permitAll()
            
    				// h2콘솔관련
    				.and()
    					.csrf().ignoringRequestMatchers(
    						new AntPathRequestMatcher("/h2-console/**"))
    				.and()
    					.headers()
    					.addHeaderWriter(new XFrameOptionsHeaderWriter(
    							XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
                    	
    				// 로그인/로그아웃 관련
    				.and()
    					.logout()
    					.logoutUrl("/user/logout")
    					.logoutSuccessUrl("/")
    					.invalidateHttpSession(true)
    				.and()
    					.oauth2Login()
    					//.defaultSuccessUrl("/")
    					.successHandler(new CustomAuthSuccessHandler())
    					.userInfoEndpoint()
    					.userService(customOAuth2UserService)
    				;
    		return http.build();
        }
    
    }

     

    이슈 : WebSecurityConfigurerAdapter Deprecated...

    우리 프로젝트는 Spring Security 6을 사용했기 때문에...아래 문서를 참고하여 방식을 달리해줬다.

    누구처럼 검색하면서 4시간이나 날리지 말것..

     

     

     

    Spring | Home

    Cloud Your code, any cloud—we’ve got you covered. Connect and scale your services, whatever your platform.

    spring.io

     

     

    @Configuration

    - 스프링의 환경설정 파일임을 의미하는 애너테이션

     

    @EnableWebSecurity

    - 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션

     

    new AntPathRequestMatcher("/**")).permitAll();

    - 로그인을 하지 않더라도 모든 페이지에 접근할 수 있다. (로그인이 필요한 서비스는 따로 로직을 마련해두었다.)

     

    .logoutUrl("user/logout")

    - 우리는 로그아웃 버튼 클릭 시 로그아웃 페이지로 이동하게끔 처리를 해두었기 때문에, 따로 url을 지정해 두었다.

     

    .invalidateHttpSession(true)

    - 로그아웃시 세션 정보를 삭제함

     

    .defaultSuccessUrl("")

    - 로그인 성공 시 이동할 url

     

    .successHandler(new CustomAuthSuccessHandler())

    - 로그인시 추가적인 조작이 필요해서 따로 추가해주었다.

     

     

    ▼ 관련 포스팅

     

    [프로젝트 리뷰] Spring Security 로그인 후 이전 페이지로 이동 - Referer, addFlashAttribute

    개발환경 STS 4.16.1 Springboot 3.0.1 (springsecurity6) Thymeleaf Java 17 JPA Gradle 7.6 관련 포스팅 - 소셜 로그인 구현 [프로젝트 리뷰] Spring Security OAuth2 소셜 로그인(구글, 네이버, 카카오) 구현 개발환경 STS 4.16.

    rkgh17.tistory.com

     

     

    .userInfoEndpoint

    - 로그인 성공 후 사용자 정보를 가져올 때의 설정

     

    .userService(customOAuth2UserService)

    - 로그인 성공 시 우리가 생성한 customOAuth2UserService로 후속처리를 해준다는 의미

     


    Controller

     

    UserController.java

    import java.util.Map;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.servlet.support.RequestContextUtils;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletResponse;
    
    
    @Controller
    @RequestMapping("user")
    public class UserController {
    	
    	@GetMapping("login")
    	public String login(HttpServletRequest request) {
        
    		/* 로그인 성공 시 이전 페이지로 이동 */
    		String uri = request.getHeader("Referer");
    		
    		if (uri==null) {
    			// null일시 이전 페이지에서 addFlashAttribute로 보내준 uri을 저장
    			Map<String, ?> paramMap = RequestContextUtils.getInputFlashMap(request);
    			uri = (String) paramMap.get("referer");
    			
    			// 이전 url 정보 담기
    			request.getSession().setAttribute("prevPage", uri);
    
    		}else {
    			// 이전 url 정보 담기
    			request.getSession().setAttribute("prevPage", uri);
    		}	
    		
    		return "login";
    	}
    	
    	@GetMapping("logout")
    	public String logoutpage() {
    		
    		return "logout";
    	}
    	
    	@GetMapping("logout/do")
    	public String logout(HttpServletRequest request, HttpServletResponse response) throws Exception{
    		Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    		
    		if (auth != null) {
    			new SecurityContextLogoutHandler().logout(request, response, auth);
    		}
    		return "redirect:/main";
    	}
    
    }

    로그인 / 로그아웃 시 여러 상황들을 따로 조작해두었기 때문에 위와 같이 코드를 작성했다.

     

     


    Reference

     

     

     

    [Spring Security] 스프링 부트 OAuth2-client를 이용한 소셜(구글, 네이버, 카카오) 로그인 하기

    저번 시간에는 직접 컨트롤러에서 요청을 구현하여서 OAuth2 인증을 처리해봤습니다. 이번 시간에는 OAuth2-client 라이브러리를 이용해서, 소셜 로그인 API를 구현해보도록 하겠습니다. 개발 환경 Int

    iseunghan.tistory.com

     

     

    댓글

Designed by Tistory.