프로그래밍

[웹 프로그래밍] 스프링부트JPA 3. 스프링 시큐리티와 OAuth2.0. 간편로그인 구현하기.

tt2t2am1118 2023. 1. 4. 04:33
반응형

오늘은 간편로그인 구현. 이부분을 하려고 합니다. 전에 로그인구현의 내용을 다지우고... 구글 간편로그인. 이런 것으로 바꾸려고 하네요. 참조한 책은 이 책입니다.

 

 

https://search.daum.net/search?w=bookpage&bookId=5140806&tab=introduction&DA=LB2&q=%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8%20aws

 

스프링부트 aws – Daum 검색

Daum 검색에서 스프링부트 aws에 대한 최신정보를 찾아보세요.

search.daum.net

스프링 부트와 AWS로 혼자 구현하는 웹서비스' 라는 책입니다. 스프링부트 JPA의 기본적인 내용, 무중단 배포하는 방법등, 웹 서비스를 만들어나가기 위한 작업들이 잘 기록되어 있기 때문에, 실제 이 책의 내용으로 공부하는 것을 추천하네요.

 

 간편로그인 구현. ...

 

로그인 할때에, 회원정보에서 각 역할정보를 따로 실을 수 있어요. Spring Boot의 기능이군요. Role이라는 칼럼이 있는데, ADMIN이라고 하면, 관리자. 그렇게 정해진 것은 없구요. 이런 식으로 역할별 네이밍을 해주는 것이죠.

 

 ADMIN, COMPANY, USER 이렇게 3가지 롤을 만들어주기로 합시다. 그리고, 로그인 한 후, 페이지가 각각 다르겠죠.

 

 관리자, 기업회원, 회원. 이렇게 구분해보기로 합시다. 그리고, 우선은 간편로그인을 만들기 위한 작업을 해주어야겠죠~.

 

 

구글 서비스 등록을 해줌니다. 

https://console.cloud.google.com/ 여기에서, 프로젝트 선택.

적당한 이름으로 프로젝트 이름을 적은 뒤, 만들어 줌니다. 그런 후에, API 및 서비스 메뉴를 클릭해서 이동 해줌니다.

 

 

사용자 인증정보를 클릭해서 이동해줌니다.

 

OAuth 동의 화면, 외부로 만들어 줌니다.

 

별표 쳐진 부분만 적으면서 죽 이어 적습니다.

 

범위는 여기까지만 선택해줌니다. 책에 나온 범위를 선택했네요.

저장 후 계속. ...

 

 테스트 사용자는 인증 전까지 100명까지 사용가능하다네요. 입력하지 않고, 저장 후 계속 버튼을 눌려서 완료해 줌니다.

 

 

그 다음, 사용자 인증 정보를 만듬니다. 사용자 인증 정보에서, OAuth 클라이언트 ID.

웹 애플리케이션을 선택하고, 이름에는 프로젝트 이름을 적어 줌니다. 아래로 내려와서, 승인된 리디렉션 URI에 다음과 같이 적어 줌니다.

http://localhost:8080/login/oauth2/code/google

 

 

이렇게 생성하게 되면, 클라이언트ID와 비밀번호를 생성해 주는데요.

 

 

 

 

이것을 application-oauth.properties 파일에 등록해줌니다. 파일을 만들어야겠죠~. src/main/resources/ 디렉토리에 만들어 줌니다.

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

클라이언트 id와 비밀번호를 적어주세요.

 

application.properties 파일에 이 한줄을 적어줌니다. 그럼으로서, application-oauth.properties파일을 접근할 수 있게 됨니다.

spring.profiles.include=oauth

 

oauth2를 사용가능하게 한 클라이언트 id, 클라이언트 비밀번호는 Github에 올라가면 안되기 때문에, .gitignore파일에 한줄의 파일을 추가해줌니다.

application-oauth.properties

 

 

구글 로그인 연동하기.

 

사용자 정보를 담을 User 클래스를 만들어 줌니다. 그 다음, Role, Enum 클래스도 만들어 줌니다. UserRepository 클래스 생성.

User 클래스. ...

package org.example.domain.user;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.example.domain.BaseTimeEntity;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

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

Role 클래스. ...

package org.example.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;

}

 

UserRepository.java 파일. ...

package org.example.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);
}

 

이제 시큐리티 설정. ...

 

build.gradle 파일에 의존성 설정을 추가해줌니다.

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

 

config.auth 패키지를 만들어 줌니다. SecurityConfig 클래스를 생성해 줌니다. config.auth.SecurityConfig

package org.example.config.auth;

import lombok.RequiredArgsConstructor;
import org.example.domain.user.Role;
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/**", "/profile").permitAll()
                .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                .anyRequest().authenticated()
                .and()
                .logout()
                .logoutSuccessUrl("/")
                .and()
                .oauth2Login()
                .userInfoEndpoint()
                .userService(customOAuth2UserService);
    }
}

CustomOAuth2UserService 클래스도 만들어 줌니다.

package org.example.config.auth;

import lombok.RequiredArgsConstructor;
import org.example.domain.user.User;
import org.example.domain.user.UserRepository;
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
public 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();

        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, SessionUser클래스도 만들어 줌니다.

 

패키지, config.auth.dto.OAuthAttributes 클래스.

package org.example.config.auth.dto;

import lombok.Builder;
import lombok.Getter;
import org.example.domain.user.Role;
import org.example.domain.user.User;

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) {
        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 User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}

 

패키지, config.auth.dto.SessionUser 클래스.

package org.example.config.auth.dto;

import lombok.Getter;
import org.example.domain.user.User;

import java.io.Serializable;

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

이제, html페이지에 로그인 버튼과 로그인 성공시 사용자 이름을 보여주는 코드를 만들어 줌니다.

 

html파일이름을 index_log_leaf.html 로 만들었고, 타임리프 형식입니다. 책의 내용과 다르네요. 참조할 점은, model의 값. 뷰페이지로 데이터를 넘길때에 값의 이름을 ${}이런 값으로 타임리프에서는 인식하는데요. 머스테치에서는, {{#userName}}. 이렇게 표현해줌니다.

 

index_log_leaf.html 파일.

<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">

<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
    <link rel="stylesheet" href="/css/nav.css">
    <title>오늘, 일 하시는 것은 어떤가요. 좋은 하루되세요.</title>
    <style>
        .navbar-brand {
            font-size: 1rem;
        }
        .card {
            margin-bottom: 10px;
        }

        .nav_bottom {
            margin-bottom: 40px;
        }
    </style>
    <script th:inline="javascript">
        /*<![CDATA[*/
        let result = [[${workPlanList}]]
        /*]]>*/
    </script>
</head>

<body>
<div class="fixed-bottom">
    <nav class="navbar navbar-expand-lg   nav1">
        <div class="container-fluid">
            <a class="navbar-brand" href="#">스마트 팩토리</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
                    aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon" style="color:black;margin-top:5px;"><i class="bi bi-justify"></i></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav">
                    <li class="nav-item">
                        <a sec:authorize="isAnonymous()" class="nav-link active" aria-current="page" href="#">일 찾아보기</a>
                    </li>
                    <li class="nav-item">
                        <a sec:authorize="isAuthenticated()" class="nav-link" href="#">출근 하기</a>
                    </li>
                    <li class="nav-item">
                        <a sec:authorize="isAuthenticated()" class="nav-link" href="#">마이 페이지</a>
                    </li>
                    <li class="nav-item">
                        <a sec:authorize="isAuthenticated()" class="nav-link" th:href="@{/test/board}">test 게시판</a>
                    </li>
                    <li>
                        <a sec:authorize="isAuthenticated()" class="nav-link">안녕하세요. <span th:text="${userName}"></span>님</a>
                    </li>
                    <li>
                        <a sec:authorize="isAnonymous()" class="nav-link" th:href="@{/oauth2/authorization/google}">Google 로그인</a>
                    </li>
                    <li>
                        <a sec:authorize="isAuthenticated()" class="nav-link" th:href="@{/logout}">로그아웃</a>
                    </li>
                    <li>
                        <a sec:authorize="isAnonymous()" class="nav-link" th:href="@{/signup}">회원가입</a>
                    </li>
                    <li class="nav-item" style="display:none" id="email">{{email}}</li>
                </ul>
            </div>
        </div>
    </nav>
</div>
<div class="container">



    <!-- <h5> A 푸드. 음식. 김치를 만들어보세요.</h5>-->
    <br />
    <br/>
    <th:block th:each="coList: ${coperationLists}">
        <h5><span th:text="${coList.coperationName}"></span>, <span th:text="${coList.catchPrice}"></span></h5>
        <br/>
        <div class="row  justify-content-center" >
            <div class="col d-flex justify-content-center" th:each="list : ${workPlanLists}" th:if="${coList.coperationName == list.coperation.coperationName}" >
                <div class="card" style="width: 18rem;">

                    <div class="card-body">
                        <h5 class="card-title" th:text="${list.coperation.coperationName}"></h5>
                        <p class="card-text" th:text="${list.workPlanTitle}"></p>
                        <a href="#" class="btn btn-primary">보기</a>
                    </div>
                    <div class="card-footer">
                        <span th:text="${list.workPlanTag}"></span> | <span th:text="${#temporals.format(list.workPlanStartDate, 'yyyy년 MM월 dd일')}"></span> ~
                    </div>
                </div>
            </div>
        </div>
        <br />
        <hr />
        <br />
    </th:block>



</div>
<br />
<br />
<br />
<br />

<nav class="navbar nav_bottom">
    <div class="container-fluid">
        <div class="navbar-text" href="#">
            <i class="bi bi-emoji-smile"></i>
            스마트 팩토리, UI 만들어봤습니다. 2021년 6월 21일 ~ 2021 7월 9일., 2022년 12월 29일 ~ ... <br />한번 만들어보세요~. 일하기가 더 좋아졌으면 좋겠습니다. 좋은 개발되세요~.
            감사합니다.<br />작성자: 최현일
            |
            Github주소 <a href="https://github.com/infott2t/smartFactory-ex">@infott2t</a>
            <br/>
        </div>
    </div>
</nav>


</div>





<!-- Optional JavaScript; choose one of the two! -->

<!-- Option 1: Bootstrap Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
        crossorigin="anonymous"></script>

<!-- Option 2: Separate Popper and Bootstrap JS -->
<!--
  <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
  -->
<script src="/js/jquery-3.6.0.js"></script>
<script>
    $(document).ready(function(){
        var email = $("#email").text()

        setTimeout(function () {
            window.ReactNativeWebView.postMessage('{"email" : "'+email+'"}')
        }, 2000)


    });

</script>
</body>

</html>

그리고, 컨트롤러의 "/"부분을 위의 html 파일과 연결시켜줌니다. 

BaseController.java


private final HttpSession httpSession

@GetMapping("/")
    public String indexDefault(Model model, HttpSession session){
    ....
    ...
    
    SessionUser user = (SessionUser) httpSession.getAttribute("user");
    model.addAttribute("userName", user.getName());
    
    return "index_log_leaf"
    }

 

실제 로그인하면, 구글 로그인이 된 것을 확인 할 수 있습니다~~.

 

실제로 책의 내용을 보고서 하는 것을 추천하네요~.

 

 

 

Github에는 이미 완성된 부분이 올려져 있으니, 코드를 작성하지 않아도 될검니다. 단지 gitignore가 된, application-oauth.properties 파일의 생성과, 구글 간편로그인을 위한 구글 클라우드 등록이 필요할 거예요~.

infott2t/ex05-springboot-querydsl (github.com)

 

GitHub - infott2t/ex05-springboot-querydsl

Contribute to infott2t/ex05-springboot-querydsl development by creating an account on GitHub.

github.com

 

공부해보세요~.

 

--

저의 글, 봐 주셔서 감사합니다.

반응형