diff --git a/build.gradle b/build.gradle index 13e1b22..e654387 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,16 @@ dependencies { // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' } tasks.named('test') { diff --git a/src/main/java/com/example/UMC/domain/config/SecurityConfig.java b/src/main/java/com/example/UMC/domain/config/SecurityConfig.java new file mode 100644 index 0000000..c2f6835 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/config/SecurityConfig.java @@ -0,0 +1,61 @@ +package com.example.UMC.domain.config; + +import com.example.UMC.domain.user.security.CustomUserDetailsService; +import com.example.UMC.domain.user.security.JwtAuthFilter; +import com.example.UMC.domain.user.util.JwtUtil; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomUserDetailsService customUserDetailsService; + private final JwtUtil jwtUtil; + + private final String[] allowUris = { + "/signup", + "/login", + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 X + .formLogin(AbstractHttpConfigurer::disable) // 폼 로그인 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/UMC/domain/enums/entity/Role.java b/src/main/java/com/example/UMC/domain/enums/entity/Role.java new file mode 100644 index 0000000..34951c9 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/enums/entity/Role.java @@ -0,0 +1,5 @@ +package com.example.UMC.domain.enums.entity; + +public enum Role { + ROLE_ADMIN, ROLE_USER +} diff --git a/src/main/java/com/example/UMC/domain/user/controller/UserController.java b/src/main/java/com/example/UMC/domain/user/controller/UserController.java new file mode 100644 index 0000000..ae0c376 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/controller/UserController.java @@ -0,0 +1,54 @@ +package com.example.UMC.domain.user.controller; + + +import com.example.UMC.domain.user.dto.request.UserReqDTO; +import com.example.UMC.domain.user.entity.User; +import com.example.UMC.domain.user.exception.code.UserErrorCode; +import com.example.UMC.domain.user.security.CustomUserDetails; +import com.example.UMC.domain.user.service.UserService; +import com.example.UMC.domain.user.util.JwtUtil; +import com.example.UMC.global.apiPayload.ApiResponse; +import com.example.UMC.global.apiPayload.code.GeneralSucessCode; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + + +import java.util.Optional; + +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final JwtUtil jwtUtil; + + // 회원가입 API + @PostMapping("/signup") + public ApiResponse signUp(@RequestBody @Valid UserReqDTO.JoinDTO dto) { + // 1. 회원가입 처리 + User user = userService.signup(dto); + + // 2. 바로 CustomUserDetails 생성 + JWT 발급 + CustomUserDetails userDetails = new CustomUserDetails(user); + String accessToken = jwtUtil.createAccessToken(userDetails); + + return ApiResponse.onSucess(GeneralSucessCode.OK, accessToken); + } + + // 로그인 API + @PostMapping("/login") + public ApiResponse login(@RequestParam String email, + @RequestParam String password) { + + Optional userDetailsOpt = userService.loginForJwt(email, password); + + if (userDetailsOpt.isEmpty()) { + return ApiResponse.onFailure(UserErrorCode.INVALID_CREDENTIALS, null); + } + + String accessToken = jwtUtil.createAccessToken(userDetailsOpt.get()); + return ApiResponse.onSucess(GeneralSucessCode.OK, accessToken); + } +} + diff --git a/src/main/java/com/example/UMC/domain/user/converter/UserConverter.java b/src/main/java/com/example/UMC/domain/user/converter/UserConverter.java new file mode 100644 index 0000000..f59eb7d --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/converter/UserConverter.java @@ -0,0 +1,25 @@ +package com.example.UMC.domain.user.converter; + +import com.example.UMC.domain.enums.entity.Role; +import com.example.UMC.domain.user.dto.request.UserReqDTO; +import com.example.UMC.domain.user.entity.User; + +public class UserConverter { + public static User toUser( + UserReqDTO.JoinDTO dto, + String password, + Role role + ) { + return User.builder() + .name(dto.name()) + .email(dto.email()) + .password(password) + .role(role) + .birth(dto.birth()) + .address(dto.address()) + .gender(dto.gender()) + .loginId(dto.email()) + .build(); + } + +} diff --git a/src/main/java/com/example/UMC/domain/user/dto/request/UserReqDTO.java b/src/main/java/com/example/UMC/domain/user/dto/request/UserReqDTO.java new file mode 100644 index 0000000..c6c08c4 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/dto/request/UserReqDTO.java @@ -0,0 +1,34 @@ +package com.example.UMC.domain.user.dto.request; + +import com.example.UMC.domain.enums.entity.Gender; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + + +public class UserReqDTO { + + public record JoinDTO( + @NotBlank + String name, + @Email + String email, + @NotBlank + String password, + @NotNull + Gender gender, + @NotNull + String birth, + @NotNull + String address + ) {} + + + // 로그인 + public record LoginDTO( + @NotBlank + String email, + @NotBlank + String password + ){} +} diff --git a/src/main/java/com/example/UMC/domain/user/entity/User.java b/src/main/java/com/example/UMC/domain/user/entity/User.java index ce295f0..0778f80 100644 --- a/src/main/java/com/example/UMC/domain/user/entity/User.java +++ b/src/main/java/com/example/UMC/domain/user/entity/User.java @@ -1,6 +1,7 @@ package com.example.UMC.domain.user.entity; import com.example.UMC.domain.enums.entity.Gender; +import com.example.UMC.domain.enums.entity.Role; import com.example.UMC.domain.enums.entity.Status; import com.example.UMC.global.commmon.BaseEntity; import com.example.UMC.domain.mission.entity.UserMission; @@ -57,6 +58,9 @@ public class User extends BaseEntity { @Column(name = "password", nullable = false, length = 100) private String password; + @Enumerated(EnumType.STRING) + private Role role; + @Column(name = "point", nullable = false) private Integer point; @@ -85,4 +89,20 @@ public class User extends BaseEntity { // 유저 미션 (User_Mission) 1:N @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) private List missions; + + @PrePersist + public void prePersist() { + if (this.status == null) { + this.status = Status.ACTIVE; // 원하는 기본 상태 + } + if (this.privacyAgree == null) { + this.privacyAgree = true; // 기본 동의 + } + if (this.point == null) { + this.point = 0; // 기본 포인트 0 + } + if (this.role == null) { + this.role = Role.ROLE_USER; // 기본 권한 + } + } } diff --git a/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java b/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java index ea92c8f..721db1c 100644 --- a/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java +++ b/src/main/java/com/example/UMC/domain/user/exception/code/UserErrorCode.java @@ -8,6 +8,7 @@ @Getter @AllArgsConstructor public enum UserErrorCode implements BaseErrorCode { + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "USER4001", "아이디 또는 비밀번호가 일치하지 않습니다."), USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4004", "해당 사용자를 찾을 수 없습니다."), DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "USER4002","이미 존재하는 이메일입니다."); diff --git a/src/main/java/com/example/UMC/domain/user/security/CustomUserDetails.java b/src/main/java/com/example/UMC/domain/user/security/CustomUserDetails.java new file mode 100644 index 0000000..b398cac --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/security/CustomUserDetails.java @@ -0,0 +1,31 @@ +package com.example.UMC.domain.user.security; + +import com.example.UMC.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; + +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(() -> user.getRole().toString()); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } +} diff --git a/src/main/java/com/example/UMC/domain/user/security/CustomUserDetailsService.java b/src/main/java/com/example/UMC/domain/user/security/CustomUserDetailsService.java new file mode 100644 index 0000000..4db6f33 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/security/CustomUserDetailsService.java @@ -0,0 +1,29 @@ +package com.example.UMC.domain.user.security; + +import com.example.UMC.domain.user.entity.User; +import com.example.UMC.domain.user.exception.UserException; +import com.example.UMC.domain.user.exception.code.UserErrorCode; +import com.example.UMC.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername( + String username + ) throws UsernameNotFoundException { + // 검증할 Member 조회 + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + // CustomUserDetails 반환 + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/example/UMC/domain/user/security/JwtAuthFilter.java b/src/main/java/com/example/UMC/domain/user/security/JwtAuthFilter.java new file mode 100644 index 0000000..7d39a1f --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/security/JwtAuthFilter.java @@ -0,0 +1,73 @@ +package com.example.UMC.domain.user.security; + +import com.example.UMC.domain.user.util.JwtUtil; +import com.example.UMC.global.apiPayload.ApiResponse; +import com.example.UMC.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + try { + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 + String email = jwtUtil.getEmail(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } catch (Exception e) { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + ApiResponse errorResponse = ApiResponse.onFailure( + GeneralErrorCode.UNAUTHORIZED, + null + ); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getOutputStream(), errorResponse); + } + } +} diff --git a/src/main/java/com/example/UMC/domain/user/service/UserService.java b/src/main/java/com/example/UMC/domain/user/service/UserService.java new file mode 100644 index 0000000..8468518 --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/service/UserService.java @@ -0,0 +1,51 @@ +package com.example.UMC.domain.user.service; + +import com.example.UMC.domain.enums.entity.Role; +import com.example.UMC.domain.user.converter.UserConverter; +import com.example.UMC.domain.user.dto.request.UserReqDTO; +import com.example.UMC.domain.user.entity.User; +import com.example.UMC.domain.user.repository.UserRepository; +import com.example.UMC.domain.user.security.CustomUserDetails; +import com.example.UMC.domain.user.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + // 회원가입 + public User signup(UserReqDTO.JoinDTO dto) { + String salt = passwordEncoder.encode(dto.password()); + User user = UserConverter.toUser(dto, salt, Role.ROLE_USER); + return userRepository.save(user); + } + + // 로그인(세션) + public Optional login(String email, String password) { + // 이메일로 유저 조회 + Optional userOpt = userRepository.findByEmail(email); + + if (userOpt.isPresent()) { + User user = userOpt.get(); + // 비밀번호 확인 + if (passwordEncoder.matches(password, user.getPassword())) { + return Optional.of(user); + } + } + return Optional.empty(); // 로그인 실패 + } + + // JWT 발급용: CustomUserDetails까지 만들어 주는 로그인 + public Optional loginForJwt(String email, String password) { + return userRepository.findByEmail(email) + .filter(user -> passwordEncoder.matches(password, user.getPassword())) + .map(CustomUserDetails::new); + } +} diff --git a/src/main/java/com/example/UMC/domain/user/util/JwtUtil.java b/src/main/java/com/example/UMC/domain/user/util/JwtUtil.java new file mode 100644 index 0000000..e80bfcc --- /dev/null +++ b/src/main/java/com/example/UMC/domain/user/util/JwtUtil.java @@ -0,0 +1,84 @@ +package com.example.UMC.domain.user.util; + +import com.example.UMC.domain.user.security.CustomUserDetails; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(CustomUserDetails user) { + return createToken(user, accessExpiration); + } + + // 토큰에서 이메일 가져오기 + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); + } catch (JwtException e) { + return null; + } + } + + // 토큰 유효성 확인 + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(CustomUserDetails user, Duration expiration) { + Instant now = Instant.now(); + + String authorities = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(user.getUsername()) + .claim("role", authorities) + .claim("email", user.getUsername()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(expiration))) + .signWith(secretKey) + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1dd099b..a2cfef0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,3 +23,9 @@ spring: init: mode: always encoding: UTF-8 + +jwt: + token: + secretKey: ZGh3YWlkc2F2ZXdhZXZ3b2EgMTM5ZXUgMDMxdWMyIHEyMiBAIDAgKTJFVio= + expiration: + access: 14400000