From ef667db990028295906c7fa717924c4df537debf Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 14 Feb 2026 17:28:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(auth):=20JWT=20=EA=B8=B0=EB=B0=98=20Google?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=B8=EC=A6=9D=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entity: User, Role, RoleUrlPattern, UserStatus enum - Repository: UserRepository, RoleRepository (fetch join 쿼리) - Auth: GoogleTokenVerifier, JwtTokenProvider, JwtAuthenticationFilter - API: POST /api/auth/google, GET /api/auth/me, POST /api/auth/logout - DTO: AuthResponse, UserResponse, RoleResponse, GoogleLoginRequest - SecurityConfig: JWT 필터 등록, CORS 설정, 공개 엔드포인트 정의 - 초기 데이터: roles + role_url_patterns 시드 (data.sql) Co-Authored-By: Claude Opus 4.6 --- .../com/gcsc/guide/auth/AuthController.java | 96 ++++++++++++++++ .../gcsc/guide/auth/GoogleTokenVerifier.java | 57 ++++++++++ .../guide/auth/JwtAuthenticationFilter.java | 57 ++++++++++ .../com/gcsc/guide/auth/JwtTokenProvider.java | 66 +++++++++++ .../com/gcsc/guide/config/SecurityConfig.java | 41 ++++++- .../java/com/gcsc/guide/dto/AuthResponse.java | 7 ++ .../gcsc/guide/dto/GoogleLoginRequest.java | 8 ++ .../java/com/gcsc/guide/dto/RoleResponse.java | 27 +++++ .../java/com/gcsc/guide/dto/UserResponse.java | 37 +++++++ src/main/java/com/gcsc/guide/entity/Role.java | 47 ++++++++ .../com/gcsc/guide/entity/RoleUrlPattern.java | 38 +++++++ src/main/java/com/gcsc/guide/entity/User.java | 103 ++++++++++++++++++ .../com/gcsc/guide/entity/UserStatus.java | 8 ++ .../gcsc/guide/repository/RoleRepository.java | 19 ++++ .../gcsc/guide/repository/UserRepository.java | 24 ++++ src/main/resources/application.yml | 7 ++ src/main/resources/data.sql | 11 ++ 17 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/gcsc/guide/auth/AuthController.java create mode 100644 src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java create mode 100644 src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java create mode 100644 src/main/java/com/gcsc/guide/dto/AuthResponse.java create mode 100644 src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java create mode 100644 src/main/java/com/gcsc/guide/dto/RoleResponse.java create mode 100644 src/main/java/com/gcsc/guide/dto/UserResponse.java create mode 100644 src/main/java/com/gcsc/guide/entity/Role.java create mode 100644 src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java create mode 100644 src/main/java/com/gcsc/guide/entity/User.java create mode 100644 src/main/java/com/gcsc/guide/entity/UserStatus.java create mode 100644 src/main/java/com/gcsc/guide/repository/RoleRepository.java create mode 100644 src/main/java/com/gcsc/guide/repository/UserRepository.java create mode 100644 src/main/resources/data.sql diff --git a/src/main/java/com/gcsc/guide/auth/AuthController.java b/src/main/java/com/gcsc/guide/auth/AuthController.java new file mode 100644 index 0000000..174467d --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/AuthController.java @@ -0,0 +1,96 @@ +package com.gcsc.guide.auth; + +import com.gcsc.guide.dto.AuthResponse; +import com.gcsc.guide.dto.GoogleLoginRequest; +import com.gcsc.guide.dto.UserResponse; +import com.gcsc.guide.entity.User; +import com.gcsc.guide.repository.UserRepository; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr"; + + private final GoogleTokenVerifier googleTokenVerifier; + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + /** + * Google ID Token으로 로그인/회원가입 처리 후 JWT 발급 + */ + @PostMapping("/google") + public ResponseEntity googleLogin(@Valid @RequestBody GoogleLoginRequest request) { + GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken()); + if (payload == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다"); + } + + String email = payload.getEmail(); + String name = (String) payload.get("name"); + String avatarUrl = (String) payload.get("picture"); + + userRepository.findByEmail(email) + .ifPresentOrElse( + existingUser -> { + existingUser.updateProfile(name, avatarUrl); + existingUser.updateLastLogin(); + userRepository.save(existingUser); + }, + () -> createNewUser(email, name, avatarUrl) + ); + + User userWithRoles = userRepository.findByEmailWithRoles(email) + .orElseThrow(); + + String token = jwtTokenProvider.generateToken( + userWithRoles.getId(), userWithRoles.getEmail(), userWithRoles.isAdmin()); + + return ResponseEntity.ok(new AuthResponse(token, UserResponse.from(userWithRoles))); + } + + /** + * 현재 인증된 사용자 정보 조회 + */ + @GetMapping("/me") + public ResponseEntity getCurrentUser(Authentication authentication) { + Long userId = (Long) authentication.getPrincipal(); + + User user = userRepository.findByIdWithRoles(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다")); + + return ResponseEntity.ok(UserResponse.from(user)); + } + + /** + * 로그아웃 (Stateless JWT이므로 서버 측 처리 없음, 프론트에서 토큰 삭제) + */ + @PostMapping("/logout") + public ResponseEntity logout() { + return ResponseEntity.noContent().build(); + } + + private User createNewUser(String email, String name, String avatarUrl) { + User newUser = new User(email, name, avatarUrl); + + if (AUTO_ADMIN_EMAIL.equals(email)) { + newUser.activate(); + newUser.grantAdmin(); + log.info("관리자 자동 승인: {}", email); + } + + newUser.updateLastLogin(); + return userRepository.save(newUser); + } +} diff --git a/src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java b/src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java new file mode 100644 index 0000000..60dbd44 --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/GoogleTokenVerifier.java @@ -0,0 +1,57 @@ +package com.gcsc.guide.auth; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +@Slf4j +@Component +public class GoogleTokenVerifier { + + private final GoogleIdTokenVerifier verifier; + private final String allowedEmailDomain; + + public GoogleTokenVerifier( + @Value("${app.google.client-id}") String clientId, + @Value("${app.allowed-email-domain}") String allowedEmailDomain + ) { + this.verifier = new GoogleIdTokenVerifier.Builder( + new NetHttpTransport(), GsonFactory.getDefaultInstance()) + .setAudience(Collections.singletonList(clientId)) + .build(); + this.allowedEmailDomain = allowedEmailDomain; + } + + /** + * Google ID Token을 검증하고 페이로드를 반환한다. + * 검증 실패 또는 허용되지 않은 이메일 도메인이면 null을 반환한다. + */ + public GoogleIdToken.Payload verify(String idTokenString) { + try { + GoogleIdToken idToken = verifier.verify(idTokenString); + if (idToken == null) { + log.warn("Google ID Token 검증 실패: 유효하지 않은 토큰"); + return null; + } + + GoogleIdToken.Payload payload = idToken.getPayload(); + String email = payload.getEmail(); + + if (email == null || !email.endsWith("@" + allowedEmailDomain)) { + log.warn("허용되지 않은 이메일 도메인: {}", email); + return null; + } + + return payload; + } catch (Exception e) { + log.error("Google ID Token 검증 중 오류: {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java b/src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b3898fb --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.gcsc.guide.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = extractToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserIdFromToken(token); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, token, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String extractToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java new file mode 100644 index 0000000..58a0127 --- /dev/null +++ b/src/main/java/com/gcsc/guide/auth/JwtTokenProvider.java @@ -0,0 +1,66 @@ +package com.gcsc.guide.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long expirationMs; + + public JwtTokenProvider( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expiration-ms}") long expirationMs + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expirationMs = expirationMs; + } + + public String generateToken(Long userId, String email, boolean isAdmin) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationMs); + + return Jwts.builder() + .subject(userId.toString()) + .claim("email", email) + .claim("isAdmin", isAdmin) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + } + + public Long getUserIdFromToken(String token) { + Claims claims = parseToken(token); + return Long.parseLong(claims.getSubject()); + } + + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.debug("JWT 토큰 검증 실패: {}", e.getMessage()); + return false; + } + } + + private Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/gcsc/guide/config/SecurityConfig.java b/src/main/java/com/gcsc/guide/config/SecurityConfig.java index 6fb7b2a..b1386a6 100644 --- a/src/main/java/com/gcsc/guide/config/SecurityConfig.java +++ b/src/main/java/com/gcsc/guide/config/SecurityConfig.java @@ -1,28 +1,65 @@ package com.gcsc.guide.config; +import com.gcsc.guide.auth.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Value("${app.cors.allowed-origins:http://localhost:5173,https://guide.gc-si.dev}") + private List allowedOrigins; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**", "/actuator/health", "/h2-console/**").permitAll() + .requestMatchers( + "/api/auth/**", + "/api/health", + "/actuator/health", + "/h2-console/**" + ).permitAll() + .requestMatchers("/api/admin/**").authenticated() .anyRequest().authenticated() ) - .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())); + .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(allowedOrigins); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + return source; + } } diff --git a/src/main/java/com/gcsc/guide/dto/AuthResponse.java b/src/main/java/com/gcsc/guide/dto/AuthResponse.java new file mode 100644 index 0000000..ecde172 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/AuthResponse.java @@ -0,0 +1,7 @@ +package com.gcsc.guide.dto; + +public record AuthResponse( + String token, + UserResponse user +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java b/src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java new file mode 100644 index 0000000..af0a48e --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/GoogleLoginRequest.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotBlank; + +public record GoogleLoginRequest( + @NotBlank String idToken +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/RoleResponse.java b/src/main/java/com/gcsc/guide/dto/RoleResponse.java new file mode 100644 index 0000000..ef8ede5 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/RoleResponse.java @@ -0,0 +1,27 @@ +package com.gcsc.guide.dto; + +import com.gcsc.guide.entity.Role; +import com.gcsc.guide.entity.RoleUrlPattern; + +import java.util.List; + +public record RoleResponse( + Long id, + String name, + String description, + List urlPatterns +) { + + public static RoleResponse from(Role role) { + List patterns = role.getUrlPatterns().stream() + .map(RoleUrlPattern::getUrlPattern) + .toList(); + + return new RoleResponse( + role.getId(), + role.getName(), + role.getDescription(), + patterns + ); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/UserResponse.java b/src/main/java/com/gcsc/guide/dto/UserResponse.java new file mode 100644 index 0000000..ec386f2 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/UserResponse.java @@ -0,0 +1,37 @@ +package com.gcsc.guide.dto; + +import com.gcsc.guide.entity.User; + +import java.time.LocalDateTime; +import java.util.List; + +public record UserResponse( + Long id, + String email, + String name, + String avatarUrl, + String status, + boolean isAdmin, + List roles, + LocalDateTime createdAt, + LocalDateTime lastLoginAt +) { + + public static UserResponse from(User user) { + List roles = user.getRoles().stream() + .map(RoleResponse::from) + .toList(); + + return new UserResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getAvatarUrl(), + user.getStatus().name(), + user.isAdmin(), + roles, + user.getCreatedAt(), + user.getLastLoginAt() + ); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/Role.java b/src/main/java/com/gcsc/guide/entity/Role.java new file mode 100644 index 0000000..37f92d9 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/Role.java @@ -0,0 +1,47 @@ +package com.gcsc.guide.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "roles") +@Getter +@NoArgsConstructor +public class Role { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(length = 255) + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @OneToMany(mappedBy = "role", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private List urlPatterns = new ArrayList<>(); + + public Role(String name, String description) { + this.name = name; + this.description = description; + } + + public void update(String name, String description) { + this.name = name; + this.description = description; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java b/src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java new file mode 100644 index 0000000..2d02d80 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/RoleUrlPattern.java @@ -0,0 +1,38 @@ +package com.gcsc.guide.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "role_url_patterns") +@Getter +@NoArgsConstructor +public class RoleUrlPattern { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "role_id", nullable = false) + private Role role; + + @Column(name = "url_pattern", nullable = false, length = 255) + private String urlPattern; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public RoleUrlPattern(Role role, String urlPattern) { + this.role = role; + this.urlPattern = urlPattern; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/User.java b/src/main/java/com/gcsc/guide/entity/User.java new file mode 100644 index 0000000..57ff158 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/User.java @@ -0,0 +1,103 @@ +package com.gcsc.guide.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false, length = 100) + private String name; + + @Column(name = "avatar_url", length = 500) + private String avatarUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserStatus status = UserStatus.PENDING; + + @Column(name = "is_admin", nullable = false) + private boolean isAdmin = false; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles = new HashSet<>(); + + public User(String email, String name, String avatarUrl) { + this.email = email; + this.name = name; + this.avatarUrl = avatarUrl; + } + + public void activate() { + this.status = UserStatus.ACTIVE; + } + + public void reject() { + this.status = UserStatus.REJECTED; + } + + public void disable() { + this.status = UserStatus.DISABLED; + } + + public void grantAdmin() { + this.isAdmin = true; + } + + public void revokeAdmin() { + this.isAdmin = false; + } + + public void updateLastLogin() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void updateProfile(String name, String avatarUrl) { + this.name = name; + this.avatarUrl = avatarUrl; + } + + public void updateRoles(Set roles) { + this.roles = roles; + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/UserStatus.java b/src/main/java/com/gcsc/guide/entity/UserStatus.java new file mode 100644 index 0000000..f2f1946 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/UserStatus.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.entity; + +public enum UserStatus { + PENDING, + ACTIVE, + REJECTED, + DISABLED +} diff --git a/src/main/java/com/gcsc/guide/repository/RoleRepository.java b/src/main/java/com/gcsc/guide/repository/RoleRepository.java new file mode 100644 index 0000000..c8e5409 --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/RoleRepository.java @@ -0,0 +1,19 @@ +package com.gcsc.guide.repository; + +import com.gcsc.guide.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface RoleRepository extends JpaRepository { + + Optional findByName(String name); + + @Query("SELECT DISTINCT r FROM Role r LEFT JOIN FETCH r.urlPatterns") + List findAllWithUrlPatterns(); + + @Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id") + Optional findByIdWithUrlPatterns(Long id); +} diff --git a/src/main/java/com/gcsc/guide/repository/UserRepository.java b/src/main/java/com/gcsc/guide/repository/UserRepository.java new file mode 100644 index 0000000..7f2dfbe --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/UserRepository.java @@ -0,0 +1,24 @@ +package com.gcsc.guide.repository; + +import com.gcsc.guide.entity.User; +import com.gcsc.guide.entity.UserStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.urlPatterns WHERE u.id = :id") + Optional findByIdWithRoles(Long id); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.urlPatterns WHERE u.email = :email") + Optional findByEmailWithRoles(String email); + + List findByStatus(UserStatus status); + + long countByStatus(UserStatus status); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bdaa7a8..b625f85 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,10 +8,15 @@ spring: jpa: open-in-view: false + defer-datasource-initialization: true properties: hibernate: format_sql: true + jackson: + serialization: + write-dates-as-timestamps: false + server: port: ${SERVER_PORT:8080} @@ -23,6 +28,8 @@ app: google: client-id: ${GOOGLE_CLIENT_ID:} allowed-email-domain: gcsc.co.kr + cors: + allowed-origins: ${CORS_ORIGINS:http://localhost:5173,https://guide.gc-si.dev} # Actuator management: diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..475970f --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,11 @@ +-- 초기 롤 시드 데이터 +INSERT INTO roles (name, description, created_at) VALUES + ('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()), + ('DEVELOPER', '전체 개발 가이드 접근', NOW()), + ('FRONT_DEV', '프론트엔드 개발 가이드만', NOW()); + +-- 롤별 URL 패턴 +INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES + ((SELECT id FROM roles WHERE name = 'ADMIN'), '/**', NOW()), + ((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', NOW()), + ((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW());