diff --git a/build.gradle b/build.gradle index 9f9d4b1..8ffbcc3 100644 --- a/build.gradle +++ b/build.gradle @@ -20,15 +20,19 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-session-jdbc' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-session-jdbc-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + implementation 'org.springframework.boot:spring-boot-starter-security' } tasks.named('test') { diff --git a/src/main/java/com/example/user/controller/InternalUserController.java b/src/main/java/com/example/user/controller/InternalUserController.java new file mode 100644 index 0000000..355f9ee --- /dev/null +++ b/src/main/java/com/example/user/controller/InternalUserController.java @@ -0,0 +1,29 @@ +package com.example.user.controller; + +import com.example.user.dto.request.SignInRequest; +import com.example.user.dto.response.UserAuthResponse; +import com.example.user.service.InternalUserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/users") +public class InternalUserController { + + private final InternalUserService internalUserService; + + @PostMapping("/authenticate") + public ResponseEntity authenticate( + @RequestBody SignInRequest request + ) { + UserAuthResponse response = internalUserService.authenticate(request); + + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/example/user/controller/UserController.java b/src/main/java/com/example/user/controller/UserController.java new file mode 100644 index 0000000..e156198 --- /dev/null +++ b/src/main/java/com/example/user/controller/UserController.java @@ -0,0 +1,64 @@ +package com.example.user.controller; + +import com.example.user.dto.request.SignUpRequest; +import com.example.user.dto.request.UserUpdateRequest; +import com.example.user.dto.response.SignUpResponse; +import com.example.user.dto.response.UserSearchResponse; +import com.example.user.global.dto.ApiResponse; +import com.example.user.global.security.UserPrincipal; +import com.example.user.global.util.ResponseUtil; +import com.example.user.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + + @PostMapping("/signup") + public ResponseEntity> signup( + @Valid @RequestBody SignUpRequest request + ) { + SignUpResponse response = userService.signup(request); + + ApiResponse apiResponse = ResponseUtil.success("create user", response); + + return ResponseEntity.status(201).body(apiResponse); + } + + @GetMapping("/me") + public ResponseEntity> getMyProfile( + @AuthenticationPrincipal UserPrincipal principal + ) { + UserSearchResponse response = userService.getMyProfile(principal.getUserId()); + + ApiResponse apiResponse = ResponseUtil.success("select my profile", response); + + return ResponseEntity.ok(apiResponse); + } + + @PatchMapping("/me") + public ResponseEntity update( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody UserUpdateRequest request + ) { + userService.update(principal.getUserId(), request); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/me") + public ResponseEntity delete( + @AuthenticationPrincipal UserPrincipal principal + ) { + userService.delete(principal.getUserId()); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/example/user/dto/request/SignInRequest.java b/src/main/java/com/example/user/dto/request/SignInRequest.java new file mode 100644 index 0000000..a838ba5 --- /dev/null +++ b/src/main/java/com/example/user/dto/request/SignInRequest.java @@ -0,0 +1,7 @@ +package com.example.user.dto.request; + +public record SignInRequest( + String email, + String password +) { +} diff --git a/src/main/java/com/example/user/dto/request/SignUpRequest.java b/src/main/java/com/example/user/dto/request/SignUpRequest.java new file mode 100644 index 0000000..9623b90 --- /dev/null +++ b/src/main/java/com/example/user/dto/request/SignUpRequest.java @@ -0,0 +1,17 @@ +package com.example.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record SignUpRequest( + @NotBlank + @Email + String email, + + @NotBlank + String password, + + @NotBlank + String name +) { +} diff --git a/src/main/java/com/example/user/dto/request/UserUpdateRequest.java b/src/main/java/com/example/user/dto/request/UserUpdateRequest.java new file mode 100644 index 0000000..e69f11f --- /dev/null +++ b/src/main/java/com/example/user/dto/request/UserUpdateRequest.java @@ -0,0 +1,11 @@ +package com.example.user.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UserUpdateRequest( + String name, + + @JsonProperty("image_url") + String imageUrl +) { +} diff --git a/src/main/java/com/example/user/dto/response/SignUpResponse.java b/src/main/java/com/example/user/dto/response/SignUpResponse.java new file mode 100644 index 0000000..617780d --- /dev/null +++ b/src/main/java/com/example/user/dto/response/SignUpResponse.java @@ -0,0 +1,13 @@ +package com.example.user.dto.response; + +import com.example.user.entity.User; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SignUpResponse( + @JsonProperty("user_id") + String userId +) { + public static SignUpResponse from(User user) { + return new SignUpResponse(user.getId()); + } +} diff --git a/src/main/java/com/example/user/dto/response/UserAuthResponse.java b/src/main/java/com/example/user/dto/response/UserAuthResponse.java new file mode 100644 index 0000000..121a77d --- /dev/null +++ b/src/main/java/com/example/user/dto/response/UserAuthResponse.java @@ -0,0 +1,18 @@ +package com.example.user.dto.response; + +import com.example.user.entity.Role; +import com.example.user.entity.User; + +public record UserAuthResponse( + String userId, + String name, + String role +) { + public static UserAuthResponse from(User user) { + return new UserAuthResponse( + user.getId(), + user.getName(), + user.getRole().name() + ); + } +} diff --git a/src/main/java/com/example/user/dto/response/UserSearchResponse.java b/src/main/java/com/example/user/dto/response/UserSearchResponse.java new file mode 100644 index 0000000..cf38a58 --- /dev/null +++ b/src/main/java/com/example/user/dto/response/UserSearchResponse.java @@ -0,0 +1,27 @@ +package com.example.user.dto.response; + +import com.example.user.entity.User; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.LocalDateTime; + +public record UserSearchResponse( + @JsonProperty("image_url") + String imageUrl, + + String email, + + String name, + + @JsonProperty("created_at") + LocalDateTime createdAt +) { + public static UserSearchResponse from(User user) { + return new UserSearchResponse( + user.getImageUrl(), + user.getEmail(), + user.getName(), + user.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/example/user/entity/Role.java b/src/main/java/com/example/user/entity/Role.java new file mode 100644 index 0000000..2b4b6be --- /dev/null +++ b/src/main/java/com/example/user/entity/Role.java @@ -0,0 +1,6 @@ +package com.example.user.entity; + +public enum Role { + USER, + ADMIN +} diff --git a/src/main/java/com/example/user/entity/User.java b/src/main/java/com/example/user/entity/User.java new file mode 100644 index 0000000..64a28ad --- /dev/null +++ b/src/main/java/com/example/user/entity/User.java @@ -0,0 +1,77 @@ +package com.example.user.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Getter +@Table(name = "users") +public class User { + @Id + private String id; + + @Column(nullable = false, unique = true) + private String email; + + private String password; + + @Column(nullable = false) + private String name; + + @Column(name = "image_url") + private String imageUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Column(name = "auth_provider") + private String authProvider; + + @Column(name = "provider_id") + private String providerId; + + @CreationTimestamp + @Column(nullable = false, name = "created_at") + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public static User create(String email, String password, String name) { + return User.builder() + .id(UUID.randomUUID().toString()) + .email(email) + .password(password) + .name(name) + .role(Role.USER) + .build(); + } + + public void update(String name, String imageUrl) { + if (this.deletedAt != null) { + throw new IllegalStateException("삭제된 유저"); + } + if (name != null) this.name = name; + if (imageUrl != null) this.imageUrl = imageUrl; + } + + public void delete() { + if (this.deletedAt != null) { + throw new IllegalStateException("삭제된 유저"); + } + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/example/user/global/config/PasswordEncoderConfig.java b/src/main/java/com/example/user/global/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..ef298af --- /dev/null +++ b/src/main/java/com/example/user/global/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.example.user.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/user/global/config/SecurityConfig.java b/src/main/java/com/example/user/global/config/SecurityConfig.java new file mode 100644 index 0000000..50324ae --- /dev/null +++ b/src/main/java/com/example/user/global/config/SecurityConfig.java @@ -0,0 +1,37 @@ +package com.example.user.global.config; + +import com.example.user.global.security.JwtFilter; +import lombok.RequiredArgsConstructor; +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; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .formLogin(formLogin -> formLogin.disable()) + .httpBasic(httpBasic -> httpBasic.disable()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/users/signup").permitAll() + .requestMatchers("/users/me").authenticated() + .anyRequest().permitAll() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/user/global/dto/ApiResponse.java b/src/main/java/com/example/user/global/dto/ApiResponse.java new file mode 100644 index 0000000..7b5ab93 --- /dev/null +++ b/src/main/java/com/example/user/global/dto/ApiResponse.java @@ -0,0 +1,7 @@ +package com.example.user.global.dto; + +public record ApiResponse( + String message, + T data +) { +} \ No newline at end of file diff --git a/src/main/java/com/example/user/global/security/JwtFilter.java b/src/main/java/com/example/user/global/security/JwtFilter.java new file mode 100644 index 0000000..b43b3ff --- /dev/null +++ b/src/main/java/com/example/user/global/security/JwtFilter.java @@ -0,0 +1,59 @@ +package com.example.user.global.security; + +import com.example.user.entity.Role; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + String token = request.getHeader("Authorization"); + + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + + try { + if (jwtProvider.validateToken(token)) { + String userId = jwtProvider.getUserId(token); + Role role = jwtProvider.getRole(token); + + UserPrincipal principal = new UserPrincipal(userId, role); + + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + SecurityContextHolder.clearContext(); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/example/user/global/security/JwtProvider.java b/src/main/java/com/example/user/global/security/JwtProvider.java new file mode 100644 index 0000000..dd61c56 --- /dev/null +++ b/src/main/java/com/example/user/global/security/JwtProvider.java @@ -0,0 +1,60 @@ +package com.example.user.global.security; + + +import com.example.user.entity.Role; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; + +@Component +public class JwtProvider { + + private final SecretKey secretKey; + + public JwtProvider(@Value("${jwt.secret}") String secret) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String getUserId(String token) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims.getSubject(); + } + + public Role getRole(String token) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + String role = claims.get("role", String.class) + .replace("ROLE_", ""); + + return Role.valueOf(role); + } + + + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/com/example/user/global/security/UserPrincipal.java b/src/main/java/com/example/user/global/security/UserPrincipal.java new file mode 100644 index 0000000..2cca408 --- /dev/null +++ b/src/main/java/com/example/user/global/security/UserPrincipal.java @@ -0,0 +1,17 @@ +package com.example.user.global.security; + +import com.example.user.entity.Role; +import lombok.Getter; + +@Getter +public class UserPrincipal { + + private final String userId; + + private final Role role; + + public UserPrincipal(String userId, Role role) { + this.userId = userId; + this.role = role; + } +} diff --git a/src/main/java/com/example/user/global/util/ResponseUtil.java b/src/main/java/com/example/user/global/util/ResponseUtil.java new file mode 100644 index 0000000..87bf288 --- /dev/null +++ b/src/main/java/com/example/user/global/util/ResponseUtil.java @@ -0,0 +1,13 @@ +package com.example.user.global.util; + +import com.example.user.global.dto.ApiResponse; + +public class ResponseUtil { + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(message, data); + } + + public static ApiResponse success(String message) { + return new ApiResponse<>(message, null); + } +} diff --git a/src/main/java/com/example/user/repository/UserRepository.java b/src/main/java/com/example/user/repository/UserRepository.java new file mode 100644 index 0000000..5a98f40 --- /dev/null +++ b/src/main/java/com/example/user/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.example.user.repository; + +import com.example.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/example/user/service/InternalUserService.java b/src/main/java/com/example/user/service/InternalUserService.java new file mode 100644 index 0000000..aa7487e --- /dev/null +++ b/src/main/java/com/example/user/service/InternalUserService.java @@ -0,0 +1,43 @@ +package com.example.user.service; + +import com.example.user.dto.request.SignInRequest; +import com.example.user.dto.response.UserAuthResponse; +import com.example.user.entity.User; +import com.example.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class InternalUserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public UserAuthResponse authenticate(SignInRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + + validateActiveUser(user); + validatePassword(request.password(), user.getPassword()); + + return UserAuthResponse.from(user); + } + + // 삭제 여부 확인 + private void validateActiveUser(User user) { + if (user.getDeletedAt() != null) { + throw new IllegalStateException("삭제된 유저"); + } + } + + // 비밀번호 확인 + private void validatePassword(String rawPassword, String encodedPassword) { + if (!passwordEncoder.matches(rawPassword, encodedPassword)) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + } +} diff --git a/src/main/java/com/example/user/service/UserService.java b/src/main/java/com/example/user/service/UserService.java new file mode 100644 index 0000000..b21bb23 --- /dev/null +++ b/src/main/java/com/example/user/service/UserService.java @@ -0,0 +1,74 @@ +package com.example.user.service; + +import com.example.user.dto.request.SignUpRequest; +import com.example.user.dto.request.UserUpdateRequest; +import com.example.user.dto.response.SignUpResponse; +import com.example.user.dto.response.UserSearchResponse; +import com.example.user.entity.User; +import com.example.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + // 회원가입 (유저 생성) + @Transactional + public SignUpResponse signup(SignUpRequest request) { + if (userRepository.existsByEmail(request.email())) { + throw new IllegalArgumentException("이미 존재하는 이메일"); + } + + String encodedPassword = passwordEncoder.encode(request.password()); + + User user = User.create( + request.email(), + encodedPassword, + request.name() + ); + userRepository.save(user); + + return SignUpResponse.from(user); + } + + // 내 정보 조회 + public UserSearchResponse getMyProfile(String userId) { + User user = getActiveUser(userId); + return UserSearchResponse.from(user); + } + + // 수정 + @Transactional + public void update(String userId, UserUpdateRequest request) { + User user = getActiveUser(userId); + if (request.name() != null && request.name().isBlank()) { + throw new IllegalArgumentException("이름은 비어있을 수 없음"); + } + user.update(request.name(), request.imageUrl()); + } + + // 삭제 + @Transactional + public void delete(String userId) { + User user = getActiveUser(userId); + user.delete(); + } + + // 삭제 안 된 유저 찾기 + private User getActiveUser(String userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유저 없음")); + + if (user.getDeletedAt() != null) { + throw new IllegalStateException("삭제된 유저"); + } + + return user; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml deleted file mode 100644 index 02be784..0000000 --- a/src/main/resources/application.yaml +++ /dev/null @@ -1,3 +0,0 @@ -spring: - application: - name: User diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..f2e5405 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,20 @@ +server: + port: ${SERVER_PORT:8081} + +spring: + application: + name: User + + datasource: + url: "jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:momentlit_user}" + username: "${DB_USERNAME:user_user}" + password: "${DB_PASSWORD:user_password}" + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: "${JPA_DDL_AUTO:update}" + show-sql: true + +jwt: + secret: "${JWT_SECRET:momentlit-user-service-jwt-secret-key-for-local-test-1234567890}"