로그인/로그아웃 기능 구현

jihan kong·2023년 1월 31일
0
post-thumbnail

스프링 시큐리티를 이용해 로그인/로그아웃 기능을 구현

스프링 시큐리티에서 UserDetailService 인터페이스는 DB에서 회원 정보를 가져오는 역할을 담당한다는 것을 알게 되었다. 따라서 이를 구현하는 클래스를 만들어 로그인 기능을 구현했다. 이전에 만들었던 MemberServiceUserDetailsService 를 구현해보았다.

MemberService.java

package com.shop.service;

import com.shop.entity.Member;
import com.shop.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
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 javax.transaction.Transactional;

@Service
@Transactional                                              
@RequiredArgsConstructor                                    
public class MemberService implements UserDetailsService { 

	// ...코드 생략

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {      // UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 오버라이딩. 로그인할 유저의 email을 파라미터로 받음.
        Member member = memberRepository.findByEmail(email);

        if(member == null) {
            throw new UsernameNotFoundException(email);
        }

        return User.builder()                           // UserDetail을 구현하고 있는 User 객체를 반환해줌. User 객체를 생성하기 위해 생성자로 회원의 이메일, 비밀번호, role을 파라미터로 넘겨줌.
                .username(member.getEmail())
                .password(member.getPassword())
                .roles(member.getRole().toString())
                .build();
    }
}

시큐리티 설정을 위해 SecurityConfig 설정도 해주었다.

SecurityConfig.java

package com.shop.config;

import com.shop.service.MemberService;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig {

    @Autowired
    MemberService memberService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/members/login")            // 로그인 페이지 URL
                .defaultSuccessUrl("/")                 // 로그인 성공 시 이동할 URL
                .usernameParameter("email")             // 로그인 시 사용할 파라미터 이름
                .failureUrl("/members/login/error")     // 로그인 실패 시 이동할 URL
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout"))    // 로그아웃 URL
                .logoutSuccessUrl("/")                  // 로그아웃 성공 시 이동할 URL
        ;
}

그런데, 이렇게 폼으로 로그인을 구현하고 보니 뭔가 허전했다. 요즘은 구글 로그인 혹은 네이버 아이디로 로그인하는 방식과 같은 Social Login을 많이 사용하는 추세이다. 따라서 나는 이를 구현하고 싶었다. 이를 위해 열심히 구글링을 하고 공부를 했다.

OAuth2 동작 방식

OAuth2는 다음과 같은 방식으로 동작한다.
( 출처 : https://about-tech.tistory.com/entry/Security-OAuth-20%EB%9E%80-%EA%B0%9C%EB%85%90-%EC%9D%B8%EC%A6%9D%EB%B0%A9%EC%8B%9D)

  1. 사용자가 특정 어플리케이션에서 OAuth 서비스를 요청한다. 웹 어플리케이션(Client)에서는 OAuth 서비스에 Authorization Code를 요청한다.

  2. OAuth 서비스는 Redirect를 통해 Client에게 Authorization Code를 부여한다.

  3. Client는 Server에게 OAuth 서비스에서 전달받은 Authorization Code를 보낸다.

  4. Server는 Authorization Code를 다시 OAuth 서비스에 전달해 Access Token을 전달받는다.

  5. Server는 Access Token으로 Client를 인증하고 요청에 대한 응답을 반환한다.


이처럼 OAuth 2.0 방식은 기존 인증방식과 다르게 인증을 중개하는 방식이다. 이미 사용자 정보를 가지고 있는 소셜 서비스(Google, Naver, Kakao, Github 등)에서 인증을 대신 진행해주고, 접근 권한에 대해 토큰을 발급한 후 이를 통해 웹 서버에서 인증을 가능하게 하는 것이다. 소셜 서비스가 인증(Authentication)을 대신 해주는 셈이다.

OAuth2 구글 로그인 방식 구현

OAuth2 구글 로그인 방식을 구현하기 위해서는 다음의 로직들이 필요했다.

  1. DefaultOAuth2UserService 를 상속해서 만든 커스텀 OAuth2UserService
  2. UserDetails 메서드에 실제 OAuth2User 구현
  3. 위의 내용들을 반영하는 SecurityConfig 설정
  4. MemberService 에서 📌 loadUserByUsername 메서드를 구현하여 회원을 찾도록 함.
  5. MemberController 에서 UserDetail
    Form 로그인이면 UserDetails,
    OAuth2 로그인이면 OAuth2User 타입으로 반환

📌 loadUserByUsername
OAuth2로 로그인 하는 경우, 사용자가 직접 회원가입을 하지 않는다. 따라서 loadUserByUsername 이라는 메소드를 구현하여 먼저 회원을 찾고,
없는 회원이라면 회원가입 처리하는 로직을 구현해주어야한다.

1. PrincipalOauth2UserService (OAuth2UserServcie)

// ...import 생략

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

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

        OAuth2User oAuth2User = super.loadUser(userRequest);

        String provider = userRequest.getClientRegistration().getRegistrationId();    //google
        String providerId = oAuth2User.getAttribute("sub");
        String name = provider+"_"+providerId;  			
        // 사용자가 입력한 적은 없지만 만들어준다

        String uuid = UUID.randomUUID().toString().substring(0, 7);
        String password = bCryptPasswordEncoder.encode("패스워드"+uuid);  
        // 사용자가 입력한 적은 없지만 만들어준다

        String email = oAuth2User.getAttribute("email");
        Role role = Role.SOCIAL;

        Member byUsername = memberRepository.findByEmail(email);

        //DB에 없는 사용자라면 회원가입처리
        if(byUsername == null){
            byUsername = Member.oauth2Register()
                    .password(password).email(email).role(role)
                    .provider(provider).providerId(providerId)
                    .build();
            memberRepository.save(byUsername);
        }

        return new PrincipalDetails(byUsername, oAuth2User.getAttributes());
    }
}
  • OAuth2User를 반환하기 위해 loadUser() 메서드를 구현
  • super.loadUser() 를 통해 OAuth2 user 정보를 받아올 수 있음.
  • byUsername 변수를 통해 MemberRepository 에 save하는 방식으로 회원가입 처리 가능.

2. PrincipalDetails 메서드 (UserDetails)

@Getter
@ToString
public class PrincipalDetails implements UserDetails, OAuth2User {

    private Member member;
    private Map<String, Object> attributes;

    //UserDetails : Form 로그인 시 사용
    public PrincipalDetails(Member member) {
        this.member = member;
    }

    //OAuth2User : OAuth2 로그인 시 사용
    public PrincipalDetails(Member member, Map<String, Object> attributes) {
        //PrincipalOauth2UserService 참고
        this.member = member;
        this.attributes = attributes;
    }

    /**
     * UserDetails 구현
     * 해당 유저의 권한목록 리턴
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return member.getRole().toString();
            }
        });
        return collect;
    }

    /**
     * UserDetails 구현
     * 비밀번호를 리턴
     */
    @Override
    public String getPassword() {
        return member.getPassword();
    }

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

    /**
     * OAuth2User 구현
     * @return
     */
    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    /**
     * OAuth2User 구현
     * @return
     */
    @Override
    public String getName() {
        String sub = attributes.get("sub").toString();
        return sub;
    }
}

UserDetalis, OAuth2User를 구현한 객체

  • 일반 로그인과 OAuth 로그인 사용자를 구별
  • 유저의 권한을 리턴하기 위해 public Collection<? extends GrantedAuthority> getAuthorities() 메서드를 사용. 이는 SecurityFilterChain 에서 권한을 체크할 때 사용하는 것.

3. SecurityConfig

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig {

    @Autowired
    MemberService memberService;

    @Autowired
    private PrincipalOauth2UserService principalOauth2UserService;


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/members/login")            // 로그인 페이지 URL
                .defaultSuccessUrl("/")                 // 로그인 성공 시 이동할 URL
                .usernameParameter("email")             // 로그인 시 사용할 파라미터 이름
                .failureUrl("/members/login/error")     // 로그인 실패 시 이동할 URL
                .and()
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/members/logout"))    // 로그아웃 URL
                .logoutSuccessUrl("/")                  // 로그아웃 성공 시 이동할 URL
                .and()                                  // OAuth
                .oauth2Login()
                .defaultSuccessUrl("/")
                .userInfoEndpoint()                     // OAuth2 로그인 성공 후 가져올 설정들
                .userService(principalOauth2UserService);  // 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
        ;

구글 로그인이 완료된 뒤의 후처리를 여기서 해준다.

4. MemberService

@Service
@Transactional                                              
@RequiredArgsConstructor                                    
public class MemberService implements UserDetailsService {  

    private final MemberRepository memberRepository;

	// 회원정보 저장, 중복회원 검사 메소드 생략

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {      // UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 오버라이딩. 로그인할 유저의 email을 파라미터로 받음.
        Member member = memberRepository.findByEmail(email);

        if(member == null) {
            throw new UsernameNotFoundException(email);
        }

        return User.builder()                           // UserDetail을 구현하고 있는 User 객체를 반환해줌. User 객체를 생성하기 위해 생성자로 회원의 이메일, 비밀번호, role을 파라미터로 넘겨줌.
                .username(member.getEmail())
                .password(member.getPassword())
                .roles(member.getRole().toString())
                .build();
    }
}

5. MemberController

@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {                 // 회원가입을 위한 컨트롤러

    @Autowired
    private MemberRepository memberRepository;

    private final MemberService memberService;
    private final PasswordEncoder passwordEncoder;

 	// .. 기존 로그인 매핑 생략 (/new, /login, /login/error)

    
    // !!!! OAuth로 로그인 시 이 방식대로 하면 CastException 발생함
    @GetMapping("/form/loginInfo")
    @ResponseBody
    public String formLoginInfo(Authentication authentication, @AuthenticationPrincipal PrincipalDetails principalDetails){

        PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
        String member = principal.getUsername();
        System.out.println(member);

        String user1 = principalDetails.getUsername();
        System.out.println(user1);
        
        return member.toString();
    }


    @GetMapping("/oauth/loginInfo")
    @ResponseBody
    public String oauthLoginInfo(Authentication authentication, @AuthenticationPrincipal OAuth2User oAuth2UserPrincipal){
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        Map<String, Object> attributes = oAuth2User.getAttributes();
        System.out.println(attributes);
        // PrincipalOauth2UserService의 getAttributes내용과 같음

        Map<String, Object> attributes1 = oAuth2UserPrincipal.getAttributes();
        // attributes == attributes1

        return attributes.toString();     //세션에 담긴 user가져올 수 있음
    }


    @GetMapping("/loginInfo")
    @ResponseBody
    public String loginInfo(Authentication authentication, @AuthenticationPrincipal PrincipalDetails principalDetails){
        String result = "";

        PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
        if(principal.getName() == null) {
            result = result + "Form 로그인 : " + principal;
        }else{
            result = result + "OAuth2 로그인 : " + principal;
        }
        return result;
    }

}
  • OAuth 로그인 관련 매핑해줌
    "/form/loginInfo", "/oauth/loginInfo" 에 따라 다르게 로그인할 수 있게됨.

🕹️구글로그인 작동화면

profile
학습하며 도전하는 것을 즐기는 개발자

0개의 댓글