ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Firebase 에서 OAuth2 , JWT 로 전환기(2)
    Project/TravelFeelDog 2023. 9. 22. 19:38

    이전 글입니다.

    https://chosunghyun18.tistory.com/127

     

    Firebase 에서 OAuth2 , JWT 로 전환기(1)

    도입 배경 기존의 프로젝트는 웹과 앱 모두를 지원을 하는 상황 , 유저의 로그인 인증여부를 firebase Auth 서비스를 사용하였다. 당시 한달이라는 개발 기간안에 Spring Security 까지 다룰 여력이 부족

    chosunghyun18.tistory.com

     

    프로젝트에 Spring Security 와 OAuth2를 적용한 구글 로그인의 관한 글입니다.

    0.구글 클라우드 설정 내용은 제외하였습니다.

    1.Spring Secuirty , Filter 의 깊은 내용은 없습니다.

    2. JWT 적용은 다음장에서 다룰 예정입니다.

    3. yml 관련 설정은 글 하단에 있습니다.

     

     

    다음과 같은 유저 케이스를 위한 글입니다.

     

    인증관련  케이스

    구글 로그인시  사용자의 이름 또는 관련 정보가 guset 로 MySQL로 저장이 됩니다. 

     

    인증 /인가가 필요 없는 케이스

     

    1. 지정한 웹 url 을 통한 접속시 로그인 가능한 페이지가 보입니다.

    2.  SpringBoot 3.1 의 내부 메트릭 정보 확인 

    3.  SpringBoot  의  Dispatcher servelt 의 정상 작동을 테스트하는 간단한 test api 이용

    4.  Swagger page 확인

     

    [version]  SpringBoot 3.1 

    Spring Boot Starter Security : 3.1

    -> security config : 6.1.0

    -> security web : 6.1.0

     

    [참고] Spring boot 와 Aws로 혼자 구현하는 웹서비스를 참고하여 제작하였습니다.

    Spring boot 와 Aws로 혼자 구현하는 웹서비스 깃 저장소

     

    GitHub - jojoldu/freelec-springboot2-webservice

    Contribute to jojoldu/freelec-springboot2-webservice development by creating an account on GitHub.

    github.com

     

    1 .책에서 제공하는 예시 코드와 버전의 차이로 문법상의 차이가 있습니다.

    2. 프로젝트의 상황에 맞춰 일부 로직을 제외했습니다.

     

    [외부 라이브러리]

    /**auth */
        implementation 'org.springframework.boot:spring-boot-starter-security'
        implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
        implementation 'org.springframework.boot:spring-boot-starter-mustache' // 최초 접속을 확인하기 위한 유저 페이지
        
        /** metric */
        implementation 'org.springframework.boot:spring-boot-starter-actuator'
        implementation 'io.micrometer:micrometer-registry-prometheus'
        
        /** docs */
        implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

     

    버전을 명시하지 않을시 gradle 의 자동 버전을 맞춰주는 기능으로 인하여 SpringBoot3.1 와 맞는 버전으로 주입됩니다.

     

     

    auth 패키지에는 다음과 같은 클라스 또는 패키지 가 들어갑니다.

     

    package :  ~ .global.secure.auth 

     

    0. SpringSecurityConfig

     

    Spring Frame Work 의 컴포넌트 스캔을 활용한 설정을 저장하는 역할을  Bean 으로 등록해 줍니다.

    접속 가능한 경로 , 인증 이 필요한 경로 및 인가 가 필요한 경로등 다양한 설정을 할 수 있습니다.

     

    코드는 다음과 같습니다.

     

    기본 설정은 책   "Spring boot 와 Aws로 혼자 구현하는 웹서비스" 을 따라 설정을 하였습니다.

    package travelfeeldog.global.secure.auth;
    
    import jakarta.servlet.DispatcherType;
    import lombok.RequiredArgsConstructor;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
    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.configurers.AbstractHttpConfigurer;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.security.web.SecurityFilterChain;
    import travelfeeldog.domain.member.domain.model.Role;
    
    import static org.springframework.security.config.Customizer.withDefaults;
    
    @Configuration
    @EnableMethodSecurity
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SpringSecurityConfig {
    	//private final JWTProvider jwtProvider; 아직 JWT 를 적용하기 전으로 주석처리를 해둡니다.
        private final CustomOAuth2UserService customOAuth2UserService;
    	
        
        // @Bean 데이터 베이스의 비밀번호는 암호화가 되어야하는 규칙이 있으므로 JWT 추가시 같이 추가해줍니다.
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    	
        // MAIN Settings
        // SPRINGBOOT 3 의 외부 요청은 람다 표현식으로 사용합니다.
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
            httpSecurity
                    .csrf(
                            AbstractHttpConfigurer::disable
                    ).cors(
                            AbstractHttpConfigurer::disable
                    )
                    .authorizeHttpRequests(request -> request
                            .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
                            .requestMatchers("/", "/test/**"
                                    ,"/swagger-ui/**","/usage" // swagger를 위한 주소 경로 입니다.
                                    ,"/actuator/health").permitAll()
                            .requestMatchers("/api/v1/**").hasRole(Role.USER.name())
                            .anyRequest().authenticated()
                    )
                    .logout(withDefaults())
                    .oauth2Login(request -> request
                            .userInfoEndpoint(userInfoEndpointConfig ->
                                    userInfoEndpointConfig.userService(customOAuth2UserService)));
    
            return httpSecurity.build();
        }
    }

     

    위설정의 따라  5개의 주소는 인증없이 접속이 가능합니다.

     

    homepage : http://localhost:8080/

    브라우저 커넥션 check : http://localhost:8080/test/1

    metric check: http://localhost:8080/actuator/health 

     

    Swagger 

    http://localhost:8080/swagger-ui/index.html

    http://localhost:8080/uage

     

     

    Swagger 의 버전을 꼭 확인 해주시길 바라며 , 버전을 명시해주셔야 합니다.

       

     

    개발중인 상황을 위하여 모든 사람이 Swagger 를 접속하지만 , 추가적인 인가를 배포이후 에 설정해 줍시다. 

     

     

    package travelfeeldog.global.secure.auth 

    1.  CustomOAuth2UserService

     

    책에 따르면 구글 프로필이 변경될 경우 사용자의 이름과 사진주소를 같이 업데이트 하는 로직을 가집니다.

     

    그러나 사용자의 서비스와 구글 프로필의 서비스가 서로 의존성을 갖게 되며 서비스 성격에 맞지 않기 때문에 

    Save 관련 함수를 분리/수정해줍니다.

     

    package travelfeeldog.global.secure.auth;
    
    import java.util.Collections;
    
    import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    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 lombok.RequiredArgsConstructor;
    import jakarta.servlet.http.HttpSession;
    import org.springframework.transaction.annotation.Transactional;
    import travelfeeldog.domain.member.domain.model.Member;
    import travelfeeldog.domain.member.infrastructure.MemberRepository;
    import travelfeeldog.global.secure.auth.dto.OAuthAttributes;
    import travelfeeldog.global.secure.auth.dto.SessionUser;
    
    @RequiredArgsConstructor
    @Service
    @Transactional  //  Data JPA 를 사용하지 않아 서비스레이어서 선언을 해줌으로 EntityManager 를 받도록합니다.
    public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        private final MemberRepository 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();
    
            OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
    
            Member user = getByEmail(attributes);
            user = saveOrUpdate(user);
            httpSession.setAttribute("user", new SessionUser(user));
    
            return new DefaultOAuth2User(
                    Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                    attributes.getAttributes(),
                    attributes.getNameAttributeKey());
        }
        public Member saveOrUpdate(Member user) {
            return userRepository.save(user).orElseThrow(() -> new IllegalArgumentException("Save Or Update Error"));
        }
        public Member getByEmail(OAuthAttributes attributes) {
            return  userRepository.findMemberForLogin(attributes.getEmail()).orElse(attributes.toEntity());
        }
    
    }

     

    1. 객체를 저장 또는 업데이트 하는 함수  saveOrUpdate()

     

    userRepository 는 인터페이스로 구현을 하였으며  구현체는 다음과 같습니다.

     

    getByEmail 시 읽기 전용 트랜잭션을 사용하지 않고 영속성 컨택스트에서 저장을 하는 이점을 살린 코드이며

    DataJPA support 라이브러의 SimpleJPA 의 save 로직을 참고하여 만들었습니다.

     

    *em.find 를 사용하였지만 쿼리 최적화가 가능한 부분입니다.

    @Override
        public Optional<Member> save(Member member) {
            try {
                Assert.notNull(member, "member must not be null");
                Member existingMember = null;
                if (member.getId() != null) { //could be change shorter
                    existingMember = em.find(Member.class, member.getId());
                }
                if (existingMember == null) {
                    em.persist(member);
                } else {
                    em.merge(member);
                }
                return Optional.of(member);
            } catch (IllegalArgumentException e) {
                return Optional.empty();
            }
        }

     

    2. 이메일 일치 여부를 확인 하는 함수  getByEmail()

    1. 사용자가 가입이 되어있는 유저일시 이메일이 이미 데이터 베이스에 있어 객체로 반환됩니다.

    2. 사용자가 가입이 되어 있지 않은 유저일시 OAuthAttributes 의 객체의 엔티티 변환으로 멤버 객체가 생성됩니다.

    public Member getByEmail(OAuthAttributes attributes) {
            return  userRepository.findMemberForLogin(attributes.getEmail()).orElse(attributes.toEntity());
        }

     

    Repository 에 findByEmail 이 있지만  다음과 같이 작성하면 두가지 장점이 있습니다.

     

    1. 함수 사용의 명확성 

     

    2. 이메일이 아닌 다른정보로 유저의 가입 여부를 확인 하는 요구 사항의 변경의 대한 변경 사항 최소화

     

    @Override
        public Optional<Member> findByEmail(String email) {
            try {
                Member member = em.createQuery("SELECT m FROM Member m WHERE m.email = :email",
                                Member.class)
                        .setParameter("email", email)
                        .getSingleResult();
                return Optional.of(member);
            } catch (NoResultException e) {
                return Optional.empty();
            }
        }
    
        @Override
        public Optional<Member> findMemberForLogin(String email) {
            return findByEmail(email);
        }

     

    package travelfeeldog.global.secure.auth

    2. LoginUserArgumentResolver

    책에서 제공된 코드와 같으며 추가적인 변경사항은 없습니다. 

     

    package travelfeeldog.global.secure.auth;
    
    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 jakarta.servlet.http.HttpSession;
    import travelfeeldog.global.secure.auth.dto.SessionUser;
    
    @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");
        }
    }

     

    package travelfeeldog.global.secure.auth

     

    3. LoginUser

    책에서 제공된 코드와 같으며 추가적인 변경사항은 없습니다. 

    package travelfeeldog.global.secure.auth;
    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 {
    }

     

    [dto 패키지]

    package travelfeeldog.global.secure.auth.dto;

    1.SessionUser

    제공된 코드느 name 으로 받지만 , 기존 프로젝트는 name filed 가 없어 nickName 으로 받습니다.

    2. 화면에 guest 인지 유저인지 출력에 필요한 dto 역할도 하니 role 을 추가합니다.

    package travelfeeldog.global.secure.auth.dto;
    
    import java.io.Serializable;
    import lombok.Getter;
    import travelfeeldog.domain.member.domain.model.Member;
    
    @Getter
    public class SessionUser implements Serializable {
        private String nickName;
        private String email;
        private String imageUrl;
        private String role;
        public SessionUser(Member user) {
            this.nickName = user.getNickName();
            this.email = user.getEmail();
            this.imageUrl = user.getImageUrl();
            this.role = user.getRoleKey();
        }
    }

     

    2.OAuthAttributes

     

    Member toEntity 의 변경 사항 : name  -> nickName 으로 변경

     

    구글의 프로필 정보를 불러올시 사진 , 이름 정보를 가져옵니다. 

     

    구글의 인증 기능만 사용하는 상황에서는 둘중에 하나만 받는 방식으로 코드 변경이 가능합니다. 

    package travelfeeldog.global.secure.auth.dto;
    
    import lombok.Builder;
    import lombok.Getter;
    
    import java.util.Map;
    import travelfeeldog.domain.member.domain.model.Member;
    import travelfeeldog.domain.member.domain.model.Role;
    
    @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) {
            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();
        }
    
        public Member toEntity() {
            return Member.ByAccountBuilder()
                    .nickName(name)
                    .email(email)
                    .imageUrl(picture)
                    .role(Role.GUEST)
                    .build();
        }
    }

     

    Test 화면을 위한 컨트롤러 

     

    목록을 출력할 일이 없으니 관련 메서드는 삭제후 role 정보를 추가 합니다.

    package travelfeeldog.web.presentaion;
    
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import travelfeeldog.global.secure.auth.LoginUser;
    import travelfeeldog.global.secure.auth.dto.SessionUser;
    
    @RequiredArgsConstructor
    @Controller
    public class IndexController {
        @GetMapping("/")
        public String index(Model model, @LoginUser SessionUser user) {
            if (user != null) {
                model.addAttribute("userName", user.getNickName());
                model.addAttribute("userRole", user.getRole());  // role 정보 추가
            }
            return "index";
        }
    }

     

    결과 화면은 다음과 같습니다.

     

    [DB 에 유저 데이터가 없는 경우]

     

    회원 가입

    [데이터 베이스에 들어간 모습]

    good

     

     

    [데이터가 있는 경우]

    로그인 전

     

    로그인 후 

     

     

     

    Mustache 의 한글 인코딩이 ??? 인 경우 yml 설정으로 해결해 줍니다.

    server:
      port: 8080
      servlet:
        encoding:

     

     

     

    [YML]

    - 대략 적인 yml 입니다.

     

    application.yml 

    server:
      port: 8080
      servlet:
        encoding:
          force: true
    
    spring:
      datasource:
        url: jdbc:mysql://${Db_URL}:${Db_PORT}/${Db_Name}?rewriteBatchedStatements=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999
        username: ${Db_User_Name}
        password: ${Db_User_PassWord}
        driver-class-name: com.mysql.cj.jdbc.Driver
      jpa:
        database: mysql
        database-platform: org.hibernate.spatial.dialect.mysql.MySQL8SpatialDialect
        hibernate:
          ddl-auto: ${DDL_AUTO_MODE}
        properties:
          hibernate:
            show_sql: true
            format_sql: true
            default_batch_fetch_size: 500
            dialect: org.hibernate.dialect.MySQLDialect
        generate-ddl: false
        defer-datasource-initialization: false
        open-in-view: false
    
      sql:
        init:
          mode: ${SQL_INIT_MODE}
    
    
      mvc:
        path match:
          matching-strategy: ant_path_matcher
      config.import: classpath:application-credentials.yml
      profiles:
        active: credentials
        include:
          - aws
          - credentials
          - jwt
          - oauth
    
    logging:
      level:
        com.zaxxer.hikari: DEBUG
        org.hibernate:
          SQL: debug
          type: trace
          springframework:
            core.LocalVariableTableParameterNameDiscoverer : error

     

    application.-oauthyml 

    spring:
      security:
        oauth2:
          client:
            registration:
              google:
                redirect-uri: http://localhost:8080/login/oauth2/code/google
                client-id : ${Google_Client_Id}
                client-secret: ${Google_Client_Secret}
                scope:
                  - email
                  - profile

     

    UI 변경점 은 다음 저장소에 있습니다 

    (별 추가 해주시면 감사하겠습니다)

     

    깃허브 저장소

     

    GitHub - HUFS-Capstone-23-01/TravelFeelDog-Server: 여행필독서 앱과 웹을 위한 벡엔드 저장소입니다

    여행필독서 앱과 웹을 위한 벡엔드 저장소입니다. Contribute to HUFS-Capstone-23-01/TravelFeelDog-Server development by creating an account on GitHub.

    github.com

     

Designed by Tistory.