
create user 'cos'@'%' identified by 'cos1234';
GRANT ALL PRIVILEGES ON *.* TO 'cos'@'%';
create database security;
use security;
위 쿼리문을 통해 DB 사용자와 새로운 DB를 생성한다
java 폴더 우클릭 -> Mark Directory as -> Source Root로 기존 프로젝트에 대한 Source Root를 새롭게 설정해주고 나면 
위와 같이 정상적으로 로그인 페이지가 나타나는 것을 볼 수 있다.
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에 등록된다
@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로 접근하여주면
위와 같이 회원가입 페이지에서 회원가입을 진행할 수 있고
정상적으로 회원 데이터가 저장된 것을 확인할 수 있다.
<!--/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 페이지를 요청하면 권한이 없기 때문에 접속할 수 없는 것을 볼 수 있다.
먼저 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(){
...
위와 같은 방식으로 메소드에 대한 권한을 설정해줄 수 있다.
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 버전을 위와 같이 특정해주면 해결된다 
<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에 userInfoEndpoint와 userService를 추가하고
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를 구분해주기 위해 provider와 providerId 필드를 User Model에 추가한다.
userRequest 정보 : 구글 로그인 버튼 클릭 -> 로그인 완료 -> Code 리턴 (OAuth-Client Lib) -> AccessToken 요청OAuth2User oAuth2User = super.loadUser(userRequest);
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를 통해 위 모식도와 같이 구현할 수 있다.
PrincipaDetails을 사용하는 목적은 아래와 같이 2가지이다.
1. 회원가입을 진행했을 때 서비스에서는 User 객체가 필요하기 때문에 PrincipaDetails에 User 객체를 포함하기 위함이다.
2. Security Session [ Authentication [ UserDetails, OAuth2User ] ]
위와 같이 Security Session의 UserDetails, 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를 통해 로그인하는 것을 구분하여 로그인 요청을 완료한다.
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")인 경우에 oAuth2UserInfo를 FaceBookUserInfo를 통해 생성해주어 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