Skip to content

Commit 4630f5e

Browse files
ekfrehdTueBack
andauthored
Feature/auth jwt system (#21)
* feat: OAuth2 소셜 로그인(카카오, 구글) 기본 연동 구현 - build.gradle: oauth2-client, webflux 의존성 추가 - application.yml: 카카오, 구글 OAuth2 클라이언트 설정 추가 - Member 엔티티 및 관련 VO: provider, providerId, isVerified 등 소셜/인증 관련 필드 추가 - SecurityConfig: oauth2Login 설정 활성화 및 CustomOAuth2UserService 등록 - CustomOAuth2UserService: 소셜 사용자 정보 파싱 및 DB 저장/업데이트 로직 구현 - OAuthAttributes: 플랫폼별 사용자 정보 표준화 DTO 구현 * 중간커밋 * . * feat: Auth 서비스 JWT 토큰 발급 구현 --------- Co-authored-by: TueBack <ekfrehd@github.com>
1 parent 49517a2 commit 4630f5e

23 files changed

Lines changed: 1037 additions & 197 deletions

api-test.http

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
### 전역 변수 설정 (필요시 변경)
2+
@auth_host = http://localhost:8080
3+
@trip_host = http://localhost:8081
4+
@email = test@naver.com
5+
@password = password1234
6+
7+
### 1. [Auth] 회원가입
8+
POST {{auth_host}}/users
9+
Content-Type: application/json
10+
11+
{
12+
"email": "{{email}}",
13+
"password": "{{password}}",
14+
"name": "테스터"
15+
}
16+
17+
### 2. [Auth] 로그인 및 토큰 발급
18+
# 주의: LoginAuthenticationFilter 구현에 따라 id, password를 헤더로 전송합니다.
19+
POST {{auth_host}}/login
20+
Content-Type: application/json
21+
id: {{email}}
22+
password: {{password}}
23+
24+
> {%
25+
// 응답에서 accessToken을 추출하여 전역 변수 'auth_token'에 저장
26+
client.global.set("auth_token", response.body.data.accessToken);
27+
client.log("Acquired Token: " + response.body.data.accessToken);
28+
%}
29+
30+
### 3. [Trip] 여행 생성 (토큰 사용)
31+
POST {{trip_host}}/trips
32+
Content-Type: application/json
33+
Authorization: Bearer {{auth_token}}
34+
35+
{
36+
"locationId": "550e8400-e29b-41d4-a716-446655440001",
37+
"title": "제주도 우정 여행",
38+
"description": "친구들과 함께하는 즐거운 제주도 여행입니다.",
39+
"start": "2026-07-01",
40+
"end": "2026-07-05",
41+
"open": true,
42+
"maxParticipants": 4,
43+
"category": "DOMESTIC",
44+
"hashTags": ["제주도", "우정여행", "맛집탐방"]
45+
}
46+
47+
### 4. [Trip] 생성된 여행 목록 조회 (검증)
48+
GET {{trip_host}}/trips
49+
Content-Type: application/json
50+
Authorization: Bearer {{auth_token}}

build.gradle

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
plugins {
22
id 'java'
3-
id 'org.springframework.boot' version '3.5.0'
3+
id 'org.springframework.boot' version '3.4.1'
44
id 'io.spring.dependency-management' version '1.1.7'
55
}
66

@@ -28,6 +28,11 @@ dependencies {
2828
implementation 'org.springframework.boot:spring-boot-starter-security'
2929
implementation 'org.springframework.boot:spring-boot-starter-validation'
3030
implementation 'org.springframework.boot:spring-boot-starter-web'
31+
32+
// [신규 추가] OAuth2 클라이언트 및 WebClient 의존성
33+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
34+
implementation 'org.springframework.boot:spring-boot-starter-webflux'
35+
3136
implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.5"
3237
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5"
3338
compileOnly 'org.projectlombok:lombok'
@@ -47,4 +52,4 @@ dependencies {
4752

4853
tasks.named('test') {
4954
useJUnitPlatform()
50-
}
55+
}

settings.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
plugins {
2+
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
3+
}
14
rootProject.name = 'auth'
Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
package com.retrip.auth.application.config;
22

3-
43
import com.retrip.auth.domain.entity.Member;
4+
import lombok.Getter;
55
import org.springframework.security.core.GrantedAuthority;
66
import org.springframework.security.core.authority.SimpleGrantedAuthority;
77
import org.springframework.security.core.userdetails.UserDetails;
8+
import org.springframework.security.oauth2.core.user.OAuth2User;
89

910
import java.util.Collection;
11+
import java.util.Map;
1012
import java.util.stream.Collectors;
1113

12-
13-
public class CustomUserDetails implements UserDetails {
14+
@Getter
15+
public class CustomUserDetails implements UserDetails, OAuth2User {
1416

1517
private final Member member;
18+
private Map<String, Object> attributes;
19+
1620

1721
public CustomUserDetails(Member member) {
1822
this.member = member;
1923
}
2024

25+
26+
public CustomUserDetails(Member member, Map<String, Object> attributes) {
27+
this.member = member;
28+
this.attributes = attributes;
29+
}
30+
2131
@Override
2232
public Collection<? extends GrantedAuthority> getAuthorities() {
2333
return member.getAuthorities().getValues().stream()
@@ -27,11 +37,23 @@ public Collection<? extends GrantedAuthority> getAuthorities() {
2737

2838
@Override
2939
public String getPassword() {
40+
3041
return member.getPassword().getValue();
3142
}
3243

3344
@Override
3445
public String getUsername() {
3546
return member.getEmail().getValue();
3647
}
37-
}
48+
49+
50+
@Override
51+
public Map<String, Object> getAttributes() {
52+
return attributes;
53+
}
54+
55+
@Override
56+
public String getName() {
57+
return member.getId().toString();
58+
}
59+
}

src/main/java/com/retrip/auth/application/config/JwtConfig.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import lombok.RequiredArgsConstructor;
55
import org.springframework.boot.context.properties.ConfigurationProperties;
66

7-
87
@Getter
98
@RequiredArgsConstructor
109
@ConfigurationProperties("token.jwt")
1110
public class JwtConfig {
12-
private final String secret;
11+
12+
private final String privateKey;
13+
private final String publicKey;
14+
1315
private final String header;
1416
private final String prefix;
1517
private final AccessConfig access;
@@ -26,4 +28,4 @@ public static class AccessConfig {
2628
public static class RefreshConfig {
2729
private final int expireMin;
2830
}
29-
}
31+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package com.retrip.auth.application.config;
2+
3+
import com.retrip.auth.application.in.response.LoginResponse;
4+
import io.jsonwebtoken.*;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.security.core.GrantedAuthority;
10+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
11+
import org.springframework.stereotype.Component;
12+
13+
import java.security.KeyFactory;
14+
import java.security.PrivateKey;
15+
import java.security.PublicKey;
16+
import java.security.spec.PKCS8EncodedKeySpec;
17+
import java.security.spec.X509EncodedKeySpec;
18+
import java.time.Instant;
19+
import java.time.temporal.ChronoUnit;
20+
import java.util.*;
21+
import java.util.stream.Collectors;
22+
import com.retrip.auth.application.config.CustomUserDetails;
23+
24+
/**
25+
* JWT 토큰의 생성(Sign) 및 검증(Verify)을 담당하는 클래스 (RSA 방식)
26+
*/
27+
@Slf4j
28+
@Component
29+
@RequiredArgsConstructor
30+
public class JwtProvider {
31+
32+
private final JwtConfig jwtConfig;
33+
34+
/**
35+
* [생성] 인증 정보를 기반으로 RSA 서명된 Access/Refresh Token 생성
36+
*/
37+
public LoginResponse.TokenResponse generateTokens(Authentication authentication) {
38+
Instant now = Instant.now();
39+
String authorities = String.join(",", getAuthorities(authentication));
40+
41+
String memberId = authentication.getName();
42+
String email = authentication.getName();
43+
44+
Object principal = authentication.getPrincipal();
45+
if (principal instanceof CustomUserDetails userDetails) {
46+
memberId = userDetails.getName(); // CustomUserDetails.getName()은 UUID(String) 반환
47+
email = userDetails.getUsername(); // CustomUserDetails.getUsername()은 이메일 반환
48+
}
49+
50+
String accessToken = createToken(
51+
memberId, // sub (UUID)
52+
email, // claim: username (Email)
53+
authorities,
54+
now,
55+
jwtConfig.getAccess().getExpireMin()
56+
);
57+
58+
String refreshToken = createToken(
59+
memberId, // sub (UUID)
60+
email, // claim: username (Email)
61+
authorities,
62+
now,
63+
jwtConfig.getRefresh().getExpireMin()
64+
);
65+
66+
return new LoginResponse.TokenResponse(accessToken, refreshToken);
67+
}
68+
69+
private String createToken(String subject, String username, String authorities, Instant issuedAt, long expirationMinutes) {
70+
try {
71+
PrivateKey privateKey = getPrivateKey(jwtConfig.getPrivateKey());
72+
Instant expiration = issuedAt.plus(expirationMinutes, ChronoUnit.MINUTES);
73+
74+
return Jwts.builder()
75+
.subject(subject)
76+
.claims(
77+
Map.of(
78+
"username", username,
79+
"authorities", authorities
80+
)
81+
)
82+
.issuedAt(Date.from(issuedAt))
83+
.expiration(Date.from(expiration))
84+
.signWith(privateKey, Jwts.SIG.RS256)
85+
.compact();
86+
} catch (Exception e) {
87+
throw new RuntimeException("토큰 생성 실패", e);
88+
}
89+
}
90+
91+
/**
92+
* [검증] 토큰 유효성 검사 (RSA Public Key 사용)
93+
*/
94+
public boolean validateToken(String token) {
95+
try {
96+
PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey());
97+
Jwts.parser()
98+
.verifyWith(publicKey)
99+
.build()
100+
.parseSignedClaims(token);
101+
return true;
102+
} catch (SecurityException | MalformedJwtException e) {
103+
log.info("Invalid JWT Token", e);
104+
} catch (ExpiredJwtException e) {
105+
log.info("Expired JWT Token", e);
106+
} catch (UnsupportedJwtException e) {
107+
log.info("Unsupported JWT Token", e);
108+
} catch (IllegalArgumentException e) {
109+
log.info("JWT claims string is empty.", e);
110+
} catch (Exception e) {
111+
log.error("JWT validation error", e);
112+
}
113+
return false;
114+
}
115+
116+
/**
117+
* [파싱] 토큰에서 인증 객체 추출
118+
*/
119+
public Authentication getAuthentication(String token) {
120+
try {
121+
PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey());
122+
Claims claims = Jwts.parser()
123+
.verifyWith(publicKey)
124+
.build()
125+
.parseSignedClaims(token)
126+
.getPayload();
127+
128+
String username = claims.get("username", String.class);
129+
String authoritiesStr = claims.get("authorities", String.class);
130+
131+
List<GrantedAuthority> authorities = Arrays.stream(authoritiesStr.split(","))
132+
.map(String::trim)
133+
.map(SimpleGrantedAuthority::new)
134+
.collect(Collectors.toList());
135+
136+
return new UsernamePasswordAuthenticationToken(username, null, authorities);
137+
138+
} catch (Exception e) {
139+
throw new RuntimeException("인증 정보 추출 실패", e);
140+
}
141+
}
142+
143+
public Claims parseClaims(String token) {
144+
try {
145+
PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey());
146+
return Jwts.parser()
147+
.verifyWith(publicKey)
148+
.build()
149+
.parseSignedClaims(token)
150+
.getPayload();
151+
} catch (ExpiredJwtException e) {
152+
// 만료된 토큰이어도 정보를 꺼내기 위해 Claims 반환
153+
return e.getClaims();
154+
} catch (Exception e) {
155+
throw new RuntimeException("토큰 파싱 실패", e);
156+
}
157+
}
158+
159+
//키 파싱 헬퍼
160+
private PrivateKey getPrivateKey(String key) throws Exception {
161+
String sanitizedKey = key
162+
.replace("-----BEGIN PRIVATE KEY-----", "")
163+
.replace("-----END PRIVATE KEY-----", "")
164+
.replaceAll("\\s", "");
165+
byte[] keyBytes = Base64.getDecoder().decode(sanitizedKey);
166+
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
167+
return KeyFactory.getInstance("RSA").generatePrivate(spec);
168+
}
169+
170+
private PublicKey getPublicKey(String key) throws Exception {
171+
String sanitizedKey = key
172+
.replace("-----BEGIN PUBLIC KEY-----", "")
173+
.replace("-----END PUBLIC KEY-----", "")
174+
.replaceAll("\\s", "");
175+
byte[] keyBytes = Base64.getDecoder().decode(sanitizedKey);
176+
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
177+
return KeyFactory.getInstance("RSA").generatePublic(spec);
178+
}
179+
180+
private List<String> getAuthorities(Authentication authentication) {
181+
return authentication.getAuthorities().stream()
182+
.map(GrantedAuthority::getAuthority)
183+
.toList();
184+
}
185+
}

0 commit comments

Comments
 (0)