Vue와 연결을 위한 설정

뚜우웅이·2025년 4월 13일

캡스톤 디자인

목록 보기
9/35

yml 수정

application.yml

frontend:
  oauth:
    # 소셜 로그인 성공 후 토큰을 전달하며 리다이렉션될 Vue 페이지 경로
    callback-url: http://localhost:8081/oauth/callback # 예시: Vue 개발 서버 + 콜백 라우트
  reset-password-url: http://localhost:8081/reset-password # 실제 사용할 프론트엔드 URL로 변경

CORS Config 생성

WebConfig

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**") // /api/** 경로에 대해 CORS 적용
                .allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081") // 허용할 출처 (Vue 개발 서버 주소)
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") // 허용할 HTTP 메서드
                .allowedHeaders("*") // 모든 헤더 허용
                .allowCredentials(true) // 인증 정보(쿠키, JWT 등) 허용
                .maxAge(3600); // pre-flight 요청 캐시 시간 ()
        // 필요시 다른 경로 추가 (: /oauth2/**)
        registry.addMapping("/oauth2/**")
                .allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081")
                .allowedMethods("GET", "POST", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
        registry.addMapping("/login/oauth2/code/**")
                .allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081")
                .allowedMethods("GET", "POST", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}
  • /api/** 경로: http://localhost:8081 또는 http://127.0.0.1:8081 (주로 Vue 개발 서버)에서 오는 모든 종류의 API 요청(GET, POST, PUT, PATCH, DELETE, OPTIONS)을 허용한다. 모든 헤더와 인증 정보(쿠키, JWT 토큰 등) 전송도 허용한다.

  • /oauth2/** 경로: 소셜 로그인을 시작하는 경로에 대해서도 동일한 출처에서의 GET, POST, OPTIONS 요청 및 인증 정보 전송을 허용한다.

  • /login/oauth2/code/** 경로: 소셜 로그인 후 백엔드로 돌아오는 콜백 경로에 대해서도 동일한 출처에서의 GET, POST, OPTIONS 요청 및 인증 정보 전송을 허용한다.

  • 결론적으로, 이 설정은 포트 8081에서 실행되는 프론트엔드(Vue) 애플리케이션이 백엔드 서버의 API 및 OAuth2 관련 기능과 안전하게 통신할 수 있도록 허용하는 역할을 한다.

접근 제한 변경

WebSecurityConfig

// 요청별 인가 설정
                .authorizeHttpRequests(authorize -> authorize
                        // 공개 경로 설정
                        // 정적 리소스, 기본 페이지, 인증/OAuth 관련 API 등 명시적 허용
                        .requestMatchers("/", "/index.html", "/favicon.ico", "/static/**", "/assets/**", "/js/**", "/css/**" ).permitAll()
                        // // --- Vue Router가 처리할 경로들 ---
                        .requestMatchers("/reset-password",  "/oauth/callback").permitAll()

                        .requestMatchers("/api/auth/**").permitAll() // 일반 로그인/회원가입 API
                        .requestMatchers("/oauth2/**").permitAll()            // OAuth2 로그인 시작 URL (e.g., /oauth2/authorization/google)
                        .requestMatchers("/login/oauth2/code/**").permitAll() // OAuth2 리다이렉션 URI
                        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용
                        .requestMatchers("/h2-console/**").permitAll() // H2 콘솔 허용

                        // /api 로 시작하는 경로는 인증 요구 (위에서 permitAll된 /api/auth/** 제외)
                        .requestMatchers("/api/**").authenticated()
                        .anyRequest().permitAll() // 나머지 요청은 허용
                )

API 경로 외 명시적으로 허용되지 않은 경로는 permitAll() 처리하여, 해당 요청이 프론트엔드로 전달되고 Vue 라우터가 클라이언트 사이드 라우팅을 처리할 수 있도록 한다.

소셜 로그인 핸들러

OAuth2LoginSuccessHandler

@Slf4j
@Component
@Transactional // 트랜잭션 추가 (사용자 생성/업데이트, 리프레시 토큰 저장)
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;
    private final UserRepository userRepository;
    private final ObjectMapper objectMapper; // 오류 응답 전송 시 필요할 수 있음

    // 프론트엔드 콜백 URL 주입
    @Value("${frontend.oauth.callback-url}")
    private String frontendCallbackUrl;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("OAuth2 Login Success Handler: Authentication successful.");
        clearAuthenticationAttributes(request); // 이전 인증 정보 클리어

        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        Map<String, Object> attributesMap = oAuth2User.getAttributes();
        String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId();

        log.debug("소셜 로그인 - provider: {}, attributes: {}", registrationId, attributesMap);

        User user;
        try {
            // 네이버 로그인 특별 처리 (응답 구조가 다를 수 있음)
            if ("naver".equals(registrationId)) {
                user = processNaverLogin(attributesMap);
            } else { // 구글, 카카오 등 다른 소셜 로그인 처리
                String userNameAttributeName = getUserNameAttributeName(registrationId);
                OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, attributesMap);
                user = findOrCreateUser(attributes);
            }
        } catch (Exception e) {
            log.error("소셜 로그인 사용자 처리 중 오류 발생: {}", e.getMessage(), e);
            // 오류 발생 시 사용자에게 알릴 오류 페이지로 리다이렉트하거나 오류 응답 전송
            sendErrorResponse(response, "소셜 로그인 처리 중 오류가 발생했습니다.");
            return;
        }


        // 사용자 정보 기반으로 JWT 생성 및 프론트엔드로 리다이렉션
        sendTokenViaRedirect(user, request, response);
    }

    // 네이버 로그인 처리 로직
    private User processNaverLogin(Map<String, Object> attributesMap) {
        Map<String, Object> responseData;
        if (attributesMap.containsKey("response")) {
            responseData = (Map<String, Object>) attributesMap.get("response");
        } else {
            responseData = attributesMap; // 최상위 레벨에 데이터가 있는 경우
        }

        if (responseData == null || !responseData.containsKey("id")) {
            log.error("네이버 응답에서 사용자 ID를 찾을 수 없습니다");
            throw new IllegalStateException("네이버 로그인 처리 중 필수 정보(ID) 누락");
        }

        String providerId = String.valueOf(responseData.get("id"));
        String email = (String) responseData.get("email");
        String name = (String) responseData.get("name");

        Optional<User> existingUser = userRepository.findByProviderAndProviderId("naver", providerId);

        if (existingUser.isPresent()) {
            User user = existingUser.get();
            if (name != null) { // 이름 정보가 있으면 업데이트
                user.updateOAuthInfo(name);
            }
            log.info("기존 네이버 사용자 로그인: providerId={}", providerId);
            return user;
        } else {
            // 이메일 중복 체크 및 처리 (선택적이지만 권장)
            String userEmail = email != null ? email : providerId + "@naver.com"; // 이메일 없으면 임시 생성
            if (email != null && userRepository.existsByEmail(email)) {
                log.warn("네이버 로그인 시도: 이미 가입된 이메일 {}", email);
                // 이미 가입된 이메일에 네이버 계정 정보를 연결하거나, 에러 처리
                // 예: 기존 계정에 provider 정보 업데이트
                User userByEmail = userRepository.findByEmail(email)
                        .orElseThrow(() -> new IllegalStateException("이메일 존재하나 사용자 못찾음: " + email)); // 비정상 케이스
                userByEmail.updateProvider("naver", providerId);
                if (name != null) userByEmail.updateOAuthInfo(name);
                log.info("기존 이메일({}) 계정에 네이버 계정(ID:{}) 연결", email, providerId);
                return userByEmail;
                // 또는 throw new IllegalStateException("이미 가입된 이메일입니다: " + email);
            }

            // 신규 사용자 등록
            User newUser = User.builder()
                    .name(name != null ? name : "네이버 사용자") // 이름 없으면 기본값
                    .email(userEmail)
                    .password(UUID.randomUUID().toString()) // 비밀번호는 임의값 사용
                    .role(UserRole.ROLE_USER)
                    .enabled(true)
                    .provider("naver")
                    .providerId(providerId)
                    .build();
            userRepository.save(newUser);
            log.info("새 네이버 사용자 등록: providerId={}", providerId);
            return newUser;
        }
    }


    // 구글, 카카오 등 사용자 찾기 또는 생성 메서드
    private User findOrCreateUser(OAuthAttributes attributes) {
        Optional<User> existingUser = userRepository.findByProviderAndProviderId(
                attributes.provider(), attributes.providerId());

        if (existingUser.isPresent()) {
            User user = existingUser.get();
            user.updateOAuthInfo(attributes.name()); // 이름 등 변경사항 업데이트
            log.info("기존 {} 사용자 로그인: providerId={}, email={}",
                    attributes.provider(), attributes.providerId(), user.getEmail());
            return user;
        }

        // 이메일로 기존 사용자 찾기 (다른 방식으로 가입했을 수 있음)
        if (attributes.email() != null) {
            Optional<User> userByEmail = userRepository.findByEmail(attributes.email());
            if (userByEmail.isPresent()) {
                User user = userByEmail.get();
                // 기존 계정에 소셜 로그인 정보 연결
                user.updateProvider(attributes.provider(), attributes.providerId());
                user.updateOAuthInfo(attributes.name()); // 이름 업데이트
                log.info("기존 이메일({}) 사용자에 {} 계정 연결: providerId={}",
                        user.getEmail(), attributes.provider(), attributes.providerId());
                return user;
            }
        }

        // 신규 사용자 생성
        User newUser = attributes.toEntity(); // OAuthAttributes에서 User 엔티티 생성
        userRepository.save(newUser);
        log.info("새 {} 사용자 등록: providerId={}, email={}",
                attributes.provider(), attributes.providerId(), newUser.getEmail());
        return newUser;
    }

    // JWT 토큰 생성 및 프론트엔드 콜백 URL로 리다이렉션
    private void sendTokenViaRedirect(User user, HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 1. CustomUserDetails 생성 (JWT 페이로드에 넣을 정보 포함)
        CustomUserDetails userDetails = new CustomUserDetails(
                user.getId(),
                user.getEmail(),
                user.getPassword(), // 비밀번호는 JWT 생성 시 직접 사용되지는 않음
                user.getRole().name(),
                user.isEnabled()
        );

        // 2. Authentication 객체 생성 (JWT 생성용)
        Authentication jwtAuthentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());

        // 3. JWT 토큰 생성 (Access Token, Refresh Token)
        String accessToken = jwtProvider.createAccessToken(jwtAuthentication);
        String refreshToken = jwtProvider.createRefreshToken(jwtAuthentication);

        // 4. Refresh Token DB 저장/업데이트
        refreshTokenService.saveRefreshToken(refreshToken, user.getId());

        // 5. 토큰 URL 인코딩 (URL에 포함시키기 위함)
        String encodedAccessToken = URLEncoder.encode(accessToken, StandardCharsets.UTF_8);
        String encodedRefreshToken = URLEncoder.encode(refreshToken, StandardCharsets.UTF_8);

        // 6. 프론트엔드 콜백 URL 생성 (토큰 포함)
        String targetUrl = UriComponentsBuilder.fromUriString(frontendCallbackUrl)
                .queryParam("accessToken", encodedAccessToken)
                .queryParam("refreshToken", encodedRefreshToken)
                // 필요시 추가 정보 전달 (예: 최초 로그인 여부 등)
                // .queryParam("isNewUser", ...)
                .build().toUriString();

        // 7. 프론트엔드로 리다이렉션
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
        log.info("OAuth2 로그인 성공, 프론트엔드({})로 토큰과 함께 리다이렉션 완료.", targetUrl);
    }

    // registrationId 기반으로 userNameAttributeName 결정
    private String getUserNameAttributeName(String registrationId) {
        // OIDC 표준은 'sub'를 사용.
        return switch (registrationId.toLowerCase()) {
            case "google" -> "sub";
            case "kakao" -> "sub";
            case "naver" -> "response"; // 네이버는 고유 구조 사용 (실제 ID는 response 객체 내 'id')
            default -> throw new IllegalArgumentException("지원하지 않는 소셜 로그인입니다: " + registrationId);
        };
    }

    // 오류 발생 시 응답 전송
    private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        // 간단한 오류 메시지만 전달하거나, ResponseDTO 사용
        ResponseDTO<Void> responseDTO = ResponseDTO.error(HttpServletResponse.SC_BAD_REQUEST, message);
        response.getWriter().write(objectMapper.writeValueAsString(responseDTO));
        response.getWriter().flush();
    }
}
  • 전에 작성했던 코드에서 최적화도 해주었다.

  • 프론트엔드로 리다이렉션
    생성된 Access TokenRefresh Token을 URL 쿼리 파라미터 형태로 포함하여, 사전에 설정된 프론트엔드(Vue)의 특정 콜백 URL (rontend.oauth.callback-url 값)로 사용자의 브라우저를 리다이렉션 시킨다. (토큰 값은 URL 인코딩 처리됨)

Swagger용 컨트롤러

SocialLoginDocController

@Tag(name = "소셜 로그인 (OAuth2)", description = "소셜 로그인 시작 (문서용)")
@RestController
public class SocialLoginDocController {

    @Operation(summary = "소셜 로그인 시작 (Google, Kakao, Naver 등)",
            description = "이 경로로 GET 요청을 보내면(브라우저에서 링크 클릭) 해당 소셜 서비스 로그인 페이지로 리다이렉션됩니다. API 클라이언트에서 직접 호출하는 용도가 아닙니다." +
                    "http://localhost:8081/oauth/callback로 콜백 요청을 보냅니다.")
    @GetMapping("/oauth2/authorization/{provider}")
    public void socialLoginRedirect(@PathVariable String provider) {
        // 실제 로직은 없음. Spring Security가 처리.
        // 이 메서드는 Swagger 문서 생성을 위해 존재.
    }
}

Swagger 문서 표기를 위한 목적이기 때문에 로직이 없다.

Email 서비스

sendPasswordResetEmail

// 프론트엔드 비밀번호 재설정 URL 주입
    @Value("${frontend.reset-password-url}")
    private String frontendResetPasswordUrl;
    
     public void sendPasswordResetEmail(String to, String resetToken) {
        String subject = "FreeMarket 비밀번호 재설정";
        String resetUrl = frontendResetPasswordUrl + "?token=" + resetToken;

        String content = """
                <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
                    <h2>비밀번호 재설정</h2>
                    <p>안녕하세요. FreeMarket 비밀번호 재설정을 요청하셨습니다.</p>
                    <p>아래 링크를 클릭하여 비밀번호를 재설정해 주세요:</p>
                    <p><a href="%s" style="display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px;">비밀번호 재설정</a></p>
                    <p>이 링크는 30분 동안만 유효합니다.</p>
                    <p>비밀번호 재설정을 요청하지 않으셨다면 이 이메일을 무시하셔도 됩니다.</p>
                    <p>감사합니다.<br>FreeMarket</p>
                </div>
                """.formatted(resetUrl);

        sendEmail(to, subject, content);
    }

이메일에 들어가서 비밀번호 재설정 버튼을 누르게 되면 yml에서 설정 해둔 url로 이동하게 된다. 리다이렉트 된 url에서 새 비밀번호를 입력폼에 입력하고 REST API를 호출한다.

경로 (Endpoint): /api/auth/password/reset-verify

profile
공부하는 초보 개발자

0개의 댓글