Skip to content
Open
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
11 changes: 10 additions & 1 deletion mintori/Umc10th/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ dependencies {
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

// Swagger (현재 버전 유지)
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1'

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.example.umc10th.domain.user.controller;

import com.example.umc10th.domain.user.dto.request.LoginRequest;
import com.example.umc10th.domain.user.dto.request.UserRequest;
import com.example.umc10th.domain.user.dto.response.MyPageResponse;
import com.example.umc10th.domain.user.dto.response.TokenResponse;
import com.example.umc10th.domain.user.dto.response.UserResponse;
import com.example.umc10th.domain.user.service.UserService;
import com.example.umc10th.global.apiPayload.ApiResponse;
Expand All @@ -27,6 +29,11 @@ public ApiResponse<UserResponse> signUp(@Valid @RequestBody UserRequest request)
return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, userService.signUp(request));
}

@PostMapping("/login")
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.onSuccess(GeneralSuccessCode.OK, userService.login(request));
}

@GetMapping("/{userId}/mypage")
public ApiResponse<MyPageResponse> getMyPage(@PathVariable Long userId) {
return ApiResponse.onSuccess(GeneralSuccessCode.OK, userService.getMyPage(userId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ public class UserConverter {
private UserConverter() {
}

public static User toUser(UserRequest request) {
public static User toUser(UserRequest request, String encodedPassword) {
return User.builder()
.name(request.name())
.email(request.email())
.password(request.password())
.password(encodedPassword)
.gender(request.gender())
.address(request.address())
.point(0L)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.example.umc10th.domain.user.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@NotBlank(message = "이메일을 입력해주세요")
@Email
String email,

@NotBlank(message = "비밀번호를 입력해주세요")
String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.umc10th.domain.user.dto.response;

public record TokenResponse(
Long userId,
String tokenType,
String accessToken,
Long expiresIn
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public enum UserErrorCode implements BaseErrorCode {
USER_NOT_FOUND("USER_404", "존재하지 않는 유저입니다.", HttpStatus.NOT_FOUND),
DUPLICATE_EMAIL("USER_409", "이미 가입된 이메일입니다.", HttpStatus.CONFLICT),
INVALID_USER_REQUEST("USER_400", "유저 요청이 올바르지 않습니다.", HttpStatus.BAD_REQUEST),
LOGIN_FAILED("USER_401", "이메일 또는 비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED),
;

private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.example.umc10th.domain.user.service;

import com.example.umc10th.domain.user.dto.request.LoginRequest;
import com.example.umc10th.domain.user.dto.request.UserRequest;
import com.example.umc10th.domain.user.dto.response.MyPageResponse;
import com.example.umc10th.domain.user.dto.response.TokenResponse;
import com.example.umc10th.domain.user.dto.response.UserResponse;

public interface UserService {

UserResponse signUp(UserRequest request);

TokenResponse login(LoginRequest request);

MyPageResponse getMyPage(Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@
import com.example.umc10th.domain.mission.repository.UserMissionRepository;
import com.example.umc10th.domain.review.repository.ReviewRepository;
import com.example.umc10th.domain.user.converter.UserConverter;
import com.example.umc10th.domain.user.dto.request.LoginRequest;
import com.example.umc10th.domain.user.dto.request.UserRequest;
import com.example.umc10th.domain.user.dto.response.MyPageResponse;
import com.example.umc10th.domain.user.dto.response.TokenResponse;
import com.example.umc10th.domain.user.dto.response.UserResponse;
import com.example.umc10th.domain.user.entity.User;
import com.example.umc10th.domain.user.exception.UserException;
import com.example.umc10th.domain.user.exception.code.UserErrorCode;
import com.example.umc10th.domain.user.repository.UserRepository;
import com.example.umc10th.global.jwt.JwtTokenProvider;
import com.example.umc10th.global.security.CustomUserDetails;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -23,18 +32,48 @@ public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMissionRepository userMissionRepository;
private final ReviewRepository reviewRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;

@Override
@Transactional
public UserResponse signUp(UserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new UserException(UserErrorCode.DUPLICATE_EMAIL);
}
User user = UserConverter.toUser(request);
// BCrypt 로 비밀번호를 솔트와 함께 해시 처리
String encodedPassword = passwordEncoder.encode(request.password());
User user = UserConverter.toUser(request, encodedPassword);
User saved = userRepository.save(user);
return UserConverter.toUserResponse(saved);
}

@Override
public TokenResponse login(LoginRequest request) {
// AuthenticationManager 가 CustomUserDetailsService + PasswordEncoder 로 자격 증명을 검증
Authentication authentication;
try {
authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password())
);
} catch (AuthenticationException e) {
// 사용자 없음 / 비밀번호 불일치 등 모든 인증 실패를 동일하게 처리
throw new UserException(UserErrorCode.LOGIN_FAILED);
}

CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal();
String accessToken = jwtTokenProvider.createAccessToken(
principal.getUserId(), principal.getUsername());

return new TokenResponse(
principal.getUserId(),
"Bearer",
accessToken,
jwtTokenProvider.getAccessTokenValidityInSeconds()
);
}

@Override
public MyPageResponse getMyPage(Long userId) {
User user = userRepository.findById(userId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.example.umc10th.global.config;

import com.example.umc10th.global.jwt.JwtAuthenticationFilter;
import com.example.umc10th.global.jwt.JwtTokenProvider;
import com.example.umc10th.global.security.CustomAccessDeniedHandler;
import com.example.umc10th.global.security.CustomAuthenticationEntryPoint;
import com.example.umc10th.global.security.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final CustomUserDetailsService userDetailsService;
private final JwtTokenProvider jwtTokenProvider;

private static final String[] PUBLIC_ENDPOINTS = {
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/error",
};

@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt: 해시할 때마다 salt 를 자동 생성해 해시 문자열에 함께 저장
return new BCryptPasswordEncoder();
}

/**
* 로그인(이메일/비밀번호) 인증을 처리하는 AuthenticationManager.
* - CustomUserDetailsService 로 사용자를 로드하고 PasswordEncoder 로 비밀번호를 비교
*/
@Bean
public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// REST API 이므로 CSRF / 폼 로그인 / HTTP Basic 비활성화
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// JWT 기반이므로 세션을 사용하지 않는 STATELESS 정책
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 인가 설정: 회원가입 / 로그인만 Public, 그 외 모든 API 는 Private
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
// 회원가입 API (Public)
.requestMatchers(HttpMethod.POST, "/api/users/signup").permitAll()
// 로그인 API (Public)
.requestMatchers(HttpMethod.POST, "/api/users/login").permitAll()
// 그 외 모든 요청은 인증 필요 (Private)
.anyRequest().authenticated()
)
// 인증/인가 실패 시 통일된 응답 형식으로 반환
.exceptionHandling(handler -> handler
// 인증 실패 (미인증 사용자) -> 401
.authenticationEntryPoint(authenticationEntryPoint)
// 인가 실패 (권한 없음) -> 403
.accessDeniedHandler(accessDeniedHandler)
)
// UsernamePasswordAuthenticationFilter 앞에 JWT 인증 필터 추가
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService),
UsernamePasswordAuthenticationFilter.class
);

return http.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.example.umc10th.global.jwt;

import com.example.umc10th.global.security.CustomUserDetailsService;
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.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
* 매 요청마다 Authorization 헤더의 JWT 를 검증해 SecurityContext 에 인증 정보를 등록한다.
* 토큰이 없거나 유효하지 않으면 인증을 설정하지 않고 통과시키며,
* 이후 Private API 라면 SecurityConfig 의 AuthenticationEntryPoint 가 401 응답을 반환한다.
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";

private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService userDetailsService;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
String email = jwtTokenProvider.getEmail(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));

SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
// 토큰 처리 중 문제가 생기면 인증을 설정하지 않고 진행 -> EntryPoint 가 401 응답
SecurityContextHolder.clearContext();
log.warn("JWT 인증 처리 실패: {}", e.getMessage());
}

filterChain.doFilter(request, response);
}

private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}
Loading