Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")

implementation("io.github.cdimascio:java-dotenv:5.2.2")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
implementation("me.paulschwarz:spring-dotenv:4.0.0")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2")
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/back/BackApplication.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back;

import io.github.cdimascio.dotenv.Dotenv;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
Expand All @@ -9,6 +10,11 @@
public class BackApplication {

public static void main(String[] args) {
Dotenv dotenv = Dotenv.load();
System.out.println("KAKAO_OAUTH2_CLIENT_ID: " + dotenv.get("KAKAO_OAUTH2_CLIENT_ID"));
System.out.println("GOOGLE_OAUTH2_CLIENT_ID: " + dotenv.get("GOOGLE_OAUTH2_CLIENT_ID"));
System.out.println("NAVER_OAUTH2_CLIENT_ID: " + dotenv.get("NAVER_OAUTH2_CLIENT_ID"));

SpringApplication.run(BackApplication.class, args);
}

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/back/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public class User {
@Column(nullable = false, unique = true, length = 50)
private String nickname; // 고유 닉네임

// OAuth2 관련 필드
@Column(unique = true, length = 100)
private String oauthId; // OAuth 제공자별 고유 ID (예: kakao_123456789)

private Double abvDegree; // 알콜도수(회원 등급)

@CreatedDate // JPA Auditing 적용
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

Optional<User> findByOauthId(String oauthId);
Optional<User> findByEmail(String email);
Optional<User> findByNickname(String nickname);
boolean existsByNicknameAndIdNot(String nickname, Long id);
}

62 changes: 62 additions & 0 deletions src/main/java/com/back/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

import com.back.domain.user.entity.User;
import com.back.domain.user.repository.UserRepository;
import com.back.global.exception.ServiceException;
import com.back.global.rsData.RsData;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class UserService {
Expand All @@ -17,4 +22,61 @@ public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id));
}


public User joinSocial(String oauthId, String email, String nickname){
userRepository.findByOauthId(oauthId)
.ifPresent(user -> {
throw new ServiceException(409, "이미 존재하는 계정입니다.");
});

// 고유한 닉네임 생성
String uniqueNickname = generateUniqueNickname(nickname);

User user = User.builder()
.email(email)
.nickname(uniqueNickname)
.abvDegree(0.0)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.role("USER")
.oauthId(oauthId)
.build();

return userRepository.save(user);
}

@Transactional
public RsData<User> findOrCreateOAuthUser(String oauthId, String email, String nickname) {
Optional<User> existingUser = userRepository.findByOauthId(oauthId);

if (existingUser.isPresent()) {
// 기존 사용자 업데이트 (이메일만 업데이트)
User user = existingUser.get();
user.setEmail(email);
return RsData.of(200, "회원 정보가 업데이트 되었습니다", user); //더티체킹
} else {
User newUser = joinSocial(oauthId, email, nickname);
return RsData.of(201, "사용자가 생성되었습니다", newUser);
}
}

public String generateUniqueNickname(String baseNickname) {
// null이거나 빈 문자열인 경우 기본값 설정
if (baseNickname == null || baseNickname.trim().isEmpty()) {
baseNickname = "User";
}

String nickname = baseNickname;
int counter = 1;

// 중복 체크 및 고유한 닉네임 생성
while (userRepository.findByNickname(nickname).isPresent()) {
nickname = baseNickname + counter;
counter++;
}

return nickname;
}

}
6 changes: 5 additions & 1 deletion src/main/java/com/back/global/rq/Rq.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
Expand All @@ -24,6 +25,9 @@ public class Rq {
private final HttpServletResponse resp;
private final UserService userService;

@Value("${custom.cookie.secure:false}")
private boolean cookieSecure;

public User getActor() {
return Optional.ofNullable(
SecurityContextHolder
Expand Down Expand Up @@ -85,7 +89,7 @@ public void setCrossDomainCookie(String name, String value, int maxAge) {
ResponseCookie cookie = ResponseCookie.from(name, value)
.path("/")
.maxAge(maxAge)
.secure(true)
.secure(cookieSecure)
.sameSite("None")
.httpOnly(true)
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Map;

@Component
@RequiredArgsConstructor
Expand Down Expand Up @@ -54,9 +55,9 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt
if (
//추후 로그인 필요한 api 추가 설정
uri.startsWith("/h2-console") ||
uri.startsWith("/api/login/oauth2/") ||
(method.equals("GET") && uri.equals("api/~~")) ||
(method.equals("POST") && uri.equals("/api/user/login"))
uri.startsWith("/login/oauth2/") ||
(method.equals("GET") && uri.equals("/api/~~")) ||
(method.equals("POST") && uri.equals("/api/~"))

) {
filterChain.doFilter(request, response);
Expand Down Expand Up @@ -120,8 +121,8 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt
user.getId(),
user.getEmail(),
user.getNickname(),
"",
user.getAuthorities()
user.getAuthorities(),
Map.of() // JWT 인증에서는 빈 attributes
);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.back.global.security;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;

@Component
@RequiredArgsConstructor
public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {

private final ClientRegistrationRepository clientRegistrationRepository;

private DefaultOAuth2AuthorizationRequestResolver createDefaultResolver() {
// Spring Security 기본 Authorization URI 사용
return new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI
);
}

@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest req = createDefaultResolver().resolve(request);
return customizeState(req, request);
}

@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest req = createDefaultResolver().resolve(request, clientRegistrationId);
return customizeState(req, request);
}

private OAuth2AuthorizationRequest customizeState(OAuth2AuthorizationRequest req, HttpServletRequest request) {
if (req == null) return null;

// 요청 파라미터에서 redirectUrl 가져오기
String redirectUrl = request.getParameter("redirectUrl");
if (redirectUrl == null) redirectUrl = "/";

// CSRF 방지용 nonce 추가
String originState = UUID.randomUUID().toString();

// redirectUrl#originState 결합
String rawState = redirectUrl + "#" + originState;

// Base64 URL-safe 인코딩
String encodedState = Base64.getUrlEncoder().encodeToString(rawState.getBytes(StandardCharsets.UTF_8));

return OAuth2AuthorizationRequest.from(req)
.state(encodedState) // state 교체
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.back.global.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@Slf4j
public class CustomOAuth2LoginFailureHandler implements AuthenticationFailureHandler {

@Value("${FRONTEND_URL}")
private String frontendUrl;

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {

log.error("OAuth2 로그인 실패: {}", exception.getMessage());

// 프론트엔드 에러 페이지로 리다이렉트
String redirectUrl = frontendUrl + "/oauth/error?message=" + exception.getMessage();

response.sendRedirect(redirectUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.back.global.security;

import com.back.domain.user.service.UserService;
import com.back.global.jwt.JwtUtil;
import com.back.global.rq.Rq;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

@Component
@RequiredArgsConstructor
public class CustomOAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
private final Rq rq;
private final JwtUtil jwtUtil;
private final UserService userService;

@Value("${FRONTEND_URL}")
private String frontendUrl;

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();

// Access Token 생성
String accessToken = jwtUtil.generateAccessToken(securityUser.getId(), securityUser.getEmail());

// 쿠키에 토큰 저장
rq.setCrossDomainCookie("accessToken", accessToken, (int) TimeUnit.MINUTES.toSeconds(20));

// 프론트엔드로 리다이렉트
String redirectUrl = frontendUrl + "/oauth/success";

response.sendRedirect(redirectUrl);
}
}
Loading