[Spring Boot] aLog Project - Spring Security와 OAuth2.0으로 로그인 기능 구현하기 1

김광현·2023년 9월 10일
0

Project - aLog

목록 보기
9/12
post-thumbnail

[Spring Boot] aLog Project - 수정, 삭제 페이지 만들기 에 이어서 Spring Security와 OAuth2.0으로 구글 로그인 기능을 구현합니다. 🏆


💻 구글 서비스 등록

구글 클라우드 플랫폼 주소로 이동합니다. 그리고 다음과 같이 프로젝트를 생성합니다.


[OAuth 클라이언트 ID] 항목을 클릭합니다.


클라이언트 ID가 생성되기 전에 동의 화면 구성이 필요하므로 안내에 따라 [동의 화면 구성] 버튼을 클릭합니다.


범위 탭에서 다음과 같이 각 항목을 선택합니다.

  • 애플리케이션 이름 : 구글 로그인 시 사용자에게 노출될 애플리케이션이름을 이야기합니다.
  • 지원 이메일 : 사용자 동의 화면에서 노출될 이메일 주소입니다.
  • Google API의 범위 : 이번에 등록할 구글 서비스에서 사용할 범위 목록입니다.

다음으로 OAuth 클라이언트 ID 만들기 화면으로 이동합니다.

다음과 같이 URL 주소를 등록해야 합니다. 여기서 [승인된 리디렉션 URL] 항목만 등록합니다.

승인된 리디렉션 URL

  • 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트 할 URL입니다.
  • Spring Boot 2 버전의 Security에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있습니다.
  • 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요가 없습니다.

✅ 추가 작성
+ [승인된 리디렉션 URL]에 오타가 있어서 http://localhost:8080/login/oauth2/code/google로 수정했습니다.



application-oauth.yml

application.yml가 있는 src/main/resources/ 디렉토리에 application-oauth.yml 파일을 생성합니다.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 클라이언트 ID
            client-secret: 클라이언트 보안 비밀
            scope: profile, email


application.yml

application.yml에서 application-oauth.yml를 포함하도록 구성합니다.

spring:
  profiles:
    include: oauth


.gitignore

보안을 위해 깃허브에 application-oauth.yml 파일이 올라가는 것을 방지하는 코드를 추가 작성합니다.

application-oauth.yml

추가한 뒤 커밋했을 때 커밋 파일 목록에 application-oauth.yml 파일이 나오지 않으면 성공입니다.



💻 구글 로그인 연동하기

User.java

domain 패키지 아래에 user 패키지를 생성 후 User.java 클래스를 생성합니다.

package com.aLog.domain.user;

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

import javax.persistence.*;

@Getter
@NoArgsConstructor
@Entity
@Table(name = "Users")
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();
    }
}

📁 코드 설명

1. @Enumerated(EnumType.STRING)
	- JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정합니다.
    - 기본적으로 int로 된 숫자가 저장됩니다.

✅ 추가 작성
MySQL 예약어 문제가 발생하여, User.java 클래스에 @Table(name = "Users") 어노테이션을 추가합니다.



Role.java

각 사용자의 권한을 관리할 Enum 클래스 Role.java를 생성합니다.

package com.aLog.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;
}

Spring Security에서는 권한 코드에 항상 ROLE_이 앞에 있어야만 합니다.



UserRepository.java

User의 CRUD를 책임질 UserRepository.java도 생성합니다.

package com.aLog.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);
    
}

📁 코드 설명

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


build.gradle

Spring Security 관련 의존성 하나를 추가합니다.

// oauth2 client 의존성
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

📁 코드 설명

1. spring-boot-starter-oauth2-client
	- 소셜 로그인 등 클라이언트 입장에서 소결 기능 구현 시 필요한 의존성입니다.
    - spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해줍니다.

SecurityConfig.java

config.auth 패키지를 생성 후 SecurityConfig.java 클래스를 생성합니다.

package com.aLog.config.auth;

import com.aLog.domain.user.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;


@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests(authorize -> authorize
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                    .anyRequest().authenticated())
                .logout(logout -> logout
                    .logoutSuccessUrl("/"))
                .oauth2Login(oauth2Login -> oauth2Login
                    .userInfoEndpoint()
                    .userService(customOAuth2UserService));

        return http.build();
    }
}

📁 코드 설명

1. @EnableWebSecurity
	- Spring Security 설정들을 활성화시켜 줍니다.
    
2. .csrf().disable().headers().frameOptions().disable()
	- h2-console 화면을 사용하기 위해 해당 옵션들을 disable 합니다.
    
3. authorizeRequests
	- URL 별 권한 관리를 설정하는 옵션의 시작점입니다.
    - authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있습니다.
    
4. antMatchers
	- 권한 관리 대상을 지정하는 옵션입니다.
    - URL, HTTP 메소드별로 관리가 가능합니다.
    - 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 주었습니다.
    - "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 했습니다.
    
5. anyRequest
	- 설정된 값들 이외 나머지 URL들을 나타냅니다.
    - authenticated()를 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 합니다.

6. logout().logoutSuccessUrl("/")
	- 로그아웃 기능에 대한 여러 설정의 진입점입니다.
    - 로그아웃 성공 시 / 주소로 이동합니다.
    
7. oauth2Login
	- OAuth2 로그인 기능에 대한 여러 설정의 진입점입니다.

8. userInfoEndpoint
	- OAuth2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당합니다.
    
9. userService
	- 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록합니다.
    - 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있습니다.

✅ 추가 작성

Spring Security
	- WebSecurityConfigurerAdapter는 deprecated 되어, 상속을 받지 않고 모두 Bean으로 등록하여 사용하는 방식으로 변경됐습니다.

SecurityFilterChain
	- 작성 방식이 람다 형식으로 변경되었습니다.(권장)
	- 람다 형식으로 변경되면서, and()로 각 처리 계층에 대한 구분을 해주지 않아도 됩니다.
	- 처리 계층에 대한 구성 로직을 한눈에 보기 좀 더 쉬워졌습니다.


CustomOAuth2UserService.java

구글 로그인 이후 가져온 사용자의 정보들을 기반으로 가입 및 정보수정, 세션 저장 등의 기능을 지원하는 CustomOAuth2UserService.java 클래스를 생성합니다.

package com.aLog.config.auth;

import com.aLog.config.auth.dto.OAuthAttributes;
import com.aLog.config.auth.dto.SessionUser;
import com.aLog.domain.user.User;
import com.aLog.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
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);
    }
}

📁 코드 설명

1. registrationId
	- 현재 로그인 진행 중인 서비스를 구분하는 코드입니다.
    
2. userNameAttributeName
	- OAuth2 로그인 진행 시 키가 되는 필드값을 이야기합니다. Primary Key와 같은 의미입니다.
    - 구글의 경우 기본적으로 코드를 지원하지만, 네이버 카카오 등은 기본 지원하지 않습니다. 구글의 기본 코드는 "sub"입니다.
    
3. OAuthAttributes
	- OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스입니다.
    
4. SessionUser
	- 세션에 사용자 정보를 저장하기 위한 Dto 클래스입니다.

구글 사용자 정보가 업데이트 되었을 때를 대비하여 update 기능도 같이 구현되었습니다. 사용자의 이름이나 프로필 사진이 변경되면 User Entity에도 반영됩니다.



OAuthAttributes.java

config.auth.dto 패키지를 생성 후 OAuthAttributes.java 클래스를 생성합니다.

package com.aLog.config.auth.dto;

import com.aLog.domain.user.Role;
import com.aLog.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();
    }
}

📁 코드 설명

1. of()
	- OAuth2User에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야만 합니다.
    
2. toEntity()
	- User Entity를 생성합니다.
    - OAuthAttributes에서 Entity를 생성하는 시점은 처음 가입할 때 입니다.
    - 가입할 때의 기본 권한을 GUEST로 주기 위해서 role 빌더값에는 Role.GUEST를 사용합니다.


SessionUser.java

config.auth.dto 패키지에 SessionUser.java 클래스를 추가합니다.

package com.aLog.config.auth.dto;

import com.aLog.domain.user.User;
import lombok.Getter;

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

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



💻 구글 로그인 테스트

index.mustache

index.mustache에 로그인 버튼과 로그인 성공 시 사용자 이름을 보여주는 코드를 작성합니다.

{{>layout/header}}

    <h1>aLog</h1>
    <div class="col-md-12">
        <!-- 로그인 기능 영역 -->
        <div class="row">
            <div class="col-md-6">
                <a href="/posts/save" role="button" class="btn btn-outline-primary">글 등록</a>
                {{#userName}}
                    Logged in as: <span id="user">{{userName}}</span>
                    <a href="/logout" class="btn btn-outline-info" role="button">Logout</a>
                {{/userName}}
                {{^userName}}
                    <a href="/oauth2/authorization/google" class="btn btn-outline-secondary" role="button">Google Login</a>
                {{/userName}}
            </div>
        </div>
        <br>
        <!-- 목록 출력 영역 -->
        ...

📁 코드 설명

1. {{#userName}}
	- 머스테치는 다른 언어와 같은 if문을 제공하지 않습니다.
    - true/false 여부만 판단합니다.
    - 머스테치에서는 항상 최종값을 넘겨줘야 합니다.
    
2. a href="/logout"
	- Spring Security에서 기본적으로 제공하는 로그아웃 URL입니다.
    
3. {{^userName}}
	- 머스테치에서 해당 값이 존재하지 않는 경우에는 ^ 를 사용합니다.
    - userName이 없다면 로그인 버튼을 노출시키도록 구성했습니다.
    
4. a href="/oauth2/authorization/google"
	- Spring Security에서 기본적으로 제공하는 로그인 URL입니다.


IndexController.java

index.mustache에서 userName을 사용할 수 있게 IndexController.java에서 userName을 model에 저장하는 코드를 추가합니다.

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

📁 코드 설명

1. (SessionUser) httpSession.getAttribute("user")
	- CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했습니다.
    
2. if (user != null)
	- 세션에 저장된 값이 있을 때만 model에 userName으로 등록합니다.
    - 세션에 저장된 값이 없으면 model에는 아무런 값이 없는 상태이니 로그인 버튼이 보이게 됩니다.

✅ 추가 작성
구글 로그인 후 게시글을 등록하기 위해서는 초기 로그인된 사용자의 권한은 GUEST이기 때문에, update users set role='USER'; SQL 명령어를 입력하여 권한을 USER로 변경해야 게시글이 등록 가능합니다.


✔ Spring Security와 OAuth2.0으로 구글 로그인, 로그아웃, 회원가입, 권한관리 기능을 모두 구현해봤습니다.

profile
🅸nterest 🆂olving 🆃horough 🅹udgment

0개의 댓글