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
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ dependencies {
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1'

// 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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import com.example.umc10th.domain.member.exception.code.MemberSuccessCode;
import com.example.umc10th.domain.member.service.MemberService;
import com.example.umc10th.global.apiPayload.ApiResponse;
import com.example.umc10th.global.security.AuthMember;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

Expand Down Expand Up @@ -47,8 +49,19 @@ public ApiResponse<MemberResDTO.Home> getHome(

// 3. 마이페이지 조회
@GetMapping("/members/me")
public ApiResponse<MemberResDTO.MyPage> getMyPage(@RequestParam Long memberId) {
MemberResDTO.MyPage result = memberService.getMyPage(memberId);
public ApiResponse<MemberResDTO.MyPage> getMyPage(
@AuthenticationPrincipal AuthMember authMember
) {
MemberResDTO.MyPage result = memberService.getMyPage(authMember.getMember());
return ApiResponse.onSuccess(MemberSuccessCode.GET_MY_PAGE, result);
}

// 4. 로그인
@PostMapping("/auth/login")
public ApiResponse<MemberResDTO.Login> login(
@Valid @RequestBody MemberReqDTO.Login request
) {
MemberResDTO.Login result = memberService.login(request);
return ApiResponse.onSuccess(MemberSuccessCode.LOGIN, result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,10 @@ public static MemberResDTO.Home toHome(
.pageInfo(PageInfoDTO.from(missionPage)) // 페이지 정보 변환
.build();
}

public static MemberResDTO.Login toLogin(String accessToken) {
return MemberResDTO.Login.builder()
.accessToken(accessToken)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,16 @@ public record SignUp(

@NotEmpty(message = "필수 약관 동의가 필요합니다.")
List<String> agreedTerms // ["AGE", "SERVICE", "PRIVACY"]
) {
}

@Builder
public record Login(
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,

@NotBlank(message = "비밀번호는 필수입니다.")
String password
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,9 @@ public record MyPage(
Integer point,
String profileUrl
) {}

@Builder
public record Login(
String accessToken
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public class Member extends BaseEntity {
private String socialUid;

@Enumerated(EnumType.STRING)
@Column(name = "social_type", nullable = false)
@Column(name = "social_type", nullable = false, length = 20)
@Builder.Default
private SocialType socialType = SocialType.LOCAL;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public enum MemberErrorCode implements BaseErrorCode {

// 회원가입 관련 에러
FOOD_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_1", "유효하지 않은 선호 음식입니다."),
TERM_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_2", "유효하지 않은 약관입니다.");
TERM_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_2", "유효하지 않은 약관입니다."),

PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "비밀번호가 일치하지 않습니다.");

private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum MemberSuccessCode implements BaseSuccessCode {

SIGN_UP(HttpStatus.OK, "MEMBER200_1", "회원가입에 성공했습니다."),
GET_HOME(HttpStatus.OK, "MEMBER200_2", "홈 화면 조회에 성공했습니다."),
GET_MY_PAGE(HttpStatus.OK, "MEMBER200_3", "성공적으로 유저를 조회했습니다.");
GET_MY_PAGE(HttpStatus.OK, "MEMBER200_3", "성공적으로 유저를 조회했습니다."),
LOGIN(HttpStatus.OK, "MEMBER200_2", "로그인에 성공했습니다.");

Comment on lines 12 to 16
private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import com.example.umc10th.domain.member.repository.TermRepository;
import com.example.umc10th.domain.mission.entity.mapping.MemberMission;
import com.example.umc10th.domain.mission.repository.MemberMissionRepository;
import com.example.umc10th.global.security.AuthMember;
import com.example.umc10th.global.security.util.JwtUtil;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
Expand All @@ -37,6 +39,7 @@ public class MemberService {
private final FoodRepository foodRepository;
private final TermRepository termRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;

@PersistenceContext
private EntityManager em; // MemberFood/MemberTerm을 직접 persist하기 위해
Expand Down Expand Up @@ -101,9 +104,7 @@ public MemberResDTO.SignUp signUp(MemberReqDTO.SignUp request) {
}

// 마이페이지
public MemberResDTO.MyPage getMyPage(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));
public MemberResDTO.MyPage getMyPage(Member member) {
return MemberConverter.toMyPage(member);
}

Expand All @@ -125,4 +126,22 @@ public MemberResDTO.Home getHome(Long memberId, Integer page, Integer size) {
// 4. 컨버터로 변환
return MemberConverter.toHome(member, received, completed, inProgress, missionPage);
}

// 로그인
@Transactional(readOnly = true)
public MemberResDTO.Login login(MemberReqDTO.Login request) {
// 1. 이메일로 회원 조회
Member member = memberRepository.findByEmail(request.email())
.orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));

// 2. 비밀번호 검증 (입력 평문 vs DB 암호문)
if (!passwordEncoder.matches(request.password(), member.getPassword())) {
throw new MemberException(MemberErrorCode.PASSWORD_NOT_MATCH);
}

// 3. JWT 발급
String accessToken = jwtUtil.createAccessToken(new AuthMember(member));

return MemberConverter.toLogin(accessToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,29 @@

import com.example.umc10th.global.security.CustomAccessDenied;
import com.example.umc10th.global.security.CustomEntryPoint;
import com.example.umc10th.global.security.CustomUserDetailsService;
import com.example.umc10th.global.security.filter.JwtAuthFilter;
import com.example.umc10th.global.security.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.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;

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;

private final CustomEntryPoint customEntryPoint;
private final CustomAccessDenied customAccessDenied;

Expand All @@ -26,6 +34,7 @@ public class SecurityConfig {
"/swagger-resources/**",
"/v3/api-docs/**",
"/auth/**",
"/api/v1/auth/**", // 회원가입 + 로그인 = 인증 없이 허용

Comment on lines 35 to 38
// 폼 로그인 페이지 자체
"/login",
Expand All @@ -36,6 +45,10 @@ public class SecurityConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
// 세션 안 씀 (토큰 기반)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(requests -> requests
.requestMatchers(allowUris).permitAll()
.anyRequest().authenticated()
Expand All @@ -44,10 +57,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.authenticationEntryPoint(customEntryPoint)
.accessDeniedHandler(customAccessDenied)
)
.formLogin(form -> form
.defaultSuccessUrl("/swagger-ui/index.html", true)
.permitAll()
)
// 폼 로그인 / 기본 로그인 제거
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// 우리 JWT 필터를 인증 필터 앞에 삽입
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
Expand All @@ -57,6 +71,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
return http.build();
}

@Bean
public JwtAuthFilter jwtAuthFilter() {
return new JwtAuthFilter(jwtUtil, customUserDetailsService);
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.example.umc10th.global.security.filter;

import com.example.umc10th.global.apiPayload.ApiResponse;
import com.example.umc10th.global.apiPayload.code.BaseErrorCode;
import com.example.umc10th.global.apiPayload.code.GeneralErrorCode;
import com.example.umc10th.global.security.CustomUserDetailsService;
import com.example.umc10th.global.security.util.JwtUtil;
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 {
// 1. 헤더에서 토큰 가져오기
String token = request.getHeader("Authorization");

// 2. 토큰이 없거나 Bearer 형식이 아니면 그냥 통과
if (token == null || !token.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}

// 3. "Bearer " 떼기
token = token.replace("Bearer ", "");

Comment on lines +46 to +48
// 4. 유효한 토큰이면 인증 객체 생성
if (jwtUtil.isValid(token)) {
String email = jwtUtil.getEmail(token);
UserDetails user = customUserDetailsService.loadUserByUsername(email);
Authentication auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(auth);
}

// 5. 다음 필터로
filterChain.doFilter(request, response);

} catch (Exception e) {
// 토큰 파싱 실패 / 만료 / 서명 불일치 등 → 401 응답통일
Comment on lines +59 to +63
ObjectMapper mapper = new ObjectMapper();
BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED;

response.setContentType("application/json;charset=UTF-8");
response.setStatus(code.getStatus().value());

ApiResponse<Void> errorResponse = ApiResponse.onFailure(code, null);
mapper.writeValue(response.getOutputStream(), errorResponse);
}
}
}
Loading