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
5 changes: 5 additions & 0 deletions mengduck/week4/umc10th-yongjae/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.1'
implementation 'org.springframework.boot:spring-boot-starter-security'

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'

runtimeOnly 'com.mysql:mysql-connector-j'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.example.umc10thyongjae.domain.auth.dto.request.LoginRequestDto;
import org.example.umc10thyongjae.domain.auth.dto.request.SignUpRequestDto;
import org.example.umc10thyongjae.domain.auth.dto.response.LoginResponseDto;
import org.example.umc10thyongjae.domain.auth.dto.response.SignUpResponseDto;
import org.example.umc10thyongjae.domain.auth.dto.response.UserInfoResponseDto;
import org.example.umc10thyongjae.domain.auth.service.AuthService;
import org.example.umc10thyongjae.global.apiPayload.ApiResponse;
import org.example.umc10thyongjae.global.apiPayload.code.GeneralSuccessCode;
import org.example.umc10thyongjae.global.security.entity.AuthUser;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
Expand All @@ -17,18 +21,28 @@ public class AuthController {
private final AuthService authService;

@PostMapping("/signUp")
public ApiResponse<SignUpResponseDto> signUp(
public ApiResponse<Void> signUp(
@RequestBody @Valid SignUpRequestDto dto
) {
Long userId = authService.signUp(dto);

return ApiResponse.onSuccess(GeneralSuccessCode.OK, new SignUpResponseDto(userId));
return ApiResponse.onSuccess(GeneralSuccessCode.OK, null);
}
Comment on lines 23 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

회원가입 응답을 Void로 둘지, userId 같은 식별자를 내려줄지는 다음 클라이언트 플로우 기준도 생각해보면 좋을듯!

예를 들어 가입 직후 로그인 화면으로 보내는 구조라면 Void도 충분하고,
가입 직후 온보딩/프로필 설정으로 이어지는 구조라면 userId나 accessToken 같은 응답이 필요할 수도 있겠지?

서비스에서 userId를 반환하는데 컨트롤러에서 반환하지 않도록 바꿔줘서 한번 얘기해본다!


@PostMapping("/login")
public ApiResponse<LoginResponseDto> login(
@RequestBody @Valid LoginRequestDto dto
) {
LoginResponseDto result = authService.login(dto);

return ApiResponse.onSuccess(GeneralSuccessCode.OK, result);
}

@GetMapping("/users/me")
public ApiResponse<UserInfoResponseDto> retrieveUserInfo(
@RequestAttribute long userId
) {
@AuthenticationPrincipal AuthUser authUser
) {
long userId = authUser.getUser().getId();
UserInfoResponseDto result = authService.getUserInfo(userId);

return ApiResponse.onSuccess(GeneralSuccessCode.OK, result);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.example.umc10thyongjae.domain.auth.dto.request;

import jakarta.validation.constraints.NotBlank;

public record LoginRequestDto(
@NotBlank(message = "아이디 입력은 필수입니다.")
String id,

@NotBlank(message = "비밀번호 입력은 필수입니다.")
String pwd
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.example.umc10thyongjae.domain.auth.dto.response;

public record LoginResponseDto(

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.

맹덕이는 로그인 결과 반환으로 accessToken만 반환하도록 설계 했구나. 나는 acccessToken에다가 userId까지 반환하도록 설계를 했어. 프론트에서 로그인한뒤 바로 마이페이지로 보내는 것 같은 상황을 생각해보면 유저식별자가 필요할거라고 생각했기 때문이야. 스터디때 서로 의견 한번 얘기해보면 좋겠다

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 도 필요하면 내려주고~ 무엇보다 만료시간은 웬만하면 같이 내려주는게 좋아 맹덕!

String accessToken
) {
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package org.example.umc10thyongjae.domain.auth.service;

import lombok.RequiredArgsConstructor;
import org.example.umc10thyongjae.domain.auth.dto.request.LoginRequestDto;
import org.example.umc10thyongjae.domain.auth.dto.request.SignUpRequestDto;
import org.example.umc10thyongjae.domain.auth.dto.response.LoginResponseDto;
import org.example.umc10thyongjae.domain.auth.dto.response.UserInfoResponseDto;
import org.example.umc10thyongjae.domain.auth.entity.FoodPreference;
import org.example.umc10thyongjae.domain.auth.entity.Term;
Expand All @@ -15,7 +17,10 @@
import org.example.umc10thyongjae.domain.auth.repository.UserRepository;
import org.example.umc10thyongjae.domain.auth.repository.UserTermRepository;
import org.example.umc10thyongjae.global.apiPayload.exception.AlreadyRegisterUserException;
import org.example.umc10thyongjae.global.apiPayload.exception.LoginUnavailableException;
import org.example.umc10thyongjae.global.apiPayload.exception.NotDataFoundException;
import org.example.umc10thyongjae.global.security.entity.AuthUser;
import org.example.umc10thyongjae.global.security.util.JwtUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -35,6 +40,7 @@ public class AuthService {
private final FoodPreferenceRepository foodPreferenceRepository;
private final UserFoodPreferenceRepository userFoodPreferenceRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;

@Transactional
public Long signUp(SignUpRequestDto dto) {
Expand Down Expand Up @@ -63,6 +69,20 @@ public Long signUp(SignUpRequestDto dto) {
return user.getId();
}

@Transactional(readOnly = true)

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.

로그인 서비스에서는 DB작업을 조회만 하니까 readOnly=true로 선언해주는거 되게 좋은것 같다. 나도 반영해봐야지

public LoginResponseDto login(LoginRequestDto dto) {
User user = authRepository.findByLoginId(dto.id())
.orElseThrow(LoginUnavailableException::new);

if (!passwordEncoder.matches(dto.pwd(), user.getPwd())) {
throw new LoginUnavailableException();
}

String accessToken = jwtUtil.createAccessToken(new AuthUser(user));

return new LoginResponseDto(accessToken);
}

@Transactional(readOnly = true)
public UserInfoResponseDto getUserInfo(long userId) {
User entity = authRepository.findById(userId).orElseThrow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
public enum GeneralErrorCode implements BaseErrorCode {
BAD_REQUEST(HttpStatus.BAD_REQUEST, "BAD_REQUEST", "잘못된 요청입니다. "),
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "인증되지 않았습니다."),
LOGIN_UNAVAILABLE(HttpStatus.UNAUTHORIZED, "LOGIN_UNAVAILABLE", "아이디 또는 비밀번호가 일치하지 않습니다."),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👍

FORBIDDEN(HttpStatus.FORBIDDEN, "FORBIDDEN", "접근이 금지되었습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "NOT_FOUND", "해당 리소스를 찾을 수 없습니다."),
ALREADY_REGISTER_USER(HttpStatus.CONFLICT, "ALREADY_REGISTER_USER", "이미 가입된 사용자입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.example.umc10thyongjae.global.apiPayload.exception;

import org.example.umc10thyongjae.global.apiPayload.code.GeneralErrorCode;

public class LoginUnavailableException extends CommonException {

public LoginUnavailableException() {
super(GeneralErrorCode.LOGIN_UNAVAILABLE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;

//TODO- @AuthenticationPrincipal AuthUser authUser로 전체 대체 후 제거
@Deprecated
public class HeaderAuthInterceptor implements HandlerInterceptor {

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
package org.example.umc10thyongjae.global.config;

import lombok.RequiredArgsConstructor;
import org.example.umc10thyongjae.domain.auth.service.CustomUserDetailsService;
import org.example.umc10thyongjae.global.apiPayload.handler.CustomAccessDenied;
import org.example.umc10thyongjae.global.apiPayload.handler.CustomEntryPoint;
import org.example.umc10thyongjae.global.security.filter.JwtAuthFilter;
import org.example.umc10thyongjae.global.security.util.JwtUtil;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.accept.HeaderContentNegotiationStrategy;

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService;

private final String[] allowUris = {
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
"/auth/signUp",
"/auth/login",
"/login"
};

Expand All @@ -42,12 +52,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
)
.accessDeniedHandler(customAccessDenied())
)
.formLogin(form -> form
.usernameParameter("id")
.passwordParameter("pwd")
.defaultSuccessUrl("/swagger-ui/index.html", true)
.permitAll()
)
.addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(AbstractHttpConfigurer::disable)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
Expand All @@ -71,4 +78,9 @@ public CustomAccessDenied customAccessDenied() {
public CustomEntryPoint customEntryPoint() {
return new CustomEntryPoint();
}

@Bean
public JwtAuthFilter jwtAuthFilter() {
return new JwtAuthFilter(jwtUtil, customUserDetailsService);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public void addInterceptors(InterceptorRegistry registry) {
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/signUp",
"/auth/login",
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.example.umc10thyongjae.global.security.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.example.umc10thyongjae.domain.auth.entity.User;
import org.jspecify.annotations.Nullable;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Getter
@RequiredArgsConstructor
public class AuthUser implements UserDetails {
private final User user;

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

@Override
public @Nullable String getPassword() {
return user.getPwd();
}

@Override
public String getUsername() {
return user.getLoginId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package org.example.umc10thyongjae.global.security.filter;

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.example.umc10thyongjae.domain.auth.service.CustomUserDetailsService;
import org.example.umc10thyongjae.global.apiPayload.ApiResponse;
import org.example.umc10thyongjae.global.apiPayload.code.BaseErrorCode;
import org.example.umc10thyongjae.global.apiPayload.code.GeneralErrorCode;
import org.example.umc10thyongjae.global.security.util.JwtUtil;
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 tools.jackson.databind.ObjectMapper;

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 ", "");

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.

여기서 toen.replace를 사용하면 문자열 안에 있는 Bearer이 모두 삭제되기 때문에 혹시 모를 상황을 대비해 token.substring(7,token.length())를 이용해서 앞의 7글자만 제거하는 방식도 있더라

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

이거 되게 신박한 생각이네 ...
엄청 낮은 확률로 나올 에러겠지만 발생하면 원인을 아예 못찾겠다

반영해볼게 땡큐!!

// 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.getStatus().value());

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

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