제육's 휘발성 코딩
반응형

OAuth2 로그인

  • 소셜 로그인 사용을 통하여 서비스 개발 집중
  • 기존 1.5 버전에서 2.0 버전으로 바뀌면서 enum 대체
    • CommonOAuth2Provider enum 기능
    • 구글, 깃허브, 페이스북 옥타등 기본 설정값 제공 - 다른 소셜 로그인은 추가 필요

구글 로그인

프로젝트 생성

image

  • 프로젝트 생성 - API 및 서비스 - 사용자 인증 정보 - 사용자 인증 정보 만들기 - OAuth 클라이언트 ID - 동의 화면 구성

image

OAuth 클라이언트 ID 만들기

image

  • 클라이언트 ID와 보안 비밀번호가 발급된다.
  • 리다리렉트 URI : {도메인}/login/oauth2/code/google 로 등록하자 위에 사진 오타

properties 등록

spring.security.oauth2.client.registration.google.client-id=
spring.security.oauth2.client.registration.google.client-secret=
spring.security.oauth2.client.registration.google.scope = profile, email
  • 발급 받은 id와 비밀번호를 등록한다.
  • 새로운 properties에 저장하고 싶다면 기존 프로퍼티에 spring.profiles.include=oauth를 추가해준다. (application-oauth.properties) 추가
git rm -r --cached .
git add .
git commit -m "fixed untracked files"
  • 노출시 위험한 정보는 gitignore에서 관리하자. 만약 커밋목록에 나올경우 다음과 같은 명령어로 캐시를 삭제하자.

사용자 엔티티 생성

package com.crawler.domain.user;

import com.crawler.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

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

}
package com.crawler.domain.user;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Role {

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

    private final String key;
    private final String title;
}
package com.crawler.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}
  • User 엔티티 관련 코드 작성

시큐리티 설정

implementation('org.springframework.boot:spring-boot-starter-oauth2-client')
  • gradle에 스프링 시큐리티 의존성 추가
package com.crawler.config.auth;

import com.crawler.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@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()
                .and()
                    .authorizeRequests()
                    .antMatchers("/", "/css/**", "/images/**",
                            "/js/**", "/h2-console/**").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated() // 나머지 모든 URL 로그인한 사용자만 사용 
                .and()
                    .logout()
                        .logoutSuccessUrl("/") // 로그아웃시 시작 주소
                .and()
                    .oauth2Login()
                        .userInfoEndpoint()
                            .userService(customOauth2UserService); 
    }
}
  • @EnableWebSecurity : 스프링 시큐리티 설정을 활성화
  • csrf() : h2-console 사용을 위해 disable
  • .authorizeRequest : URI 별 권한 관리 설정 - 이후 andMatcher 사용 가능 - /api/v1 은 USER 권한만 사용 가능
  • userService : 소셜 로그인 성공 시 후속 조치를 진행할 인터페이스 구현체 등록
package com.crawler.config.auth;

import com.crawler.config.auth.dto.OAuthAttributes;
import com.crawler.config.auth.dto.SessionUser;
import com.crawler.domain.user.User;
import com.crawler.domain.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpSession;
import java.util.Collections;

@RequiredArgsConstructor
@Service
class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final UserRepository userRepository;
    private final HttpSession httpSession;

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

        String registrationId = userRequest.getClientRegistration().getRegistrationId(); // 현재 로그인 진행중인 서비스 구분 (여러 소셜 로그인시 구별)
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName(); // 구글의 기본코드인 sub 

        //OAuthAttributes를 통해 가져온 OAuth2User의 attribute를 담은 클래스 
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        User user = saveOrUpdate(attributes);
        httpSession.setAttribute("user", new SessionUser(user)); // 세션에 사용자 정보를 저장하기위한 DTO 클래스 

        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);
    }
}
  • 로그인 이후 가져온 사용자의 정보를 기반으로 가입, 세션 저장등 기능 수행
  • SessionUser 라는 새로운 DTO를 만드는 이유는 직렬화를 엔티티에 구현하면 추후 유지보수가 어려워지기 때문
package com.crawler.config.auth.dto;

import com.crawler.domain.user.Role;
import com.crawler.domain.user.User;
import lombok.Builder;
import lombok.Getter;

import java.util.Map;

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

    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() : 가입할 때 기본권한을 GUEST로 주기 위해서 사용 (처음 가입 시점)
<div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-primary">글등록</a>
                {{#userName}}
                    Login 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>
                {{/userName}}
            </div>
  • 로그인 기능 추가 - mustache 템플릿 엔진 사용
package com.crawler.web;

import com.crawler.config.auth.LoginUser;
import com.crawler.config.auth.dto.SessionUser;
import com.crawler.service.posts.PostsService;
import com.crawler.web.dto.PostsResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import javax.servlet.http.HttpSession;

@RequiredArgsConstructor
@Controller
public class IndexController {

    private final PostsService postsService;
    private final HttpSession httpSession;

    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("posts", postsService.findAllDesc());
        SessionUser user = (SessionUser) httpSession.getAttribute("user");
        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }

    ...
}
  • Controller 생성 - 추후 SessionUser는 어노테이션으로 개선

image

  • 로그인 성공 화면

참고 - 어노테이션 기반 개선

  • SessionUser는 세션 값이 필요할 때마다 직접 세션에서 값을 가져와야 한다.
  • 이 부분을 메서드 인자로 세션 값을 바로 받아올 수 있도록 변경
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
  • 커스텀 어노테이션 생성
package com.crawler.config.auth;

import com.crawler.config.auth.dto.SessionUser;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpSession;

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

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}
  • @LoginUser를 사용하기 위한 환경 구성
package com.crawler.config;

import com.crawler.config.auth.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserArgumentResolver);
    }
}
  • LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가
@GetMapping("/")
    public String index(Model model, @LoginUser SessionUser user) {
        model.addAttribute("posts", postsService.findAllDesc());

        if (user != null) {
            model.addAttribute("userName", user.getName());
        }
        return "index";
    }
  • Controller를 수정하여 이제 어느 컨트롤러던지 어노테이션을 통해 세션 정보를 가져올 수 있게 됬다.

세션 저장소로 DB 사용

  • 어플리케이션을 재실행 시 로그인이 풀리는 것은 세션이 내장 톰캣의 메모리에 저장되기 때문이다.
  • 즉, 배포할 때마다 톰캣이 재시작 되므로 로그아웃 되는 것이다.
  • WAS간 공용 세션을 사용할 수 있는 가장 쉬운방법은 DB이다.
  • Redis, Memcached와 같은 메모리 DB를 세션저장소로 사용하는 방법도 있다. (주로 B2C 서비스에서 사용 - 외부 메모리, 유료)
implementation('org.springframework.session:spring-session-jdbc')
  • gradle 의존성 추가
spring.session.store-type=jdbc
  • application.properties에 세션 저장소를 jdbc를 사용하도록 설정

h2-console에 들어가보면 spring_session 테이블이 생성된 것을 볼 수 있다.

WAS 배포전에는 재시작하면 세션이 풀린다. 이후, AWS를 이용한 RDS 서비스로 배포시 세션이 풀리지 않는다.

네이버 로그인

imageimage

  • 어플리케이션 등록 후 ID, 비밀번호를 복사하여 properties에 수동 등록 한다. (네이버는 스프링에 공식 지원 X)
spring.security.oauth2.client.registration.naver.client-id=
spring.security.oauth2.client.registration.naver.client-secret=
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization_grant_type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name, email, profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver
spring.security.oauth2.client.provider.naver.authorization_uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token_uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user_info_uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user_name_attribute=response
  • application-auth.properties에 네이버 수동 등록 (OAuth2Provider 미제공)
  • 네이버 회원 조회 시 이름을 반드시 response로 해야 한다. (JSON 반환)

시큐리티 설정 등록

image

  • 왼쪽과 같이 OAuthAttributes 클래스에 네이버 판단해주는 코드와 생성자를 추가해준다.

image

  • 결과 화면

본 포스팅은 프리렉-이동욱님의 '스프링 부트와 AWS로 혼자 구현하는 웹 서비스' 책을 참고하였습니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^