OAuth와 일반 로그인은 Authentication에 저장되는 타입이 다르다.
그래서 Controller 등에서 사용할 때 OAuth 로그인 유저와 일반 로그인 유저를 동일한 코드로 처리할 수가 없다.
캐스팅에도 문제가 된다.
따라서 이를 동일한 타입으로 처리할 수 있도록 바꾼다.
이해가 안될 수 있다. 아래의 글을 읽어보자
OAuth를 사용하면 구글, 페이스북, 네이버 등의 로그인으로 로그인을 지원할 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
oauth2-client 라는 라이브러리를 통해 쉽게 구현을하게된다.
@Configuration
@EnableWebSecurity //스프링 시큐리티 필터가 스프링 필터체인에 등록 됩니다.
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) //@Secured 어노테이션 활성화 , @preAuthorize @PostAuthorize 어노테이션 활성화
public class SecurityConfig {
@Autowired
private PrincipalOauth2UserService principalOauth2UserService;
@Bean
public BCryptPasswordEncoder encoderPwd() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
http.csrf().disable();
http.authorizeHttpRequests()
.requestMatchers("/user/**").authenticated()
.requestMatchers("/manager").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().permitAll()
.and()
//일반 적인 로그인
.formLogin()
.loginPage("/loginForm") //로그인 페이지 url
.loginProcessingUrl("/login") //이 url을 로그인 기능을 담당하게 함
.defaultSuccessUrl("/") // 성공하면 이 url로 가게 해라
.and()
//OAuth 로그인
.oauth2Login()
.loginPage("/loginForm") //로그인 페이지 url
.userInfoEndpoint()
.userService(principalOauth2UserService); //OAuth 가 들어오면 이 서비스로 매핑됨
return http.build();
}
}
/login
으로 정보가 들어옴UserDetailsService
를 구현한 @Service
PrincipalDetailsService
의 loadUserByUsername
메서드를 호출한다.UserDetail
타입을 Return한다.@AuthenticationPrincipal
와 함께 불러올 수 있다.principalOauth2UserService
의 loadUser
메서드를 호출한다.OAuth2User
객체를 반환한다.@AuthenticationPrincipal
와 함께 불러올 수 있다.로그인 형식에 따라 Authentication에 저장된 타입이 UserDetail
, OAuth2User
로 다름
Controller 에서는 불러올 때 OAuth이냐 일반로그인이냐에 따라 다른 타입을 불러와야함.
예시)
@ResponseBody
@GetMapping("/test/login")
public String testLogin(Authentication authentication, @AuthenticationPrincipal PrincipalDetail principalDetail) { //Authentication 을 DI (의존성 주입)
System.out.println("/test/login==========================");
PrincipalDetail principalDetails = (PrincipalDetail) authentication.getPrincipal();
System.out.println("principalDetail.getUser() = " + principalDetails.getUser());
System.out.println("principalDetail.getUser() = " + principalDetail.getUser());
return "세션 정보 확인하기";
}
@ResponseBody
@GetMapping("/test/oauth/login")
public String testOauthLogin(Authentication authentication,@AuthenticationPrincipal OAuth2User oauth) { //Authentication 을 DI (의존성 주입)
System.out.println("/test/oauth/login==========================");
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
System.out.println("oAuth2User.getAttributes() = " + oAuth2User.getAttributes());
System.out.println("oauth.getAttributes() = " + oauth.getAttributes());
return "세션 정보 확인하기";
}
이를 해결하기 위해 UserDetail
과 OAuth2User
을 동시에 구현하는 클래스를 만들어서, 공통적으로 그 객체로만 사용할 수 있게 하면 됨
@Data
public class PrincipalDetail implements UserDetails, OAuth2User {
private User user;
private Map<String,Object> attributes;
//생성자
//일반 로그인
public PrincipalDetail(User user) {
this.user = user;
}
//OAuth 로그인
public PrincipalDetail(User user, Map<String, Object> attributes) {
this.user = user;
this.attributes = attributes;
}
//OAuth2User의 메서드
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
//별로 안중요 안씀
@Override
public String getName() {
return null;
}
//UserDetails의 메서드
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<>();
collect.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRole();
}
});
return collect;
}
@Override
public String getPassword() {
return user.getPassword();
}
//... 아래는 생략 그냥 오버라이드해서 getPassword같이 구현만 함
}
@NoArgsConstructor
@Entity
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String email;
private String role;
private String provider;
private String providerId;
@CreationTimestamp
private Timestamp createDate;
@Builder
public User(String username, String password, String email, String role, String provider, String providerId) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
this.provider = provider;
this.providerId = providerId;
}
}
Service는 양쪽 로그인에서 각각 필요하다.
이 역할은 메서드를 구현하여 UserDetails
와 OAuth2User
를 리턴하는 것이다.
이 메서드가 실행되면 컨트롤러에서 @AuthenticationPrincipal
을 사용하여 가져올 수 있다.
위의 그림처럼 두개의 인터페이스를 모두 구현하는 PrincipalDetail
구현체를 만들었으므로, 양쪽 모두에서 PrincipalDetail
을 리턴하도록 하면 된다.
@Service
public class PrincipalDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
//함수 종료시 @AuthenticationPrincipal 어노테이션이 만들어진다.
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("username : " + username);
User userEntity = userRepository.findByUsername(username);
if(userEntity != null){
return new PrincipalDetail(userEntity); //User 타입을 인자로 하는 생성자
}
return null;
}
}
@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
@Autowired
private UserRepository userRepository;
//구글로 부터 받은 userRequest 데이터에 대한 후처리 되는 함수
//함수종료시 @AuthenticationPrincipal 어노테이션이 만들어진다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws {
OAuth2User oAuth2User = super.loadUser(userRequest);
//각 서비스에 맞게 정보를 가져옴
//OAuth2UserInfo는 직접 만든 인터페이스 이고,
//각 브랜드별로 구현체를 만듬
OAuth2UserInfo oAuth2UserInfo;
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 {
System.out.println("우리는 구글과 페이스 북만 지원해요");
return null;
}
String provider = oAuth2UserInfo.getProvider(); // google
String providerId = oAuth2UserInfo.getProviderId();
String username = provider+"_"+providerId;
String email = oAuth2UserInfo.getEmail();
String role = "ROLE_USER";
User userEntity = userRepository.findByUsername(username);
if (userEntity == null) {
System.out.println(provider + " 로그인이 최초입니다.");
//강제 회원가입
//회원 DB에 추가함
//password 가 null 이기 때문에 일반적인 회원가입을 할 수가 없음
userEntity = User.builder()
.username(username)
.email(email)
.role(role)
.provider(provider)
.providerId(providerId)
.build();
userRepository.save(userEntity);
} else {
System.out.println(provider +" 로그인을 이미 한 적이 있습니다.");
}
return new PrincipalDetail(userEntity,oAuth2User.getAttributes());
}
}
PrincipalDetail
의 생성자에 넣어주었다.OAuth2UserInfo
인터페이스를 만들고 각각 브랜드별로 구현체를 만들도록 했다. security:
oauth2:
client:
registration:
google:
client-id:
client-secret:
scope:
- email
- profile
facebook:
client-id:
client-secret:
scope:
- email
- public_profile
보안상 id와 key값은 가려 놓았다.
scope 값의 작명은 제공하는 서비스마다 다르다.
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/oauth2/authorization/facebook">페이스북 로그인</a>
이 주소는 이 라이브러리를 사용하는 이상 고정이다.
뒤의 브랜드명만 바꿀수 있다.
@ResponseBody
@GetMapping("/user")
public String user(@AuthenticationPrincipal PrincipalDetail principalDetail) {
System.out.println("principalDetail.getUser() = " + principalDetail.getUser());
return "user";
}
이런식으로 가져와서 사용할 수 있다.
다른 방법으로는
@ResponseBody
@GetMapping("/user")
public String user(Authentication authentication) {
PrincipalDetail principalDetail = (PrincipalDetail) authentication.getPrincipal();
// ...
return "user";
}
이와 같이 구현 할 수 있다.
원래는 OAuth와 일반 로그인을 했을 때 각각 UserDetail
과 OAuth2User
로 각각 다른 타입으로 Authentication에 저장되기 때문에 이를 각각 구현하기도 번거롭고, 캐스팅을 하기도 애매했다.
하지만 위의 방식대로 두개의 인터페이스를 모두 구현하는 PrincipalDetail
을 만들어서 똑같이 처리할 수 있게 바꾸었고,
이를 위해 각각을 처리하는 서비스를 구현하여 동일하게 PrincipalDetail
을 생성하여 리턴하도록 구현하였다.