스프링부트 스터디 2주차. 5단원
Spring security는 막강한 인증(Authentication)과 인가(Authorization 혹은 권한 부여) 기능을 가진 프레임워크입니다. 사실상 스프링 기반의 애플리케이션의 보안 표준이며, 인터셉터 필터 기반의 보안 기능의 구현보다 스프링 시큐리티를 통해 구현하는 것을 적극적으로 권장하고 있습니다.
스프링의 대부분 프로젝트들(Mvc, Data, Batch) 처럼 확장성을 고려한 프레임워크다 보니 당양한 요구사항을 손쉽게 추가하고 변경할 수 있습니다. 설정이 쉽다는 뜻이지요.
이번 장에서는 스프링 시큐리티와 OAuth 2.0을 구현한 구글 로그인을 연동하여 로그인 기능을 만들어보겠습니다.
1. 소셜로그인과 스프링부트 버전, 라이브러리
많은 서비스에서 id/password방식보다 구글 페이스북 네이버 와 같은 소셜 로그인 기능을 사용합니다. 직접 구현하면 아래 목록처럼 많은 것들을 다 구현해야 하지만 소셜 로그인을 이용하면 해당 목록의 것들을 모두 구글, 페이스북, 네이버 등의 서버에 맡기면 되니 서비스 개발에 집중할 수 있다는 장점이 있습니다.
- 로그인 시 보안
- 회원가입 시 이메일 혹은 전화번호 인증
- 비밀번호 찾기
- 비밀번호 변경
- 회원정보 변경
또 spring-security-oauth2-autoconfigure 라이브러리 덕분에 스프링부트의 버전이 달라져도 OAuth의 연동 설정을 그대로 사용할 수 있습니다. 즉, 기존에 안전하게 작동하던 코드를 그대로 사용할 수 있다는 장점이 있습니다.
하지만, 이 책에서는 스프링부트2 방식의 Spring Security Oauth2 Client 라이브러리를 사용할 것입니다.
이유1) 스프링 팀에서 기존 1.5에서 사용되던 spring-security-oauth 프로젝트는 유지 상태(maintenance mode)로 결정했으며 더 이상 신규 기능 추가 없이 버그 수정정도만 추가될 것이고, 스프링 부트2 방식만 신규 기능을 지원하겠다고 했다.
이유2) 스프링 부트용 라이브러리(starter)출시
Spring Boot 2 용 라이브러리인 spring-security-oauth2-client는 Spring Boot와의 통합을 고려하여 Starter라는 형태로 출시되었습니다. Starter는 Spring Boot에서 외부 라이브러리와 쉽게 통합하고 설정할 수 있게 도와주는 라이브러리입니다. 즉, spring-security-oauth2-client는 Spring Boot 환경에서 보다 편리하게 사용할 수 있게 만들어졌습니다.
이유3) 기존에 사용되던 방식은 확장 포인트가 적절히 오픈되어 있지 않아 직접 상속하거나 오버라이딩해야 하지만 신규 라이브러리는 확장 포인트를 고려해서 설계된 상태다.
# **확장 포인트(Extension Points)**는 소프트웨어나 시스템에서 특정 기능을 추가하거나 변경할 수 있도록 미리 정의된 지점을 의미합니다. 즉, 기본 제공되는 기능을 변경하거나 새로운 기능을 추가할 수 있는 지점들입니다.
.porperties .yml 파일의 설정차이
1) 1.5버전에서는 소셜 로그인의 도메인 url주소를 모두 명시해야 하지만 2.0 방식에서는 client인증 정보만 입력하면 됩니다.
2) 그리고 1.5버전에서 입력했던 값들은 2.0버전에 오면서 모두 enum으로 대체되었습니다.Spring Boot 1.5에서는 URL이나 기타 설정 값을 수동으로 설정해야 했습니다.Spring Boot 2.0에서는 이러한 설정 값들이 enum으로 정의되어 있어, 설정이 훨씬 더 구조화되고 관리하기 쉬워졌습니다. 예를 들어, authorization-grant-type, scope 등과 같은 항목들이 enum 값으로 설정됩니다.
public enum CommonOAuth2Provider {
GOOGLE {
@Override
public ClientRegistration.Builder getBuilder(String clientId, String clientSecret) {
return getBuilder(clientId, clientSecret, "https://accounts.google.com");
}
},
FACEBOOK {
@Override
public ClientRegistration.Builder getBuilder(String clientId, String clientSecret) {
return getBuilder(clientId, clientSecret, "https://www.facebook.com");
}
},
GITHUB {
@Override
public ClientRegistration.Builder getBuilder(String clientId, String clientSecret) {
return getBuilder(clientId, clientSecret, "https://github.com");
}
},
// Add more providers as needed
// This method is used by all providers to configure the OAuth2 ClientRegistration
private static ClientRegistration.Builder getBuilder(String clientId, String clientSecret, String providerUrl) {
return ClientRegistration.withRegistrationId(providerUrl)
.clientId(clientId)
.clientSecret(clientSecret)
.scope("user_info")
.authorizationUri(providerUrl + "/oauth/authorize")
.tokenUri(providerUrl + "/oauth/token")
.userInfoUri(providerUrl + "/oauth/userinfo")
.userNameAttributeName("name")
.clientName(providerUrl);
}
// Abstract method to be implemented by each provider
public abstract ClientRegistration.Builder getBuilder(String clientId, String clientSecret);
}
이욍의 다른 소셜로그인(네이버, 카카오 등)을 추가한다면 직접 다 추가해주어야 합니다.
2. 구글 서비스 등록
https://console.cloud.google.com/
Google 클라우드 플랫폼
로그인 Google 클라우드 플랫폼으로 이동
accounts.google.com
1) 해당 링크로 들어가서 프로젝트를 생성하고 api및 서비스탭(왼쪽)을 누릅니다.
2) 사용자 인증정보에서OAuthClientId를 만들어봅니다.
3) 웹 애플리케이션을 만듭니다.
4) 이름을 입력하고 승인된 리디렉션 아이디에 다음과 같이 적습니다.
http://localhost:8080/login/oauth2/code/google
승인된 리디렉션 URI는 서비스에서 파라미터로 인증정보를 주었을때 인증이 성공하면 구글에서 리다이렉트할 URL입니다.
스프링 부트2 버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드} 로 리다이렉트 URL을 지원하고 있습니다. 사용자가 구글 로그인 버튼을 클릭하고 인증을 승인하면 구글 서버에서 인증이 완료된 후 http://localhost:8080/login/oauth2/code/google로 리디렉션합니다.
Spring Security에서는 기본적으로 OAuth2 인증의 리디렉션 URI 처리를 이미 구현하고 있기 때문에, 별도로 이를 처리하는 Controller를 만들지 않아도 됩니다. 즉, google이라는 소셜 로그인 서비스의 인증이 성공하면 Spring Security가 자동으로 /login/oauth2/code/google URI로 리디렉션하여 인증을 완료합니다.
개발 중에는 http://localhost:8080/login/oauth2/code/google와 같이 로컬 서버에서 리디렉션 URI를 사용합니다. 이후 배포 단계에서는 실제 도메인 주소로 이 URI를 변경해야 하며, AWS 서버에 배포할 경우 해당 서버의 도메인 주소를 리디렉션 URI에 추가해야 합니다.
5) 클라이언트 ID와 클라이언트 보안 비밀 clientSecret 코드를 프로젝트에서 설정하겠습니다.
application-oauth.properties 파일을 생성하고 새앋 파일에 클라이언트 ID와 클라이언트 보안 비밀 clientSecret 코드를 등록합니다.
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는 OAuth2 인증에서 권한 범위를 정의하는 파라미터입니다. OAuth2 인증을 수행할 때, 클라이언트 애플리케이션(예: 웹사이트, 모바일 앱)이 사용자에게 요청하는 정보나 자원에 대한 접근 권한을 명시하는 역할을 합니다.
이 scope는 기본값이 openid, profile, email입니다. 강제로 profile, email을 등록한 이유는 openid라는 scope가 있으면 Open Id Provider로 인식하기 때문입니다. 이렇게 인식되면 OpenId Provider인 서비스(ex.google)와 아닌 서비스(ex. anver, kakao)로 나눠서 각각 OAuthService를 만들어야 합니다.
스프링 부트의 프로파일(Profile) 기능은 애플리케이션의 환경 설정을 분리하여 다양한 실행 환경에 따라 다른 설정을 적용할 수 있도록 도와주는 메커니즘입니다.
스프링 부트에서는 properties이름을 application-XXX.properties로 지으면 XXX라는 이름의 profile이 생성되어 이를 통해 관리할 수 있습니다. 즉, profile= XXX라는 식으로 호출하면 해당 properties의 설정들을 가져올 수 있습니다.
이 책에서는 스프링부트 기본 설정파일인 application.properties에서 application-oauth.properties 를 포함하도록 구성합니다.
spring.jpa.show_sql=true
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.profiles.include= oauth //추가
이제 이설정값을 사용할 수 있게되었습니다.
6) .gitignore 등록
구글 로그인을 위한 클라이언트 ID와 클라이언트 보안 비밀은 보안이 중요한 정보들입니다. 이들이 외부에 노출될 경우 언제든 개인정보를 가져갈 수 있는 취약점이 될 수 있습니다.
아래 내용을 gitignore에 추가하여 보안키를 숨깁시다.
application-oauth.properties
3. 구글 로그인 연동하기- User Entity
1) 우선 사용자 정보를 담당한 도메인인 User클래스를 생성합니다.
패키지는 domain아래 user패키지를 생성합니다.
Role
package com.ulbbang.book.firstproject.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;
}
@Getter
- 의미: Lombok 라이브러리에서 제공하는 애노테이션으로, Enum의 모든 필드에 대한 Getter 메서드를 자동으로 생성합니다.
- 결과적으로, getKey()와 getTitle() 메서드가 자동 생성됩니다.
@RequiredArgsConstructor
- 의미: Lombok 애노테이션으로, **final**이나 **@NonNull**이 붙은 필드를 초기화하는 생성자를 자동 생성합니다.
- 이 애노테이션 덕분에 아래와 같은 생성자가 자동으로 만들어집니다:
public Role(String key, String title) {
this.key = key;
this.title = title;
}
Enum 상수:
- GUEST: 권한 키가 ROLE_GUEST, 설명은 "손님".
- USER: 권한 키가 ROLE_USER, 설명은 "일반 사용자".
- 스프링 시큐리티에서는 권한 코드에 항상 ROLE_ 이 앞에 있어야만 합니다.
private final String key;와 private final String title;
- 두 필드는 각각 Enum 상수의 권한 키와 설명을 저장합니다. 이 필드는 각 Enum 상수가 생성될 때 생성자에 의해 초기화됩니다.
enum 클래스는 **열거형(enum)**을 정의하는 특별한 데이터 타입입니다. 주로 고정된 상수값 집합을 정의할 때 사용됩니다. Java의 enum은 단순히 상수 집합을 넘어서, 데이터와 메서드를 함께 포함할 수 있는 강력한 기능을 제공합니다.
User
package com.ulbbang.book.firstproject.domain.user;
import com.ulbbang.book.firstproject.domain.posts.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; // Role은 별도의 Enum으로 정의
@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();
}
}
@Entity:
JPA 어노테이션으로, 이 클래스가 데이터베이스 테이블과 매핑됨을 나타냅니다.
@Id:
이 필드가 데이터베이스 테이블의 **기본 키(primary key)**임을 나타냅니다.
기본 키는 데이터베이스에 데이터를 추가(insert)할 때 자동으로 생성되거나, 직접 개발자가 설정할 수 있습니다. JPA에서는 기본 키를 자동으로 생성하도록 설정할 수 있는 여러 생성 전략이 있습니다.
GenerationType.IDENTITY )
AUTO_INCREMENT는 MySQL에서 제공하는 기능으로, 기본 키 값을 자동으로 증가시키는 옵션입니다.
@GeneratedValue(strategy = GenerationType.IDENTITY):
기본 키의 값을 데이터베이스가 자동 생성하도록 설정합니다.
IDENTITY 전략은 데이터베이스의 AUTO_INCREMENT 기능을 사용합니다.
@Column:
데이터베이스의 컬럼과 매핑됩니다.
nullable = false:
이 필드는 반드시 값이 존재해야 함을 나타냅니다.
@Enumerated(EnumType.STRING)
JPA로 데이터베이스로 저장할 때 Enum값을 어떤 형태로 저장할지를 결정합니다, 기본적으로 int로 된 숫자가 저장됩니다. 숫자로 저장되면 데이터베이스로 확인할 때 그 값이 무슨 코드를 의미하는 지 알 수 없기 때문에 문자열(EnumType.STRING)으로 저장될 수 있도록 선언합니다.
이 필드는 Role이라는 열거형(enum) 타입과 매핑됩니다. 데이터베이스에는 enum 값의 문자열 표현이 저장됩니다.
- 예: Role.USER → "USER", Role.GUEST → "GUEST"
Role role:
사용자의 권한을 나타내는 필드로, Role은 별도로 정의된 열거형(enum) 클래스입니다.
@Builder:
Lombok 어노테이션으로, 빌더 패턴을 사용할 수 있게 합니다.
빌더 패턴은 가독성을 높이고, 선택적으로 매개변수를 설정할 수 있는 장점이 있습니다.
@Builder
public User(String name, String email, String picture, Role role) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
}
update(String name, String picture)
this**를 반환하여 체이닝 형태로 호출할 수 있습니다.
getRoleKey()
역할 키 반환
2) 마지막으로 User의 CRUD를 책임질 UserRepository도 생성합니다.
package com.ulbbang.book.firstproject.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 소셜로그인으로 반환되는 값 중 email을 통해 이미 생성된 사용자인지 처음 가입하는 사용자인지 판단하기 위한 메소드입니다.
4. 스프링 시큐리티 설정
이제 User Entity관련 코드를 모두 작성했으니 시큐리티 설정을 진행하겠습니다.
1) build.gradle에 아래 코드를 추가하자!
implementation 'org.springframework.boot:spring-boot-starter-security' 를 추가하면 spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해줍니다.
이 의존성을 추가하면 OAuth2 클라이언트와 JWT(JSON Web Token) 기능을 사용할 수 있게 되며, spring-security-oauth2-client와 spring-security-oauth2-jose 라이브러리가 자동으로 포함되어 OAuth2 인증 및 JWT 관련 작업을 쉽게 구현할 수 있습니다.
buildscript {
ext {
springBootVersion = '2.6.3'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group = 'org.example'
version = '1.0-SNAPSHOT'
sourceCompatibility = '11'
targetCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2'
implementation 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'junit:junit:4.13.2'
implementation 'org.springframework.boot:spring-boot-starter-mustache'
implementation 'org.springframework.boot:spring-boot-starter-security' //추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' //추가
}
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
}
File -> Invalidate Caches / Restart를 클릭한 후, 다시 확인하면 의존성이 적용되어 있습니다.
2) config.auth 패키지를 도메인 패키지 안에 추가합니다.
시큐리티 관련 클래스는 모두 이곳에 담습니다.
CustomOAuth2UserService.java 클래스를 생성합니다.
SecurityConfig
package com.ulbbang.book.firstproject.config.auth;
import com.ulbbang.book.firstproject.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()
.and().logout().logoutSuccessUrl("/")
.and().oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);
}
}
다음 클래스는 HTTP 요청에 대한 인증/인가 설정과 CSRF, 헤더 보안 등의 기본 보안 설정, OAuth 2.0 인증 처리 로그아웃 처리를 합니다.
@EnableWebSecurity
Spring Security 설정을 활성화하는 애너테이션으로 WebSecurityConfigurerAdapter를 상속하여 커스텀 보안 설정을 구현하도록 설정합니다.
@RequiredArgsConstructor
final 필드나 @NonNull로 선언된 필드를 매개변수로 받는 생성자를 자동으로 생성합니다.
여기서는 customOAuth2UserService 필드를 초기화합니다.
private final CustomOAuth2UserService customOAuth2UserService;
OAuth 2.0 로그인 이후 사용자 정보를 처리하는 역할을 담당합니다.
CustomOAuth2UserService는 사용자 정보를 로드한 뒤 데이터베이스에 저장하거나 업데이트하며, 인증된 사용자 정보를 세션에 저장하는 기능을 수행합니다.
OAuth 2.0 인증 제공자(Google, Facebook 등)에서 반환한 사용자 정보는 앱의 데이터베이스 형식과 다를 수 있습니다. 이를 통합하기 위해 사용자 정보를 변환 및 처리하는 별도의 서비스가 필요하기 때문에 쓰입니다.
configure(HttpSecurity http)
Spring Security의 HTTP 요청에 대한 보안 설정을 정의하는 메서드입니다. 모든 HTTP 요청에 대해 어떤 보안 규칙을 적용할지 정의합니다.
- CSRF 보호 및 Frame 옵션 설정.
- URL별 접근 권한 제어.
- 로그아웃 처리.
- OAuth 2.0 로그인 설정.
http.csrf().disable().headers().frameOptions().disable();
csrf().disable(): CSRF(Cross-Site Request Forgery) 보호를 비활성화합니다.
#CSRF: Cross-Site Request Forgery의 약자로, 사용자가 의도하지 않은 요청을 서버에 보내는 공격. 공격자가 사용자의 브라우저를 통해 악의적인 요청을 서버로 보내는 공격입니다.
API 서버나 H2 Console 테스트 시 사용하는 설정입니다.
REST API는 보통 Authorization 헤더(예: Bearer Token)를 통해 인증을 처리하며, CSRF 토큰이 필요하지 않습니다.
REST API 서버에서는 일반적으로 CSRF 토큰을 사용하지 않으므로 비활성화하였습니다. 실제 운영 환경에서는 신중히 사용해야 합니다. CSRF 보호를 활성화하면 Spring Security는 요청마다 CSRF 토큰을 검증합니다. CSRF 토큰이 없는 요청에 대해 서버가 403 Forbidden 에러를 반환합니다.
CSRF 공격이 발생하는 주요 조건을 React Native 앱이 충족하지 않기 때문에 토큰 기반 인증(JWT) 또는 OAuth를 사용하는 경우, CSRF 보호 대신 토큰 자체의 유효성을 검증하여 보안을 유지하는 것을 추천합니다.
frameOptions().disable(): H2 데이터베이스 콘솔 사용을 위해 iframe 관련 보안 헤더 비활성화.
X-Frame-Options 헤더를 통해 웹 페이지가 iframe에서 렌더링되지 않도록 제한합니다. H2 Database Console은 iframe을 사용하기 때문에 활성화된 상태에서는 제대로 작동하지 않기 때문에 상요하는 것으로 테스트 환경에서만 사용하며, 운영 환경에서는 보안상 활성화하는 것이 권장합니다.
authorizeRequests(): HTTP 요청에 대해 접근 권한을 설정.
antMatchers()
특정 URL 패턴에 대해 권한을 지정합니다.
permitAll(): 모든 사용자(인증되지 않은 사용자 포함) 접근 허용합니다.
hasRole(Role.USER.name()): /api/v1/** 경로는 USER 권한이 있는 사용자만 접근 허용합니다.
anyRequest().authenticated(): 나머지 모든 요청은 인증된 사용자만 접근 가능합니다.
.and().logout().logoutSuccessUrl("/");
로그아웃 성공 후 리디렉션될 URL로 홈페이지(/)를 설정했습니다.
.and().oauth2Login().userInfoEndpoint().userService(customOAuth2UserService);
oauth2Login()
- OAuth 2.0 로그인 프로세스를 설정.
- 외부 인증 제공자(Google, Facebook 등)를 통해 사용자 인증을 처리.
userInfoEndpoint()
- 인증 성공 후 사용자 정보를 가져오는 엔드포인트를 설정.
- 반환된 사용자 정보를 처리하기 위해 별도의 서비스를 등록.
userService(customOAuth2UserService)
- OAuth 2.0 인증 성공 후 사용자 정보를 처리하는 서비스로 CustomOAuth2UserService를 사용.
- 주요 작업:
- 인증 제공자로부터 반환된 사용자 정보를 로드.
- 사용자 정보를 앱 데이터베이스 형식에 맞게 변환.
- 새 사용자 정보 저장 또는 기존 사용자 정보 업데이트.
- 인증된 사용자 정보를 세션에 저장.
CustomOAuth2UserService
package com.ulbbang.book.firstproject.config.auth;
import com.ulbbang.book.firstproject.config.auth.dto.OAuthAttributes;
import com.ulbbang.book.firstproject.config.auth.dto.SessionUser;
import com.ulbbang.book.firstproject.domain.user.User;
import com.ulbbang.book.firstproject.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();
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);
}
}
논리 흐름 요약
- OAuth2 로그인 요청이 들어오면 loadUser() 메서드가 실행됩니다.
- OAuth2 제공자에서 사용자 정보를 가져오고, 해당 정보를 OAuthAttributes 객체로 변환합니다.
- 이메일을 기준으로 사용자가 이미 존재하는지 확인하고, 존재하면 정보를 업데이트하고, 없으면 새로 생성하여 데이터베이스에 저장합니다.
- 저장된 사용자 정보를 세션에 저장하여 로그인 상태를 유지합니다.
- 사용자의 권한을 설정하고 DefaultOAuth2User 객체를 반환하여 Spring Security가 인증 정보를 처리할 수 있도록 합니다.
- loadUser 메서드
loadUser 메서드는 OAuth2 로그인 인증을 처리하며, 사용자 정보를 가져오고 세션에 저장합니다.
delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
본 제공되는 DefaultOAuth2UserService를 사용하여 OAuth2 제공자에서 사용자 정보를 가져옵니다.
여기서 인증을 성공적으로 받으면 oAuth2User 객체가 생성됩니다,(로그인 성공여부 체크됨)
registrationId: OAuth2 제공자 이름(예: Google, Facebook).
userNameAttributeName: 인증 응답에서 사용자 이름으로 식별되는 필드 이름(예: sub).
OAuthAttributes: 인증 응답에서 사용자 정보를 추출하고 관리하는 DTO(Data Transfer Object). 이 객체를 통해 인증 제공자의 데이터를 표준화합니다.
User user = saveOrUpdate(attributes);
사용자 정보가 이미 데이터베이스에 있다면 업데이트, 없다면 저장.
httpSession.setAttribute("user", new SessionUser(user));
인증된 사용자 정보를 세션에 저장. SessionUser는 사용자 정보의 일부만 포함하는 DTO.
OAuthAttributes 객체 생성: OAuthAttributes.of() 메서드를 호출하여 OAuth2 제공자로부터 받은 사용자 정보(oAuth2User.getAttributes())를 OAuthAttributes 객체로 변환합니다. 이 객체는 나중에 사용자 정보를 다룰 때 사용됩니다.
DefaultOAuth2User 반환
- SimpleGrantedAuthority: Spring Security 권한 정보.
- attributes.getAttributes(): OAuth2 제공자로부터 가져온 사용자 속성.
- attributes.getNameAttributeKey(): 사용자 이름 속성.
saveOrUpdate 메서드
saveOrUpdate 메서드는 사용자 정보를 데이터베이스에 저장하거나 갱신합니다.
saveOrUpdate 메서드 정의: OAuthAttributes 객체를 받아서 User 객체를 저장하거나 업데이트하는 메서드입니다.
User user = userRepository.findByEmail(attributes.getEmail())
주어진 이메일을 통해 userRepository에서 해당 사용자가 이미 존재하는지 확인합니다.
map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
기존 사용자 업데이트: 사용자가 이미 존재하는 경우, 기존 엔티티를 업데이트합니다. update() 메서드는 사용자의 이름과 프로필 사진을 최신 값으로 갱신합니다.
.orElse(attributes.toEntity());
사용자 없으면 새로 생성: 사용자가 없으면 attributes.toEntity()를 호출하여 새로운 User 객체를 생성합니다.
return userRepository.save(user);
사용자 저장: 최종적으로 새로 생성되거나 업데이트된 User 객체를 데이터베이스에 저장합니다.
OAuthAttributes
package com.ulbbang.book.firstproject.config.auth;
import com.ulbbang.book.firstproject.config.auth.dto.OAuthAttributes;
import com.ulbbang.book.firstproject.config.auth.dto.SessionUser;
import com.ulbbang.book.firstproject.domain.user.User;
import com.ulbbang.book.firstproject.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();
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);
}
}
주요 역할 - OAuth 로그인 정보를 전달하는 DTO
- OAuth2 로그인 응답을 처리: OAuth2 제공자로부터 받은 사용자 정보를 추상화하여 저장합니다.
- 사용자 정보를 User 객체로 변환: 필요한 정보만 뽑아서 실제 도메인 객체인 User로 변환합니다.
loadUser 메서드
OAuth2UserService 인터페이스의 메서드를 오버라이드하여 OAuth2 사용자 정보를 로드합니다. OAuth2UserRequest 객체를 통해 사용자의 인증 정보를 가져옵니다.
DefaultOAuth2UserService
Spring Security에서 제공하는 기본 OAuth2 사용자 서비스로, 실제 OAuth2 사용자 정보를 로드합니다. delegate 객체는 이 기본 서비스를 사용하여 oAuth2User 객체를 가져옵니다.
- 사용자가 OAuth2 로그인 시도 -> loadUser 메서드 호출.
- DefaultOAuth2UserService를 사용하여 OAuth2 제공자로부터 사용자 정보를 로드.
- OAuthAttributes 객체에 사용자 정보 저장.
- 이메일을 기준으로 사용자 정보가 존재하는지 확인하고, 존재하면 업데이트, 없으면 새로 생성.
- 생성된 사용자 정보를 세션에 저장하여 로그인 상태 유지.
- DefaultOAuth2User 객체를 반환하여 Spring Security의 인증 처리에 사용.
SessionUser - 세션에 저장할 사용자 정보를 전달하는 DTO
package com.ulbbang.book.firstproject.config.auth.dto;
import com.ulbbang.book.firstproject.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 클래스는 세션에 저장하기 위해 필요한 사용자 정보를 전송하는 DTO입니다. 사용자 로그인 후 세션에 저장할 정보는 일반적으로 필요한 최소한의 정보만 포함되므로, 이 클래스는 로그인 후 세션에 저장할 정보만 포함합니다.
주요 역할
- 세션에 저장할 사용자 정보 전송: 세션에 사용자 정보를 저장하여, 사용자가 인증된 상태에서 다른 페이지로 이동해도 세션을 통해 로그인 상태를 유지하도록 합니다.
- 애플리케이션 내에서 인증된 사용자 정보만 저장: 로그인한 사용자에게 필요한 최소한의 정보(예: 이름, 이메일 등)를 담고 있습니다.
User클래스를 그대로 사용하지 않고 SessionUser를 직접 만든 이유는 직렬화 코드가 필요해서입니다. User클래스는 엔티티이기 때문에 언제 다른 엔티티와 관계가 형성될지 모릅니다. 따라서 직렬화 기능을 가진 세션 Dto를 추가로 만들었습니다.
#직렬화(Serialization)는 객체를 바이트 스트림(byte stream) 형태로 변환하는 과정입니다. 이 과정을 통해 객체를 파일로 저장하거나 네트워크를 통해 전송할 수 있게 됩니다. 직렬화된 객체는 다시 역직렬화(Deserialization)를 통해 원래의 객체로 복원할 수 있고 이렇게 전송가능하도록 직렬화가 가능한 객체만 세션에 저장할 수 있습니다.
indexController
index.mustache(프론트)에서 userName을 사용할 수 있게 @GetMapping("/")의 내용을 변경합니다.
package com.ulbbang.book.firstproject.web;
import com.ulbbang.book.firstproject.config.auth.dto.SessionUser;
import com.ulbbang.book.firstproject.service.posts.PostsService;
import com.ulbbang.book.firstproject.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 save() {
return "posts-save";
}
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
PostsResponseDto dto = postsService.findById(id);
model.addAttribute("post", dto);
return "posts-update";
}
}
(SessionUser) httpSession.getAttribute("user");
CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구성했습니다. 로그인 성공시 user속성값을 가져옵니다.
if(user != null)
세션에 저장된 값이 있을 때만 model에 userName으로 등록합니다. 세션에 저장된 값이 없으면 model에 아무 값이 없는 상태이니 프론트엔드에서는 userName이 존재하는지 여부를 기반으로 로그인 버튼을 숨기거나 보여주는 등의 처리를 할 수 있습니다.
프론트 코드
//{{#userName}}: 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>
<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
하지만 현재 사용자 권한은 GUEST이기 때문에 글을 쓸 수 없습니다.
403권한 에러가 발생했습니다.
h2-console에서 일반 사용자로 바꿔봅시다.
SELECT * FROM USER;
UPDATE user SET role = 'USER';
이제 로그아웃했다가 다시 들어가면 정상적으로 글을 쓸 수 있습니다.
4. 어노테이션 기반으로 개선하기
IndexController의 SessionUser user = (SessionUser) httpSession.getAttribute("user"); 를 어노테이션화해봅시다.
아래 코드는 Spring Boot에서 현재 로그인한 사용자의 정보를 편리하게 전달받기 위한 커스텀 애너테이션과 이를 처리하는 Argument Resolver를 구현한 것입니다.
# Argument Resolver는 Spring MVC에서 컨트롤러 메서드의 매개변수를 처리하고 값을 바인딩하는 컴포넌트입니다.
LoginUser
package com.ulbbang.book.firstproject.config.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 {
}
- @Target(ElementType.PARAMETER): 이 애너테이션은 메서드의 매개변수에만 사용할 수 있습니다.
- @Retention(RetentionPolicy.RUNTIME): 애너테이션 정보가 런타임까지 유지되어 리플렉션을 통해 참조할 수 있습니다.
LoginUserArgumentResolver
package com.ulbbang.book.firstproject.config.auth;
import com.ulbbang.book.firstproject.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");
}
}
- 이 클래스는 HandlerMethodArgumentResolver 인터페이스를 구현하여 컨트롤러 메서드의 매개변수에 값을 주입하는 기능을 제공합니다.
- @Component: Spring이 이 클래스를 Bean으로 등록하여 사용할 수 있도록 합니다.
- @RequiredArgsConstructor: final 필드를 생성자로 자동 주입합니다. (httpSession이 주입됩니다.)
supportsParameter
- 매개변수에 @LoginUser 애너테이션이 붙어 있는지 확인합니다.
- 매개변수 타입이 SessionUser 클래스인지 확인합니다.
- 위 두 조건을 모두 만족하면 true를 반환합니다.
resolveArgument
supportsParameter 메서드가 true를 반환한 매개변수에 실제 데이터를 바인딩합니다.
- HttpSession에서 "user"라는 키로 저장된 값을 가져옵니다.
- 가져온 값(로그인한 사용자 정보)을 매개변수에 주입합니다.
사용예시)
@GetMapping("/profile")
public String getProfile(@LoginUser SessionUser user) {
System.out.println(user.getName()); // 로그인한 사용자의 이름 출력
return "profile";
}
이렇게 생성된 LoginUserArgumentResolver가 스프링에서 인식될 수 있도록 WebMvcConfigurer에 추가하겠습니다.
package com.ulbbang.book.firstproject.config.auth;
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> arumentResolvers) {
arumentResolvers.add(loginUserArgumentResolver);
}
}
기본 제공되지 않는 커스텀 Argument Resolver는 WebMvcConfigurer의 addArgumentResolvers를 통해 Spring MVC에 등록해야 합니다.
HandlerMethodArgumentResolver는 항상 WebMvcConfigurerd의 addArgumentResolvers를 통해추가해야 합니다. 다른 Handler-MethodArgumentResolver가 필요하다면 같은 방식으로 추가해주면 됩니다.
이제 IndexController의 SessionUser user = (SessionUser) httpSession.getAttribute("user"); 를 @LoginUser로 개선해보겠습니다. 이제 어느 컨트롤러든지 @LoginUser를 사용하면 세션 정보를 가져올 수 있습니다.
@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user) {
model.addAttribute("posts", postsService.findAllDesc());
if(user != null) {
model.addAttribute("userName", user.getName());
}
return "index";
}
5. 세션 저장소로 데이터베이스 이용하기
지금 우리가 만든 서비스는 서버를 재실행하면 로그인이 풀립니다.
이것은 세션이 내장 톰캣의 메모리에 저장되기 때문입니다. 기본적으로 세션은 실행되는 Web Application Server의 메모리에서 저장되고 호출됩니다. 메모리에 저장되다 보니 내장 톰캣( Apache Tomcat )처럼 애플리케이션 실행 시 항상 초기화가 이루어집니다.
이렇게 사용하면
1) 배포할 때마다 톰캣이 재시작
2) 2대이상의 서버에서 서비스하고 있다면 톰캣마다 세션 동기화 설정을 해야 합니다.
애플리케이션을 배포할 때, 보통 로드 밸런싱을 위해 여러 대의 서버를 운영합니다. 사용자가 로그인하면 세션 데이터가 사용자가 연결된 특정 서버의 메모리에 저장됩니다. 만약 사용자의 요청이 다른 서버로 라우팅되면, 해당 서버에는 세션 데이터가 없으므로 로그인 정보가 유지되지 않습니다.
그러므로 서버 간에 세션 데이터를 공유할 수 있도록 설정해야 합니다.
서버간의 세션동기화 문제해결)
1) 톰캣 세션공유를 위해 추가 설정을 합니다.
톰캣 자체적으로 세션 데이터를 여러 서버 간에 공유할 수 있는 설정을 제공합니다.
각 서버가 자신의 세션 데이터를 다른 서버에 복제하여 세션을 동기화하는 세션 복제 (세션 클러스터링) 방식으로 이루어집니다.
하지만 이는 설정이 복잡하고 네트워크 트래픽을 증가시킬 수 있습니다. server.xml에서 클러스터링 활성화할 수 있습니다.
2) MySQL과 같은 데이터베이스를 세션 저장소로 사용합니다.
많은 설정이 필요없지만 로그인 요청마다 DB IO(DB접근)이 발생하여 성능상 이슈가 발생할 수 있습니다. 로그인 요청이 많이 없는 백오피스, 사내 시스템 용도에서 사용합니다.
세션 데이터를 톰캣 메모리가 아닌 데이터베이스에 저장하여 서버 간에 공유할 수 있습니다.
하지만 데이터베이스는 디스크 기반이므로, 읽기/쓰기 성능이 Redis 등 메모리 기반 DB보다 느립니다.
3) Redis, Memcached와 같은 메모리 DB를 세션 저장소로 사용합니다.
Redis와 같은 메모리 기반 데이터베이스를 활용하여 세션 데이터를 관리하는 방식입니다. 기본적으로 메모리에 저장하므로 서버가 재시작되면 데이터가 유실될 수 있습니다. 하지만 Redis는 스냅샷(RDB) 또는 지속적 쓰기(AOF)를 통해 데이터를 디스크에 저장하여 내구성을 보장할 수 있습니다.
B2C서비스에서 가장 많이 사용하는 방식입니다. 실제 서비스를 사용하기 위해서는 Embedded Redis와 같은 방식이 아닌 외부 메모리 서버가 필요합니다. 대규모 트래픽에 적합합니다.
4) 쿠키기반 세션관리
세션 데이터를 서버에서 관리하지 않고, 사용자의 쿠키에 암호화하여 저장합니다.
JW T(Json Web Token)로 사용자 정보와 만료시간을 포함한 토큰 생성 하면 구현이 쉽습니다.
Redis는 돈을 줘야 사용할 수 있으므로 데이터베이스를 사용해봅시다.
1)spring-session-jdbc 등록
build.gradle에 다음의 의존성을 등록합니다.
buildscript {
ext {
springBootVersion = '2.6.3'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group = 'org.example'
version = '1.0-SNAPSHOT'
sourceCompatibility = '11'
targetCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2'
implementation 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'junit:junit:4.13.2'
implementation 'org.springframework.boot:spring-boot-starter-mustache'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.session:spring-session-jdbc' //추가
}
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
}
application.properties에 다음 설정을 추가합니다. 세션저장소를 jdbc로 선택하게 하는 코드입니다.
spring.jpa.show_sql=true
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.profiles.include= oauth
spring.session.store-type=jdbc //추가
# JDBC(Java Database Connectivity)는 Java에서 데이터베이스와 상호작용하기 위한 표준 API입니다. JDBC를 사용하면 Java 애플리케이션에서 데이터베이스에 연결하고, SQL 쿼리를 실행하며, 결과를 처리할 수 있습니다. JDBC는 데이터베이스 독립적인 API로 설계되어 있어, 다양한 관계형 데이터베이스(DBMS)와 상호작용할 수 있습니다.
이제 h2-console로 접속하여 아래 명령어를 입력하면 확인할 수 있습니다.
SELECT * FROM SPRING_SESSION
그래도 지금은 기존과 동일하게 스프링을 재시작하면 세션이 풀립니다. H2기반으로 스프링이 재실행될 때 H2도 재시작되기 때문입니다. AWS에서 배포하게 되면 AWS의 데이터베이스인 RDS(Relational Database Service)를 사용하게 되니 이때부터는 세션이 풀리지 않습니다.
+) 추가로 gradle clean build를 했을 때 test에서 문제 발생
@WebMvcTest는 주로 특정 컨트롤러를 테스트할 때 사용되며, 이때 해당 컨트롤러와 관련된 빈들만 로드합니다. 만약 HelloController가 다른 서비스나 빈을 의존하고 있다면, 그 빈들이 자동으로 로드되지 않기 때문에 의존성 주입이 제대로 이루어지지 않습니다. 이럴 경우 @MockBean을 사용하여 필요한 빈을 모킹(mocking)해야 합니다.
이 부분은 뒤에서
6. 네이버로그인
1) 네이버 api 이용 등록
네이버 로그인에서 제공하는 ‘이메일 주소' 정보는 이용자가 계정에 등록한 '연락처 이메일' 정보입니다.
(네이버ID > 내 프로필 > 연락처 이메일)
즉, ‘{naverid}@naver.com’ 형태의 네이버 계정 이메일이 아니기 때문에 계정별로 고유한 값이 아니며, 네이버 메일 외 다른 도메인으로도 설정 가능합니다.
http://localhost:8080/login/oauth2/code/naver
서비스 URL은 웹 서비스나 API가 제공되는 주소입니다. 콜백 URL은 특정 작업이 완료된 후, 외부 서비스나 시스템이 결과를 반환하거나 알림을 보내는 URL입니다. 보통 OAuth 인증, 웹훅(Webhook), 파일 업로드 후 처리 등에 사용됩니다. 구글에서 등록한 리디렉션 URL과 같은 역할을 합니다.
등록을 완료하면 ClientID와 ClientSecret이 생성됩니다.
2) 키값등록
main>resources>application-oauth.properties에 키값을 등록합니다.
네이버에서는 스프링 시큐리티를 공식 지원하지 않기 때문에 그동안 Common-OAuth2Provider에서 해주던 값들도 전부 수동으로 입력해야 합니다.
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
# registration
spring.security.oauth2.client.registration.naver.client-id=네이버클라이언트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
# provider
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
- spring.security.oauth2.client.registration.naver.redirect-uri: 사용자가 인증을 완료한 후, 네이버가 리디렉션할 콜백 URL을 설정합니다. {baseUrl}과 {action}은 애플리케이션의 기본 URL과 인증 후 리디렉션 경로를 설정하는 변수입니다. {registrationId}는 등록된 클라이언트의 이름(여기서는 'naver')을 나타냅니다.
- spring.security.oauth2.client.registration.naver.authorization-grant-type: 인증 과정에서 사용할 인증 방식을 지정합니다. authorization_code는 사용자가 로그인 후 인증 코드를 받은 후, 이를 사용해 액세스 토큰을 요청하는 방식입니다.
- spring.security.oauth2.client.registration.naver.scope: OAuth2 로그인 요청 시, 네이버로부터 요청할 **권한 범위(scope)**를 설정합니다. name, email, profile_image는 사용자의 이름, 이메일, 프로필 이미지를 요청하는 권한입니다.
- spring.security.oauth2.client.registration.naver.client-name: 이 OAuth2 로그인 클라이언트의 이름을 설정합니다. 여기서는 'Naver'로 설정되어 있습니다.
- spring.security.oauth2.client.provider.naver.authorization-uri: 네이버 로그인 화면으로 이동하는 인증 URL입니다. 사용자가 로그인하고 승인하면 인증 코드를 받을 수 있는 페이지입니다.
- spring.security.oauth2.client.provider.naver.token-uri: 네이버의 토큰 발급 URL입니다. 사용자가 인증을 완료하고 나면, 인증 코드(authorization_code)를 이용해 액세스 토큰을 요청하는 URL입니다.
- spring.security.oauth2.client.provider.naver.user-info-uri: 사용자가 로그인한 후, 네이버로부터 사용자 정보를 가져올 수 있는 API URL입니다. 이 URL을 통해 사용자 이름, 이메일, 프로필 이미지 등을 조회할 수 있습니다.
- spring.security.oauth2.client.provider.naver.user-name-attribute: 네이버에서 반환하는 사용자 정보 중, 사용자 이름을 지정하는 속성입니다. response는 반환된 JSON 객체에서 사용자 이름을 찾을 수 있는 필드를 나타냅니다. 보통 네이버 API 응답에서는 response 객체 안에 name, email 등이 포함되어 있습니다.
네이버 오픈 API의 로그인 회원 결과는 다음과 같습니다.
{
"resultcode": "00",
"message": "success",
"response": {
"id": "12345678",
"email": "user@example.com",
"name": "홍길동",
"nickname": "길동이",
"profile_image": "https://ssl.pstatic.net/static/pwe/address/img_profile_07.gif",
"age": "30",
"birth": "19950101",
"gender": "M",
"mobile": "01012345678",
"address": "서울특별시 강남구"
}
}
스프링 시큐리티에서는 하위 필드를 명시하지 못하고 최상위 필드만 user_name으로 지정가능합니다. 위 네이버 오픈 API의 로그인 회원 결과를 보면 네이버의 응답값 최상위 필드는 resultcode, message, response 3개이기 때문에 해당 세 필드 중 하나를 user_name으로 지정해야 합니다.
3) 스프링 시큐리티 등록
response를 user_name으로 지정합시다.
도메인>config>auto>dto의 OAuthAttributes에 다음과 같이 네이버인지 판단하는 코드와 네이버 생성자를 추가합니다.
package com.ulbbang.book.firstproject.config.auth.dto;
import com.ulbbang.book.firstproject.domain.user.Role;
import com.ulbbang.book.firstproject.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) {
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();
}
public User toEntity(){
return User.builder()
.name(name)
.email(email)
.picture(picture)
.role(Role.GUEST)
.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();
}
}
Naver로그인도 완성했습니다!
7. 기존 테스트에 스프링 시큐리티 적용하기
앞선 장에서 단위 테스트에 대해 배웠지요. 지금 스프링 시큐리티를 적용한 채로 gradle clean build를 진행하면 오류가 날 것입니다. 기존 테스트에 시큐리티 적용이 되지 않아서 생긴 문제인데요.
기존에는 바로 API를 호출할 수 있어 테스트 코드 역시 바로 API를 호출했지만 이제는 기존의 API 코드에 인증에 대한 권한이 필요합니다. 이를 위해 테스트 코드마다 인증한 사용자가 호출한 것처럼 작동하도록 수정하겠습니다.
테스트를 실행해보면 롬복만 이용한 테스트 외에 스프링을 이용한 테스트는 모두 실패한 것을 확인할 수 있습니다.
1) CustomOAuthUserService를 찾을 수 없음
main에서 application.properties는 자동으로 가져오지만 application-oauth.properties는 가져오지 못하기 때문입니다.
application.properties를 test의 resource안에 작성하여 봅시다.
spring.jpa.show_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
spring.session.store-type=jdbc
# Test OAuth
spring.security.oauth2.client.registration.google.client-id=test
spring.security.oauth2.client.registration.google.client-secret=test
spring.security.oauth2.client.registration.google.scope=profile,email
hibernate 버전이 Spring Boot 2.1 -> 2.2로 바뀌면서 dialect 관련 설정 방법이 바뀌었음
test package의 application.properties에 db dialect를 h2용으로 설정하니 해결
참고 사이트)
https://velog.io/@qmfpsp/du5uhxyy
2) 302 Status Code
posts 등록이 안되는 문제를 해결해봅시다.
스프링 시큐리티 설정상 인증되지 않은 사용자 요청은 이동시킵니다. 그래서 이런 API 요청은 임의로 인증된 사용자를 추가하여 API만 테스트할 수 있게 해보겠습니다.
build.gradle에 다음과 같이 spring-security-test 설정을 추가합니다.
buildscript {
ext {
springBootVersion = '2.6.3'
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
}
}
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group = 'org.example'
version = '1.0-SNAPSHOT'
sourceCompatibility = '11'
targetCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.h2database:h2'
implementation 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'junit:junit:4.13.2'
implementation 'org.springframework.boot:spring-boot-starter-mustache'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.session:spring-session-jdbc'
// Spring Security Test 추가
testImplementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
}
PostsApiControllerTest의 2개 테스트 메소드에 임의 사용자 인증을 추가합니다.
@Test
@WithMockUser(roles="USER")
public void testCreatePost() throws Exception { //Posts등록
//..
@Test
@WithMockUser(roles="USER")
public void PostsEdited() throws Exception {
@WithMockUser는 사용자 이름, 권한(roles), 속성 등을 설정하여 가짜 사용자를 생성합니다.
USER로 roles을 설정했기 때문에이 사용자는 테스트 요청 시 인증된 사용자로 간주됩니다.
3) @SpringBootTest에서 MockMvc
이제 MockMvc에서만 작동하는 @WithMockUser를 위해 @SpringBootTest에서 MockMvc를 사용하도록 설정해봅시다.
PostsApiControllerTest를 아래와 같이 변경하세요!
package com.ulbbang.book.firstproject.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ulbbang.book.firstproject.domain.posts.Posts;
import com.ulbbang.book.firstproject.domain.posts.PostsRepository;
import com.ulbbang.book.firstproject.web.dto.PostsSaveRequestDto;
import com.ulbbang.book.firstproject.web.dto.PostsUpdateRequestDto;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsApiControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private PostsRepository postsRepository;
//MockMvc 설정 추가
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@Before
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(springSecurity())
.build();
}
@After
public void tearDown() throws Exception {
postsRepository.deleteAll();
}
@Test
@WithMockUser(roles="USER")
public void testCreatePost() throws Exception { //Posts등록
//given
String title = "title";
String content = "content";
PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
.title(title)
.content(content)
.author("author")
.build();
String url = "http://localhost:" + port + "/api/v1/posts";
//when
mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
//then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(title);
assertThat(all.get(0).getContent()).isEqualTo(content);
}
@Test
@WithMockUser(roles="USER")
public void PostsEdited() throws Exception {
//given
Posts savedPosts = postsRepository.save(Posts.builder()
.title("title")
.content("content")
.author("author")
.build());
Long updateId = savedPosts.getId();
String expectedTitle = "title2";
String expectedContent = "content2";
PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
.title(expectedTitle)
.content(expectedContent)
.build();
String url = "http://localhost:" + port + "/api/v1/posts/" + updateId;
//when
mvc.perform(put(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(new ObjectMapper().writeValueAsString(requestDto)))
.andExpect(status().isOk());
//then
List<Posts> all = postsRepository.findAll();
assertThat(all.get(0).getTitle()).isEqualTo(expectedTitle);
assertThat(all.get(0).getContent()).isEqualTo(expectedContent);
}
}
3) WebMvcTest에서 CustomOAuth2UserSErvice를 찾을 수 없음
helloControlleTest는 @WebMvcTest를 사용한다는 다른 점이 있씁니다.
WebMvcTest는 CustomOAuth2UserSErvice를 스캔하지 않기 때문에 문제가 발생했습니다. @WebMvcTest는 WebSecurityConfigureAdapter, WebMvcConfigrer를 비롯한 @ControllerAdvice, @Controller를 읽습니다.
즉, @Repository, @Service, @Component는 스탠되지 않습니다.
이 문제를 해결하기 위해 HelloControllerTest의 스캔대상에서 SecurityConfig를 제거합니다. 그리고 @WithMockUser를 추가합니다.
package com.ulbbang.book.firstproject.web;
import com.ulbbang.book.firstproject.config.auth.SecurityConfig;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@RunWith(SpringRunner.class)
@WebMvcTest(controllers = HelloController.class,
excludeFilters = {
@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class)
}
)
public class HelloControllerTest {
@Autowired
private MockMvc mvc;
@WithMockUser(roles="USER")
@Test
public void hello가_리턴된다() throws Exception {
String hello = "hello";
mvc.perform(get("/hello"))
.andExpect(status().isOk())
.andExpect(content().string(hello));
}
@WithMockUser(roles="USER")
@Test
public void helloDto가_리턴된다() throws Exception {
String name = "hello";
int amount = 1000;
mvc.perform(
get("/hello/dto")
.param("name", name)
.param("amount", String.valueOf(amount)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is(name)))
.andExpect(jsonPath("$.amount", is(amount)));
}
}
제거하고 나면 @EnableJpaAuditing에 의해 오류가 또 발생합니다 @Entity클래스가 필요하다는 오류인데 @EnableJpaAuditing과 @SpringBootApplication를 분리하여 해결할 수 있습니다.
main>도메인> Application.java에서 @EnableJpaAuditing를 삭제합니다.
package com.ulbbang.book.firstproject;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
public class Application {
public static void main(String[] args){
SpringApplication.run(Application.class, args);
}
}
도메인>config>Webconfig 파일에 다음을 추가하세요!
package com.ulbbang.book.firstproject.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
이제 게시판을 모두 완성했으니 AWS를 이용하여 나만의 서비스를 직접 배포하고 운영하는 과정을 진행하겠습니다.
이 포스트를 모두 읽은 당신에게
[JPA] 개념 정리(영속화, 영속성 컨텍스트)
여태 spring-boot-starter-data-jpa 가 JPA의 전부인 줄 알았지만 아니었다. Java에서 대표적인 ORM이 JPA이고(표준이 되었음) 그 구현체 Hibernate가 있는 것과 객체는 객체답게, RDB는 RDB답게 설계하면 ORM 프레
seungh1024.tistory.com