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 teemo/week4/umc10th/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ dependencies {
// 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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
import com.example.umc10th.domain.user.service.UserService;
import com.example.umc10th.global.apiPayload.ApiResponse;
import com.example.umc10th.global.apiPayload.code.GeneralSuccessCode;
import com.example.umc10th.global.security.entity.AuthUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
Expand All @@ -30,6 +32,18 @@ public ApiResponse<UserResDTO.MyPage> getMyPage(@PathVariable Long userId) {
return ApiResponse.of(GeneralSuccessCode.OK, userService.getMyPage(userId));
}

@GetMapping("/users/me/mypage")
@Operation(summary = "내 마이페이지 조회 API", description = "JWT 토큰으로 인증된 사용자의 마이페이지 정보를 조회하는 API입니다.")
public ApiResponse<UserResDTO.MyPage> getMyPage(@AuthenticationPrincipal AuthUser member) {
return ApiResponse.of(UserSuccessCode.OK, userService.getMyPage(member));
}

@GetMapping("/users/me")
@Operation(summary = "내 정보 조회 API", description = "JWT 토큰으로 인증된 사용자의 정보를 조회하는 API입니다.")
public ApiResponse<UserResDTO.MyPage> getMyInfo(@AuthenticationPrincipal AuthUser member) {
return ApiResponse.of(UserSuccessCode.OK, userService.getMyPage(member));
}

@GetMapping("/auth/terms")
@Operation(summary = "약관 목록 조회 API", description = "회원가입 시 필요한 약관 목록을 조회하는 API입니다.")
public ApiResponse<UserResDTO.TermList> getTerms() {
Expand All @@ -42,6 +56,12 @@ public ApiResponse<UserResDTO.JoinResult> join(@RequestBody @Valid UserReqDTO.Jo
return ApiResponse.of(UserSuccessCode.JOIN_SUCCESS, userService.joinUser(request));
}

@PostMapping("/auth/login")
@Operation(summary = "로그인 API", description = "이메일과 비밀번호로 로그인 후 JWT Access Token을 발급하는 API입니다.")
public ApiResponse<UserResDTO.LoginResult> login(@RequestBody @Valid UserReqDTO.Login request) {
return ApiResponse.of(UserSuccessCode.LOGIN_SUCCESS, userService.login(request));
}

@GetMapping("/users/{userId}/missions")
@Operation(summary = "나의 미션 목록 조회 API", description = "진행 중(is_completed=false) 또는 완료(is_completed=true)된 미션 목록을 조회합니다.")
@Parameter(name = "is_completed", description = "완료 여부 (true: 완료, false: 진행 중)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ public static UserResDTO.JoinResult toJoinResultDTO(User user) {
.build();
}

public static UserResDTO.JoinResult toJoinResultDTO(User user, String accessToken) {
return UserResDTO.JoinResult.builder()
.userId(user.getId())
.createdAt(user.getCreatedAt())
.accessToken(accessToken)
.build();
}

public static UserResDTO.LoginResult toLoginResultDTO(User user, String accessToken) {
return UserResDTO.LoginResult.builder()
.userId(user.getId())
.accessToken(accessToken)
.build();
}

public static User toUser(UserReqDTO.Join request) {
Gender gender = switch (request.getGender()) {
case 1 -> Gender.MALE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,14 @@ public static class Join {
@NotNull
private List<Long> memberTerms;
}

@Getter
public static class Login {
@Email
@NotBlank
private String email;

@NotBlank
private String password;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ public class UserResDTO {
public static class JoinResult {
private Long userId;
private LocalDateTime createdAt;
private String accessToken;
}

@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class LoginResult {
private Long userId;
private String accessToken;
}

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

USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER404", "사용자를 찾을 수 없습니다.");
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER404", "사용자를 찾을 수 없습니다."),
EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER400_1", "이미 사용 중인 이메일입니다."),
PASSWORD_NOT_MATCH(HttpStatus.UNAUTHORIZED, "USER401_1", "비밀번호가 일치하지 않습니다.");
Comment on lines +13 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 비밀번호 가 틀렸다는 걸 그대로 내려주는건 보안상으로는 좋은 느낌은 아냐..!!
이메일 불일치랑 비밀번호 불일치를 다르게 내려주면, 어떤 이메일들이 가입되어있는지 확인할수가 있겠지?? user enumeration 라는 키워드로 찾아보면 이런 관련된 여러 이슈들이 나오니까 한 번 찾아보면 좋겠다!


private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
public enum UserSuccessCode implements BaseSuccessCode {

OK(HttpStatus.OK, "MEMBER200_1", "성공적으로 유저를 조회했습니다."),
LOGIN_SUCCESS(HttpStatus.OK, "MEMBER200_2", "로그인에 성공하였습니다."),
JOIN_SUCCESS(HttpStatus.CREATED, "MEMBER201", "회원가입에 성공하였습니다.");

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@

public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import com.example.umc10th.domain.user.repository.FoodRepository;
import com.example.umc10th.domain.user.repository.TermRepository;
import com.example.umc10th.domain.user.repository.UserRepository;
import com.example.umc10th.global.security.entity.AuthUser;
import com.example.umc10th.global.security.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
Expand All @@ -29,9 +31,14 @@ public class UserService {
private final FoodRepository foodRepository;
private final TermRepository termRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;

@Transactional
public UserResDTO.JoinResult joinUser(UserReqDTO.Join request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new UserException(UserErrorCode.EMAIL_ALREADY_EXISTS);
}

User newUser = UserConverter.toUser(request);
newUser.encodePassword(passwordEncoder.encode(request.getPassword()));

Expand All @@ -53,12 +60,31 @@ public UserResDTO.JoinResult joinUser(UserReqDTO.Join request) {
List<UserTerm> userTermList = UserConverter.toUserTermList(termList);
userTermList.forEach(userTerm -> userTerm.setMapping(newUser));

return UserConverter.toJoinResultDTO(userRepository.save(newUser));
User savedUser = userRepository.save(newUser);
String accessToken = jwtUtil.createAccessToken(new AuthUser(savedUser));

return UserConverter.toJoinResultDTO(savedUser, accessToken);
}

public UserResDTO.LoginResult login(UserReqDTO.Login request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));

if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new UserException(UserErrorCode.PASSWORD_NOT_MATCH);
}

String accessToken = jwtUtil.createAccessToken(new AuthUser(user));
return UserConverter.toLoginResultDTO(user, accessToken);
}

public UserResDTO.MyPage getMyPage(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
return UserConverter.toMyPageDTO(user);
}

public UserResDTO.MyPage getMyPage(AuthUser member) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에 메소드랑 중복 이름인데 같은 역할이면 둘 중 하나 삭제해야할 듯

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 이전에 userId 기반으로 잘 해줬는데 왜 AuthUser 받는 메서드 추가했을까 ?? authUser 는 따지자면 인증 계층에서 쓰이는 객체니까, 서비스 계층까지 넘겨주면 책임이 좀 섞이게 보일 수 있을거같아.
컨트롤러에서 authMember 에서 userId 만 추출해서 넘겨줘서 책임 분리가 된 좋은 설계로 수정해 볼 수 있을듯!!

return UserConverter.toMyPageDTO(member.getUser());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
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;
import com.example.umc10th.global.security.filter.JwtAuthFilter;
import com.example.umc10th.global.security.service.CustomUserDetailsService;
import com.example.umc10th.global.security.util.JwtUtil;

@Configuration
@EnableWebSecurity
Expand All @@ -17,10 +22,13 @@ public class SecurityConfig {

private final CustomAccessDenied customAccessDenied;
private final CustomEntryPoint customEntryPoint;
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;

private final String[] allowUris = {
"/api/auth/signup",
// "/h2-console/**", // DB 확인용 콘솔 허용
"/api/auth/login",
"/h2-console/**",
"/swagger-ui/**",
"/v3/api-docs/**"
};
Expand All @@ -29,28 +37,29 @@ public class SecurityConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
Comment on lines +40 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿굿

.authorizeHttpRequests(auth -> auth
.requestMatchers(allowUris).permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.defaultSuccessUrl("/swagger-ui/index.html", true)
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.exceptionHandling(exception -> exception
.accessDeniedHandler(customAccessDenied)
.authenticationEntryPoint(customEntryPoint)
)
.headers(headers -> headers.frameOptions(options -> options.sameOrigin()));
.headers(headers -> headers.frameOptions(options -> options.sameOrigin()))
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);

return http.build();
}

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

/// 남이 만들어준 라이브러리인데 내가 DI해서 쓰고싶을 때.. 빈 등록을 해서 쓴다. @Configuration도 붙여줘야 한다.
@Bean
public PasswordEncoder passwordEncoder() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
Expand All @@ -17,7 +18,7 @@ public class AuthUser implements UserDetails {

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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.service.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 {
// 토큰 가져오기
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) {
ObjectMapper mapper = new ObjectMapper();
BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED;

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

ApiResponse<Void> errorResponse = ApiResponse.onFailure(code, null);

mapper.writeValue(response.getOutputStream(), errorResponse);
}
}
}
Loading