Skip to content

Commit 2a9083f

Browse files
authored
Merge pull request #188 from asowjdan/feat/oauth
Feat[oauth] : 소셜 로그인 기능 구현
2 parents 4c13d12 + f2243c7 commit 2a9083f

40 files changed

+2869
-547
lines changed

backend/.env.default

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
SPRING_PROFILES_ACTIVE=NEED_TO_SET
2-
32
SPRING_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET
43

5-
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__KAKAO__CLIENT_ID=NEED_TO_SET
6-
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__KAKAO__CLIENT_SECRET=NEED_TO_SET
7-
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_ID=NEED_TO_SET
8-
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_SECRET=NEED_TO_SET
4+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_ID=NEED_TO_SET
5+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_CLIENT_SECRET=NEED_TO_SET
6+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_CLIENT_ID=NEED_TO_SET
7+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_CLIENT_SECRET=NEED_TO_SET
98

109
CUSTOM_JWT_SECRET_KEY=NEED_TO_SET
1110
CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=NEED_TO_SET
1211

12+
# Email
13+
SEND_EMAIL_ADDRESS=NEED_TO_SET
14+
SEND_EMAIL_PASSWORD=NEED_TO_SET
15+
16+
# PROD
17+
PROD_URL=NEED_TO_SET
18+
PROD_FRONTEND_URL=NEED_TO_SET
19+
PROD_CORS_ALLOWED_ORIGINS=NEED_TO_SET
20+
PROD_OAUTH2_KAKAO_REDIRECT_URI=NEED_TO_SET
21+
PROD_OAUTH2_NAVER_REDIRECT_URI=NEED_TO_SET
22+
PROD_OAUTH2_SUCCESS_REDIRECT_URL=NEED_TO_SET
23+
PROD_OAUTH2_FAILURE_REDIRECT_URL=NEED_TO_SET
1324
PROD_DATASOURCE_URL=NEED_TO_SET
1425
PROD_DATASOURCE_DRIVER=NEED_TO_SET
1526
PROD_DATASOURCE_USERNAME=NEED_TO_SET
@@ -19,16 +30,33 @@ PROD_REDIS_HOST=NEED_TO_SET
1930
PROD_REDIS_PORT=NEED_TO_SET
2031
PROD_REDIS_PASSWORD=NEED_TO_SET
2132

33+
PROD_QDRANT_HOST=NEED_TO_SET
34+
PROD_QDRANT_PORT=NEED_TO_SET
35+
36+
# DEV
37+
DEV_URL=NEED_TO_SET
38+
DEV_FRONTEND_URL=NEED_TO_SET
39+
DEV_CORS_ALLOWED_ORIGINS=NEED_TO_SET
40+
DEV_OAUTH2_KAKAO_REDIRECT_URI=NEED_TO_SET
41+
DEV_OAUTH2_NAVER_REDIRECT_URI=NEED_TO_SET
42+
DEV_OAUTH2_SUCCESS_REDIRECT_URL=NEED_TO_SET
43+
DEV_OAUTH2_FAILURE_REDIRECT_URL=NEED_TO_SET
2244
DEV_DATASOURCE_URL=NEED_TO_SET
2345
DEV_DATASOURCE_USERNAME=NEED_TO_SET
2446
DEV_DATASOURCE_PASSWORD=NEED_TO_SET
2547
DEV_DATASOURCE_DRIVER=NEED_TO_SET
2648
DEV_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET
49+
DEV_DATASOURCE_PORT=NEED_TO_SET
50+
DEV_DB_ROOT_PASSWORD=NEED_TO_SET
2751

2852
DEV_REDIS_HOST=NEED_TO_SET
2953
DEV_REDIS_PORT=NEED_TO_SET
3054
DEV_REDIS_PASSWORD=NEED_TO_SET
3155

56+
DEV_QDRANT_HOST=NEED_TO_SET
57+
DEV_QDRANT_PORT=NEED_TO_SET
58+
59+
# TEST
3260
TEST_DATASOURCE_URL=NEED_TO_SET
3361
TEST_DATASOURCE_USERNAME=NEED_TO_SET
3462
TEST_DATASOURCE_PASSWORD=NEED_TO_SET
@@ -37,4 +65,17 @@ TEST_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET
3765

3866
TEST_REDIS_HOST=NEED_TO_SET
3967
TEST_REDIS_PORT=NEED_TO_SET
40-
TEST_REDIS_PASSWORD=NEED_TO_SET
68+
TEST_REDIS_PASSWORD=NEED_TO_SET
69+
70+
# AI
71+
OPENAI_API_KEY=NEED_TO_SET
72+
73+
# Base application.yml variables (no profile-specific prefix)
74+
SPRING_AI_VECTORSTORE_QDRANT_HOST=NEED_TO_SET
75+
SPRING_AI_VECTORSTORE_QDRANT_PORT=NEED_TO_SET
76+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KAKAO_REDIRECT_URI=NEED_TO_SET
77+
SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_NAVER_REDIRECT_URI=NEED_TO_SET
78+
CUSTOM_CORS_ALLOWED_ORIGINS=NEED_TO_SET
79+
CUSTOM_OAUTH2_REDIRECT_URL=NEED_TO_SET
80+
CUSTOM_OAUTH2_FAILURE_URL=NEED_TO_SET
81+
CUSTOM_FRONTEND_URL=NEED_TO_SET

backend/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies {
3838
implementation 'org.springframework.boot:spring-boot-starter-validation'
3939
implementation 'org.springframework.boot:spring-boot-starter-web'
4040
implementation 'org.springframework.boot:spring-boot-starter-actuator'
41+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
4142
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5'
4243

4344
// API Documentation (문서화)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.ai.lawyer.domain.auth.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@Builder
8+
public class OAuth2LoginResponse {
9+
private boolean success;
10+
private String message;
11+
}

backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java

Lines changed: 154 additions & 240 deletions
Large diffs are not rendered by default.
Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
package com.ai.lawyer.domain.member.dto;
22

3-
import lombok.AllArgsConstructor;
4-
import lombok.Getter;
5-
63
import java.time.LocalDateTime;
74

8-
@Getter
9-
@AllArgsConstructor
10-
public class MemberErrorResponse {
11-
private final String message;
12-
private final int status;
13-
private final String error;
14-
private final LocalDateTime timestamp;
15-
5+
public record MemberErrorResponse(
6+
String message,
7+
int status,
8+
String error,
9+
LocalDateTime timestamp
10+
) {
1611
public static MemberErrorResponse of(String message, int status, String error) {
1712
return new MemberErrorResponse(message, status, error, LocalDateTime.now());
1813
}
19-
}
14+
}

backend/src/main/java/com/ai/lawyer/domain/member/dto/MemberResponse.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.ai.lawyer.domain.member.dto;
22

33
import com.ai.lawyer.domain.member.entity.Member;
4+
import com.ai.lawyer.domain.member.entity.MemberAdapter;
5+
import com.ai.lawyer.domain.member.entity.OAuth2Member;
46
import lombok.*;
57

68
import java.time.LocalDateTime;
@@ -14,6 +16,7 @@ public class MemberResponse {
1416

1517
private Long memberId;
1618
private String loginId;
19+
private String email; // 이메일 추가
1720
private Integer age;
1821
private Member.Gender gender;
1922
private Member.Role role;
@@ -25,6 +28,7 @@ public static MemberResponse from(Member member) {
2528
return MemberResponse.builder()
2629
.memberId(member.getMemberId())
2730
.loginId(member.getLoginId())
31+
.email(member.getLoginId()) // 로컬 회원은 loginId가 이메일
2832
.age(member.getAge())
2933
.gender(member.getGender())
3034
.role(member.getRole())
@@ -33,4 +37,24 @@ public static MemberResponse from(Member member) {
3337
.updatedAt(member.getUpdatedAt())
3438
.build();
3539
}
36-
}
40+
41+
public static MemberResponse from(MemberAdapter memberAdapter) {
42+
return switch (memberAdapter) {
43+
case null -> throw new IllegalArgumentException("MemberAdapter cannot be null");
44+
case Member member -> from(member);
45+
case OAuth2Member oauth2Member -> MemberResponse.builder()
46+
.memberId(oauth2Member.getMemberId())
47+
.loginId(oauth2Member.getLoginId())
48+
.email(oauth2Member.getEmail()) // OAuth2Member의 email 컬럼
49+
.age(oauth2Member.getAge())
50+
.gender(oauth2Member.getGender())
51+
.role(oauth2Member.getRole())
52+
.name(oauth2Member.getName())
53+
.createdAt(oauth2Member.getCreatedAt())
54+
.updatedAt(oauth2Member.getUpdatedAt())
55+
.build();
56+
default ->
57+
throw new IllegalArgumentException("Unsupported member type: " + memberAdapter.getClass().getName());
58+
};
59+
}
60+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.Email;
5+
import jakarta.validation.constraints.NotBlank;
6+
import jakarta.validation.constraints.NotNull;
7+
import lombok.AllArgsConstructor;
8+
import lombok.Builder;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
12+
@Getter
13+
@Builder
14+
@NoArgsConstructor
15+
@AllArgsConstructor
16+
@Schema(description = "OAuth2 로그인 테스트 요청 (개발/테스트용)")
17+
public class OAuth2LoginTestRequest {
18+
19+
@NotBlank(message = "이메일은 필수입니다")
20+
@Email(message = "올바른 이메일 형식이어야 합니다")
21+
@Schema(description = "사용자 이메일", example = "[email protected]")
22+
private String email;
23+
24+
@NotBlank(message = "이름은 필수입니다")
25+
@Schema(description = "사용자 이름", example = "홍길동")
26+
private String name;
27+
28+
@NotNull(message = "나이는 필수입니다")
29+
@Schema(description = "사용자 나이", example = "25")
30+
private Integer age;
31+
32+
@NotBlank(message = "성별은 필수입니다")
33+
@Schema(description = "사용자 성별 (MALE/FEMALE/OTHER)", example = "MALE")
34+
private String gender;
35+
36+
@NotBlank(message = "Provider는 필수입니다")
37+
@Schema(description = "OAuth Provider (KAKAO/NAVER)", example = "KAKAO")
38+
private String provider;
39+
40+
@NotBlank(message = "Provider ID는 필수입니다")
41+
@Schema(description = "OAuth Provider의 사용자 ID", example = "123456789")
42+
private String providerId;
43+
}

backend/src/main/java/com/ai/lawyer/domain/member/entity/Member.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
@Entity
1212
@Table(name = "member",
1313
indexes = {
14-
@Index(name = "idx_member_loginid", columnList = "loginid")
14+
@Index(name = "idx_member_login_id", columnList = "login_id")
1515
})
1616
@Getter
1717
@Setter
@@ -20,14 +20,14 @@
2020
@Builder
2121
@ToString(exclude = "password")
2222
@EqualsAndHashCode(of = "memberId")
23-
public class Member {
23+
public class Member implements MemberAdapter {
2424

2525
@Id
2626
@GeneratedValue(strategy = GenerationType.IDENTITY)
2727
@Column(name = "member_id", nullable = false)
2828
private Long memberId;
2929

30-
@Column(name = "loginid", nullable = false, unique = true, length = 100)
30+
@Column(name = "login_id", nullable = false, unique = true, length = 100)
3131
@Email(message = "올바른 이메일 형식이 아닙니다")
3232
@NotBlank(message = "이메일(로그인 ID)은 필수입니다")
3333
private String loginId;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.ai.lawyer.domain.member.entity;
2+
3+
/**
4+
* Member와 OAuth2Member를 통합해서 처리하기 위한 어댑터 인터페이스
5+
* TokenProvider, JwtAuthenticationFilter 등에서 동일한 방식으로 처리 가능
6+
*/
7+
public interface MemberAdapter {
8+
Long getMemberId();
9+
String getLoginId();
10+
String getName();
11+
Integer getAge();
12+
Member.Gender getGender();
13+
Member.Role getRole();
14+
15+
/**
16+
* 이메일 반환
17+
* - Member: loginId 반환 (loginId가 이메일)
18+
* - OAuth2Member: email 컬럼 반환
19+
*/
20+
default String getEmail() {
21+
if (isLocalMember()) {
22+
return getLoginId(); // 로컬 회원은 loginId가 이메일
23+
} else if (isOAuth2Member()) {
24+
return this.getEmail();
25+
}
26+
return null;
27+
}
28+
29+
/**
30+
* 로컬 회원인지 확인
31+
*/
32+
default boolean isLocalMember() {
33+
return this instanceof Member;
34+
}
35+
36+
/**
37+
* OAuth2 회원인지 확인
38+
*/
39+
default boolean isOAuth2Member() {
40+
return this instanceof OAuth2Member;
41+
}
42+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.ai.lawyer.domain.member.entity;
2+
3+
import jakarta.persistence.*;
4+
import jakarta.validation.constraints.*;
5+
import lombok.*;
6+
import org.hibernate.annotations.CreationTimestamp;
7+
import org.hibernate.annotations.UpdateTimestamp;
8+
9+
import java.time.LocalDateTime;
10+
11+
@Entity
12+
@Table(name = "oauth2_member",
13+
indexes = {
14+
@Index(name = "idx_oauth2_member_login_id", columnList = "login_id"),
15+
@Index(name = "idx_oauth2_member_provider", columnList = "provider, provider_id")
16+
})
17+
@Getter
18+
@Setter
19+
@NoArgsConstructor
20+
@AllArgsConstructor
21+
@Builder
22+
@ToString
23+
@EqualsAndHashCode()
24+
public class OAuth2Member implements MemberAdapter {
25+
26+
@Id
27+
@GeneratedValue(strategy = GenerationType.IDENTITY)
28+
@Column(name = "member_id", nullable = false)
29+
private Long memberId;
30+
31+
@Column(name = "login_id", nullable = false, unique = true, length = 100)
32+
@Email(message = "올바른 이메일 형식이 아닙니다")
33+
@NotBlank(message = "이메일(로그인 ID)은 필수입니다")
34+
private String loginId;
35+
36+
@Column(name = "email", nullable = false, length = 100)
37+
@Email(message = "올바른 이메일 형식이 아닙니다")
38+
@NotBlank(message = "이메일은 필수입니다")
39+
private String email;
40+
41+
@Column(name = "age", nullable = false)
42+
@NotNull(message = "나이는 필수입니다")
43+
@Min(value = 14, message = "최소 14세 이상이어야 합니다")
44+
private Integer age;
45+
46+
@Enumerated(EnumType.STRING)
47+
@Column(name = "gender", nullable = false, length = 10)
48+
@NotNull(message = "성별은 필수입니다")
49+
private Member.Gender gender;
50+
51+
@Enumerated(EnumType.STRING)
52+
@Column(name = "role", nullable = false, length = 20)
53+
@Builder.Default
54+
private Member.Role role = Member.Role.USER;
55+
56+
@Column(name = "name", nullable = false, length = 20)
57+
@NotBlank(message = "이름은 필수입니다")
58+
private String name;
59+
60+
@Enumerated(EnumType.STRING)
61+
@Column(name = "provider", nullable = false, length = 20)
62+
@NotNull(message = "OAuth Provider는 필수입니다")
63+
private Provider provider;
64+
65+
@Column(name = "provider_id", nullable = false, length = 100)
66+
@NotBlank(message = "Provider ID는 필수입니다")
67+
private String providerId;
68+
69+
@CreationTimestamp
70+
@Column(name = "created_at", nullable = false, updatable = false)
71+
private LocalDateTime createdAt;
72+
73+
@UpdateTimestamp
74+
@Column(name = "updated_at")
75+
private LocalDateTime updatedAt;
76+
77+
@Getter
78+
public enum Provider {
79+
KAKAO("카카오"), NAVER("네이버");
80+
private final String description;
81+
Provider(String description) { this.description = description; }
82+
}
83+
}

0 commit comments

Comments
 (0)