[Spring] 5장 Spring Security와 OAuth 2.0으로 로그인 기능 구현하기

Date:
Updated:

카테고리:

5장 - Spring Security와 OAuth 2.0으로 로그인 기능 구현해보자!

Spring Security : 막강한 인증과 권한 부여 기능을 가진 프레임워크

✔️👉🏻 OAuth 2.0 동작 방식의 이해

5.1 Spring Security와 Spring Security Oauth2 클라이언트

  • OAuth 로그인 구현시 로그인 보안, 비밀번호 찾기, 변경 등을 모두 구글, 페이스북, 네이버 등에 맡기면 된다!

5.2 구글 서비스 등록

5.2.1 프로젝트 생성 후 동의 화면 구성

OAuth 동의 화면 구성 중 Google API의 범위?
이번에 등록할 구글 서비스에서 사용할 범위 목록이다.
기본값은 email, profile, openid 이며 이외 다른 정보들도 사용하고 싶다면 범위 추가 버튼으로 ! 스크린샷 2022-12-02 오후 2 45 56


5.2.2 OAuth 클라이언트 ID 만들기

2

승인된 리디렉션 URI

  • 서비스에서 파라미터로 인증 정보를 줬을 때 인증이 성공하면 구글에서 리다이렉트 할 URL이다.
  • spring boot 2 버전의 security에선 기본적으로
    {도메인}/login/oauth2/code/{소셜서비스코드} 로 리다이렉트 URL을 지원하고 있다.
  • 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요 ❌
  • AWS 서버에 배포하게 되면, localhost 외에 추가로 주소를 추가해야 한다. -> 추후!

생성 후 clientIdclientSecret 코드를 복사해 프로젝트에서 설정하자

5.2.3 application-oauth.properties 파일 생성

1
2
3
spring.security.oauth2.client.registration.google.client-id=클라이언트 ID
spring.security.oauth2.client.registration.google.client-secret=클라이언트 보안 비밀
spring.security.oauth2.client.registration.google.scope=profile, email
  • 강제로 profile, email을 등록한 이유는 openid라는 scope이 있으면 Open Id Provider로 인식,
    => 그러면 OpenId Provider인 서비스(구글)와 그렇지 않은 서비스(네이버 카카오)로 나눠서 각각 OAuth2Service를 만들어야 한다.

  • properties의 이름을 application-xxx.properties 로 만들면 profile=xxx 라는 식으로 호출하여
    해당 프로퍼티의 설정을 가져올 수 있다.
  • 호출하는 방식은 여러 방식이 있지만 이 책에서는 스프링 부트의 기본 설정 파일인 application.properties에서 application-oauth.properties를 포함하도록 구성!


application.properties에 다음과 같이 코드를 추가하자

1
spring.profiles.include=oauth

🔥 클라이언트 ID클라이언트 보안 비밀은 보안이 중요한 정보!
.gitignoreapplication-oauth.properties를 추가하자


5.3 구글 로그인 연동하기

5.3.1 domain/user/User 클래스 생성

👇🏻 처음 보는 부분!!
@Enumerated(EnumType.STRING)

  • JPA로 DB에 저장할 때 Enum 값을 어떤 형태로 저장할지!
  • 숫자로 저장되면 DB로 확인할 때 그 값이 무슨 코드를 의미하는지 알 수 없다! 그래서 문자열로 저장될 수 있도록
  • Converter로도 할 수 있음


5.3.2 Enum 클래스 Role 생성 : 각 사용자의 권한 관리

1
2
3
4
5
6
7
8
9
10
@Getter
@RequiredArgsConstructor
public enum Role {

    GUEST("ROLE_GUEST", "손님"),
    USER("ROLE_USER", "일반 사용자");

    private final String key;
    private final String title;
}

spring security 에서는 권한 코드에 항상 ROLE_이 앞에 있어야 한다.
그래서 코드별 키 값을 ROLE_GUEST, ROLE_USER 등으로 지정한다


5.3.3 UserRepository : User의 CRUD를 책임진다!

1
2
3
4
public interface UserRepository extends JpaRepository<User, Long> {
    
    Optional<User> findByEmail(String email);
}

findByEmail : 소셜 로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입한 사용자인지 판단하기 위한 메소드

5.3.4 build.gradle에 spring security 관련 의존성 추가

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

소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성


5.3.5 SecurityConfig 클래스 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated()
                .and()
                    .logout()
                        .logoutSuccessUrl("/")
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService);
    }
}
  • @EnableWebSecurity : Spring Security 설정들을 활성화시켜준다.
  • .csrf().disable().headers().frameOptions().disable()
    • h2-console 화면을 사용하기 위해 해당 옵션들을 disable 한다.
  • authorizeRequests
    • URL 별 권한 관리를 설정하는 옵션의 시작점
    • authorizeRequests 가 선언되어야만 antMatchers 옵션 사용 가능
  • antMatchers
    • 권한 관리 대상을 지정하는 옵션, URL, HTTP 메소드 별로 관리 가능
    • “/” 등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한 준다
    • “/api/v1/**” 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록
  • anyRequest
    • 설정된 값 이외의 나머지 URL을 나타냄
    • 여기선 authenticated()를 추가해 나머지 URL들은 모두 인증된 사용자들(로그인한 사용자) 에게만 허용하게 함
  • logout().logoutSuccessUrl(”/”)
    • 로그아웃 기능에 대한 여러 설정의 진입점
    • 로그아웃 성공시 / 주소로 이동
  • oauth2Login : OAuth 2 로그인 기능에 대한 여러 설정의 진입점
  • userInfoEndpoint : OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정 담당
  • userService
    • 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록
    • 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있음.


🔥 WebSecurityConfigurerAdapter deprecated 이슈


5.3.6 CustomOAuth2UserService 클래스

구글 로그인 이후 가져온 사용자의 정보(email, name, picture)들을 기반으로,
가입정보 수정, 세션 저장 등의 기능 지원

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@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();

        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);
    }
}
  • registrationId
    • 현재 로그인 진행중인 서비스를 구분하는 코드
    • 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분할 때 사용
  • userNameAttributeName
    • OAuth2 로그인 진행 시 키가 되는 필드값! Primary Key와 같은 의미
    • 구글의 경우 기본적으로 코드를 지원, 기본 코드는 "sub"이며 네이버, 카카오 등은 기본 지원하지 ❌
    • 이후 네이버 로그인과 구글 로그인을 동시 지원할 때 사용
  • OAuthAttributes 클래스
    • OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스, 이후 네이버 등 다른 소셜 로그인도 이 클래스 사용
  • SessionUser 클래스
    • 세션에 사용자 정보를 저장하기 위한 Dto 클래스
    • :question: 왜 User 클래스를 쓰지 않고 새로 만들어서 쓸까 :question:
      🚨 User을 썼을 때의 에러 : User 클래스에 직렬화를 구현하지 않았다는 에러
      → User 클래스가 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될지 모른다.
      → 성능이슈, 부수효과가 발생
      → 그래서 직렬화 기능을 가진 세션 Dto SessionUser를 하나 추가로 만드는 것이 낫다


5.3.7 OAuthAttribute 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
    return ofGoogle(userNameAttributeName, attributes);
}

private 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()
            .name(name)
            .email(email)
            .picture(picture)
            .role(Role.GUEST)
            .build();
}
  • of
    • OAuth2User에서 반환하는 사용자 정보는 Map 이기 때문에 값 하나하나를 변환해야 함
  • toEntity()
    • User 엔티티 생성
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때!
    • 가입할 때의 기본 권한GUEST로 주기 위해서 role 빌더값에는 Role.GUEST 사용


5.3.8 SessionUser 클래스 : config.auth.dto 패키지에

1
2
3
4
5
6
7
8
9
10
11
12
@Getter
public class SessionUser implements Serializable {
    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();
    }
}

SessionUser에서는 인증된 사용자 정보만 필요
name, email, picture만 필드로 선언

✅ 로그인 테스트


5.4 어노테이션 기반으로 개선하기

앞서 만든 코드에서 개선할 부분을 찾아보자!

IndexController 에서 세션값을 가져오는 부분!

1
SessionUser user = (SessionUser) httpSession.getAttribute("user");

index 메소드 외에 다른 컨트롤러나 메소드에서 세션값이 필요하면 직접 세션에서 값을 가져와야 한다.. -> 메소드 인자로 세션값을 바로 받을 수 있도록 하자!

  • config.auth 패키지에 @LoginUser 어노테이션을 생성한다
  • config.auth 패키지에 @LoginUser 어노테이션을 생성한다
1
2
3
4
@Target(ElementType.PARAMETER) 
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
  • @Target : 이 어노테이션이 생성될 수 있는 위치를 지정, PARAMETER로 지정했으니 메소드의 파라미터로 선언된 객체에서만 사용 가능 ⭕️ (이 외에도 클래스 선언문에 쓸 수 있는 TYPE이 있음)
  • interface : 이 파일을 어노테이션 클래스로 지정


같은 위치에 LoginUserArgumentResolver 생성
이는 HandlerMethodArgumentResolver 인터페이스의 구현체!

  • 기능 : 조건에 맞는 경우 메소드가 있다면 그 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 ⭕️
1
2
3
4
5
6
7
8
9
10
11
12
13
@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());

        return isLoginUserAnnotation && isUserClass;
    }

컨트롤러 메서드특정 파라미터를 지원하는지 판단
여기선 파라미터에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class 인 경우 true 반환


1
2
3
4
5
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}

파라미터에 전달할 객체를 생성한다!
여기선 세션에서 객체를 가져온다.


이렇게 생성된 LoginUserArgumentResolver가 스프링에서 인식될 수 있도록
WebMvcConfigure에 추가하자
configWebConfig 클래스를 생성하여 다음과 같이 설정을 추가해주자!

1
2
3
4
5
6
7
8
9
@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(loginUserArgumentResolver);
    }

HandlerMethodArgumentResolver 는 항상 WebMvcConfigureraddArgumentResolvers() 를 통해 추가해야 한다!

이제 진짜 @LoginUser 를 써서 반복되는 코드를 개선해보자

1
2
3
4
5
6
7
8
9
10
11
    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());

//        SessionUser user = (SessionUser) httpSession.getAttribute("user");

        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

@LoginUser SessionUser user처럼
@LoginUser만 쓰면 세션 정보가져올 수 있다!


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

추가적으로 개선해보자!

문제 :one: : 우리가 만든 서비스는 애플리케이션을 재실행하면 로그인이 풀림

  • :question: 이는 세션이 내장 톰캣의 메모리에 저장되기 때문이다
    • 기본적으로 세션은 실행되는 WAS의 메모리에서 저장되고 호출된다!
    • 메모리에 저장되다 보니 내장 톰캣처럼 애플리케이션 실행 시 실행되는 구조에선 항상 초기화
      -> 즉 배포할 때마다 톰캣이 재시작됨..

문제 :two: : 2대 이상의 서버에서 서비스하고 있다면, 톰캣마다 세션 동기화 설정을 해줘야 한다.
그래서 현업에선 세션 저장소에 대해 3가지 중 한 가지 선택!

  1. 톰캣 세션 사용
  2. MySQL 같은 데이터베이스를 세션 저장소로 사용
  3. Redis, Memcached 와 같은 메모리 DB를 세션 저장소로 사용


우리는 2번 을 선택하자! 설정이 간단하고 사용자가 많은 서비스가 아니고, 비용절감을 위해!


5.6 네이버 로그인 연동하기

naver

naver2

댓글남기기