[Spring Security] OAuth와 일반 로그인 동시에 구현하기

박소은·2024년 10월 8일
3

Spring Security

목록 보기
3/3

프로젝트를 진행하며 OAuth 로그인과 일반 로그인을 동시에 구현하게 되었다. Spring Security의 formLogin과 oauth2Login 기능을 동시에 사용하는 방법은 아래와 같다.

1. Spring Security 설정

package org.example.catch_line.config;

@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록된다.
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
// secured 어노테이션 사용 가능(특정 메서드에 간단하게 걸고 싶을 때 사용) "ROLE_ADMIN"
// @PreAuthorize 어노테이션 사용 가능 "hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')" 함수가 실행되기 전에 권한을 검사
@Configuration
@RequiredArgsConstructor
public class SecurityConfig{

    private final PrincipleOAuth2DetailsService principleOAuth2DetailsService;
    private final PrincipalDetailsService principalDetailsService;

    @Bean
    public static BCryptPasswordEncoder bCryptPasswordEncoder() {   // for hash encrypt
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, PrincipleOAuth2DetailsService principleOAuth2DetailsService) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))  // CORS 설정을 최신 방식으로 변경
                .csrf(AbstractHttpConfigurer::disable)
                .headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()))  // frameOptions 설정
                .authorizeHttpRequests(requests ->requests
                        .requestMatchers("/**", "/static/**", "/images/**", "/signup", "/login", "/restaurants/**", "/owner").permitAll()
                        .requestMatchers("/members", "/history").authenticated() // 인증이 필요
                        .anyRequest().permitAll()
                )
                // 일반 로그인
                .formLogin(formLogin -> formLogin
                        .loginPage("/login")  // 로그인 페이지 URL
                        .loginProcessingUrl("/loginProcess")  // 로그인 처리 URL (여기에 POST 요청이 와야 함)
                        .defaultSuccessUrl("/")
                        .failureUrl("/login?error=true")
                        .permitAll()
                )
				        // Oauth 로그인
                .oauth2Login(login -> login
                        .loginPage("/login/oauth")
                        .defaultSuccessUrl("/loginSuccess")
                        .userInfoEndpoint()
                        .userService(principleOAuth2DetailsService) // OAuth 사용자 로그인 처리
                )
                .userDetailsService(principalDetailsService);  // 일반 사용자 로그인 처리

//                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080", "http://localhost:8081"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

2. Authentication 객체에 저장되는 PrincipalDetail → UserDetails, OAuth2User 둘 다 구현

두 개의 생성자를 통해 일반 로그인 사용자와 OAuth 로그인 사용자를 구분했다. 일반 로그인을 한 경우 UserDetails가 생기며, OAuth로 로그인을 할 경우 OAuth2User가 생성된다.

package org.example.catch_line.config.auth;

import lombok.Data;
import org.apache.logging.log4j.util.Strings;
import org.example.catch_line.common.constant.Role;
import org.example.catch_line.user.member.model.entity.MemberEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

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

@Data
public class PrincipalDetail implements UserDetails, OAuth2User {

    private MemberEntity member;

    private Map<String,Object> attributes;

    // 일반 로그인 생성자
    public PrincipalDetail(MemberEntity member) {
        this.member = member;
    }

    // Oauth 로그인 생성자
    public PrincipalDetail(MemberEntity member, Map<String, Object> attributes) {
        this.member = member;
        this.attributes = attributes;
    }

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

    // 잘 사용하지 않는다.
    @Override
    public String getName() {
        return Strings.EMPTY;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return Role.USER.getDescription();
            }
        });
        return collect;
    }

    @Override
    public String getPassword() {
        return member.getPassword() != null ? member.getPassword().getEncodedPassword() : "";
    }

    @Override
    public String getUsername() {
        return member.getEmail().getEmailValue();
    }

}

일반 로그인과 OAuth 로그인 각각 서비스 구현


1. 일반 로그인 서비스 구현 PrincipalDetailsService

package org.example.catch_line.config.auth;

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    // 시큐리티 session (내부 Authentication (내부 UserDetails ))
    private final MemberDataProvider memberDataProvider;

    // 함수 종료 시 @AuthenticationPrincipal 어노테이션이 만들어진다.
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        MemberEntity member = memberDataProvider.provideMemberByEmail(new Email(username));
        return new PrincipalDetail(member);

    }
}

2. OAuth2 로그인 서비스 구현

package org.example.catch_line.config.auth;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.example.catch_line.common.constant.Role;
import org.example.catch_line.common.model.vo.Email;
import org.example.catch_line.user.member.model.entity.MemberEntity;
import org.example.catch_line.user.member.model.provider.MemberDataProvider;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
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 org.thymeleaf.util.StringUtils;

import java.util.List;
import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class PrincipleOAuth2DetailsService extends DefaultOAuth2UserService {

    private final MemberDataProvider memberDataProvider;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        // Role generate
        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(Role.USER.getDescription());

        Map<String, Object> attributes = oAuth2User.getAttributes();
        log.info("attributes: {}", attributes);

        Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
        String name = (String) properties.get("nickname"); // properties에서 닉네임 가져오기

        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        String email = (String) kakaoAccount.get("email"); // kakao_account에서 이메일 가져오기

        String provider = userRequest.getClientRegistration().getRegistrationId(); // kakao
        Long providerId = ((Number) attributes.get("id")).longValue();
        String nickname = provider + "_" + StringUtils.substring(providerId, 0, 7); // kakao_이름

        if (memberDataProvider.isNotDuplicateKakaoMember(providerId, new Email(email))) {
            MemberEntity member = MemberEntity.builder()
                    .name(name)
                    .nickname(nickname)
                    .email(new Email(email))
                    .kakaoMemberId(providerId)
                    .build();
            memberDataProvider.saveMember(member);
        }

        MemberEntity member = memberDataProvider.provideMemberByKakaoMemberId(providerId);

        // 어떤 OAuth2 공급자를 통해 로그인하는지, 해당 공급자에서 사용자의 고유 식별자를 나타내는 필드명이 무엇인지를 반환한다.
        // 지금 kakao login만 사용하기 때문에 필요없지만, 추후 구현 위해 남겨 놓는다.
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        return new PrincipalDetail(member, oAuth2User.getAttributes());
    }
}

마치며

일반 로그인과 OAuth2 로그인을 동시에 사용하기 위해 Security 설정을 어떻게 할 수 있는지 알아봤다.

profile
Backend Developer

2개의 댓글

comment-user-thumbnail
2024년 11월 6일

스프링 시큐리티에 대해 공부하고 있었는데 좋은 글 감사합니다~

1개의 답글