Spring Boot Security - 1

SangYeon Min·2024년 6월 24일

STUDY-SPRING-BOOT

목록 보기
7/8
post-thumbnail

Project Configuration

DB 및 사용자 생성

create user 'cos'@'%' identified by 'cos1234';
GRANT ALL PRIVILEGES ON *.* TO 'cos'@'%';
create database security;
use security;

위 쿼리문을 통해 DB 사용자와 새로운 DB를 생성한다

Trouble Shooting : Intellij no main class specifed

java 폴더 우클릭 -> Mark Directory as -> Source Root로 기존 프로젝트에 대한 Source Root를 새롭게 설정해주고 나면
위와 같이 정상적으로 로그인 페이지가 나타나는 것을 볼 수 있다.


Security Config

package com.cos.securityex01.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) // 특정 주소 접근시 권한 및 인증을 위한 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Bean
	public BCryptPasswordEncoder encodePwd() {
		return new BCryptPasswordEncoder();
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		
		http.csrf().disable();
		http.authorizeRequests()
			.antMatchers("/user/**").authenticated()
			//.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_USER')")
			//.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') and hasRole('ROLE_USER')")
			.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
			.anyRequest().permitAll() // 다른 요청은 모두 Permit
		.and()
			.formLogin() 
			.loginPage("/login")
            // loginProcessingUrl은 loginProc 주소가 호출되면
            // Secutrity가 낚아채서 대신 로그인을 수행한다
			.loginProcessingUrl("/loginProc")
			.defaultSuccessUrl("/");
	}
}

@EnableWebSecurity Spring Security 필터가 Spring FilterChain에 등록된다

/join, /joinProc

@Controller
public class IndexController {

	@Autowired
	private UserRepository userRepository;

	@Autowired
    // SecurityConfig의 BCryptPasswordEncoder Bean을 가져온다
	private BCryptPasswordEncoder bCryptPasswordEncoder;

	...

	@GetMapping("/join")
	public String join() {
		return "join";
	}

	@PostMapping("/joinProc")
	public String joinProc(User user) {
		System.out.println("회원가입 진행 : " + user);
		String rawPassword = user.getPassword();
		String encPassword = bCryptPasswordEncoder.encode(rawPassword);
		user.setPassword(encPassword);
		user.setRole("ROLE_USER");
		userRepository.save(user);
		return "redirect:/";
	}
}

위와 같이 join 페이지를 만들어주고 해당 URL로 접근하여주면
위와 같이 회원가입 페이지에서 회원가입을 진행할 수 있고
정상적으로 회원 데이터가 저장된 것을 확인할 수 있다.

/login, /loginProc

<!--/templates/login.html-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/loginProc" method="post">
	<input type="text" name="username" />
	<input type="password" name="password" />
	<button>로그인</button>
</form>
</body>
</html>

현재 login 폼은 위와 같으면 로그인 버튼을 클릭하게 되면
전체 폼을 /loginProc으로 전달한다

// /config/auth/PrincipalDetails.java
package com.cos.securityex01.config.auth;

import java.util.ArrayList;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import com.cos.securityex01.model.User;

import lombok.Data;

// Authentication 객체에 저장할 수 있는 유일한 타입
@Data
public class PrincipalDetails implements UserDetails{

	private User user;

	public PrincipalDetails(User user) {
		super();
		this.user = user;
	}
	
	@Override
	public String getPassword() {
		return user.getPassword();
	}

	@Override
	public String getUsername() {
		return user.getUsername();
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
    // 만약 1년동안 로그인하지 않았을 때
    // 휴면계정으로 변환하게 만들기 위한 상황 등에서 사용
	public boolean isEnabled() {
    	// (현재 시간 - 마지막 로그인 시간) > 1 이면
        // return false 하는 방법으로 사용할 수 있다
        // 현재는 무조건 true를 리턴
		return true;
	}
	
	@Override
    // 해당 User의 권한을 리턴하는 곳
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collet = new ArrayList<GrantedAuthority>();
        // user의 Role을 추가하여 Collection을 리턴해준다
		collet.add(()->{ return user.getRole();});
		return collet;
	}


	
}

Security가 /loginProc 주소 요청이 오면 낚아채서 로그인을 진행시킨다
또한 로그인 진행이 완료되면 Security Session을 만들어준다 (=Security ContextHolder)

이때 세션이 가질 수 있는 Object는 Authentication 객체로 제한된다
Authentication안에 User 정보가 있어야 하는데
이러한 User Object의 타입은 UserDetails 타입의 객체로 제한된다

Security Session [ Authentication [UserDetails] ] 관계이다

// /config/auth/PrincipalDetailsService.java

package com.cos.securityex01.config.auth;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.cos.securityex01.model.User;
import com.cos.securityex01.repository.UserRepository;

@Service
public class PrincipalDetailsService implements UserDetailsService{

	@Autowired
	private UserRepository userRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		User user = userRepository.findByUsername(username);
		if(user == null) {
			return null;
		}
		return new PrincipalDetails(user);
	}

}

SecurityConfig에서 loginProcessingUrl("/loginProc")
/loginProc 요청이 오면 자동으로 UserDetailService 타입으로
IoC 되어 있는 loadUserByUsername 메소드가 실행된다

@Service PrincipalDetailsService를 자동으로 IoC로 등록한다

// /repository/UserRepository.java

package com.cos.securityex01.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import com.cos.securityex01.model.User;

// JpaRepository 를 상속하면 자동 컴포넌트 스캔됨.
public interface UserRepository extends JpaRepository<User, Integer>{
	
    // JPA Naming
	// SELECT * FROM user WHERE username = 1?
	User findByUsername(String username);
	// SELECT * FROM user WHERE username = 1? AND password = 2?
	// User findByUsernameAndPassword(String username, String password);
	
	// @Query(value = "select * from user", nativeQuery = true)
	// User find마음대로();
}

userRepository에서 loadUserByUsername 메소드에서 사용할 JPA Query를 정의한다.
위와 같이 로그인이 정상적으로 진행되는 것을 볼 수 있다. 이때 로그아웃을 하고 /user 페이지로 이동하면 다시 로그인 화면이 나오게 되는데 그 상태에서 로그인을 진행하면 이전과는 다르게 인덱스 페이지가 아닌 user 페이지로 이동하는 것을 볼 수 있다.
.defaultSuccessUrl("/"); 이는 해당 메소드의 기능으로 특정 페이지를 요청해서 로그인을 진행하면 해당 페이지로 이동시켜준다는 의미이다.
이때 /manager와 /admin 페이지를 요청하면 권한이 없기 때문에 접속할 수 없는 것을 볼 수 있다.

Authorization 처리

먼저 admin, manager 이름을 가진 user를 새롭게 회원가입하고

UPDATE USER SET role='ROLE_MANAGER' WHERE username='manager';
UPDATE USER SET role='ROLE_ADMIN' WHERE username='admin';
COMMIT;

위와 같이 role을 업데이트 해주어 임의의 user들에 대해 권한을 부여한다

// /config/securityConfig.java
.antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') and hasRole('ROLE_MANAGER')")

이후 위와 같이 manager, admin 페이지에 대한 matcher를 별도로 Global로 설정하고

// /config/securityConfig.java
// 특정 주소 접근시 권한 및 인증을 위한 어노테이션 활성화
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) 

이때 SecurityConfig에 Annotation을 추가한 후
securedEnabled를 True로 설정하면

// /cotroller/indexController.java
...
	@Secured("ROLE_MANAGER")
	@GetMapping("/manager")
	public @ResponseBody String manager() {
		return "매니저 페이지입니다.";
	}

위와 같이 간단하게 하나의 메소드에 대해 @Secured 어노테이션을 사용할 수 있고
prePostEnabled를 True로 설정했을 때는

// /cotroller/indexController.java
...
  @PreAuthorize("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
  @GetMapping("/data")
  public @ResponseBody String data(){
  	...

위와 같은 방식으로 메소드에 대한 권한을 설정해줄 수 있다.


OAuth2.0

Trouble Shooting

Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'

maven install이나 빌드를 수행했을 때 발생하는
해당 에러는 JDK21과 호환되지 않는 Lombok 관련 버전 문제로
JDK21과 호환되는 최소 Lombok 버전은 1.18.30이기 때문에

<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<version>1.18.30</version>
	<optional>true</optional>
</dependency>

pom.xml을 에서 lombok 버전을 위와 같이 특정해주면 해결된다

Google OAuth Setup

  1. 코드를 작성하기 이전에 GoogleAPIs에서 프로젝트를 만들어준다
  2. 이후 OAuth 동의 화면에서 외부 항목을 설정하고 애플리케이션을 생성하고
  3. OAuth 클라이언트 ID를 생성한다. 이때 승인된 리디렉션 URL에 localhost 주소를 입력한다
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

spring-boot-starter-oauth2-client 의존성을 pom.xml에 추가하여 OAuth2.0를 사용할 수 있게 해준다.

# application.yaml
...
  security:
    oauth2:
      client:
        registration:
          # /oauth2/authorization/google 주소를 동작하게 한다.
          google: 
            client-id: # client-id
            client-secret: # client-secret
            scope:
            - email
            - profile

이후 위와 같이 application.yaml에 위와 같이 google 관련 설정을 추가한다.

<!-- login.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/loginProc" method="post">
	<input type="text" name="username" />
	<input type="password" name="password" />
	<button>로그인</button>
</form>

<br />

	<h1>Social Login</h1>
	<br />
	<!-- javascript:; 는 클릭해도 반응을 없게 하는 키워드 -->
	<a href="/oauth2/authorization/google" > 
	<img src="https://pngimage.net/wp-content/uploads/2018/06/google-login-button-png-1.png"
		alt="google" width="357px" height="117px">
	</a>
</body>
</html>

이후 위와 같이 login.html에 Google OAuth에 관한 하이퍼링크를 추가한다.

// SecurityConfig.java
...
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http.csrf().disable();
		http.authorizeRequests()
				.antMatchers("/user/**").authenticated()
				// .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') or
				// hasRole('ROLE_USER')")
				// .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN') and
				// hasRole('ROLE_USER')")
				.antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
				.anyRequest().permitAll()
				.and()
				.formLogin()
				.loginPage("/login")
				.loginProcessingUrl("/loginProc")
				.defaultSuccessUrl("/")
				.and()
                // oauth2Login(), loginPage() 추가
				.oauth2Login()
				.loginPage("/login")

또한 SecurityConfig에서 oauth2Login(), loginPage()를 추가한다
이후 로그인 페이지에서 Google OAuth 로그인을 진행하면 로그인은 정상적으로 진행되만 아직 후처리를 진행하지 않았기 때문에 Forbidden 오류가 발생하게 된다.

OAuth2.0이 진행되는 과정은 아래와 같다
1. 코드를 받는다 (=인증이 완료되었다)
2. 액세스 토큰을 코드를 통해서 받는다 (=권한이 부여된다)
3. 권한을 통해 사용자 프로필 정보를 받아온다
4-1. 정보를 토대로 회원가입을 자동으로 진행시킨다
4-2. 사용자 프로필 정보가 현 서비스에서 부족할 경우 추가 동작을 진행한다

  • 구글 로그인의 경우 완료된 이후에 코드가 아닌 액세스 토큰과 사용자 프로필 정보를 한번에 받아온다
// SecurityConfig.java
.oauth2Login()
.loginPage("/login")
.userInfoEndpoint()
.userService(principalOauth2UserService);

따라서 위와 같이 SecurityConfig에 userInfoEndpointuserService를 추가하고
PrincipalOauth2UserService 클래스를 새롭게 정의한다

// /oauth/PrincipalOauth2UserService.java
package com.cos.securityex01.config.oauth;

import java.util.Map;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import com.cos.securityex01.config.auth.PrincipalDetails;
import com.cos.securityex01.config.oauth.provider.FaceBookUserInfo;
import com.cos.securityex01.config.oauth.provider.GoogleUserInfo;
import com.cos.securityex01.config.oauth.provider.NaverUserInfo;
import com.cos.securityex01.config.oauth.provider.OAuth2UserInfo;
import com.cos.securityex01.model.User;
import com.cos.securityex01.repository.UserRepository;

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

	@Autowired
	private UserRepository userRepository;

	// userRequest 는 code를 받아서 accessToken을 응답 받은 객체
	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		OAuth2User oAuth2User = super.loadUser(userRequest); // google의 회원 프로필 조회

		// code를 통해 구성한 정보
		System.out.println("userRequest clientRegistration : " + userRequest.getClientRegistration());
		// token을 통해 응답받은 회원정보
		System.out.println("oAuth2User : " + oAuth2User);
	
		return processOAuth2User(userRequest, oAuth2User);
	}
    ...
}

이때 loadUser는 Google로부터 받은 userRequest를 후처리하는 메소드이다.
userRequest.getClientRegistration() Registration에 대한 여러 정보를 담고 있다
userRequest.getAccessToken() getTokenValue()로 토큰 정보를 받아올 수 있다
userRequest.getClientRegistration.getArttributes() email, name등 여러 정보를 받아온다
getClientRegistration().getArttributes()에 담겨있는 정보를 통해 로그인을 진행할 것이다

@Builder
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class User {
	@Id // primary key
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private int id;
	private String username;
	private String password;
	private String email;
	private String role; //ROLE_USER, ROLE_ADMIN
	// OAuth를 위해 구성한 추가 필드 2개
	private String provider;
	private String providerId;
	@CreationTimestamp
	private Timestamp createDate;
}

또한 여러 Provider를 구분해주기 위해 providerproviderId 필드를 User Model에 추가한다.

Authentication Object

  1. userRequest 정보 : 구글 로그인 버튼 클릭 -> 로그인 완료 -> Code 리턴 (OAuth-Client Lib) -> AccessToken 요청
OAuth2User oAuth2User = super.loadUser(userRequest);
  1. userRequest 정보 -> loadUser 메소드 호출 -> 구글로부터 회원 프로필
// IndexController.java
...
	@GetMapping("/test/login")
    public @ResponseBody String testLogin(Authentication authentication, @AuthenticationPrincipal PrincipaDetails userDetails){
      // 이때 authentication는 Object 타입
      PrincipaDetails principalDetails = (PrincipaDetails) authentication.getPrinciple();
      System.out.println("authentiaction" + principalDetails.getUser());
      System.out.println("userDetails :" + userDetails.getUser());
      return "세션 정보 확인";
    }

IndexController에서 위와 같이 Authentication 객체의 세션 정보를 확인하는 test route를 생성할 수 있다.
Authentication authentication DI (의존성 주입)이후 다운캐스팅을 통해 확인할 수 있다.
@AuthenticationPrincipal Annotation을 통해 세션 정보에 접근할 수 있다.

PrincipaDetails 타입으로 다운캐스팅이 가능한 이유는

public class PrincipalDetails implements UserDetails {
	...

위와 같이 PrincipaDetails 타입이 UserDetails를 implements하기 때문이다.

하지만 구글 로그인을 진행할 때는 ClassCastException이 발생하기 때문에

// IndexController.java
...
	@GetMapping("/test/oauth/login")
    public @ResponseBody String testLogin(Authentication authentication, @AuthenticationPrincipal OAuth2User oauth){
      // 이때 authentication는 Object 타입
      OAuth2User oauth2User = (OAuth2User) authentication.getPrinciple();
      System.out.println("authentiaction : " + oauth2User.getAttributes());
      System.out.println("OAuth2User : " + oauth.getAttributes());
      return "OAuth 세션 정보 확인";
    }

OAuth2User 클래스로 다운캐스팅을 해주어야 한다.
이때 oauth2User.getAttributes()로 받은 정보는 PrincipalOauth2UserService에서 받은 OAuth2User oAuth2User = super.loadUser(userRequest); 정보와 동일하다.
@AuthenticationPrincipal Annotation을 통해 OAuth2User 타입의 클래스를 통해 OAuth2 세션 정보에 접근할 수 있다.
위 내용들을 정리하면 Spring Security는 세션을 가지고 각 Session은 고유의 Security Session을 가지게 된다.
이때 Security Session에서 가질 수 있는 Object는 Authentication이 유일한데, Authentication이 가질 수 있는 User Object의 타입은 UserDetails, OAuth2User 타입으로 제한된다.

하지만 /test 예제에서 보이는 것처럼 Controller에서 Authentication 정보를 활용하기 위해 별도로 Route를 매핑해줘야 하는 것은 효율적이지 못한 방법이기 때문에 UserDetails, OAuth2User를 모두 implements하는 class를 생성하여 해당 Class를 Controller에서 사용해주면 된다.

public class PrincipalDetails implements UserDetails, OAuth2User{
	...
	private Map<String, Object> attributes;
	...
    @Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collet = new ArrayList<GrantedAuthority>();
		collet.add(()->{ return user.getRole();});
		return collet;
	}

	// 리소스 서버로 부터 받는 회원정보
	@Override
	public Map<String, Object> getAttributes() {
		return attributes;
	}
    ...

현재 프로젝트에서는 PrincipaDetails Class를 통해 위 모식도와 같이 구현할 수 있다.

Google Login, Auth Join

PrincipaDetails을 사용하는 목적은 아래와 같이 2가지이다.
1. 회원가입을 진행했을 때 서비스에서는 User 객체가 필요하기 때문에 PrincipaDetailsUser 객체를 포함하기 위함이다.
2. Security Session [ Authentication [ UserDetails, OAuth2User ] ]
위와 같이 Security SessionUserDetails, OAuth2User 정보를 가질 수 있도록 두 가지의 타입을 묶어주기 위함이다.

package com.cos.securityex01.config.auth;

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

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

import com.cos.securityex01.model.User;

import lombok.Data;

// Authentication 객체에 저장할 수 있는 유일한 타입
public class PrincipalDetails implements UserDetails, OAuth2User{

	private static final long serialVersionUID = 1L;
	private User user;
	private Map<String, Object> attributes;

	// 일반 시큐리티 로그인시 사용하는 생성자
	public PrincipalDetails(User user) {
		this.user = user;
	}
	
	// OAuth2.0 로그인시 사용하는 생성자
	public PrincipalDetails(User user, Map<String, Object> attributes) {
		this.user = user;
		this.attributes = attributes;
	}
	
	public User getUser() {
		return user;
	}

	@Override
	public String getPassword() {
		return user.getPassword();
	}

	@Override
	public String getUsername() {
		return user.getUsername();
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return true;
	}
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> collet = new ArrayList<GrantedAuthority>();
		collet.add(()->{ return user.getRole();});
		return collet;
	}

	// 리소스 서버로 부터 받는 회원정보
	@Override
	public Map<String, Object> getAttributes() {
		return attributes;
	}

	// User의 PrimaryKey
	@Override
	public String getName() {
		return user.getId()+"";
	}
	
}

attribute 정보를 받은 후 PrincipalOauth2UserService에서 후처리 진행한다

package com.cos.securityex01.config.oauth.provider;

// OAuth2.0 제공자들 마다 응답해주는 속성값이 달라서 공통으로 만들어준다.
public interface OAuth2UserInfo {
	String getProviderId();
	String getProvider();
	String getEmail();
	String getName();
}
// /oauth/provider/GoogleUserInfo.java
package com.cos.securityex01.config.oauth.provider;

import java.util.Map;

public class GoogleUserInfo implements OAuth2UserInfo{

	private Map<String, Object> attributes;
	
    public GoogleUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
	
    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

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

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

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

}

후처리를 진행하기 이전 Google Provider에 대한 GoogleUserInfo Class를 생성한다.

package com.cos.securityex01.config.oauth;

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

	@Autowired
	private UserRepository userRepository;

	// userRequest 는 code를 받아서 accessToken을 응답 받은 객체
	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		OAuth2User oAuth2User = super.loadUser(userRequest); // google의 회원 프로필 조회

		// code를 통해 구성한 정보
		System.out.println("userRequest clientRegistration : " + userRequest.getClientRegistration());
		// token을 통해 응답받은 회원정보
		System.out.println("oAuth2User : " + oAuth2User);
	
		return processOAuth2User(userRequest, oAuth2User);
	}

	private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {

		// Attribute를 파싱해서 공통 객체로 묶는다. 관리가 편함.
		OAuth2UserInfo oAuth2UserInfo = null;
		if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
			System.out.println("구글 로그인 요청");
			oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
		}

		//System.out.println("oAuth2UserInfo.getProvider() : " + oAuth2UserInfo.getProvider());
		//System.out.println("oAuth2UserInfo.getProviderId() : " + oAuth2UserInfo.getProviderId());
		Optional<User> userOptional = 
				userRepository.findByProviderAndProviderId(oAuth2UserInfo.getProvider(), oAuth2UserInfo.getProviderId());
		
		User user;
		if (userOptional.isPresent()) {
			user = userOptional.get();
			// user가 존재하면 update 해주기
			user.setEmail(oAuth2UserInfo.getEmail());
			userRepository.save(user);
		} else {
			// user의 패스워드가 null이기 때문에 OAuth 유저는 일반적인 로그인을 할 수 없음.
			user = User.builder()
					.username(oAuth2UserInfo.getProvider() + "_" + oAuth2UserInfo.getProviderId())
					.email(oAuth2UserInfo.getEmail())
					.role("ROLE_USER")
					.provider(oAuth2UserInfo.getProvider())
					.providerId(oAuth2UserInfo.getProviderId())
					.build();
			userRepository.save(user);
		}

		return new PrincipalDetails(user, oAuth2User.getAttributes());
	}
}

PrincipalOauth2UserService에서 위 코드와 같이 일반 로그인과 provider를 통해 로그인하는 것을 구분하여 로그인 요청을 완료한다.

Facebook OAuth

  security:
    oauth2:
      client:
        registration:
          ...
          facebook:
            client-id: # client-id
            client-secret: # client-secret
            scope:
            - email
            - public_profile

위와 같이 application.yaml에 facebook 관련 설정을 추가한다

<!--/templates/login.html-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/loginProc" method="post">
	<input type="text" name="username" />
	<input type="password" name="password" />
	<button>로그인</button>
</form>

<br />

	<h1>Social Login</h1>
	<br />
	<a href="/oauth2/authorization/google" > 
	<img src="https://pngimage.net/wp-content/uploads/2018/06/google-login-button-png-1.png"
		alt="google" width="357px" height="117px">
	</a>
	<br />
	<a href="/oauth2/authorization/facebook"> 
	<img src="https://pngimage.net/wp-content/uploads/2018/06/login-with-facebook-button-png-transparent-1.png"
		alt="facebook" width="357px" height="117px">
	</a>
	<br />
</body>
</html>

또한 이후 oauth2 라이브러리에 고정되어 있는 주소를 login.html에 추가한다

// /oauth/provider/FaceBookUserInfo.java
package com.cos.securityex01.config.oauth.provider;

import java.util.Map;

public class FaceBookUserInfo implements OAuth2UserInfo{

	private Map<String, Object> attributes;
	
    public FaceBookUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
	
    @Override
    public String getProviderId() {
        return (String) attributes.get("id");
    }

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

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

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

이후 Facebook provider에 대한 OAuth를 처리하기 위해 FaceBookUserInfo 클래스를 생성한다.

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

	...

	private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {

		// Attribute를 파싱해서 공통 객체로 묶는다. 관리가 편함.
		OAuth2UserInfo oAuth2UserInfo = null;
		if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
			System.out.println("구글 로그인 요청");
			oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
		} else if (userRequest.getClientRegistration().getRegistrationId().equals("facebook")) {
			System.out.println("페이스북 로그인 요청");
			oAuth2UserInfo = new FaceBookUserInfo(oAuth2User.getAttributes());
		}
        ...

이후 PrincipalOauth2UserService에서 userRequest.getClientRegistration().getRegistrationId().equals("facebook")인 경우에 oAuth2UserInfoFaceBookUserInfo를 통해 생성해주어 Facebook OAuth 로그인을 구현할 수 있다.


서비스별로 요청주소도 다르고, 응답 데이터도 다르고
네이버는 OAuth2.0 공식 지원대상이 아니기 때문에 provider 설정이 필요

  security:
    oauth2:
      client:
        registration:
          ...
          
          naver:
            client-id: # client-id
            client-secret: # client-secret
            scope:
            - name
            - email
            - profile_image
            # 클라이언트 네임은 구글 페이스북도 대문자로 시작
            client-name: Naver 
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/naver

        provider:
          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
            # 회원정보를 json의 response 키값으로 리턴해줌
            user-name-attribute: response 

위와 같이 application.yaml에 naver provider를 등록한다
이전과 동일하게 login.html에 naver 로그인 주소를 추가하고

// /oauth/provider/NaverUserInfo.java
ppackage com.cos.securityex01.config.oauth.provider;

import java.util.Map;

public class NaverUserInfo implements OAuth2UserInfo{

	private Map<String, Object> attributes;
	
    public NaverUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
	
    @Override
    public String getProviderId() {
        return (String) attributes.get("id");
    }

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

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

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

}

NaverUserInfo.java 클래스를 정의한다

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

	...

	private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {

		// Attribute를 파싱해서 공통 객체로 묶는다. 관리가 편함.
		OAuth2UserInfo oAuth2UserInfo = null;
		if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
			System.out.println("구글 로그인 요청");
			oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
		} else if (userRequest.getClientRegistration().getRegistrationId().equals("facebook")) {
			System.out.println("페이스북 로그인 요청");
			oAuth2UserInfo = new FaceBookUserInfo(oAuth2User.getAttributes());
		} else if (userRequest.getClientRegistration().getRegistrationId().equals("naver")){
			System.out.println("네이버 로그인 요청");
			oAuth2UserInfo = new NaverUserInfo((Map)oAuth2User.getAttributes().get("response"));
		} else {
			System.out.println("Google, Facebook OAuth만 지원");
		}
        ...

최종적으로 위와 같이 PrincipalOauth2UserService에 Naver 로그인 관련 코드를 추가하면 Naver 로드인 구현이 완료된다.


Reference
최주호 - 스프링부트 시큐리티 & JWT
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/dashboard

0개의 댓글