[SpringSecurity] OAuth 와 일반로그인을 동일하게 처리하기

유알·2022년 12월 18일
5

[SpringSecurity]

목록 보기
3/15

목표 미리보기

OAuth와 일반 로그인은 Authentication에 저장되는 타입이 다르다.
그래서 Controller 등에서 사용할 때 OAuth 로그인 유저와 일반 로그인 유저를 동일한 코드로 처리할 수가 없다.
캐스팅에도 문제가 된다.
따라서 이를 동일한 타입으로 처리할 수 있도록 바꾼다.

이해가 안될 수 있다. 아래의 글을 읽어보자

OAuth

OAuth를 사용하면 구글, 페이스북, 네이버 등의 로그인으로 로그인을 지원할 수 있다.

dependency

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

oauth2-client 라는 라이브러리를 통해 쉽게 구현을하게된다.

Authentication

  • Spring security 가 관리하는 세션에는 Authentication 만 저장될 수 있다.
  • Authentication 안에는 UserDetails, OAuth2User 두가지를 담을 수 있다.
  • 둘다 인터페이스로, 일반적인 로그인에는 UserDetails , OAuth 로그인에는 OAuth2User 가 사용된다.

SecurityConfig.java

@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();
    }
}

실행 순서

일반적인 로그인

  1. /login으로 정보가 들어옴
  2. UserDetailsService를 구현한 @Service PrincipalDetailsServiceloadUserByUsername 메서드를 호출한다.
  3. 이 메서드는 UserDetail 타입을 Return한다.
  4. Return된 객체는 Controller에서 @AuthenticationPrincipal 와 함께 불러올 수 있다.

OAuth 로그인

  1. 위에서 설정한 대로 principalOauth2UserServiceloadUser 메서드를 호출한다.
  2. OAuth2User객체를 반환한다.
  3. 똑같이 Return된 객체는 Controller에서 @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 "세션 정보 확인하기";
    }

해결

이를 해결하기 위해 UserDetailOAuth2User을 동시에 구현하는 클래스를 만들어서, 공통적으로 그 객체로만 사용할 수 있게 하면 됨

PrincipalDetail.java

@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같이 구현만 함


}

User.java

@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

Service는 양쪽 로그인에서 각각 필요하다.
이 역할은 메서드를 구현하여 UserDetailsOAuth2User를 리턴하는 것이다.
이 메서드가 실행되면 컨트롤러에서 @AuthenticationPrincipal을 사용하여 가져올 수 있다.
위의 그림처럼 두개의 인터페이스를 모두 구현하는 PrincipalDetail 구현체를 만들었으므로, 양쪽 모두에서 PrincipalDetail을 리턴하도록 하면 된다.

PrincipalDetailsService.java

@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;
    }
}

PrincipalOauth2UserService.java

@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());
    }
}
  • 보이다 싶이 User.builder()를 이용하여 정보를 User.java 에 매핑시켜주고, PrincipalDetail의 생성자에 넣어주었다.
  • 로그인 시도를 할 때 기존에 가입된 정보가 없으면 바로 회원에 등록하는 로직까지 포함되어 있다.
  • 각 서비스 별로 키 값이 조금씩 달라서 추후 유지 보수가 힘들다. 그래서 OAuth2UserInfo 인터페이스를 만들고 각각 브랜드별로 구현체를 만들도록 했다.
  • 일단 위와 같은 방식으로 username을 정하면 중복이 되지 않으나, 기존에 가입했던 사람이 다시 가입할 경우 중복이 될 수 있으므로 위와 같이 DB에 해당회원이 존재하는지 먼저 확인하고 가입을 진행해야 한다.

application.yml

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 
            client-secret: 
            scope:
              - email
              - profile
          facebook:
            client-id: 
            client-secret: 
            scope:
              - email
              - public_profile

보안상 id와 key값은 가려 놓았다.
scope 값의 작명은 제공하는 서비스마다 다르다.

OAuth 로그인 버튼

    <a href="/oauth2/authorization/google">구글 로그인</a>
    <a href="/oauth2/authorization/facebook">페이스북 로그인</a>

이 주소는 이 라이브러리를 사용하는 이상 고정이다.
뒤의 브랜드명만 바꿀수 있다.

Controller에서 현재 인증된 회원 정보 가져오기

    @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와 일반 로그인을 했을 때 각각 UserDetailOAuth2User로 각각 다른 타입으로 Authentication에 저장되기 때문에 이를 각각 구현하기도 번거롭고, 캐스팅을 하기도 애매했다.
하지만 위의 방식대로 두개의 인터페이스를 모두 구현하는 PrincipalDetail을 만들어서 똑같이 처리할 수 있게 바꾸었고,
이를 위해 각각을 처리하는 서비스를 구현하여 동일하게 PrincipalDetail을 생성하여 리턴하도록 구현하였다.

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글