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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ dependencies {
implementation 'software.amazon.awssdk:rekognition'
// ✅ SMTP
implementation 'org.springframework.boot:spring-boot-starter-mail'
// ✅ Oauth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// SpringAI API
implementation 'io.grpc:grpc-xds:1.62.2'
Expand Down
39 changes: 31 additions & 8 deletions src/main/java/io/github/petty/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import io.github.petty.users.jwt.JWTFilter;
import io.github.petty.users.jwt.JWTUtil;
import io.github.petty.users.jwt.LoginFilter;
import io.github.petty.users.oauth2.CustomOAuth2UserService;
import io.github.petty.users.oauth2.OAuth2SuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
Expand All @@ -18,16 +20,22 @@
@EnableWebSecurity
public class SecurityConfig {

//AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
private final AuthenticationConfiguration authenticationConfiguration;
private final JWTUtil jwtUtil;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;

public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) {
public SecurityConfig(
AuthenticationConfiguration authenticationConfiguration,
JWTUtil jwtUtil,
CustomOAuth2UserService customOAuth2UserService,
OAuth2SuccessHandler oAuth2SuccessHandler) {
this.authenticationConfiguration = authenticationConfiguration;
this.jwtUtil = jwtUtil;
this.customOAuth2UserService = customOAuth2UserService;
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
}

//AuthenticationManager Bean 등록
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
Expand All @@ -41,18 +49,33 @@ public BCryptPasswordEncoder bCryptPasswordEncoder() {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // CSRF 비활성화
.formLogin(form -> form.disable()) // 로그인 비활성화
.httpBasic(basic -> basic.disable()) // HTTP Basic 인증 비활성화
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/admin").hasRole("ADMIN")
.requestMatchers("/user").authenticated() // 로그인이 필요한 페이지
.requestMatchers("/user").authenticated()
.requestMatchers("/login/**", "/oauth2/**").permitAll()
.anyRequest().permitAll())
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
.successHandler(oAuth2SuccessHandler)
)
Comment on lines +60 to +66
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

OAuth2 로그인 구성 검증

OAuth2 로그인 구성이 적절히 설정되었지만, OAuth2 클라이언트 설정(client-id, client-secret 등)이 application.yml 또는 properties 파일에 올바르게 구성되어 있는지 확인이 필요합니다.


🏁 Script executed:

#!/bin/bash
# GitHub OAuth2 클라이언트 설정 확인
echo "GitHub OAuth2 클라이언트 설정 확인"
grep -E "spring\.security\.oauth2.*github" --include="*.yml" --include="*.properties" -r src/main/resources/
echo "---"
echo "OAuth2 관련 설정 확인"
grep -E "oauth2" --include="*.yml" --include="*.properties" -r src/main/resources/

Length of output: 310


🏁 Script executed:

#!/bin/bash
# 리소스 디렉터리에서 .yml, .yaml, .properties 확장자를 포함한 OAuth2 클라이언트 설정 확인
echo "OAuth2 클라이언트 설정 확인 (.yml, .yaml, .properties)"
grep -R -E "spring\.security\.oauth2\.client" --include="*.yml" --include="*.yaml" --include="*.properties" src/main/resources/

Length of output: 230


OAuth2 클라이언트 설정 누락 확인

src/main/resources 경로에 OAuth2 로그인에 필요한 클라이언트 등록 정보(client-id, client-secret 등)가 발견되지 않습니다. OAuth2 로그인이 정상 동작하려면 아래와 같이 설정을 추가해야 합니다.

  • 설정 파일: src/main/resources/application.yml 또는 src/main/resources/application.properties
  • 예시 (application.yml):
    spring:
      security:
        oauth2:
          client:
            registration:
              github:
                client-id: <YOUR_CLIENT_ID>
                client-secret: <YOUR_CLIENT_SECRET>
                scope: user:email
            provider:
              github:
                authorization-uri: https://github.com/login/oauth/authorize
                token-uri: https://github.com/login/oauth/access_token
                user-info-uri: https://api.github.com/user
                user-name-attribute: id
  • client-secret 등 민감 정보는 환경 변수나 CI/CD 비밀 관리자를 통해 안전하게 관리하세요.

위 설정 추가 후 OAuth2 로그인 흐름이 정상 동작하는지 테스트해주세요.

.logout(logout -> logout
.logoutUrl("/logout") // 클라이언트 측 로그아웃 요청 URL과 일치
.logoutSuccessUrl("/") // 로그아웃 성공 후 리다이렉트 URL
.deleteCookies("jwt") // 로그아웃 시 jwt 쿠키 삭제
.invalidateHttpSession(true)
.clearAuthentication(true)
)
.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class)
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class)
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

return http.build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
Expand Down Expand Up @@ -62,13 +63,13 @@ public DataSource supabaseDataSource() {
public LocalContainerEntityManagerFactoryBean supabaseEntityManagerFactory(
// datasource 2개 이상일 경우 명시
@Qualifier("supabaseDataSource") DataSource dataSource,
EntityManagerFactoryBuilder builder
) {
EntityManagerFactoryBuilder builder,
@Value("${spring.jpa.hibernate.ddl-auto}") String ddlAuto) { // ddl-auto 주입
Map<String, Object> jpaProperties = new HashMap<>();
jpaProperties.put("hibernate.physical_naming_strategy", "org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy");

// ddl.auto
jpaProperties.put("hibernate.hbm2ddl.auto", "update");
jpaProperties.put("hibernate.hbm2ddl.auto", ddlAuto);

return builder.dataSource(dataSource)
.packages(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
package io.github.petty.users.controller;

import io.github.petty.users.dto.CustomUserDetails;
import io.github.petty.users.dto.JoinDTO;
import io.github.petty.users.dto.UserProfileEditDTO;
import io.github.petty.users.entity.Users;
import io.github.petty.users.service.JoinService;
import io.github.petty.users.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.security.core.Authentication;

import java.util.UUID;

@Controller
public class UsersController {

private final JoinService joinService;
private final UserService userService;


public UsersController(JoinService joinService) {
public UsersController(JoinService joinService, UserService userService) {
this.joinService = joinService;
this.userService = userService;
}

@GetMapping("/join")
Expand All @@ -27,9 +41,9 @@ public String joinForm(Model model) {
}

@PostMapping("/join")
public String joinProcess(JoinDTO joinDTO, RedirectAttributes redirectAttributes) {
public String joinProcess(JoinDTO JoinDTO, RedirectAttributes redirectAttributes) {

Boolean joinResult = joinService.joinProcess(joinDTO);
Boolean joinResult = joinService.joinProcess(JoinDTO);
if (!joinResult) {
redirectAttributes.addFlashAttribute("error", "이미 존재하는 계정입니다!");
return "redirect:/";
Expand All @@ -47,6 +61,49 @@ public String loginForm() {
}
return "login";
}

@GetMapping("/profile/edit")
public String editProfileForm(Model model) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
return "redirect:/login";
}

Object principal = authentication.getPrincipal();
UUID currentUserId = userService.getCurrentUserId(principal);
UserProfileEditDTO userProfile = userService.getUserById(currentUserId);

model.addAttribute("userProfile", userProfile);

return "profile_edit";
}

@PostMapping("/profile/update")
public String updateProfile(@ModelAttribute UserProfileEditDTO userProfileEditDTO,
RedirectAttributes redirectAttributes) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
return "redirect:/login";
}

Object principal = authentication.getPrincipal();
UUID currentUserId = userService.getCurrentUserId(principal);

try {
// 사용자 정보 수정
userService.updateUserProfile(currentUserId, userProfileEditDTO);
redirectAttributes.addFlashAttribute("successMessage", "프로필이 성공적으로 수정되었습니다.");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "프로필 수정 중 오류가 발생했습니다: " + e.getMessage());
}
Comment on lines +96 to +102
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

예외 처리 범위가 너무 넓습니다.

현재 모든 예외(Exception)를 catch하고 있는데, 이는 너무 광범위합니다. UserService에서 발생할 수 있는 특정 예외들을 명시적으로 처리하는 것이 더 좋습니다.

     try {
         // 사용자 정보 수정
         userService.updateUserProfile(currentUserId, userProfileEditDTO);
         redirectAttributes.addFlashAttribute("successMessage", "프로필이 성공적으로 수정되었습니다.");
-    } catch (Exception e) {
+    } catch (RuntimeException e) {
         redirectAttributes.addFlashAttribute("errorMessage", "프로필 수정 중 오류가 발생했습니다: " + e.getMessage());
+    } catch (Exception e) {
+        redirectAttributes.addFlashAttribute("errorMessage", "서버 오류가 발생했습니다. 관리자에게 문의하세요.");
+        // 로깅 추가
+        // log.error("프로필 수정 중 예상치 못한 오류 발생: ", e);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
// 사용자 정보 수정
userService.updateUserProfile(currentUserId, userProfileEditDTO);
redirectAttributes.addFlashAttribute("successMessage", "프로필이 성공적으로 수정되었습니다.");
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "프로필 수정 중 오류가 발생했습니다: " + e.getMessage());
}
try {
// 사용자 정보 수정
userService.updateUserProfile(currentUserId, userProfileEditDTO);
redirectAttributes.addFlashAttribute("successMessage", "프로필이 성공적으로 수정되었습니다.");
} catch (RuntimeException e) {
redirectAttributes.addFlashAttribute("errorMessage", "프로필 수정 중 오류가 발생했습니다: " + e.getMessage());
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "서버 오류가 발생했습니다. 관리자에게 문의하세요.");
// 로깅 추가
// log.error("프로필 수정 중 예상치 못한 오류 발생: ", e);
}


// 수정 완료 후 메인 페이지로
return "redirect:/";
}
}


15 changes: 15 additions & 0 deletions src/main/java/io/github/petty/users/dto/UserProfileEditDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.github.petty.users.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserProfileEditDTO {
private String displayName; // 사용자 이름
private String phone; // 전화번호
}
Comment on lines +8 to +15
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

유효성 검증 추가 필요.

사용자 프로필 편집을 위한 DTO 생성은 좋은 접근법이지만, 입력값에 대한 유효성 검증이 누락되어 있습니다. Jakarta Validation API를 사용하여 필드에 유효성 검증 어노테이션을 추가하는 것이 좋습니다.

다음과 같이 개선할 수 있습니다:

+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.Setter;

 @Getter
 @Setter
 @NoArgsConstructor
 @AllArgsConstructor
 public class UserProfileEditDTO {
+    @NotBlank(message = "이름은 필수 입력 항목입니다")
     private String displayName;  // 사용자 이름
+    @NotBlank(message = "전화번호는 필수 입력 항목입니다")
+    @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "유효한 전화번호 형식이 아닙니다")
     private String phone;        // 전화번호
 }

이렇게 하면 컨트롤러에서 @Valid 어노테이션을 사용하여 입력값을 검증할 수 있으며, 사용자에게 명확한 오류 메시지를 제공할 수 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserProfileEditDTO {
private String displayName; // 사용자 이름
private String phone; // 전화번호
}
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserProfileEditDTO {
@NotBlank(message = "이름은 필수 입력 항목입니다")
private String displayName; // 사용자 이름
@NotBlank(message = "전화번호는 필수 입력 항목입니다")
@Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "유효한 전화번호 형식이 아닙니다")
private String phone; // 전화번호
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;

import javax.validation.constraints.Size;
import java.time.LocalDateTime;

@Entity
Expand All @@ -15,9 +16,11 @@ public class EmailVerification {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
@Column(nullable = false, length = 255)
private String email;

@Column(nullable = false)
@Size(min = 6, max = 6)
private String code;

@CreationTimestamp
Expand Down
31 changes: 23 additions & 8 deletions src/main/java/io/github/petty/users/entity/Users.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
package io.github.petty.users.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;
Expand All @@ -20,12 +15,32 @@ public class Users {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;

@Column(nullable = false, unique = true, length = 255)
private String username; // email

@Column(nullable = false, length = 60) // BCrypt로 해시된 비밀번호는 보통 60자의 길이를 가짐
@ToString.Exclude // toString()에서 비밀번호가 출력되지 않도록 제외
@JsonIgnore // 응답에서 비밀번호가 직렬화되지 않도록 제외
private String password;

@Column(nullable = false, length = 50)
private String displayName;

@Column(length = 20)
private String phone;

@Column(nullable = false, length = 20)
private String role;


// Oauth2 관련
@Column(nullable = false, length = 20)
private String provider; // oauth2 provider

@Column(length = 100)
private String providerId; // oauth2 provider id

Comment on lines +37 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

OAuth2 관련 필드 추가

GitHub OAuth2 로그인 지원을 위한 필드 추가가 적절히 이루어졌습니다. provider 필드는 인증 제공자를 구분하기 위해 nullable=false로 설정되었고, providerId는 외부 제공자의 사용자 ID를 저장하기 위해 추가되었습니다.

다만, 기존 사용자 데이터가 있을 경우 provider 필드의 nullable=false 제약으로 인해 마이그레이션 이슈가 발생할 수 있습니다. 이에 대한 마이그레이션 계획이 필요합니다.


🏁 Script executed:

#!/bin/bash
# OAuth2 관련 필드 마이그레이션 검증
echo "JPA 설정 확인"
grep -E "spring\.jpa\.hibernate\.ddl-auto" --include="*.yml" --include="*.properties" -r src/main/resources/
echo "---"
echo "마이그레이션 관련 코드(JoinService) 확인"
grep -E "provider" --include="*.java" -r src/main/java/io/github/petty/users/service/

Length of output: 307


마이그레이션 전략 추가 필요: provider 필드 non-null 제약 처리

provider 칼럼(nullable=false) 추가는 OAuth2 지원을 위해 적절하나, 아래와 같이 기존 데이터에 대한 처리가 전혀 마련되어 있지 않습니다.

  • JPA DDL 자동업데이트 설정(spring.jpa.hibernate.ddl-auto)이 확인되지 않음
  • Flyway/Liquibase 등 마이그레이션 스크립트(예: src/main/resources/db/migration/*) 미발견
  • 서비스 로직(JoinService 등)에서 기존 사용자에 대한 provider 기본값 할당 코드 없음

기존 사용자 레코드에 provider 값을 채우지 않은 상태로 칼럼을 non-null로 변경하면 애플리케이션 구동 또는 배포 시 오류가 발생합니다.
다음 중 한 가지 이상의 방안을 적용해 마이그레이션 계획을 수립해주세요:

  • Flyway/Liquibase 마이그레이션 스크립트에 DEFAULT 값 설정 또는 기존 레코드 UPDATE 쿼리 추가
  • 컬럼을 우선 nullable=true 로 변경 → 기존 데이터 백필 후 → nullable=false 로 제약 재추가
  • 또는 spring.jpa.hibernate.ddl-auto=update 등을 임시로 사용해 자동 업데이트 허용 후, 별도 스크립트로 제약 강화

위 전략을 반영해 배포 전 데이터 무결성을 보장하시기 바랍니다.

@CreationTimestamp
private LocalDateTime createdAt;
}
46 changes: 31 additions & 15 deletions src/main/java/io/github/petty/users/jwt/JWTFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import io.github.petty.users.entity.Users;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
Expand All @@ -16,35 +19,48 @@
public class JWTFilter extends OncePerRequestFilter {

private final JWTUtil jwtUtil;
private static final Logger logger = LoggerFactory.getLogger(JWTFilter.class);


public JWTFilter(JWTUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = null;

// 1. 쿠키에서 토큰 확인
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("jwt".equals(cookie.getName())) {
token = cookie.getValue();
logger.debug("Token found in Cookie");
break;
}
}
}

//request에서 Authorization 헤더를 찾음
String authorization= request.getHeader("Authorization");

//Authorization 헤더 검증
if (authorization == null || !authorization.startsWith("Bearer ")) {
// 2. 쿠키에 토큰이 없으면 Authorization 헤더에서 확인
if (token == null) {
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
token = authorization.split(" ")[1];
logger.debug("Token found in Authorization header");
}
}

System.out.println("token null");
// 토큰이 없는 경우 필터 체인 계속 진행
if (token == null) {
logger.debug("Token not found in either Cookie or Authorization header");
filterChain.doFilter(request, response);

//조건이 해당되면 메소드 종료 (필수)
return;
}

System.out.println("authorization now");
//Bearer 부분 제거 후 순수 토큰만 획득
String token = authorization.split(" ")[1];

//토큰 소멸 시간 검증
// 토큰 소멸 시간 검증
if (jwtUtil.isExpired(token)) {

System.out.println("token expired");
logger.debug("Token expired");
SecurityContextHolder.clearContext(); // 인증정보 삭제
// 토큰이 만료되었을 때 401 Unauthorized 응답을 반환
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 상태 코드
response.getWriter().write("{\"error\": \"Token has expired\"}"); // 에러 메시지
Expand Down
Loading