Spring 11: OAuth2 소셜 로그인
소셜 로그인 통합 구현
1. OAuth2 개념과 기본 원리
OAuth2란 무엇인가?
OAuth2(Open Authorization 2.0)는 인증(Authentication)과 인가(Authorization)를 위한 개방형 표준 프로토콜입니다. 사용자가 비밀번호를 직접 공유하지 않고도 제3자 애플리케이션이 자신의 리소스에 접근할 수 있도록 권한을 부여하는 메커니즘을 제공합니다.
핵심 개념
OAuth2는 "인증"이 아닌 "인가"에 초점을 맞춘 프로토콜입니다. 즉, "누구인지"보다는 "무엇을 할 수 있는지"에 대한 권한 부여가 주목적입니다.
OAuth2의 주요 구성 요소 (Roles)
Resource Owner (리소스 소유자)
보호된 리소스에 대한 접근 권한을 부여할 수 있는 엔티티. 일반적으로 최종 사용자를 의미합니다.
Client (클라이언트)
리소스 소유자를 대신하여 보호된 리소스에 접근을 요청하는 애플리케이션입니다.
Resource Server (리소스 서버)
보호된 리소스를 호스팅하고, 액세스 토큰을 사용하여 보호된 리소스 요청을 수락하고 응답하는 서버입니다.
Authorization Server (인증 서버)
리소스 소유자를 성공적으로 인증하고 권한을 얻은 후 클라이언트에게 액세스 토큰을 발급하는 서버입니다.
OAuth2 기본 플로우
+--------+ +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+OAuth2 Grant Types (권한 부여 유형)
1. Authorization Code Grant (권장)
가장 안전하고 널리 사용되는 방식으로, 서버 사이드 웹 애플리케이션에 적합합니다.
플로우:
- 사용자를 인증 서버로 리다이렉트
- 사용자가 로그인하고 권한 승인
- 인증 서버가 authorization code와 함께 클라이언트로 리다이렉트
- 클라이언트가 서버 사이드에서 authorization code를 access token으로 교환
2. Implicit Grant (비권장)
과거 SPA(Single Page Application)에서 사용되었으나, 보안상 이유로 현재는 권장되지 않습니다.
3. Resource Owner Password Credentials Grant
사용자의 아이디/패스워드를 직접 사용하는 방식으로, 높은 신뢰도가 필요한 클라이언트에서만 사용합니다.
4. Client Credentials Grant
클라이언트 자체의 자격 증명을 사용하는 방식으로, 서버 간 통신에 주로 사용됩니다.
Access Token과 Refresh Token
Access Token
- 목적: 보호된 리소스에 접근하기 위한 토큰
- 수명: 짧음 (보통 1시간 이내)
- 형태: JWT 또는 Opaque Token
- 사용: API 요청 시 Authorization 헤더에 포함
Refresh Token
- 목적: 새로운 Access Token을 발급받기 위한 토큰
- 수명: 김 (보통 몇 주에서 몇 달)
- 보안: 안전한 저장소에 보관 필요
- 사용: Access Token 만료 시 갱신 요청
grant_type=refresh_token&
refresh_token=...
OAuth2 주요 용어 정리
Scope (스코프)
클라이언트가 요청하는 권한의 범위를 정의합니다.
State Parameter
CSRF 공격을 방지하기 위한 랜덤 값입니다.
Redirect URI
인증 완료 후 사용자가 리다이렉트될 URI입니다.
Client ID/Secret
클라이언트를 식별하고 인증하기 위한 자격 증명입니다.
OAuth2 vs 기존 인증 방식
기존 방식의 문제점
- • 사용자 비밀번호를 제3자 앱에 직접 제공
- • 비밀번호 변경 시 모든 연동 앱에 영향
- • 세밀한 권한 제어 불가능
- • 보안 위험성 높음
OAuth2의 장점
- • 비밀번호 공유 없이 권한 부여
- • 토큰 기반의 제한된 접근
- • 스코프를 통한 세밀한 권한 제어
- • 토큰 만료 및 취소 가능
2. Spring OAuth2 Client - 소셜 로그인 구현
Spring OAuth2 Client 개요
Spring Security OAuth2 Client는 OAuth2 Authorization Code Grant를 사용하여 소셜 로그인(Google, Kakao, Naver 등)을 쉽게 구현할 수 있도록 지원하는 Spring Security의 모듈입니다.
주요 특징
- • Authorization Code Grant 플로우 자동 처리
- • 다양한 OAuth2 Provider 지원 (Google, GitHub, Facebook 등)
- • 커스텀 Provider 설정 가능
- • Spring Security와 완전 통합
프로젝트 설정 및 의존성
Maven 의존성 (pom.xml)
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Starter OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- Spring Boot Starter Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database (개발용) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Thymeleaf (선택사항) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>Gradle 의존성 (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}Google OAuth2 연동 설정
1. Google Cloud Console 설정
단계별 설정 과정:
- Google Cloud Console (console.cloud.google.com) 접속
- 새 프로젝트 생성 또는 기존 프로젝트 선택
- API 및 서비스 → 사용자 인증 정보 메뉴 이동
- OAuth 동의 화면 구성
- 사용자 인증 정보 → OAuth 2.0 클라이언트 ID 생성
- 애플리케이션 유형: 웹 애플리케이션 선택
- 승인된 리디렉션 URI 추가: http://localhost:8080/login/oauth2/code/google
중요 사항
리디렉션 URI는 정확히 일치해야 합니다. 개발 환경과 운영 환경의 URI를 모두 등록하세요.
2. application.yml 설정
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID:your-google-client-id}
client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret}
scope:
- openid
- profile
- email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: Google
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
token-uri: https://www.googleapis.com/oauth2/v4/token
user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
user-name-attribute: sub
# 데이터베이스 설정 (H2)
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
# 로깅 설정
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: DEBUGKakao OAuth2 연동 설정
1. Kakao Developers 설정
설정 과정:
- Kakao Developers (developers.kakao.com) 접속
- 애플리케이션 추가하기
- 앱 설정 → 플랫폼 → Web 플랫폼 등록
- 사이트 도메인: http://localhost:8080
- 제품 설정 → 카카오 로그인 → 활성화 설정
- Redirect URI: http://localhost:8080/login/oauth2/code/kakao
- 동의항목 설정 (닉네임, 프로필 사진, 카카오계정(이메일))
2. Kakao OAuth2 설정 추가
spring:
security:
oauth2:
client:
registration:
google:
# ... Google 설정 ...
kakao:
client-id: ${KAKAO_CLIENT_ID:your-kakao-client-id}
client-secret: ${KAKAO_CLIENT_SECRET:your-kakao-client-secret}
scope:
- profile_nickname
- profile_image
- account_email
client-name: Kakao
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-authentication-method: POST
provider:
google:
# ... Google Provider 설정 ...
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: idNaver OAuth2 연동 설정
1. 네이버 개발자센터 설정
설정 과정:
- 네이버 개발자센터 (developers.naver.com) 접속
- Application → 애플리케이션 등록
- 애플리케이션 이름 및 사용 API 선택 (네이버 로그인)
- 로그인 오픈 API 서비스 환경: PC 웹
- 서비스 URL: http://localhost:8080
- Callback URL: http://localhost:8080/login/oauth2/code/naver
- 제공 정보 선택 (이메일 주소, 닉네임, 프로필 사진)
2. Naver OAuth2 설정 추가
spring:
security:
oauth2:
client:
registration:
google:
# ... Google 설정 ...
kakao:
# ... Kakao 설정 ...
naver:
client-id: ${NAVER_CLIENT_ID:your-naver-client-id}
client-secret: ${NAVER_CLIENT_SECRET:your-naver-client-secret}
scope:
- name
- email
- profile_image
client-name: Naver
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
google:
# ... Google Provider 설정 ...
kakao:
# ... Kakao Provider 설정 ...
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
token-uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user-name-attribute: responseSpring Security 설정
SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.headers(headers -> headers.frameOptions().disable()) // H2 Console용
.authorizeHttpRequests(authz -> authz
.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
.requestMatchers("/login/**", "/oauth2/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService())
)
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
);
return http.build();
}
@Bean
public CustomOAuth2UserService customOAuth2UserService() {
return new CustomOAuth2UserService();
}
}로그인 페이지 구현
LoginController.java
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/")
public String home() {
return "index";
}
@GetMapping("/dashboard")
public String dashboard(Authentication authentication, Model model) {
if (authentication != null && authentication.isAuthenticated()) {
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
model.addAttribute("user", oauth2User);
model.addAttribute("name", oauth2User.getAttribute("name"));
model.addAttribute("email", oauth2User.getAttribute("email"));
}
return "dashboard";
}
}login.html (Thymeleaf)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>소셜 로그인</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="text-center">소셜 로그인</h3>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/oauth2/authorization/google"
class="btn btn-danger btn-lg">
<i class="fab fa-google"></i> Google로 로그인
</a>
<a href="/oauth2/authorization/kakao"
class="btn btn-warning btn-lg">
<i class="fas fa-comment"></i> Kakao로 로그인
</a>
<a href="/oauth2/authorization/naver"
class="btn btn-success btn-lg">
<i class="fas fa-n"></i> Naver로 로그인
</a>
</div>
<div th:if="${param.error}" class="alert alert-danger mt-3">
로그인에 실패했습니다. 다시 시도해주세요.
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>환경변수 설정 및 보안
.env 파일 생성
# .env 파일 GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret KAKAO_CLIENT_ID=your-kakao-client-id KAKAO_CLIENT_SECRET=your-kakao-client-secret NAVER_CLIENT_ID=your-naver-client-id NAVER_CLIENT_SECRET=your-naver-client-secret
보안 주의사항
- • .env 파일을 .gitignore에 추가하여 버전 관리에서 제외
- • 운영 환경에서는 환경변수나 외부 설정 서버 사용
- • Client Secret은 절대 클라이언트 사이드에 노출하지 않기
- • 정기적으로 Client Secret 갱신
3. OAuth2 사용자 정보 처리 및 관리
OAuth2User 인터페이스 이해
OAuth2User는 Spring Security에서 OAuth2 인증을 통해 얻은 사용자 정보를 나타내는 인터페이스입니다. 각 OAuth2 Provider(Google, Kakao, Naver)마다 다른 형태의 사용자 정보를 제공하므로, 이를 통합적으로 처리하는 방법을 알아보겠습니다.
OAuth2User 주요 메서드
- •
getAttributes(): 모든 사용자 속성을 Map으로 반환 - •
getAuthorities(): 사용자의 권한 정보 반환 - •
getName(): 사용자의 이름 또는 식별자 반환
Provider별 사용자 정보 구조
Google 사용자 정보
// Google OAuth2User 속성 예시
{
"sub": "123456789012345678901",
"name": "홍길동",
"given_name": "길동",
"family_name": "홍",
"picture": "https://lh3.googleusercontent.com/...",
"email": "hong@gmail.com",
"email_verified": true,
"locale": "ko"
}
// 접근 방법
String googleId = oauth2User.getAttribute("sub");
String name = oauth2User.getAttribute("name");
String email = oauth2User.getAttribute("email");
String picture = oauth2User.getAttribute("picture");Kakao 사용자 정보
// Kakao OAuth2User 속성 예시
{
"id": 1234567890,
"connected_at": "2023-01-01T00:00:00Z",
"properties": {
"nickname": "홍길동",
"profile_image": "http://k.kakaocdn.net/...",
"thumbnail_image": "http://k.kakaocdn.net/..."
},
"kakao_account": {
"profile_nickname_needs_agreement": false,
"profile_image_needs_agreement": false,
"profile": {
"nickname": "홍길동",
"thumbnail_image_url": "http://k.kakaocdn.net/...",
"profile_image_url": "http://k.kakaocdn.net/..."
},
"has_email": true,
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "hong@kakao.com"
}
}
// 접근 방법
Long kakaoId = oauth2User.getAttribute("id");
Map<String, Object> properties = oauth2User.getAttribute("properties");
String nickname = (String) properties.get("nickname");
Map<String, Object> kakaoAccount = oauth2User.getAttribute("kakao_account");
String email = (String) kakaoAccount.get("email");Naver 사용자 정보
// Naver OAuth2User 속성 예시
{
"resultcode": "00",
"message": "success",
"response": {
"id": "abcdefghijk",
"nickname": "홍길동",
"name": "홍길동",
"email": "hong@naver.com",
"gender": "M",
"age": "30-39",
"birthday": "01-01",
"profile_image": "https://ssl.pstatic.net/...",
"birthyear": "1990",
"mobile": "010-1234-5678"
}
}
// 접근 방법
Map<String, Object> response = oauth2User.getAttribute("response");
String naverId = (String) response.get("id");
String name = (String) response.get("name");
String email = (String) response.get("email");
String profileImage = (String) response.get("profile_image");사용자 엔티티 설계
User.java - JPA 엔티티
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column
private String picture;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SocialType socialType;
@Column(nullable = false)
private String socialId;
@CreationTimestamp
private LocalDateTime createdDate;
@UpdateTimestamp
private LocalDateTime modifiedDate;
@Builder
public User(String name, String email, String picture, Role role,
SocialType socialType, String socialId) {
this.name = name;
this.email = email;
this.picture = picture;
this.role = role;
this.socialType = socialType;
this.socialId = socialId;
}
public User update(String name, String picture) {
this.name = name;
this.picture = picture;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자"),
ADMIN("ROLE_ADMIN", "관리자");
private final String key;
private final String title;
}
@Getter
@RequiredArgsConstructor
public enum SocialType {
GOOGLE("google"),
KAKAO("kakao"),
NAVER("naver");
private final String registrationId;
}사용자 Repository
UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findBySocialTypeAndSocialId(SocialType socialType, String socialId);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE u.socialType = :socialType AND u.socialId = :socialId")
Optional<User> findBySocialInfo(@Param("socialType") SocialType socialType,
@Param("socialId") String socialId);
@Modifying
@Query("UPDATE User u SET u.name = :name, u.picture = :picture WHERE u.id = :id")
void updateUserInfo(@Param("id") Long id, @Param("name") String name,
@Param("picture") String picture);
}CustomOAuth2UserService 구현
CustomOAuth2UserService.java
@Service
@RequiredArgsConstructor
@Transactional
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oauth2User = delegate.loadUser(userRequest);
// 현재 로그인 진행 중인 서비스를 구분하는 코드
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2 로그인 진행 시 키가 되는 필드값 (Primary Key와 같은 의미)
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
// OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oauth2User.getAttributes());
User user = saveOrUpdate(attributes);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(),
attributes.getNameAttributeKey()
);
}
private User saveOrUpdate(OAuthAttributes attributes) {
User user = userRepository.findBySocialTypeAndSocialId(
attributes.getSocialType(),
attributes.getSocialId())
.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
.orElse(attributes.toEntity());
return userRepository.save(user);
}
}OAuthAttributes DTO
OAuthAttributes.java
@Getter
@Builder
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String picture;
private SocialType socialType;
private String socialId;
public static OAuthAttributes of(String registrationId, String userNameAttributeName,
Map<String, Object> attributes) {
switch (registrationId) {
case "google":
return ofGoogle(userNameAttributeName, attributes);
case "kakao":
return ofKakao(userNameAttributeName, attributes);
case "naver":
return ofNaver(userNameAttributeName, attributes);
default:
throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다: " + registrationId);
}
}
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"))
.socialType(SocialType.GOOGLE)
.socialId((String) attributes.get("sub"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
private static OAuthAttributes ofKakao(String userNameAttributeName,
Map<String, Object> attributes) {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return OAuthAttributes.builder()
.name((String) properties.get("nickname"))
.email((String) kakaoAccount.get("email"))
.picture((String) properties.get("profile_image"))
.socialType(SocialType.KAKAO)
.socialId(String.valueOf(attributes.get("id")))
.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"))
.socialType(SocialType.NAVER)
.socialId((String) response.get("id"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.picture(picture)
.socialType(socialType)
.socialId(socialId)
.role(Role.USER)
.build();
}
}세션 사용자 정보 관리
SessionUser.java - 세션 저장용 DTO
@Getter
@NoArgsConstructor
public class SessionUser implements Serializable {
private String name;
private String email;
private String picture;
private String socialType;
public SessionUser(User user) {
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
this.socialType = user.getSocialType().name();
}
}사용자 정보 활용 예시
@Controller
@RequiredArgsConstructor
public class UserController {
private final UserRepository userRepository;
@GetMapping("/user/profile")
public String userProfile(Authentication authentication, Model model) {
if (authentication != null && authentication.isAuthenticated()) {
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
// 사용자 정보 추출
String email = oauth2User.getAttribute("email");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
model.addAttribute("user", user);
model.addAttribute("sessionUser", new SessionUser(user));
}
return "user/profile";
}
@PostMapping("/user/update")
@ResponseBody
public ResponseEntity<String> updateUser(@RequestBody UserUpdateDto updateDto,
Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("인증이 필요합니다.");
}
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
String email = oauth2User.getAttribute("email");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
user.update(updateDto.getName(), updateDto.getPicture());
userRepository.save(user);
return ResponseEntity.ok("사용자 정보가 업데이트되었습니다.");
}
}
@Getter
@Setter
public class UserUpdateDto {
private String name;
private String picture;
}사용자 정보 보안 고려사항
데이터 보호
- • 민감한 정보는 암호화하여 저장
- • 불필요한 개인정보는 수집하지 않기
- • 정기적인 데이터 정리 및 삭제
- • GDPR, 개인정보보호법 준수
세션 관리
- • 세션 타임아웃 설정
- • 안전한 세션 저장소 사용
- • 로그아웃 시 세션 완전 삭제
- • CSRF 토큰 활용
중요 참고사항
OAuth2를 통해 받은 사용자 정보는 해당 Provider의 이용약관과 개인정보처리방침을 준수해야 합니다. 또한 사용자에게 어떤 정보를 수집하고 어떻게 사용하는지 명확히 고지해야 합니다.
4. JWT와 OAuth2 통합 - 소셜 로그인 후 JWT 발급
JWT와 OAuth2 통합의 필요성
OAuth2 소셜 로그인 후 JWT(JSON Web Token)를 발급하여 API 인증에 활용하는 방식은 현대적인 웹 애플리케이션에서 매우 일반적입니다. 이를 통해 Stateless 인증과마이크로서비스 아키텍처에 적합한 인증 시스템을 구축할 수 있습니다.
통합 아키텍처의 장점
- • OAuth2로 안전한 소셜 로그인 구현
- • JWT로 Stateless API 인증 제공
- • 프론트엔드와 백엔드 분리 가능
- • 마이크로서비스 간 인증 토큰 공유
JWT 의존성 및 기본 설정
JWT 의존성 추가 (pom.xml)
<dependencies>
<!-- 기존 OAuth2 의존성들... -->
<!-- JWT 라이브러리 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>JWT 설정 (application.yml)
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationThatShouldBeLongEnough}
expiration: 86400000 # 24시간 (밀리초)
refresh-expiration: 604800000 # 7일 (밀리초)
spring:
security:
oauth2:
client:
registration:
google:
# ... Google 설정 ...
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
kakao:
# ... Kakao 설정 ...
naver:
# ... Naver 설정 ...JWT 유틸리티 클래스 구현
JwtTokenProvider.java
@Component
@Slf4j
public class JwtTokenProvider {
private final Key key;
private final long tokenValidityInMilliseconds;
private final long refreshTokenValidityInMilliseconds;
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long tokenValidity,
@Value("${jwt.refresh-expiration}") long refreshTokenValidity) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.tokenValidityInMilliseconds = tokenValidity;
this.refreshTokenValidityInMilliseconds = refreshTokenValidity;
}
// Access Token 생성
public String createAccessToken(String email, String name, String role, String socialType) {
Date now = new Date();
Date validity = new Date(now.getTime() + tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(email)
.claim("name", name)
.claim("role", role)
.claim("socialType", socialType)
.claim("tokenType", "ACCESS")
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
// Refresh Token 생성
public String createRefreshToken(String email) {
Date now = new Date();
Date validity = new Date(now.getTime() + refreshTokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(email)
.claim("tokenType", "REFRESH")
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
// 토큰에서 사용자 정보 추출
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
if (claims.get("role") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("role").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserPrincipal principal = UserPrincipal.builder()
.email(claims.getSubject())
.name(claims.get("name", String.class))
.role(claims.get("role", String.class))
.socialType(claims.get("socialType", String.class))
.build();
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 유효성 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
// 토큰에서 Claims 추출
private Claims parseClaims(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
// 토큰에서 이메일 추출
public String getEmailFromToken(String token) {
return parseClaims(token).getSubject();
}
// 토큰 만료 시간 확인
public Long getExpiration(String token) {
Date expiration = parseClaims(token).getExpiration();
return expiration.getTime() - System.currentTimeMillis();
}
}
@Getter
@Builder
public class UserPrincipal {
private String email;
private String name;
private String role;
private String socialType;
}JWT 인증 필터
JwtAuthenticationFilter.java
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다.", authentication.getName());
} else {
log.debug("유효한 JWT 토큰이 없습니다.");
}
filterChain.doFilter(request, response);
}
// Request Header에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}OAuth2 로그인 성공 후 JWT 발급
OAuth2AuthenticationSuccessHandler.java
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
@Value("${app.oauth2.authorized-redirect-uris}")
private List<String> authorizedRedirectUris;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
// 사용자 정보 조회
String email = oauth2User.getAttribute("email");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + email));
// JWT 토큰 생성
String accessToken = jwtTokenProvider.createAccessToken(
user.getEmail(),
user.getName(),
user.getRoleKey(),
user.getSocialType().name()
);
String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail());
// Refresh Token을 데이터베이스에 저장 (선택사항)
// refreshTokenService.saveRefreshToken(user.getEmail(), refreshToken);
// 응답 설정
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// 프론트엔드로 리다이렉트하면서 토큰 전달
String targetUrl = determineTargetUrl(request, response, authentication, accessToken, refreshToken);
if (response.isCommitted()) {
log.debug("Response has already been committed. Unable to redirect to " + targetUrl);
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication,
String accessToken,
String refreshToken) {
Optional<String> redirectUri = CookieUtils.getCookie(request, "redirect_uri")
.map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("승인되지 않은 리다이렉트 URI입니다.");
}
String targetUrl = redirectUri.orElse("http://localhost:3000/oauth2/redirect");
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token", accessToken)
.queryParam("refreshToken", refreshToken)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request,
HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
CookieUtils.deleteCookie(request, response, "redirect_uri");
}
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
return authorizedRedirectUris
.stream()
.anyMatch(authorizedRedirectUri -> {
URI authorizedURI = URI.create(authorizedRedirectUri);
return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedURI.getPort() == clientRedirectUri.getPort();
});
}
private RedirectStrategy getRedirectStrategy() {
return new DefaultRedirectStrategy();
}
}통합 Security 설정
SecurityConfig.java (JWT + OAuth2)
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/", "/error", "/favicon.ico", "/**/*.png", "/**/*.gif",
"/**/*.svg", "/**/*.jpg", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
.requestMatchers("/auth/**", "/oauth2/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/comments/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authorization -> authorization
.baseUri("/oauth2/authorize")
.authorizationRequestRepository(cookieAuthorizationRequestRepository())
)
.redirectionEndpoint(redirection -> redirection
.baseUri("/oauth2/callback/*")
)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler)
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository() {
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("HEAD", "POST", "GET", "DELETE", "PUT", "PATCH"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}토큰 갱신 API
AuthController.java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody TokenRefreshRequest request) {
String refreshToken = request.getRefreshToken();
if (!jwtTokenProvider.validateToken(refreshToken)) {
return ResponseEntity.badRequest()
.body(new ApiResponse(false, "Refresh Token이 유효하지 않습니다."));
}
String email = jwtTokenProvider.getEmailFromToken(refreshToken);
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
String newAccessToken = jwtTokenProvider.createAccessToken(
user.getEmail(),
user.getName(),
user.getRoleKey(),
user.getSocialType().name()
);
return ResponseEntity.ok(new JwtAuthenticationResponse(newAccessToken, refreshToken));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody TokenRefreshRequest request) {
// Refresh Token을 블랙리스트에 추가하거나 데이터베이스에서 삭제
// refreshTokenService.deleteByToken(request.getRefreshToken());
return ResponseEntity.ok(new ApiResponse(true, "로그아웃되었습니다."));
}
@GetMapping("/user")
public ResponseEntity<?> getCurrentUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse(false, "인증이 필요합니다."));
}
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
User user = userRepository.findByEmail(userPrincipal.getEmail())
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
return ResponseEntity.ok(new UserSummary(user));
}
}
@Getter
@Setter
public class TokenRefreshRequest {
private String refreshToken;
}
@Getter
@AllArgsConstructor
public class JwtAuthenticationResponse {
private String accessToken;
private String refreshToken;
private String tokenType = "Bearer";
public JwtAuthenticationResponse(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
@Getter
@AllArgsConstructor
public class ApiResponse {
private Boolean success;
private String message;
}
@Getter
public class UserSummary {
private Long id;
private String name;
private String email;
private String picture;
private String socialType;
public UserSummary(User user) {
this.id = user.getId();
this.name = user.getName();
this.email = user.getEmail();
this.picture = user.getPicture();
this.socialType = user.getSocialType().name();
}
}프론트엔드 연동 예시 (JavaScript)
// OAuth2 로그인 처리
function handleOAuth2Login(provider) {
window.location.href = `http://localhost:8080/oauth2/authorize/${provider}?redirect_uri=${encodeURIComponent('http://localhost:3000/oauth2/redirect')}`;
}
// OAuth2 리다이렉트 처리
function handleOAuth2Redirect() {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const refreshToken = urlParams.get('refreshToken');
if (token) {
localStorage.setItem('accessToken', token);
localStorage.setItem('refreshToken', refreshToken);
// 사용자 정보 조회
fetchUserInfo();
// 메인 페이지로 리다이렉트
window.location.href = '/dashboard';
}
}
// API 요청 시 토큰 포함
async function apiRequest(url, options = {}) {
const token = localStorage.getItem('accessToken');
const config = {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
};
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
let response = await fetch(url, config);
// 토큰 만료 시 갱신 시도
if (response.status === 401) {
const newToken = await refreshAccessToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
response = await fetch(url, config);
}
}
return response;
}
// 토큰 갱신
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
redirectToLogin();
return null;
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
return data.accessToken;
} else {
redirectToLogin();
return null;
}
} catch (error) {
console.error('Token refresh failed:', error);
redirectToLogin();
return null;
}
}
function redirectToLogin() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}5. OAuth2 Resource Server - JWT 검증 및 리소스 보호
OAuth2 Resource Server 개념
OAuth2 Resource Server는 보호된 리소스를 호스팅하고, 클라이언트로부터 받은 Access Token을 검증하여 API 접근을 제어하는 서버입니다. JWT 기반의 Resource Server를 구축하여 Stateless한 API 인증을 구현할 수 있습니다.
Resource Server의 역할
- • JWT Access Token 검증
- • 사용자 권한 확인 및 인가
- • 보호된 API 엔드포인트 제공
- • 토큰 기반 Stateless 인증
Resource Server 의존성 설정
Maven 의존성 (pom.xml)
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Starter OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JWT 라이브러리 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>Resource Server 설정 (application.yml)
spring:
security:
oauth2:
resourceserver:
jwt:
# JWT 검증을 위한 설정
issuer-uri: http://localhost:8080 # Authorization Server URI
# 또는 직접 JWK Set URI 지정
# jwk-set-uri: http://localhost:8080/.well-known/jwks.json
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationThatShouldBeLongEnough}
# 서버 포트 (Resource Server)
server:
port: 8081
# 로깅 설정
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: DEBUGJWT Decoder 커스텀 설정
JwtConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class JwtConfig {
@Value("${jwt.secret}")
private String jwtSecret;
@Bean
public JwtDecoder jwtDecoder() {
// HMAC 기반 JWT Decoder 설정
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "HmacSHA512");
return NimbusJwtDecoder.withSecretKey(secretKey)
.macAlgorithm(MacAlgorithm.HS512)
.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
// JWT의 권한 클레임 설정
authoritiesConverter.setAuthorityPrefix(""); // 기본 "SCOPE_" 접두사 제거
authoritiesConverter.setAuthoritiesClaimName("role"); // 권한 클레임명 설정
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
// Principal 이름 설정 (기본적으로 'sub' 클레임 사용)
converter.setPrincipalClaimName("sub");
return converter;
}
@Bean
public Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
return new CustomJwtAuthenticationConverter();
}
}
@Component
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
// 커스텀 Principal 생성
JwtUserPrincipal principal = JwtUserPrincipal.builder()
.email(jwt.getSubject())
.name(jwt.getClaimAsString("name"))
.role(jwt.getClaimAsString("role"))
.socialType(jwt.getClaimAsString("socialType"))
.build();
return new JwtAuthenticationToken(jwt, authorities, principal);
}
private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
String role = jwt.getClaimAsString("role");
if (role != null) {
return Arrays.stream(role.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
return Collections.emptyList();
}
}
@Getter
@Builder
public class JwtUserPrincipal {
private String email;
private String name;
private String role;
private String socialType;
}Resource Server Security 설정
ResourceServerConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // 메서드 레벨 보안 활성화
@RequiredArgsConstructor
public class ResourceServerConfig {
private final JwtDecoder jwtDecoder;
private final Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter;
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
// 공개 엔드포인트
.requestMatchers("/", "/health", "/actuator/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// 인증이 필요한 엔드포인트
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// 나머지 모든 요청은 인증 필요
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder)
.jwtAuthenticationConverter(jwtAuthenticationConverter)
)
.authenticationEntryPoint(customAuthenticationEntryPoint())
.accessDeniedHandler(customAccessDeniedHandler())
);
return http.build();
}
@Bean
public AuthenticationEntryPoint customAuthenticationEntryPoint() {
return (request, response, authException) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getOutputStream().println(
"{ "error": "Unauthorized", "message": "" + authException.getMessage() + "" }"
);
};
}
@Bean
public AccessDeniedHandler customAccessDeniedHandler() {
return (request, response, accessDeniedException) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getOutputStream().println(
"{ "error": "Access Denied", "message": "" + accessDeniedException.getMessage() + "" }"
);
};
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("HEAD", "POST", "GET", "DELETE", "PUT", "PATCH"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}보호된 API 컨트롤러 구현
UserApiController.java
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
@Slf4j
public class UserApiController {
private final UserService userService;
@GetMapping("/profile")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<UserProfileResponse> getUserProfile(JwtAuthenticationToken authentication) {
JwtUserPrincipal principal = (JwtUserPrincipal) authentication.getPrincipal();
log.info("사용자 프로필 조회 요청: {}", principal.getEmail());
UserProfileResponse profile = userService.getUserProfile(principal.getEmail());
return ResponseEntity.ok(profile);
}
@PutMapping("/profile")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ApiResponse> updateUserProfile(
@RequestBody @Valid UserUpdateRequest request,
JwtAuthenticationToken authentication) {
JwtUserPrincipal principal = (JwtUserPrincipal) authentication.getPrincipal();
log.info("사용자 프로필 업데이트 요청: {}", principal.getEmail());
userService.updateUserProfile(principal.getEmail(), request);
return ResponseEntity.ok(new ApiResponse(true, "프로필이 업데이트되었습니다."));
}
@GetMapping("/posts")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<List<PostResponse>> getUserPosts(
JwtAuthenticationToken authentication,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
JwtUserPrincipal principal = (JwtUserPrincipal) authentication.getPrincipal();
List<PostResponse> posts = userService.getUserPosts(principal.getEmail(), page, size);
return ResponseEntity.ok(posts);
}
@DeleteMapping("/account")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ApiResponse> deleteAccount(JwtAuthenticationToken authentication) {
JwtUserPrincipal principal = (JwtUserPrincipal) authentication.getPrincipal();
log.warn("계정 삭제 요청: {}", principal.getEmail());
userService.deleteUser(principal.getEmail());
return ResponseEntity.ok(new ApiResponse(true, "계정이 삭제되었습니다."));
}
}
@Getter
@Setter
@Valid
public class UserUpdateRequest {
@NotBlank(message = "이름은 필수입니다.")
@Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다.")
private String name;
@URL(message = "올바른 URL 형식이어야 합니다.")
private String picture;
}
@Getter
@AllArgsConstructor
public class UserProfileResponse {
private Long id;
private String name;
private String email;
private String picture;
private String socialType;
private String role;
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;
}AdminApiController.java
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@Slf4j
public class AdminApiController {
private final UserService userService;
private final AdminService adminService;
@GetMapping("/users")
public ResponseEntity<Page<UserSummaryResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdDate") String sortBy,
@RequestParam(defaultValue = "desc") String sortDir) {
Page<UserSummaryResponse> users = adminService.getAllUsers(page, size, sortBy, sortDir);
return ResponseEntity.ok(users);
}
@GetMapping("/users/{userId}")
public ResponseEntity<UserDetailResponse> getUserDetail(@PathVariable Long userId) {
UserDetailResponse user = adminService.getUserDetail(userId);
return ResponseEntity.ok(user);
}
@PutMapping("/users/{userId}/role")
public ResponseEntity<ApiResponse> updateUserRole(
@PathVariable Long userId,
@RequestBody @Valid RoleUpdateRequest request,
JwtAuthenticationToken authentication) {
JwtUserPrincipal principal = (JwtUserPrincipal) authentication.getPrincipal();
log.info("사용자 권한 변경 요청 - 관리자: {}, 대상 사용자 ID: {}, 새 권한: {}",
principal.getEmail(), userId, request.getRole());
adminService.updateUserRole(userId, request.getRole());
return ResponseEntity.ok(new ApiResponse(true, "사용자 권한이 변경되었습니다."));
}
@DeleteMapping("/users/{userId}")
public ResponseEntity<ApiResponse> deleteUser(
@PathVariable Long userId,
JwtAuthenticationToken authentication) {
JwtUserPrincipal principal = (JwtUserPrincipal) authentication.getPrincipal();
log.warn("사용자 삭제 요청 - 관리자: {}, 대상 사용자 ID: {}", principal.getEmail(), userId);
adminService.deleteUser(userId);
return ResponseEntity.ok(new ApiResponse(true, "사용자가 삭제되었습니다."));
}
@GetMapping("/statistics")
public ResponseEntity<AdminStatisticsResponse> getStatistics() {
AdminStatisticsResponse statistics = adminService.getStatistics();
return ResponseEntity.ok(statistics);
}
}
@Getter
@Setter
public class RoleUpdateRequest {
@NotNull(message = "권한은 필수입니다.")
private Role role;
}메서드 레벨 보안 활용
PostService.java - 메서드 레벨 보안 예시
@Service
@RequiredArgsConstructor
@Transactional
public class PostService {
private final PostRepository postRepository;
private final UserRepository userRepository;
@PreAuthorize("hasRole('USER')")
public PostResponse createPost(String userEmail, PostCreateRequest request) {
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
Post post = Post.builder()
.title(request.getTitle())
.content(request.getContent())
.author(user)
.build();
Post savedPost = postRepository.save(post);
return new PostResponse(savedPost);
}
@PreAuthorize("hasRole('USER') and @postService.isPostOwner(#postId, authentication.principal.email)")
public PostResponse updatePost(Long postId, PostUpdateRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));
post.update(request.getTitle(), request.getContent());
return new PostResponse(post);
}
@PreAuthorize("hasRole('ADMIN') or @postService.isPostOwner(#postId, authentication.principal.email)")
public void deletePost(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));
postRepository.delete(post);
}
// 게시글 소유자 확인 메서드
public boolean isPostOwner(Long postId, String userEmail) {
return postRepository.findById(postId)
.map(post -> post.getAuthor().getEmail().equals(userEmail))
.orElse(false);
}
@PostAuthorize("hasRole('ADMIN') or returnObject.authorEmail == authentication.principal.email")
public PostDetailResponse getPostDetail(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));
return new PostDetailResponse(post);
}
}JWT 토큰 검증 및 예외 처리
GlobalExceptionHandler.java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(JwtException.class)
public ResponseEntity<ErrorResponse> handleJwtException(JwtException ex) {
log.error("JWT 예외 발생: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.error("JWT_ERROR")
.message("토큰이 유효하지 않습니다.")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException ex) {
log.error("접근 권한 없음: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.error("ACCESS_DENIED")
.message("접근 권한이 없습니다.")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException ex) {
log.error("인증 실패: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.error("AUTHENTICATION_FAILED")
.message("인증에 실패했습니다.")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
ErrorResponse errorResponse = ErrorResponse.builder()
.error("VALIDATION_ERROR")
.message(message)
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
}
@Getter
@Builder
public class ErrorResponse {
private String error;
private String message;
private LocalDateTime timestamp;
}API 테스트 예시
cURL을 이용한 API 테스트
# 1. 토큰 없이 보호된 API 호출 (401 Unauthorized)
curl -X GET http://localhost:8081/api/user/profile
# 2. 유효한 토큰으로 API 호출
curl -X GET http://localhost:8081/api/user/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9..."
# 3. 사용자 프로필 업데이트
curl -X PUT http://localhost:8081/api/user/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9..." \
-H "Content-Type: application/json" \
-d '{"name": "홍길동", "picture": "https://example.com/profile.jpg"}'
# 4. 관리자 권한이 필요한 API 호출
curl -X GET http://localhost:8081/api/admin/users \
-H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9..."테스트 시 확인사항
- • JWT 토큰의 만료 시간 확인
- • 권한별 API 접근 제어 테스트
- • 잘못된 토큰에 대한 에러 응답 확인
- • CORS 설정 동작 확인
6. 실전 구현 - 완전한 소셜 로그인 시스템
완전한 소셜 로그인 시스템 구축
지금까지 학습한 내용을 바탕으로 실제 운영 환경에서 사용할 수 있는완전한 소셜 로그인 시스템을 구현해보겠습니다. 에러 처리, 보안, 테스트, 모니터링까지 포함한 Production-Ready 시스템을 만들어보겠습니다.
구현할 주요 기능
- • 다중 Provider 소셜 로그인 (Google, Kakao, Naver)
- • JWT 기반 Stateless 인증
- • 토큰 갱신 및 로그아웃
- • 사용자 정보 관리 및 권한 제어
- • 포괄적인 에러 처리 및 보안
- • 통합 테스트 및 모니터링
전체 시스템 아키텍처
시스템 구성도
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Authorization │ │ Resource │
│ (React/Vue) │ │ Server │ │ Server │
│ │ │ (Spring Boot) │ │ (Spring Boot) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ 1. OAuth2 Login │ │
│──────────────────────▶│ │
│ │ │
│ 2. JWT Tokens │ │
│◀──────────────────────│ │
│ │ │
│ 3. API Calls with JWT │ │
│───────────────────────────────────────────────▶│
│ │ │
│ 4. Protected Resources│ │
│◀───────────────────────────────────────────────│
│ │ │
│ 5. Token Refresh │ │
│──────────────────────▶│ │
│ │ │
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Google │ │ Kakao │ │ Naver │
│ OAuth Server │ │ OAuth Server │ │ OAuth Server │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
OAuth2 Provider IntegrationAuthorization Server
- • OAuth2 소셜 로그인 처리
- • JWT Access/Refresh Token 발급
- • 사용자 정보 저장 및 관리
- • 토큰 갱신 및 검증
Resource Server
- • JWT 토큰 검증
- • 보호된 API 제공
- • 권한 기반 접근 제어
- • 비즈니스 로직 처리
프로젝트 구조
디렉토리 구조
src/
├── main/
│ ├── java/
│ │ └── com/example/oauth2/
│ │ ├── OAuth2Application.java
│ │ ├── config/
│ │ │ ├── SecurityConfig.java
│ │ │ ├── JwtConfig.java
│ │ │ └── CorsConfig.java
│ │ ├── controller/
│ │ │ ├── AuthController.java
│ │ │ ├── UserController.java
│ │ │ └── AdminController.java
│ │ ├── service/
│ │ │ ├── CustomOAuth2UserService.java
│ │ │ ├── UserService.java
│ │ │ └── JwtTokenService.java
│ │ ├── security/
│ │ │ ├── JwtTokenProvider.java
│ │ │ ├── JwtAuthenticationFilter.java
│ │ │ ├── OAuth2AuthenticationSuccessHandler.java
│ │ │ └── OAuth2AuthenticationFailureHandler.java
│ │ ├── domain/
│ │ │ ├── user/
│ │ │ │ ├── User.java
│ │ │ │ ├── Role.java
│ │ │ │ ├── SocialType.java
│ │ │ │ └── UserRepository.java
│ │ │ └── token/
│ │ │ ├── RefreshToken.java
│ │ │ └── RefreshTokenRepository.java
│ │ ├── dto/
│ │ │ ├── auth/
│ │ │ ├── user/
│ │ │ └── common/
│ │ ├── exception/
│ │ │ ├── GlobalExceptionHandler.java
│ │ │ ├── OAuth2AuthenticationProcessingException.java
│ │ │ └── TokenValidationException.java
│ │ └── util/
│ │ ├── CookieUtils.java
│ │ └── OAuthAttributes.java
│ └── resources/
│ ├── application.yml
│ ├── application-dev.yml
│ ├── application-prod.yml
│ └── static/
└── test/
└── java/
└── com/example/oauth2/
├── integration/
├── security/
└── service/7. OAuth2 보안 고려사항 및 실전 가이드
OAuth2 보안의 중요성
OAuth2와 JWT를 사용한 인증 시스템은 편리하지만, 보안 취약점이 존재할 수 있습니다. 실제 운영 환경에서는 다양한 보안 위협을 고려하고 적절한 대응책을 마련해야 합니다. 이 섹션에서는 실전에서 반드시 고려해야 할 보안 사항들을 다룹니다.
주요 보안 위협
- • CSRF (Cross-Site Request Forgery) 공격
- • XSS (Cross-Site Scripting) 공격
- • JWT 토큰 탈취 및 재사용
- • Authorization Code 가로채기
- • Redirect URI 조작
CSRF 공격 방어
State Parameter 활용
OAuth2 인증 요청 시 state parameter를 사용하여 CSRF 공격을 방지합니다.
@Component
@RequiredArgsConstructor
public class HttpCookieOAuth2AuthorizationRequestRepository
implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int COOKIE_EXPIRE_SECONDS = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequestCookies(request, response);
return;
}
// State parameter 검증을 위한 쿠키 저장
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,
CookieUtils.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME,
redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request,
HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}CSRF 토큰 검증
@Component
public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler {
private final HttpCookieOAuth2AuthorizationRequestRepository
httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse("/");
// State parameter 불일치 등의 보안 오류 처리
if (exception instanceof OAuth2AuthenticationException) {
OAuth2AuthenticationException oauth2Exception = (OAuth2AuthenticationException) exception;
if ("invalid_state".equals(oauth2Exception.getError().getErrorCode())) {
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", "csrf_detected")
.build().toUriString();
}
}
httpCookieOAuth2AuthorizationRequestRepository
.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}JWT 토큰 보안 강화
안전한 토큰 저장
❌ 위험한 저장 방식
- • localStorage에 JWT 저장
- • sessionStorage에 민감 정보 저장
- • URL 파라미터로 토큰 전달
- • 일반 쿠키에 토큰 저장
✅ 안전한 저장 방식
- • HttpOnly, Secure 쿠키
- • SameSite 속성 설정
- • 메모리 내 임시 저장
- • 서버 사이드 세션 (선택적)
@Component
public class SecureCookieManager {
private static final String ACCESS_TOKEN_COOKIE = "access_token";
private static final String REFRESH_TOKEN_COOKIE = "refresh_token";
private static final int ACCESS_TOKEN_EXPIRE_SECONDS = 900; // 15분
private static final int REFRESH_TOKEN_EXPIRE_SECONDS = 604800; // 7일
public void setTokenCookies(HttpServletResponse response,
String accessToken,
String refreshToken) {
// Access Token 쿠키 설정
ResponseCookie accessTokenCookie = ResponseCookie.from(ACCESS_TOKEN_COOKIE, accessToken)
.httpOnly(true)
.secure(true) // HTTPS에서만 전송
.sameSite("Strict") // CSRF 방지
.path("/")
.maxAge(ACCESS_TOKEN_EXPIRE_SECONDS)
.build();
// Refresh Token 쿠키 설정
ResponseCookie refreshTokenCookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE, refreshToken)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/auth") // 특정 경로에서만 접근 가능
.maxAge(REFRESH_TOKEN_EXPIRE_SECONDS)
.build();
response.addHeader("Set-Cookie", accessTokenCookie.toString());
response.addHeader("Set-Cookie", refreshTokenCookie.toString());
}
public void clearTokenCookies(HttpServletResponse response) {
ResponseCookie accessTokenCookie = ResponseCookie.from(ACCESS_TOKEN_COOKIE, "")
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/")
.maxAge(0)
.build();
ResponseCookie refreshTokenCookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE, "")
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/auth")
.maxAge(0)
.build();
response.addHeader("Set-Cookie", accessTokenCookie.toString());
response.addHeader("Set-Cookie", refreshTokenCookie.toString());
}
}토큰 블랙리스트 관리
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
private final JwtTokenProvider jwtTokenProvider;
private static final String BLACKLIST_PREFIX = "blacklist:";
public void blacklistToken(String token) {
String tokenId = extractTokenId(token);
Long expiration = jwtTokenProvider.getExpiration(token);
if (expiration > 0) {
redisTemplate.opsForValue().set(
BLACKLIST_PREFIX + tokenId,
"blacklisted",
expiration,
TimeUnit.MILLISECONDS
);
}
}
public boolean isTokenBlacklisted(String token) {
String tokenId = extractTokenId(token);
return redisTemplate.hasKey(BLACKLIST_PREFIX + tokenId);
}
private String extractTokenId(String token) {
// JWT의 jti 클레임 또는 토큰 해시값 사용
Claims claims = jwtTokenProvider.parseClaims(token);
return claims.getId() != null ? claims.getId() :
DigestUtils.sha256Hex(token).substring(0, 16);
}
@EventListener
public void handleLogoutEvent(UserLogoutEvent event) {
blacklistToken(event.getAccessToken());
if (event.getRefreshToken() != null) {
blacklistToken(event.getRefreshToken());
}
}
}운영 환경 보안 설정
application-prod.yml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
redirect-uri: "https://yourdomain.com/login/oauth2/code/{registrationId}"
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: "https://yourdomain.com/login/oauth2/code/{registrationId}"
naver:
client-id: ${NAVER_CLIENT_ID}
client-secret: ${NAVER_CLIENT_SECRET}
redirect-uri: "https://yourdomain.com/login/oauth2/code/{registrationId}"
jwt:
secret: ${JWT_SECRET} # 256비트 이상의 강력한 시크릿
expiration: 900000 # 15분 (짧게 설정)
refresh-expiration: 604800000 # 7일
# HTTPS 강제 설정
server:
ssl:
enabled: true
key-store: ${SSL_KEYSTORE_PATH}
key-store-password: ${SSL_KEYSTORE_PASSWORD}
key-store-type: PKCS12
port: 443
# 보안 헤더 설정
security:
headers:
frame-options: DENY
content-type-options: nosniff
xss-protection: "1; mode=block"
referrer-policy: strict-origin-when-cross-origin
# CORS 설정 (운영 환경)
app:
cors:
allowed-origins:
- https://yourdomain.com
- https://www.yourdomain.com
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: "*"
allow-credentials: true
max-age: 3600
# OAuth2 리다이렉트 URI 화이트리스트
oauth2:
authorized-redirect-uris:
- https://yourdomain.com/oauth2/redirect
- https://yourdomain.com/login
# 로깅 설정 (운영 환경)
logging:
level:
org.springframework.security: WARN
org.springframework.security.oauth2: INFO
com.example.oauth2.security: INFO
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file:
name: /var/log/oauth2-app.log보안 모니터링 및 감사
SecurityAuditService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class SecurityAuditService {
private final AuditLogRepository auditLogRepository;
private final MeterRegistry meterRegistry;
@EventListener
@Async
public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
String ipAddress = getClientIpAddress();
log.info("로그인 성공 - 사용자: {}, IP: {}", username, ipAddress);
// 메트릭 수집
meterRegistry.counter("auth.login.success",
"user", username,
"ip", ipAddress).increment();
// 감사 로그 저장
saveAuditLog("LOGIN_SUCCESS", username, ipAddress, null);
}
@EventListener
@Async
public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
String username = event.getAuthentication().getName();
String ipAddress = getClientIpAddress();
String reason = event.getException().getMessage();
log.warn("로그인 실패 - 사용자: {}, IP: {}, 이유: {}", username, ipAddress, reason);
// 실패 메트릭 수집
meterRegistry.counter("auth.login.failure",
"user", username,
"ip", ipAddress,
"reason", reason).increment();
// 감사 로그 저장
saveAuditLog("LOGIN_FAILURE", username, ipAddress, reason);
// 브루트 포스 공격 감지
checkBruteForceAttack(ipAddress);
}
@EventListener
@Async
public void handleTokenValidationFailure(TokenValidationFailureEvent event) {
String token = event.getToken();
String reason = event.getReason();
String ipAddress = getClientIpAddress();
log.warn("토큰 검증 실패 - IP: {}, 이유: {}", ipAddress, reason);
meterRegistry.counter("auth.token.validation.failure",
"ip", ipAddress,
"reason", reason).increment();
saveAuditLog("TOKEN_VALIDATION_FAILURE", null, ipAddress, reason);
}
private void checkBruteForceAttack(String ipAddress) {
// Redis를 사용한 IP별 실패 횟수 추적
String key = "failed_attempts:" + ipAddress;
String attempts = redisTemplate.opsForValue().get(key);
int failedAttempts = attempts != null ? Integer.parseInt(attempts) : 0;
failedAttempts++;
redisTemplate.opsForValue().set(key, String.valueOf(failedAttempts),
Duration.ofMinutes(15));
if (failedAttempts >= 5) {
log.error("브루트 포스 공격 감지 - IP: {}, 시도 횟수: {}", ipAddress, failedAttempts);
// IP 차단 또는 알림 발송
blockIpAddress(ipAddress);
sendSecurityAlert("브루트 포스 공격 감지", ipAddress);
}
}
private void saveAuditLog(String eventType, String username, String ipAddress, String details) {
AuditLog auditLog = AuditLog.builder()
.eventType(eventType)
.username(username)
.ipAddress(ipAddress)
.details(details)
.timestamp(LocalDateTime.now())
.build();
auditLogRepository.save(auditLog);
}
}운영 환경 보안 체크리스트
필수 보안 설정
- ✅ HTTPS 강제 사용
- ✅ 강력한 JWT Secret 키 사용
- ✅ 짧은 토큰 만료 시간 설정
- ✅ HttpOnly, Secure 쿠키 사용
- ✅ CORS 정책 엄격히 설정
- ✅ 리다이렉트 URI 화이트리스트
- ✅ Rate Limiting 구현
모니터링 및 감사
- ✅ 인증 이벤트 로깅
- ✅ 실패한 로그인 시도 추적
- ✅ 토큰 검증 실패 모니터링
- ✅ 브루트 포스 공격 감지
- ✅ 보안 메트릭 수집
- ✅ 알림 시스템 구축
- ✅ 정기적인 보안 감사
추가 권장사항
- • 정기적인 의존성 보안 업데이트
- • 침투 테스트 및 보안 감사 수행
- • 개발팀 보안 교육 실시
- • 인시던트 대응 계획 수립
- • 백업 및 복구 전략 마련