Skip to content

Commit 6f0bd32

Browse files
authored
Merge pull request #25 from prgrms-web-devcourse-final-project/feat#19
[feat] Security 설정 및 OAuth 로그인 구현#2
2 parents d538e97 + f25806a commit 6f0bd32

16 files changed

+430
-26
lines changed

build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,13 @@ dependencies {
2828
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
2929
implementation("org.springframework.boot:spring-boot-starter-validation")
3030
implementation("org.springframework.boot:spring-boot-starter-web")
31-
31+
implementation("io.github.cdimascio:java-dotenv:5.2.2")
3232
implementation("org.springframework.boot:spring-boot-starter-security")
3333
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
3434
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
3535
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
3636
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
3737
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
38-
implementation("me.paulschwarz:spring-dotenv:4.0.0")
3938
compileOnly("org.projectlombok:lombok")
4039
developmentOnly("org.springframework.boot:spring-boot-devtools")
4140
runtimeOnly("com.h2database:h2")

src/main/java/com/back/BackApplication.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back;
22

3+
import io.github.cdimascio.dotenv.Dotenv;
34
import org.springframework.boot.SpringApplication;
45
import org.springframework.boot.autoconfigure.SpringBootApplication;
56
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -9,6 +10,11 @@
910
public class BackApplication {
1011

1112
public static void main(String[] args) {
13+
Dotenv dotenv = Dotenv.load();
14+
System.out.println("KAKAO_OAUTH2_CLIENT_ID: " + dotenv.get("KAKAO_OAUTH2_CLIENT_ID"));
15+
System.out.println("GOOGLE_OAUTH2_CLIENT_ID: " + dotenv.get("GOOGLE_OAUTH2_CLIENT_ID"));
16+
System.out.println("NAVER_OAUTH2_CLIENT_ID: " + dotenv.get("NAVER_OAUTH2_CLIENT_ID"));
17+
1218
SpringApplication.run(BackApplication.class, args);
1319
}
1420

src/main/java/com/back/domain/user/entity/User.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ public class User {
3333
@Column(nullable = false, unique = true, length = 50)
3434
private String nickname; // 고유 닉네임
3535

36+
// OAuth2 관련 필드
37+
@Column(unique = true, length = 100)
38+
private String oauthId; // OAuth 제공자별 고유 ID (예: kakao_123456789)
39+
3640
private Double abvDegree; // 알콜도수(회원 등급)
3741

3842
@CreatedDate // JPA Auditing 적용

src/main/java/com/back/domain/user/repository/UserRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@
44
import org.springframework.data.jpa.repository.JpaRepository;
55
import org.springframework.stereotype.Repository;
66

7+
import java.util.Optional;
8+
79
@Repository
810
public interface UserRepository extends JpaRepository<User, Long> {
11+
12+
Optional<User> findByOauthId(String oauthId);
13+
Optional<User> findByEmail(String email);
14+
Optional<User> findByNickname(String nickname);
915
boolean existsByNicknameAndIdNot(String nickname, Long id);
1016
}
1117

src/main/java/com/back/domain/user/service/UserService.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
import com.back.domain.user.entity.User;
44
import com.back.domain.user.repository.UserRepository;
5+
import com.back.global.exception.ServiceException;
6+
import com.back.global.rsData.RsData;
57
import lombok.RequiredArgsConstructor;
68
import org.springframework.stereotype.Service;
79
import org.springframework.transaction.annotation.Transactional;
810

11+
import java.time.LocalDateTime;
12+
import java.util.Optional;
13+
914
@Service
1015
@RequiredArgsConstructor
1116
public class UserService {
@@ -17,4 +22,61 @@ public User findById(Long id) {
1722
return userRepository.findById(id)
1823
.orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id));
1924
}
25+
26+
27+
public User joinSocial(String oauthId, String email, String nickname){
28+
userRepository.findByOauthId(oauthId)
29+
.ifPresent(user -> {
30+
throw new ServiceException(409, "이미 존재하는 계정입니다.");
31+
});
32+
33+
// 고유한 닉네임 생성
34+
String uniqueNickname = generateUniqueNickname(nickname);
35+
36+
User user = User.builder()
37+
.email(email)
38+
.nickname(uniqueNickname)
39+
.abvDegree(0.0)
40+
.createdAt(LocalDateTime.now())
41+
.updatedAt(LocalDateTime.now())
42+
.role("USER")
43+
.oauthId(oauthId)
44+
.build();
45+
46+
return userRepository.save(user);
47+
}
48+
49+
@Transactional
50+
public RsData<User> findOrCreateOAuthUser(String oauthId, String email, String nickname) {
51+
Optional<User> existingUser = userRepository.findByOauthId(oauthId);
52+
53+
if (existingUser.isPresent()) {
54+
// 기존 사용자 업데이트 (이메일만 업데이트)
55+
User user = existingUser.get();
56+
user.setEmail(email);
57+
return RsData.of(200, "회원 정보가 업데이트 되었습니다", user); //더티체킹
58+
} else {
59+
User newUser = joinSocial(oauthId, email, nickname);
60+
return RsData.of(201, "사용자가 생성되었습니다", newUser);
61+
}
62+
}
63+
64+
public String generateUniqueNickname(String baseNickname) {
65+
// null이거나 빈 문자열인 경우 기본값 설정
66+
if (baseNickname == null || baseNickname.trim().isEmpty()) {
67+
baseNickname = "User";
68+
}
69+
70+
String nickname = baseNickname;
71+
int counter = 1;
72+
73+
// 중복 체크 및 고유한 닉네임 생성
74+
while (userRepository.findByNickname(nickname).isPresent()) {
75+
nickname = baseNickname + counter;
76+
counter++;
77+
}
78+
79+
return nickname;
80+
}
81+
2082
}

src/main/java/com/back/global/rq/Rq.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import jakarta.servlet.http.HttpServletResponse;
99
import lombok.RequiredArgsConstructor;
1010
import lombok.SneakyThrows;
11+
import org.springframework.beans.factory.annotation.Value;
1112
import org.springframework.http.ResponseCookie;
1213
import org.springframework.security.core.Authentication;
1314
import org.springframework.security.core.GrantedAuthority;
@@ -24,6 +25,9 @@ public class Rq {
2425
private final HttpServletResponse resp;
2526
private final UserService userService;
2627

28+
@Value("${custom.cookie.secure:false}")
29+
private boolean cookieSecure;
30+
2731
public User getActor() {
2832
return Optional.ofNullable(
2933
SecurityContextHolder
@@ -85,7 +89,7 @@ public void setCrossDomainCookie(String name, String value, int maxAge) {
8589
ResponseCookie cookie = ResponseCookie.from(name, value)
8690
.path("/")
8791
.maxAge(maxAge)
88-
.secure(true)
92+
.secure(cookieSecure)
8993
.sameSite("None")
9094
.httpOnly(true)
9195
.build();

src/main/java/com/back/global/security/CustomAuthenticationFilter.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.springframework.web.filter.OncePerRequestFilter;
2020

2121
import java.io.IOException;
22+
import java.util.Map;
2223

2324
@Component
2425
@RequiredArgsConstructor
@@ -54,9 +55,9 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt
5455
if (
5556
//추후 로그인 필요한 api 추가 설정
5657
uri.startsWith("/h2-console") ||
57-
uri.startsWith("/api/login/oauth2/") ||
58-
(method.equals("GET") && uri.equals("api/~~")) ||
59-
(method.equals("POST") && uri.equals("/api/user/login"))
58+
uri.startsWith("/login/oauth2/") ||
59+
(method.equals("GET") && uri.equals("/api/~~")) ||
60+
(method.equals("POST") && uri.equals("/api/~"))
6061

6162
) {
6263
filterChain.doFilter(request, response);
@@ -120,8 +121,8 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt
120121
user.getId(),
121122
user.getEmail(),
122123
user.getNickname(),
123-
"",
124-
user.getAuthorities()
124+
user.getAuthorities(),
125+
Map.of() // JWT 인증에서는 빈 attributes
125126
);
126127
Authentication authentication = new UsernamePasswordAuthenticationToken(
127128
userDetails,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.back.global.security;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
6+
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
7+
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
8+
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
9+
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.nio.charset.StandardCharsets;
13+
import java.util.Base64;
14+
import java.util.UUID;
15+
16+
@Component
17+
@RequiredArgsConstructor
18+
public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
19+
20+
private final ClientRegistrationRepository clientRegistrationRepository;
21+
22+
private DefaultOAuth2AuthorizationRequestResolver createDefaultResolver() {
23+
// Spring Security 기본 Authorization URI 사용
24+
return new DefaultOAuth2AuthorizationRequestResolver(
25+
clientRegistrationRepository,
26+
OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI
27+
);
28+
}
29+
30+
@Override
31+
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
32+
OAuth2AuthorizationRequest req = createDefaultResolver().resolve(request);
33+
return customizeState(req, request);
34+
}
35+
36+
@Override
37+
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
38+
OAuth2AuthorizationRequest req = createDefaultResolver().resolve(request, clientRegistrationId);
39+
return customizeState(req, request);
40+
}
41+
42+
private OAuth2AuthorizationRequest customizeState(OAuth2AuthorizationRequest req, HttpServletRequest request) {
43+
if (req == null) return null;
44+
45+
// 요청 파라미터에서 redirectUrl 가져오기
46+
String redirectUrl = request.getParameter("redirectUrl");
47+
if (redirectUrl == null) redirectUrl = "/";
48+
49+
// CSRF 방지용 nonce 추가
50+
String originState = UUID.randomUUID().toString();
51+
52+
// redirectUrl#originState 결합
53+
String rawState = redirectUrl + "#" + originState;
54+
55+
// Base64 URL-safe 인코딩
56+
String encodedState = Base64.getUrlEncoder().encodeToString(rawState.getBytes(StandardCharsets.UTF_8));
57+
58+
return OAuth2AuthorizationRequest.from(req)
59+
.state(encodedState) // state 교체
60+
.build();
61+
}
62+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.back.global.security;
2+
3+
import jakarta.servlet.ServletException;
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import jakarta.servlet.http.HttpServletResponse;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.security.core.AuthenticationException;
9+
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.io.IOException;
13+
14+
@Component
15+
@Slf4j
16+
public class CustomOAuth2LoginFailureHandler implements AuthenticationFailureHandler {
17+
18+
@Value("${FRONTEND_URL}")
19+
private String frontendUrl;
20+
21+
@Override
22+
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
23+
AuthenticationException exception) throws IOException, ServletException {
24+
25+
log.error("OAuth2 로그인 실패: {}", exception.getMessage());
26+
27+
// 프론트엔드 에러 페이지로 리다이렉트
28+
String redirectUrl = frontendUrl + "/oauth/error?message=" + exception.getMessage();
29+
30+
response.sendRedirect(redirectUrl);
31+
}
32+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.back.global.security;
2+
3+
import com.back.domain.user.service.UserService;
4+
import com.back.global.jwt.JwtUtil;
5+
import com.back.global.rq.Rq;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.beans.factory.annotation.Value;
11+
import org.springframework.security.core.Authentication;
12+
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
13+
import org.springframework.stereotype.Component;
14+
15+
import java.io.IOException;
16+
import java.util.concurrent.TimeUnit;
17+
18+
@Component
19+
@RequiredArgsConstructor
20+
public class CustomOAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
21+
private final Rq rq;
22+
private final JwtUtil jwtUtil;
23+
private final UserService userService;
24+
25+
@Value("${FRONTEND_URL}")
26+
private String frontendUrl;
27+
28+
@Override
29+
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
30+
SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
31+
32+
// Access Token 생성
33+
String accessToken = jwtUtil.generateAccessToken(securityUser.getId(), securityUser.getEmail());
34+
35+
// 쿠키에 토큰 저장
36+
rq.setCrossDomainCookie("accessToken", accessToken, (int) TimeUnit.MINUTES.toSeconds(20));
37+
38+
// 프론트엔드로 리다이렉트
39+
String redirectUrl = frontendUrl + "/oauth/success";
40+
41+
response.sendRedirect(redirectUrl);
42+
}
43+
}

0 commit comments

Comments
 (0)