Group Study (2024-2025)/Spring 입문

[Spring 입문] 4주차 - 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기 (1)

ha9eun 2024. 10. 29. 15:53

스프링 시큐리티 : 막강한 인증과 인가 기능을 가진 프레임워크

5.1 스프링 시큐리티와 스프링 시큐리티 Oauth2 클라이언트

소셜 로그인을 사용할 경우, 로그인 구현 시 보안, 회원가입 시 본인인증, 비밀번호 찾기 등 여러 기능들을 직접 구현할 필요가 없어 서비스 개발에 집중할 수 있다.

5.2 구글 서비스 등록

https://console.cloud.google.com (Google Cloud Platform)에 접속 후, ‘프로젝트 선택’ 클릭

 

‘새 프로젝트’ 클릭

등록될 서비스의 이름 입력

생성이 완료된 프로젝트를 선택하고 왼쪽 메뉴 탭을 클릭해서 ‘API 및 서비스 카테고리’로 이동

사이드바 중간에 있는 ‘사용자 인증 정보를 클릭하고 ‘사용자 인증 정보 만들기’ 클릭

‘OAuth 클라이언트 ID’ 항목 클릭

User Type 외부 선택

애플리케이션 이름, 사용자 지원 이메일, 개발자 연락처 정보 입력

‘범위 추가 및 삭제’ 버튼을 눌러서 email, profile, openid 선택

*애플리케이션 이름 : 구글 로그인 시 사용자에게 노출될 애플리케이션 이름

*지원 이메일 : 사용자 동의 화면에서 노출될 이메일 주소. 보통은 서비스의 help 이메일 주소를 사용하지만, 여기서는 본인의 이메일 주소를 사용하면 됨.

*Google API의 범위 : 이번에 등록할 구글 서비스에서 사용할 범위 목록. 이 실습에서는 email, profile, openid 세 가지만 사용함.

테스트 사용자 등록은 우선 넘긴 후, 완료한다

‘사용자 인증 정보 만들기’를 클릭 후, ‘OAuth 클라이언트 ID’ 선택

애플리케이션 유형은 ‘웹 애플리케이션’ 선택, 이름 등록

승인된 리디렉션 URI를 사진과 같이 등록

*승인된 리디렉션 URI :

  • 서비스에서 파라미터로 인증 정보를 주었을 때 인증이 성공하면 구글에서 리다이렉트할 URL
  • 스프링 부트 2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원
  • 사용자가 별도로 리다이렉트 URL을 지원하는 Controller를 만들 필요 없음. 시큐리티에서 이미 구현을 해놓은 상태
  • 현재는 개발 단계이므로 http://localhost:8080/login/oauth2/code/google 로만 등록
  • AWS에 배포하게 되면 localhost 외에 추가로 주소를 추가해야 함

생성된 클라이언트 정보 확인

클라이언트 ID와 클라이언트 보안 비밀 코드 설정

application-oauth.properties

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

*scope=profile,email : 하나의 OAuth2Service로 사용하기 위해 일부러 openid scope를 빼고 등록

 

application.properties에서 application-oauth.properties를 포함하도록 설정

spring.profiles.include=oauth

스프링 부트에서는 properties의 이름을 application-xxx.properties로 만들면 xxx라는 이름의 profile이 생성된다. profile=xxx와 같이 호출하면 해당 properties의 설정을 가져올 수 있다.

! 민감한 정보이므로 git에 올라가지 않도록 주의

.gitignore에 등록

application-oauth.properties

5.3 구글 로그인 연동하기

User 클래스 생성

사용자의 정보 담당

package com.jojoldu.book.springboot.domain.user;

import com.jojoldu.book.springboot.domain.BaseTimeEntity;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.management.relation.Role;
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();
    }
}

*Enumerated(EnumType.STRING)

  • JPA로 데이터베이스로 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정
  • 기본은 int이나, 숫자로 저장되면 그 의미를 알 수 없기 때문에 문자열로 저장될 수 있도록 선언

Role 클래스 생성

사용자의 권한 관리

package com.jojoldu.book.springboot.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;
}

스프링 시큐리티에서는 권한 코드에 항상 ROLE_ 이 앞에 있어야 한다.

UserRepository 생성

User의 CRUD 

package com.jojoldu.book.springboot.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);
}

*findByEmail : 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드

스프링 시큐리티 설정

의존성 추가

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

config.auth 패키지 생성(시큐리티 관련)

- SecurityConfig

package com.jojoldu.book.springboot.config.auth;

import com.jojoldu.book.springboot.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 //Spring Security 설정 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable() //h2-console 화면 사용
                .headers().frameOptions().disable() //h2-console 화면 사용
                .and()
                    .authorizeRequests() //URL 별 권한 관리
                    .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll() //전체열람권한
                    .antMatchers("/api/v1/**").hasRole(Role.USER.name()) //USER 권한을 가진 사람만
                    .anyRequest().authenticated() //나머지 URL은 인증(로그인)된 사용자만 허용
                .and()
                    .logout()
                        .logoutSuccessUrl("/") //로그아웃 성공 시 / 주소로 이동
                .and()
                    .oauth2Login() //OAuth2 로그인 기능 설정
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService); //구현체 등록
    }

}

- CustomOAuth2UserService 

가입, 정보수정, 세션 저장 등의 기능 지원

package com.jojoldu.book.springboot.config.auth;

import com.jojoldu.book.springboot.config.auth.dto.OAuthAttributes;
import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.domain.user.User;
import com.jojoldu.book.springboot.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<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId(); //로그인 서비스 구분
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
                .getUserInfoEndpoint().getUserNameAttributeName(); //PK와 같은 역할

        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(dto 패키지에 생성)

package com.jojoldu.book.springboot.config.auth.dto;

import com.jojoldu.book.springboot.domain.user.Role;
import com.jojoldu.book.springboot.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);
    } //OAuth2User에서 사용자 정보를 Map으로 반환하므로 값을 변환

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

- SessionUser

package com.jojoldu.book.springboot.config.auth.dto;

import com.jojoldu.book.springboot.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();
    }
}

*세션에 저장하기 위해서는 직렬화를 구현해야 하므로 User 클래스와 별도로 SessionUser 생성

로그인 테스트

화면에 로그인 버튼 추가, 로그인 성공 시 사용자 이름 표시

{{>layout/header}}

<h1>스프링부트로 시작하는 웹 서비스 Ver.2</h1>
<div class="col-md-12">
    <div class="row">
        <div class="col-md-6">
            <a href="/posts/save" role="button" class="btn btn-primary">글 등록</a>
            {{#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>
            {{/userName}}
        </div>
    </div>
    <br>
    <!-- 목록 출력 영역 -->
    <table class="table table-horizontal table-bordered">
        <thead class="thead-strong">
        <tr>
            <th>게시글번호</th>
            <th>제목</th>
            <th>작성자</th>
            <th>최종수정일</th>
        </tr>
        </thead>
        <tbody id="tbody">
        {{#posts}}
            <tr>
                <td>{{id}}</td>
                <td><a href="/posts/update/{{id}}">{{title}}</a></td>
                <td>{{author}}</td>
                <td>{{modifiedDate}}</td>
            </tr>
        {{/posts}}
        </tbody>
    </table>
</div>

{{>layout/footer}}

index.mustache에서 사용할 수 있도록 userName을 model에 저장

package com.jojoldu.book.springboot.web;

import com.jojoldu.book.springboot.config.auth.dto.SessionUser;
import com.jojoldu.book.springboot.service.posts.PostsService;
import com.jojoldu.book.springboot.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";
    }

    @GetMapping("/posts/save")
    public String postsSave() {
        return "posts-save";
    }

    @GetMapping("/posts/update/{id}")
    public String postsUpdate(@PathVariable("id") Long id, Model model) {
        PostsResponseDto dto = postsService.findById(id);
        model.addAttribute("post", dto);

        return "posts-update";
    }
}

실제 테스트

회원가입 되었는지 확인

권한 변경하여 글 등록