스프링 부트와 OAuth2 [Google]

hyyyynjn·2021년 3월 3일
1
post-thumbnail

스프링 시큐리티와 스프링 시큐리티 Oauth2

  • 직접 로그인 서비스를 구현하려면 배보다 배꼽이 커지는 경우가 생긴다.
    • 구글 서비스, 네이버 서비스를 통해 oauth를 구현한다.

구글 OAuth 인증

구글 서비스 등록

application-oauth.yml 등록

  • scope=profile,email
    • scope을 기본값 openid,profile,email에서 profile,email만 등록한다.
      • openid라는 scope이 있다면 Open Id Provider으로 인식하기 때문이다.
        • 결국 구글(OpenId Provider인 서비스)과 그 외의 서비스(네이버,카카오 등)로 나눠서 OAuth2Service를 만들어야한다.
        • 하나의 OAuth2Service를 사용하기위해 openid를 scope에서 제외한다.
  • yml또는 properties파일의 이름을 application-xxx으로 만들면 profile이 생성된다.
    • 생성된 profile을 통해 관리가 가능하다
      • profile: xxx 으로 application-xxx.yml의 설정들을 가져올 수 있다.
        • 이처럼 application-oauth.yml 파일을 포함한다.
  • application-oauth.yml 파일은 보안이 중요한 정보(클라이언트 ID, 보안 비밀)를 담고 있기때문에 .gitignore 파일에 추가하여 깃허브에 올라가는 것을 방지해야한다.
    • .gitignore에 해당 파일을 추가한뒤 커밋하였을 때, 커밋 파일 목록에 application-oauth.yml 파일이 나오지 않으면 성공이다.
      • 그럼에도 불구하고 나오는 경우는 아래의 명령어를 입력하여 git의 캐시를 삭제후 다시 add한다.
      • 참고
      git rm -r --cached .
      git add .
      git commit -m "fixed untracked files"

구글 로그인 연동하기

  • User 클래스를 생성한다
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column
    private String picture;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Builder
    public User(String name, String email, String picture, Role role) {
        this.name = name;
        this.email = email;
        this.picture = picture;
        this.role = role;
    }

    public User update(String name, String picture) {
        this.name = name;
        this.picture = picture;

        return this;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }
}
  • @Enumerated(EnumType.STRING)

    • JPA로 데이터베이스로 저자알 경우, ENUM 값을 String 형태로 저장한다.
    • 기본적으로는 int 형태로 저장된다.
  • ENUM 클래스인 Role을 생성한다.


@Getter
@RequiredArgsConstructor
public enum Role {

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

    private final String key;
    private final String title;
}
  • UserRepository 인터페이스도 생성한다
public interface UserRepository extends JpaRepository<User,Long> {

    Optional<User> findByEmail(String email);
}
  • findByEmail 메소드
    • 소셜 로그인으로 반환되는 값중에서 email을 통해 이미 생성된 사용자인지 확인하기 위한 메소드이다.

스프링 시큐리티 설정하기

  • 먼저 build.gradle에 스프링 시큐리티 관련 의존성을 추가한다.
dependencies {
    // oauth2
    compile('org.springframework.boot:spring-boot-starter-oauth2-client')
}
  • spring-boot-starter-oauth2-client
    • 클라이언트 입장에서 소셜 기능 구현시 필요한 의존성이다.
    • spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해준다.
  • 이제 config.auth 패키지를 생성하여 시큐리티 관련 클래스를 만들어야한다.
  • SecurityConfig, CustomOAuth2UserService, OAuthAttributes, SessionUser 클래스를 만든다.

@RequiredArgsConstructor
// @EnableWebSecurity
// spring security 설정들을 활성화시켜준다.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService customOAuth2UserService;

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

        // registrationId
        // 햔재 로그인 진행 중인 서비스를 구분하는 코드
        // 네이버 로그인인지, 구글 로그인인지 구분하기위해 사용한다.
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // userNameAttributeName
        // OAuth2 로그인 진행 시 키가 되는 필드값을 말한다. PK와 같은 의미이다.
        // 구글의 경우 기본적으로 코드를 지원한다. 구글의 기본 코드는 "sub"이다. (네이버, 카카오 등은 기본 지원하지 않는다.)
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName();

        // OAuthAttributes
        // OAuth2UserService를 통해 가져온 OAuth2User의 attributes를 담을 클래스이다.
        // 다른 소셜 로그인도 이 클래스를 사용한다.
        OAuthAttributes attributes = OAuthAttributes.of(registrationId,userNameAttributeName,oAuth2User.getAttributes());

        // SessionUser
        // 세션에 사용자 정보를 저장하기 위한 따로 만든 Dto 클래스이다.
        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);
    }
}
  • CustomOAuth2UserService클래스는 구글 로그인 이후 가져온 사용자의 정보(email,name,picture)를 기반으로 가입/정보수정/세션저장등의 기능을 지원한다.

@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;
    }


    // of()
    // OAuth2User 에서 반환하는 사용자 정보는 Map이므로 값 하나하나를 변환해야 한다.
    public static OAuthAttributes of(String registrationId, String userNameAttributeName,
                                     Map<String ,Object> attributes) {

        if ("naver".equals(registrationId)) {
            return ofNaver("id",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();
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String,Object> attributes) {
        Map<String,Object> response = (Map<String, Object>) attributes.get("response");

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

    // toEntity()
    // User 엔티티를 생성한다.
    // OAuthAttributes 에서 엔티티를 생성하는 시점 == 처음 가입할 때
    // 가입할 떄의 기본 권한을 GUEST로 주기 위해서 role 빌더 값에는 Role.GUEST를 설정한다.
    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}
  • toEntity()
    • 가입할 떄의 기본 권한을 GUEST로 주기 위해서 role 빌더 값에는 Role.GUEST를 설정하므로 처음 로그인하면 글쓰기 권한이 없으므로 글을 쓸 수 없다.

// SessionUser 에는 인증된 사용자 정보만 필요하다.
// 그 외의 정보는 필요 없으니 name, email, picture만 필드로 선언한다.


@Getter
public class SessionUser implements Serializable {
    private String name;
    private String email;
    private String picture;

    @Builder
    public SessionUser(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}
  • SessionUser 대신 User 클래스를 사용하면 직렬화 관련 에러가 발생한다.
  • 그렇다고 해서 User 클래스에 Serializable를 implements해주면 안된다
  • User 클래스는 엔티티 클래스이므로 다른 엔티티와 관계가 형성될 수 있다.
  • User 클래스가 자식 엔티티를 갖고 있다면 직렬화 대상에 자식들까지 포함된다. 이는 성능 이슈, 부수 효과가 발생한다.
  • 이러한 이유로 인해 직렬화 기능을 갖은 세션 Dto를 하나 추가로 만드는게 유지보수에 도움이 된다.

로그인 테스트 화면

  • index.mustache 파일
    <!--로그인 기능 영역-->
        {{#userName}}
            Logged in as : <span id="user">{{userName}}</span>
            <a href="/logout" class="btn btn-info active" role="button">Logout</a>
        {{/userName}}
        {{^userName}}
            <a href="/oauth2/authorization/google" class="btn btn-success active" role="button">Google Login</a>
            <a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
        {{/userName}}
  • a href="/logout"

    • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL이다
    • 개발자가 별도로 logout URL에 해당하는 컨트롤러를 만들 필요가 없다
  • a href="/oauth2/authorization/google"

    • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL이다.
    • 마찬가지로 별도의 컨트롤러를 만들 필요가 없다.
  • IndexController 클래스

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    // Model 객체
    // 서버 탬플릿 엔진에서 사용가능한 객체를 저장할 수 있다.
    // 해당 메소드에서는 postsService.findAllDesc()로 가져온 결과를 posts라는 이름으로 index.mustache에 전달한다.
    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());

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

        // 세션에 저장된 값이 있을 경우만 model에 userName으로 등록한다.
        if (user != null) {
            model.addAttribute("userName",user.getName());
        }
        return "index";
    }
}
  • SessionUser user = (SessionUser) httpSession.getAttribute("user");
    • 이부분은 세션값을 가져오는 부부이다.
    • index() 메소드 이외의 컨트롤러에서 세션값이 필요하면 그때마다 직접ㅂ 세션에서 값을 가져와야할것이다.
      • 같은 코드가 계속 반복된다
        • 메소드의 파라미터로 세션값을 바로 받을 수 있도록 코드를 개선해야한다 (어노테이션 기반으로 개선하기)

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

  • LoginUser 어노테이션 클래스, LoginUserArgumentResolver 클래스
// @Target(ElementType.PARAMETER)
// 해당 어노테이션이 생성될 수 있는 위치를 지정한다. 
// PARAMETER로 지정했으므로 메소드의 파라미터로 선언된 객체에서만 사용가능하다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
// @interface
// 이 파일을 어노테이션 클래스로 지정한다.
public @interface LoginUser {
}

@RequiredArgsConstructor
@Component

// LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer 에 추가한다.
// WebConfig 클래스를 생성하여 추가한다.
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {
    private final HttpSession httpSession;


    // supportsParameter()
    // 컨트롤러 메소드의 특정 파라미터를 지원하는지 판단한다.
    // parameter에 @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class 인 경우만 true를 반환한다.
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;

        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());

        return isUserClass && isLoginUserAnnotation;
    }


    // resolveArgument()
    // 파라미터에 전달할 객체를 생성한다.
    // 여기서는 세션에서 객체를 가져온다.
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {

        return httpSession.getAttribute("user");
    }
}
  • HttpSession 인터페이스

    • HttpSession 인터페이스는 둘 이상의 page request에서 사용자를 식별하거나, 웹 사이트를 방문하고 해당 사용자에 대한 정보를 저장하는 방법을 제공한다.
    • httpSession.getAttribute("user");
      • "user"라는 key로 바인딩된 세션을 반환한다. 만약 없다면 null을 반환한다.
  • 개선된 결과

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    // Model 객체
    // 서버 탬플릿 엔진에서 사용가능한 객체를 저장할 수 있다.
    // 해당 메소드에서는 postsService.findAllDesc()로 가져온 결과를 posts라는 이름으로 index.mustache에 전달한다.
    @GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());

        // 세션에 저장된 값이 있을 경우만 model에 userName으로 등록한다.
        if (user != null) {
            model.addAttribute("userName",user.getName());
        }
        return "index";
    }
}
  • index() 메소드에 @LoginUser 어노테이션이 붙은 SessionUser 객체를 파라미터로 설정하여 SessionUser user = (SessionUser) httpSession.getAttribute("user"); 코드의 중복을 개선하였다.

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

  • build.gradle에 spring-session-jdbc를 위한 의존성을 등록한다.
dependencies {
    // JPA 가 자동으로 세션 저장용 테이블을 생성하도록 하기위한 의존성
    compile('org.springframework.session:spring-session-jdbc')
}
  • application.yml 에 세션 저장소를 jdbc로 선택하도록하는 코드를 추가한다.
spring:
  session:
    store-type: jdbc
    # spring.session.jdbc.initialize-schema: always 설정이 있어야만 
    # JPA 가 자동으로 세션 저장용 테이블을 생성한다.
    jdbc:
      initialize-schema: always
  • 이후 애플리케이션을 실행하면 자동으로 SPRING_SESSION, SPRING_SESSION_ATTRIRBUTES 테이블이 자동 생성된다.
  • RDS에서는 스프링을 재시작해도 세션이 풀리지 않는다
    • H2 데이터베이스 기반으로 세션을 저장하면 스프링이 재시작할 떄 마다 세션이 풀린다.

0개의 댓글