수업내용/프로젝트

[Spring/Security] 소셜 로그인

주니어주니 2023. 3. 2. 23:24

 

  • 개발환경
    • eclipse
    • Spring Security
    • Maven
    • jsp

 

 

0. 소셜 로그인 과정 

 

OAuth2 : 외부 서비스로 소셜 로그인을 이용해 사용자 연동 처리 하는 프로토콜

 

 

카카오에서 발급받은 REST API 키를 애플리케이션에 전달해 인가코드 받음

→ 받은 인가코드를 ‘리다이렉트 URI’로 지정된 곳으로 전달

→ 인가코드 + 비밀키 = Access Token 생성

→ 액세스 토큰으로 사용자 인증

→ 사용자 정보 제공

→ 사용자 정보를 데이터베이스에 저장

→ 취득된 사용자 정보 로그인 완료

 

 

1. 라이브러리 설정 

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

 

 

2. application.yml 설정

따로 분리 : 보안 설정한 것은 gitignore 하기 위해서

 

* application.yml

spring:
  profiles:
    include:
    - auth

 

* application-auth.yml

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            authorization-grant-type: authorization_code
            client-id: [발급받은 id 키]
            client-secret: [발급받은 secret 키]
            client-authentication-method: POST
            redirect-uri: http://localhost/login/oauth2/code/kakao
            scope:
              - profile_nickname
              - profile_image
              - account_email
            client-name: Kakao
        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

 

 

3. OAuth2UserInfo 

- 카카오 로그인 시의 정보 (attributes)를 저장하기 위함

- 이 클래스를 상속받아서 카카오, 구글, 네이버 UserInfo 객체 생성

 

package com.example.security.oauth.info;

import java.util.Map;

public abstract class OAuth2UserInfo {

	private Map<String, Object> attributes;

	public OAuth2UserInfo(Map<String, Object> attributes) {
		this.attributes = attributes;
	}
	
	public Map<String, Object> getAttributes() {
		return attributes;
	}

	public abstract String getId();
	public abstract String getName();
	public abstract String getEmail();
	public abstract String getPhoto();
}

 

 

4. KakaoOAuth2UserInfo

 

* 로그인 시 정보 (attributes) 

 

package com.example.security.oauth.info;

import java.util.Map;

public class KakaoOAuth2UserInfo extends OAuth2UserInfo {

	public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
		super(attributes);
	}

	@Override
	public String getId() {
		return getAttributes().get("id").toString();
	}
	
	@Override
	@SuppressWarnings("unchecked")
	public String getName() {
		Map<String, Object> properties = (Map<String, Object>) getAttributes().get("properties");
		if(properties == null) {
			return null;
		}
		return (String) properties.get("nickname");
	}
	
	@Override
	@SuppressWarnings("unchecked")
	public String getEmail() {
		Map<String, Object> kakaoAccount = (Map<String, Object>) getAttributes().get("kakao_account");
		if(kakaoAccount == null) {
			return null;
		}
		return (String) kakaoAccount.get("email");
	}
	
	@Override
	@SuppressWarnings("unchecked")
	public String getPhoto() {
		Map<String, Object> properties = (Map<String, Object>) getAttributes().get("properties");
		if(properties == null) {
			return null;
		}
		return (String) properties.get("profile_image");
	}
}

 

 

5. CustomOAuth2UserService 

- OAuth2 공급자로부터 소셜 로그인한 정보 받아서 처리 후 CustomOAtuth2User객체 반환

- 소셜로그인의 providerType에 따라 각 소셜로그인 구현객체(KakaoOAuth2UserInfo)에서 로그인정보(attributes)를 추출

- DB에 해당 아이디로 저장된 정보가 있으면 → 최신 정보로 업데이트

  저장된 정보가 없으면 → 회원가입 처리 ( + userRole도 설정 )

 

package com.example.security.service;

import java.util.Collections;
import java.util.Date;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
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.OAuth2User;
import org.springframework.stereotype.Service;

import com.example.mapper.UserMapper;
import com.example.mapper.UserRoleMapper;
import com.example.security.oauth.info.KakaoOAuth2UserInfo;
import com.example.security.oauth.info.OAuth2UserInfo;
import com.example.security.vo.CustomOAuth2User;
import com.example.vo.User;
import com.example.vo.UserRole;

import lombok.extern.slf4j.Slf4j;

// 소셜 로그인한 정보 처리 후 OAuth2 객체 반환
@Slf4j
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

	@Autowired
	private UserMapper userMapper;
	@Autowired
	private UserRoleMapper userRoleMapper;
	
	@Override
	public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
		
		// 기본적으로 제공되는 DefaultOAuth2UserSevice 사용 : OAuth 2.0 공급자로부터 받은 액세스 토큰을 사용하여 사용자 정보를 가져옴
		OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
		OAuth2User oAuth2User = oAuth2UserService.loadUser(oAuth2UserRequest);
		
		// 현재 사용자가 로그인한 OAuth 2.0 서비스의 ID를 가져옴
		String providerType = oAuth2UserRequest.getClientRegistration().getRegistrationId();
		
        OAuth2UserInfo userInfo = null;
        
        if (providerType.equals("kakao")) {
        	userInfo = new KakaoOAuth2UserInfo(oAuth2User.getAttributes());
        }
        
		User savedUser = userMapper.getUserById(userInfo.getId());
        
        // 해당 아이디의 사용자가 이미 있을 경우 최신정보로 업데이트
        if(savedUser != null) {
        	if(!providerType.equals(savedUser.getProviderType())) {
        		throw new OAuth2AuthenticationException("소셜로그인 공급자가 일치하지 않습니다.");
        	}
        	updateUser(savedUser, userInfo);
        	
        // 해당 아이디의 사용자가 없을 경우 사용자 등록(가입)
        } else {
        	savedUser = createUser(userInfo, providerType);
        }
        
        UserRole userRole = userRoleMapper.getUserRoleByNo(savedUser.getNo());
        
        // 로그인한 사용자 객체, 사용자의 권한 정보, 소셜 로그인에 사용된 사용자 정보를 담아서 CustomOAuth2User 객체 생성
		return new CustomOAuth2User(
				savedUser,
				Collections.singletonList(new SimpleGrantedAuthority(userRole.getRoleName())),
				userInfo.getAttributes());
	}
	
	private User createUser(OAuth2UserInfo userInfo, String providerType) {
		// user 정보를 먼저 등록하고 
		User user = new User();
		user.setId(userInfo.getId());
		user.setName(userInfo.getName());
		user.setEmail(userInfo.getEmail());
		user.setPhoto(userInfo.getPhoto());
		user.setProviderType(providerType);
		user.setCreatedDate(new Date());
		user.setUpdatedDate(new Date());
		userMapper.insertUser(user);
		
		// 등록된 user의 no를 가지고 userRole 등록
		UserRole userRole = new UserRole(user.getNo(), "ROLE_USER");
		userRoleMapper.insertUserRole(userRole);
		return user;
	}

    private User updateUser(User user, OAuth2UserInfo userInfo) {

        if (userInfo.getName() != null && user.getName().equals(userInfo.getName())) {
            user.setName(userInfo.getName());
            user.setEmail(userInfo.getEmail());
            user.setPhoto(userInfo.getPhoto());
        }
        userMapper.updateUser(user);
        return user;
    }
}

 

 

6. CustomOAuth2User

- UserDetails처럼 LoginUser를 상속, OAuth2User를 구현

- OAuth2UserService의 load 메소드 실행 → OAuth2User타입인 이 객체 반환

- 얘가 Authentication의 principal에 담김

- 이 객체의 getName() 호출 → 로그인한 유저의 아이디(pk)

  이 객체의 getNickName() 호출 → 로그인한 유저의 이름 (부모객체인 LoginUser에 저장된 것)

 

package com.example.security.vo;

import java.util.Collection;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;

import com.example.vo.User;

public class CustomOAuth2User extends LoginUser implements OAuth2User{
	
	private Collection<GrantedAuthority> authorites; 
	private Map<String, Object> attributes;
	
	public CustomOAuth2User(User user, Collection<GrantedAuthority> authorities, Map<String, Object> attributes) {
		super(user);
		this.authorites = authorities;
		this.attributes = attributes;
	}

	@Override
	public Map<String, Object> getAttributes() {
		return attributes;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return authorites;
	}
	
	@Override
	public String getName() {	// authenticated principal의 name(pk값) 
		return getId();		
	}
	
	@Override
	public String getProviderType() {
		return super.getProviderType();
	}
	
}

 

 

 

 

7. LoginUser 

- 로그인한 사용자의 정보가 여기에 담김

- UserDetails 객체, CustomOAuth2User 객체가 얘를 상속받음 → 똑같은 객체로 로그인한 사용자의 정보를 사용하기 위해

- @AuthenticatedUser LoginUser loginUser : 인증된 로그인한 사용자의 정보 사용

 

package com.example.security.vo;

import com.example.vo.User;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class LoginUser {

	private String id;
	private String nickname;
	private String providerType;
	private String email;
	
	public LoginUser() {}
	
	public LoginUser(User user) {
		this.id = user.getId();
		this.nickname = user.getName();
		this.providerType = user.getProviderType();
		this.email = user.getEmail();
	}
	
}

 

 

 

8. SecurityConfig

- OAuth2 로그인 설정

 

package com.example.security;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.example.security.service.CustomEmployeeDetailsService;
import com.example.security.service.CustomOAuth2UserService;
import com.example.security.service.CustomUserDetailsService;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	@Autowired
	PasswordEncoder passwordEncoder;
	@Autowired
	CustomUserDetailsService userDetailsService;
	@Autowired
	CustomEmployeeDetailsService employeeDetailsService;
	@Autowired
	CustomOAuth2UserService customOAuth2UserService;

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
//			.csrf().disable()
			// 인가정책 설정
			.authorizeRequests()
			// 다음 페이지에 모두 접근 가능
			.antMatchers("/").permitAll()			
			.antMatchers("/user/register", "/user/registered").permitAll()
			.antMatchers("/user/login", "/emp/login").permitAll()
			.antMatchers("/oauth2/**").permitAll()
			.antMatchers("/login/oauth2/**").permitAll()
			// 인증된 사람만 다음 기능 가능 
			.antMatchers("/logout").authenticated()
			// 특정 권한을 가진 사람만 다음 페이지 접근 가능
			.antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
			.antMatchers("/emp/**").hasAnyRole("EMP", "ADMIN")
			.antMatchers("/admin/**").hasAnyRole("ADMIN")
			.anyRequest().permitAll()
		.and()
			// 인증정책 설정
			.formLogin()								// Form 로그인
			.loginPage("/user/login")
			.usernameParameter("id")
			.loginProcessingUrl("/login")				// 로그인 처리를 요청하는 URI
		.and()
			// 로그아웃 정책 설정
			.logout()
			.logoutUrl("/logout")						// 로그아웃 처리를 요청하는 URI
			.logoutSuccessUrl("/")						// 로그아웃 성공 시 재요청할 URI
		.and()
			.oauth2Login()								// OAuth2 로그인
			.loginPage("/user/login")
			.defaultSuccessUrl("/")
			.failureUrl("/user/login")
			.userInfoEndpoint()							// 로그인 성공 후 사용자정보 가져옴
			.userService(customOAuth2UserService);		// 가져온 사용자 정보를 처리할 때 사용
	}