스프링부트 공부기록(10) - 게시판 프로젝트 :: 스프링 시큐리티, OAuth 2.0 구글 로그인 구현하기
my code archive
article thumbnail
반응형

🔍스프링 시큐리티와 스프링 시큐리티 Oauth2 클라이언트?

  • 많은 서비스에서 구글, 페이스북 등 소셜 로그인 기능을 사용하는 추세.
  • 그 이유는 직접 구현하는 것보다 OAuth를 써서 구현하는 것이 더 간단하기 때문이다.
  • 로그인 시 보안, 비밀번호 변경&찾기 등등 모든 서비스를 소셜 서비스에 맡기고 그외의 개발에 집중할 수 있다.

🤍구글 서비스 등록

 

1. 구글 서비스 등록

구글 클라우드 플랫폼 으로 이동해서 프로젝트를 만든다.

API 및 서비스로 이동해서 사용자 인증 정보를 만든다.

 

먼저 동의화면을 구성해준 다음

 

 

OAuth 클라이언트 ID를 만들어준다.

  • 스프링 부트2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드} 리다이렉트 URL을 지원하고 있다.
  • 사용자가 별도로 리다이렉트 URL을 지원하는 컨트롤러를 만들 필요가 없다.

 

 

2. application-oauth.properties 파일 생성

 

scope=profile,email

  • 기본값.
  • opinid라는 scope가 있으면 Open id Provider로 인식하기 때문.
  • ㄴ구글과 그외의 서비스(네이버, 카카오 등)로 나누어 만들어야 하기 때문에 하나의 OAuth2Service로 사용하기 위해 기본값으로 강제 설정함.

 

3. application.properties 파일에 설정 코드 추가

spring.profiles.include=oauth

 

4. .위 개인 정보를 제외하고 커밋되도록 .gitignore에 코드 추가

application-oauth.properties

 

제외하고 잘 올라갔다.


🤍구글 로그인 구현하기

 

1. User 엔티티 생성

 

2. Enum 클래스 Role 생성

 

3. User CRUD 관련 UserRepository 생성

public interface UserRepository extends JpaRepository<User, Long> {
    
    //소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 판단하기 위한 메소드
    Optional<User> findByEmail(String email);
}

 

🤍스프링 시큐리티 설정

4. 의존성 추가

  • 소셜 로그인 등 소셜 기능 구현 시 필요한 의존성
 //소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
    implementation('org.springframework.boot:spring-boot-starter-oauth2-client')

 

5. 시큐리티 관련 클래스가 담긴 config.auth 패키지 만들기

 

SecurityConfig

  • @EnableWebSecurity : 스프링 시큐리티 설정 활성화
  • authorizeRequests : URL별 권한 관리 설정. 선언되어야만 anMatchers 옵션 사용 가능
  • anMatchers : 권한 관리 대상을 지정하는 옵션임. 여기에서는 /* 등 지정된 URL은 전체 열람 권한을 주고(permitAll()), 특정 주소 API는 USER 권한을 가진 사람만 가능하도록 구현함.
  • loglut().logoutSuccessUrl("/") : 로그아웃 설정 진입점, 로그아웃 성공시 / 주소로 이동
  • oauth2Login : 로그인 기능에 대한 설정 진입점
  • userInfoEndopoint : 로그인 성공 후 사용자 정보를 가져올 때 설정 담당
@RequiredArgsConstructor
@EnableWebSecurity  //스프링 시큐리티 설정 활성화
public class SecurityConfig  extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .headers().frameOptions().disable() //h2-console 화면 사용 위해 해당 옵션들을 disable하겠다.
                .and()
                .authorizeRequests()   //URL별 권한 관리를 설정하는 옵션의 시작점. 선언되어야만 antMatchers 옵션 사용 가능
                //권한 관리 대상을 지정하는 옵션
                .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
                .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                //설정된 값 이외 나머지 URL들을 나타냄.
                //나머지 URL들은 모두 인증된 사용자들에게만 허용
                .anyRequest().authenticated()
                .and()
                .logout()
                .logoutSuccessUrl("/")  //로그아웃 기능에 대한 여러 설정의 진입점
                .and()
                .oauth2Login()
                .userInfoEndpoint()
                .userService(customOAuth2UserService);

    }
}

 

CustomOAuth2UserService

  • 구글 로그인 후 가져온 사용자 정보를 기반으로 가입, 정보수정, 세션 저장 등의 기능을 지원함.

 

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        //현재 로그인 진행 중인 서비스를 구분하는 코드(추후 네이버 로그인 추가 시 네이버인지 구글인지 구분하기 위해 사용)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        //PK와 같은 의미
        //구글의 기본 코드는 "sub"
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes);

        httpSession.setAttribute("user", new SessionUser(user));
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey());
    }

    private User saveOrUpdate(OAuthAttributes attributes){
        User user = userRepository.findByEmail(attributes.getEmail()).map(entity->entity.update(attributes.getName(),attributes.getPicture()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);
    }
}

 

OAuthAttributes

  • DTO
@Getter
public class OAuthAttributes {

    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture){
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
        this.picture = picture;
    }

    //OAuth2User에서 반환하는 사용자 정보가 Map이기 때문에 값 하나하나 변환 필요.
    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes){
        return ofGoogle(userNameAttributeName, attributes);
    }

    public static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes){

        return OAuthAttributes.builder()
                .name((String)attributes.get("name"))
                .email((String)attributes.get("email"))
                .picture((String)attributes.get("picture"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public User toEntity(){
        return User.builder()                           //가입 시 기본 권한을 GUEST로 주겠다.
                .name(name).email(email).picture(picture).role(Role.GUEST)
                .build();
    }
}

 

SessionUser

  • User 클래스를 그대로 사용하지 않는 이유는 User 클래스에 직렬화를 구현하지 않았기 때문임.
  • User 클래스는 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될지 몰라 직렬화 코드를 넣을 수 없음.
  • 그러므로 직렬화 기능을 가진 세션 Dto를 하나 추가로 만드는 것이 이후 유지 보수에 많은 도움이 됨.
@Getter
public class SessionUser implements Serializable {

    /*
    세션user는 인증된 사용자 정보만 필요로 하므로 name, email, picture만 필드로 선언함.
     */

    private String name;
    private String email;
    private String picture;

    public SessionUser(User user){
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}

 

6-1. IndexController 수정

  • 로그인 화면단(index.mustache)에서 userName을 사용할 수 있도록 userName을 model에 저장
  • 로그인 성공 시 세션에 SessionUser를 저장하도록 구성.

하지만, 이렇게 되면 반복적인 코드가 생기므로 어노테이션 기반으로 개선해 본다.

 

먼저 @LoginUser 어노테이션 생성

@Target(ElementType.PARAMETER)  //어노테이션이 생성될 수 있는 위치 지정
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {   //어노테이션 클레스로 지정함.
}

 

LoginUserArgumentResolver 생성

  • supportsParameter() : @LoginUser 어노테이션이 붙어있고 파라미터 클래스 타입이 SessionUser.class일 경우 true를 반환함.
  • resolveArgument() : 파라미터에 전달할 객체를 생성함. 여기에서는 세션에서 객체를 가져옴.

 

WebConfig 생성

  • LoginUserArgumentResolver가 스프링에서 인식될 수 있도록함.

 

6-2. IndexController를 어노테이션 기반으로 다시 수정한 코드

 

7. 로그인 테스트

 

  • 구글 로그인 버튼 추가

 

  • 누르면 우리가 흔히 알고 있는 구글 로그인 화면이 나온다!

 

  • 자신의 구글 아이디로 로그인하면 짠!

 

 

  • 회원가입이 완료되어 USER 테이블에서도 확인할 수 있다!

 

🤍세션 저장소로 데이터베이스 사용하기

  • 현재 구현한 코드로는 애플리케이션 재실행 시 로그인이 풀려버린다. 세션이 내장 톰캣의 메모리에 저장되기 때문이다.
  • 해결 방법으로는 톰캣 세션 사용 / 데이터베이스 세션 저장소로 사용 / 메모리 DB 세션 저장소로 사용 3가지 방법이 있고 그중 2번째 방법을 선택해 보도록 한다.

 

1. 의존성 추가

 //데이터베이스를 세션 저장소로 사용하기 위해 추가
    implementation('org.springframework.session:spring-session-jdbc:')

 

2. application.properties 코드 추가

spring.session.store-type=jdbc
반응형
profile

my code archive

@얼레벌레 개발자👩‍💻

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

반응형