본문 바로가기
혼자서 개발새발

Spring Security ) SNS 로그인 구현(내가 보려고 정리...)

by 휴일이 2023. 1. 9.

 

 

내가 보려고 정리한 것

 

 

 

 

일단 시큐리티를 쓰려면

WebSecurityConfigurerAdapter 를 구현한 컨피그레이션 클래스를 만드러야한다~

그리고 configure 오버라이딩 해준다

(configure 메소드에서 시큐리티 설정을 함)

 

@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터 체인에 등록
@RequiredArgsConstructor
/**
 * secured
 * preAuthorize(postAuthorize)
 * 메소드에 직접 권한 걸기 true
 */
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final PrincipalOauth2UserService principalOauth2UserService;

    @Bean
    public static BCryptPasswordEncoder encoderPwd() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf()
                .disable()

                .authorizeRequests()
                .antMatchers("/user/**").authenticated() //인증만 받으면 접속 가능
                // 인증뿐만 아니라, 권한도 있어야함
                .antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
                .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll() //나머지 요청은 다 허용할게요

                .and()
                .formLogin()
                .loginPage("/loginForm") // 로그인 페이지 명시
//                .usernameParameter("id") // username 파라미터 이름 명시(기본 username)
                .loginProcessingUrl("/login") //시큐리티가 대신 로그인 진행해줌(컨트롤러에 /login 안 만들어도 됨)
                .defaultSuccessUrl("/") //로그인이 성공하면, 이 페이지로 가주세요

                /**
                 * 1. 코드 받기(인증)
                 * 2. 액세스 토큰 받기(권한)
                 * 3. 사용자 프로필 정보 가져오기
                 * 4-1. 정보를 토대로 자동 회원가입
                 * 4-2. 추가 정보 필요하다면 작성
                 */
                /**
                 * 구글 로그인?
                 * 액세스 토큰 + 사용자 프로필 정보 한꺼번에 가져옴
                 * 코드 필요 X
                 * username = google_(sub)
                 * password = (암호화)겟인데어
                 * email = 구글이메일
                 * role = ROLE_USER
                 */
                .and()
                .oauth2Login() //oauth2 로그인 허용
                .loginPage("/loginForm") //구글 로그인 인증 페이지는?
                .userInfoEndpoint() //로그인이 성공했다면?
                .userService(principalOauth2UserService) //후처리 이렇게 해주세요

        ;
    }
}

 

그리고 얘는 신기한게

로그인 인증을 자동으로 해준다...;;

 

 

 

주석 보면 되긴 하는데

 

/loginForm 이

아디비번 적는 view 페이지이고(여기서 로그인 정보 친다고 알려줌)

 

usernameParameter은 form에서

아이디를 치는 input text의 name이 기본이 username인데

다른 걸로 바꿀래? 머 이런 뜻이다

 

loginProcessingUrl은 "/login" 이 URL로 로그인 들어간단 뜻인데

컨트롤러에 로그인 안 만들어도 된다

걍 시큐리티가 알아서해줌;;;

 

마지막 디폴트석세스유알엘은 로그인이 성공하면 갈 페이지를 얘기하는 건데

시큐리티가 좀  신기한게

 

/loginForm 으로 처음 접속하거나 했으면 설정한 defaulltSuccessUrl 로 보내주는데

만약에 내가 다른 페이지를 들어가려고 했다가

로그인 인증이 막혀서 /loginForm이 떴고

로그인을 완료했다면

다른 페이지로 자동 리다이렉트해준다 ;;;;

내가 직접 http 헤더에서 전에 있었던 페이지 저장해줄 필요가 읍다....

이뇨석 매우 편리했음

 

 

 

어쨌든

보통 시큐리티 로그인 순서는


 * 1. 코드 받기(인증)
 * 2. 액세스 토큰 받기(권한)

요것인데

 

SNS로그인을 하면

 

 

 * 1. 코드 받기(인증)
 * 2. 액세스 토큰 받기(권한)

 * 3. 사용자 프로필 정보 가져오기
 * 4-1. 정보를 토대로 자동 회원가입
 * 4-2. 추가 정보 필요하다면 작성

 

4-1 까지는 자동으로 해준당ㅎ.ㅎ

4-2 는 필요하다면 추가해주기

 



 * 만약 구글 로그인이라면 ?


 * 구글이 액세스 토큰 + 사용자 프로필 정보 한꺼번에 제공
 * 로그인에 관한 코드는 필요 없음~

 * 회원가입을 한다면, 원하는 정보만 가져와서 save 하면 됨~

 

나는 sns명_아이디로 회원가입을 하고 싶었고

provideId = 해당 SNS 내 아이디의 PK

password = 대충대충

email = 해당sns 이메일

role = 자동으로 user 인증으로

 

 

 

 

아까 SecurityConfig의 configure 메소드에 보면

 

후처리를 하는 부분이 있다

얘는 sns로그인을 할 경우 후처리를 담당하는데~

OAuth2UserService 타입만 들어올 수 있답니당 ^0^

 

 

난 DefaultOAuth2UserService 를 구현해주었다

 

 

 

 

PrincipalOauth2UserService.class

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final UserRepository userRepository;

    public PrincipalOauth2UserService(BCryptPasswordEncoder bCryptPasswordEncoder, UserRepository userRepository) {
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
        this.userRepository = userRepository;
    }

    //로그인 후처리
    // 구글로 받은 userRequest 데이터에 대한 후처리

    /**
     * loadUser 는, 기본적으로 서비스 객체를 만들지 않아도 자동 발동하여
     * 로그인을 가능하게 함
     * 근데 우리가 굳이 다시 한 번 구현하는 이유?
     * return 을 PrincipalDetails 로 하기 위하여,
     * UserDetails , OAuth2UserService 를 둘 다 접근가능하게 하기 위하여
     * PrincipalDetailsService 의 loadUserByUsername 도 마찬가지...
     * 해당 메소드 종료시 @AuthenticationPrincipal 어노테이션 만들어짐
     */
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        log.info("userRequest = {}", userRequest);
        // registrationId 로 어떤 OAuth 로 로그인했는지 확인 가능
        log.info("getClientRegistration = {}", userRequest.getClientRegistration()); // 클라이언트가 누구니? (구글..)
        log.info("getAccessToken = {}", userRequest.getAccessToken()); // 토큰(토큰 정보)

        OAuth2User oAuth2User = super.loadUser(userRequest);
        // 구글 로그인 버튼 클릭 -> 구글 로그인 창 -> 로그인 완료 -> code 리턴(OAuth - Client 라이브러리) -> AccessToken 요청
        // userRequest 정보 -> loadUser() -> 구글로부터 회원 프로필 받아줌
        log.info("getAttributes = {}", super.loadUser(userRequest).getAttributes()); // 유저 값(이름, 이메일...) // sub=115894611263003886842 -> primaryKey , 구글 회원 아이디 PK

        // 회원 가입 강제 진행
        OAuth2UserInfo oAuth2UserInfo = null;
        if(userRequest.getClientRegistration().getRegistrationId().equals("google")) {
            log.info("구글 로그인 요청");
            oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
        } else if(userRequest.getClientRegistration().getRegistrationId().equals("facebook")) {
            log.info("페이스북 로그인 요청");
            oAuth2UserInfo = new FacebookUserInfo(oAuth2User.getAttributes());
        } else if(userRequest.getClientRegistration().getRegistrationId().equals("naver")) {
            log.info("네이버 로그인 요청");
            oAuth2UserInfo = new NaverUserInfo((Map) oAuth2User.getAttributes().get("response"));
        } else if(userRequest.getClientRegistration().getRegistrationId().equals("kakao")) {
            log.info("카카오톡 로그인 요청");
            oAuth2UserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
        } else {
            log.info("구글과 페이스북만 지원 가능");
        }

        String provider = oAuth2UserInfo.getProvider(); //google
        String providerId = oAuth2UserInfo.getProviderId();
        String username = provider + "_" + providerId; // google_115894611263003886842
        String password = bCryptPasswordEncoder.encode("겟인데어"); //의미는 없지만 그래도
        String email = oAuth2UserInfo.getEmail();
        String role = "ROLE_USER";

        User userEntity = userRepository.findByUsername(username);
        // 찾은 userEntity가 없다며는 회워가입
        if (userEntity == null) {
            log.info("SNS 로그인 최초 = {}", provider);
            userEntity = User.builder()
                    .username(username)
                    .password(password)
                    .email(email)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .build();
            userRepository.save(userEntity);
        } else {
            log.info("이미 로그인되어 있는 사람입니다");
        }

        // Authentication 객체 안으로 들어감
        return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
    }

 

 

 

loadUser 메소드 핵심 구성은 대충 이렇게 돼있다

복잡해보이지만,

request 온 sns에 따라 구분해서 1 - 2 - 3 순으로 동작한다

 

일단 loadUser()에 매개변수로 request를 담고

이를 OAuth2UserInfo(인터페이스) 타입으로 받는다

 

얘는 pk , sns명, 이메일, 이름 등을 받는다

public interface OAuth2UserInfo {

    String getProviderId(); //PK
    String getProvider(); //google, facebook...
    String getEmail(); //이메일
    String getName(); //이름

}

 

 

그리고,

각 sns에 맞게 인터페이스를 구현한다!

 

제일 까다로웠던 카카오..

 

public class KakaoUserInfo implements OAuth2UserInfo {

    private Map<String, Object> attributes; //oAuth2User.getAttributes
    private Map<String, Object> attributesAccount;
    private Map<String, Object> attributesProfile;

    public KakaoUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
        this.attributesAccount = (Map<String, Object>) attributes.get("kakao_account");
        this.attributesProfile = (Map<String, Object>) attributesAccount.get("profile");
    }

    @Override
    public String getProviderId() {
        return String.valueOf(attributes.get("id"));
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getEmail() {
        return (String) attributesAccount.get("email");
    }

    @Override
    public String getName() {
        return attributesProfile.get("nickname").toString();
    }
}

 

 

카카오는 정보가 좀 특이하게 온다

providerId 는 그대로 오는 대신 Long 타입으로 와서

String.valueOf() 로 타입캐스팅 해줘야하고 ( toString() 은 안먹음 )

 

email은 kakao_account 맵 형식으로 안에 들어있고

nickname은 또 그 안에 profile 맵 안에 들어있어서

하나하나 맵으로 꺼낸 다음 또 값을 다시 꺼내줘야한다 ㅠㅠ(하...)

 

 

 

페이스북과 구글 같은 경우는

글로벌 사이트라 그런지 시큐리티에서

기본으로 제공되는 녀석들이라

걍 매우 무난하고(ProvideId 이름만 조금씩 달라서 그거만 바꾸면 됨)

 

참고로

구글은 sub

네이버, 카카오, 페북은 id 이다

public class GoogleUserInfo implements OAuth2UserInfo {

    private Map<String, Object> attributes; //oAuth2User.getAttributes

    public GoogleUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}

 

 

네이버도 좀 짜증나는디

얘는 provideId 등의 값이

response 라는 이름으로 들어있어서

 

 

요렇게 response를 맵으로 한번 감싼 후 꺼내야한다

( NaverUserInfo 자체는 위와 동일함 ) 

 

 

 

그리고 그냥 로그인을 할 때

시큐리티컨피그에서 loginProcessingUrl() 설정을 해주게 되는데

 

* 시큐리티 설정에서 loginProcessingUrl("/login") 하면
* /login 으로 요청이 됐을 때
* UserDetailsService 타입으로 Ioc 되어있는
* loadUserByUsername 함수가 실행된다

ㄴ 무조건 자동임

 

근데, 얘를 구현 안 해도 자동으로 시큐리티가 해주긴 하는디

sns와 그냥 로그인을 편하게 하려면

구현하는 것이 좋다(뒤에 설명하겟음 ㅋ)

 

 

 

UserDetailsService 를 구현한 PrincipalDetailsService.class

/**
 * 시큐리티 설정에서 loginProcessingUrl("/login")
 * /login 요청이 오면
 * UserDetailsService 타입으로 Ioc 되어있는
 * loadUserByUsername 함수가 실행
 */
@Service
public class PrincipalDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;


    /**
     * (Session (Security session (Authentication (UserDetails))))
     * 해당 메소드 종료시 @AuthenticationPrincipal 어노테이션 만들어짐
     */
    @Override
    // form 에서 name 이 username 이라고 되어 있는 거랑 매핑됨
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User userEntity = userRepository.findByUsername(username);

        // username 값이 들어왔다면
        if (username != null) {
            // UserDetails 객체로 검증하세요!
            return new PrincipalDetails(userEntity);
        }
        // 안 들어왔다면 null
        return null;
    }
}

 

 

해당 메소드가 다 실행되고 나면

@AuthenticationPrincipal 메소드가 생긴다(활성화 된다? 사용 가능하다?고 하는게 맞을듯)

 

그리고 얘는

view에서 form으로 넘길 때,

name="username" 이라고 되어있는 거랑만 매핑되니까

이름 정할 때 조심히 ㅈ ㅓㅇ해야한담...ㅎㅎ

 

 

 

 

 

뭐 어쨋든 이렇게 로그인을 하면?

 

 * loadUser 는, 기본적으로 서비스 객체를 만들지 않아도 자동 발동하여
 * 로그인을 가능하게 함
 * 근데 우리가 굳이 다시 한 번 구현하는 이유?
 * return 을 PrincipalDetails 로 하기 위하여,

 

왜 PrincipalDetails 로 하느냐?

 * UserDetails , OAuth2UserService 를 둘 다 접근가능하게 하기 위하여
 * PrincipalDetailsService 의 loadUserByUsername 도 마찬가지...
 * 해당 메소드 종료시 @AuthenticationPrincipal 어노테이션 만들어짐

 

 

 

Session 안에는

Security Session 을 위한 공간이 따로 있고,

시큐리티 세션에는 Authentication 만 담을 수 있음,

근데 또 어선티케이션 얘는

UserDetails 과 OAuth2User 만 담을 수 있다 ㅡ.ㅜ

 

 

 

그래서 원래 일반 로그인한 정보를 가지고 오고 싶을 때는

UserDetails 타입을 이용하고

sns로그인은

OAuth2User 타입을 이용해야한다

.

그래서

컨트롤러에서 일반 로그인, sns로그인 매핑을 따로 구현해서

각각 타입캐스팅 해줘야한다ㅠㅠ

 

일반 로그인

(PrincipalDetails 는 UserDetails 를 구현한 클래스)

 

sns 로그인

띠용

 

각각 타입 캐스팅을 해줘야하는 어려움이 있었는데,

이를 해결하기 위해서는?

 

 

UserDetails , OAuth2User 를 한꺼번에 구현하면 되지롱~

 

 

 

/**
 *  Security Session -> Authentication -> UserDetails
 */
@Data
public class PrincipalDetails implements UserDetails, OAuth2User {

    private User user; //컴포지션
    private Map<String, Object> attributes;

    // 일반 로그인
    public PrincipalDetails(User user) {
        this.user = user;
    }

    // OAuth 로그인
    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    // 해당 유저의 권한 return
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });
        return collection;
    }
    @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() {
        /**
         *  사이트에서 n년간 로그인 안 하면
         *  휴면 계정이 된다면? ....
         */
        return true;
    }

    // OAuth 로그인 할 때 들어오는 유저 정보 가져오는...
    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return null; // pk
    }
}

 

 

이로케 설정만 해놓으면 ?

시큐리티가 알아서 로그인 인증 기타 등등을 해준다...(개신기)

 

 

 

 

아~그리고 시큐리티 설정도 해야한다

나는 application.yml 로 했다

이건 검색하면 다 나오긴 하는데

머 굳이 쓰면...

 

 

security:
  oauth2:
    client:
      registration:
        google:
          client-id: 아이디
          client-secret: 비번
          scope:
            - email
            - profile
        facebook:
          client-id: 아이디
          client-secret: 비번
          scope:
            - email
            - public_profile
        naver:
          client-id: 아이디
          client-secret: 비번
          scope:
            - name
            - email
          client-name: Naver
          authorization-grant-type: authorization_code
          redirect-uri: http://localhost:8080/login/oauth2/code/naver #주소 고정이 안 되어 있지만 관례를 따름
        kakao:
          client-id: 아이디(Rest Api용)
          client-secret: 비번(제품설정-보안에 있는 코드임)
          scope:
            - profile_nickname
            - account_email
          client-name: Kakao
          authorization-grant-type: authorization_code
          redirect-uri: http://localhost:8080/login/oauth2/code/kakao
          client-authentication-method: POST
      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 #프로필 정보는 이 주소를 호출
          user-name-attribute: response # 회원 정보를 json 으로 받음, response 키값으로 네이버가 return 해줌
        kakao:
          authorization-uri: https://kauth.kakao.com/oauth/authorize
          token-uri: https://kauth.kakao.com/oauth/token
          user-info-uri: https://kapi.kakao.com/v2/user/me
          user-name-attribute: kakao_account

 

 

그리고 네이버랑 카카오는

시큐리티가 기본 제공(?)하는 글로벌 사이트가 아니어서

provider 를 따로 설정해야한다

 

그리고 @@@카카오 할 때 @@@

registration 설정에 카카오는

client-authentication-method: POST

 

이거 반드시 넣어줘야한다;;;

안그러면 오류남..하.....

 

반드시 POST를 넣으시오

 

 

 

 

 

 

***

아 그리고 DB 설정할 때,

싴큐리티 인증, 권한 설정할 때 젤 궁금햇던게

hasRole('권한')

이거 DB에 어케 넣어야함??

어케 넣어야 시큐리티가 알아들음??

자동으로 알아들음???

이거였는디

대체 hasRole을 어케 알아들음??

 

 

걍 DB 테이블에 role 이라는 이름의 컬럼 넣고

내가 지정한 권한명을 넣어주면

시큐리티가 해당 유저 테이블의 role 컬럼을 확인해서

내가 집어넣은

hasRole('ROLE_ADMIN') 등을 찾으면 그 유저한테 권한을 준다;;;;

 

너무 완벽하게 자동화가 되어있어서 이해를 못했었음...ㅎㅎ;;;;

 

 

그래서 걍 디비에는

1.멤버 테이블에 role 칼럼 추가함

2.권한 이름을 정하고, 적절하게 넣어줌

(난 (인증만받은)일반유저 ROLE_USER,

쪼렙어드민 ROLE_MANAGER,

최고어드민 ROLE_ADMIN 이라고 줌)

3. .access() 로 role에 해당하는 단어(?)가 있으면 그게 권한이 있다고 인식하고 그 권한 가진 유저만 접근 가능하게 함

 

 

 

 

 

어쨋든 머...대충 정리한거같다

벌써 10시 52분이다......

내일 하건가기 싫다ㅠㅠ

 

728x90