Skip to content

Commit

Permalink
Feat/#13 이메일 중복 체크 메서드 및 예외 처리 추가 (#14)
Browse files Browse the repository at this point in the history
* feat: Spring Security Filter 단에서 에러 처리하는 필터 및 로직 추가

* feat: Access Token, Refresh Token 각각 만료될 시에 에러 반환하는 메서드 구현

* chore: 토큰 만료 테스트를 위해 만료시간 짧게 설정

* feat: 이메일 중복 체크 메서드 구현

* fix: 예외 추가

* add: CD 위해 SecurityConfig 권한 허용 path 추가
  • Loading branch information
dong2ast authored Jul 3, 2023
1 parent 67249d8 commit eef2a5c
Show file tree
Hide file tree
Showing 15 changed files with 161 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.sophy.sophy.common.advice;

import io.jsonwebtoken.ExpiredJwtException;
import org.sophy.sophy.common.dto.ApiResponseDto;
import org.sophy.sophy.exception.ErrorStatus;
import org.springframework.http.HttpStatus;
Expand All @@ -8,7 +9,6 @@
import org.springframework.web.bind.annotation.ResponseStatus;
import org.sophy.sophy.exception.model.SophyException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
Expand All @@ -30,6 +30,12 @@ protected ApiResponseDto handleMethodArgumentNotValidException(final MethodArgum
// return ApiResponseDto.error(ErrorStatus.INTERNAL_SERVER_ERROR);
// }

@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ExpiredJwtException.class)
protected ApiResponseDto handleExpiredRefreshTokenException(final ExpiredJwtException e) {
return ApiResponseDto.error(ErrorStatus.REFRESH_TOKEN_TIME_EXPIRED_EXCEPTION);
}

/**
* Sopt custom error
*/
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/org/sophy/sophy/config/JwtSecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.sophy.sophy.config;

import lombok.RequiredArgsConstructor;
import org.sophy.sophy.jwt.JwtExceptionFilter;
import org.sophy.sophy.jwt.JwtFilter;
import org.sophy.sophy.jwt.TokenProvider;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
Expand All @@ -12,13 +13,15 @@
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
private final JwtExceptionFilter jwtExceptionFilter;

//TokenProvider를 주입받아서 JwtFillter를 통해 Security 로직에 필터를 등록
//HttpSecurity의 userpassword인증필터에 filter 추가
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtExceptionFilter, JwtFilter.class);
}

}
6 changes: 5 additions & 1 deletion src/main/java/org/sophy/sophy/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import lombok.RequiredArgsConstructor;
import org.sophy.sophy.jwt.JwtAccessDeniedHandler;
import org.sophy.sophy.jwt.JwtAuthenticationEntryPoint;
import org.sophy.sophy.jwt.JwtExceptionFilter;
import org.sophy.sophy.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -19,6 +20,8 @@ public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

private final JwtExceptionFilter jwtExceptionFilter;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
Expand Down Expand Up @@ -51,11 +54,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.antMatchers("/auth/**").permitAll()
.antMatchers("/profile/**").permitAll()
.antMatchers("/actuator/**").permitAll()
.antMatchers("/health/**").permitAll()
.anyRequest().authenticated() //나머지 API는 전부 인증 필요

//JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
.apply(new JwtSecurityConfig(tokenProvider, jwtExceptionFilter));

return http.build();
}
Expand Down
13 changes: 10 additions & 3 deletions src/main/java/org/sophy/sophy/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import lombok.RequiredArgsConstructor;
import org.sophy.sophy.common.dto.ApiResponseDto;
import org.sophy.sophy.controller.dto.request.DuplCheckDto;
import org.sophy.sophy.controller.dto.request.MemberLoginRequestDto;
import org.sophy.sophy.controller.dto.request.MemberRequestDto;
import org.sophy.sophy.controller.dto.request.TokenRequestDto;
import org.sophy.sophy.controller.dto.response.MemberResponseDto;
import org.sophy.sophy.controller.dto.response.TokenDto;
import org.sophy.sophy.exception.SuccessStatus;
import org.sophy.sophy.service.AuthService;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
Expand All @@ -19,8 +20,9 @@ public class AuthController {
private final AuthService authService;

@PostMapping("/signup")
public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
return ResponseEntity.ok(authService.signup(memberRequestDto));
@ResponseStatus(HttpStatus.CREATED)
public ApiResponseDto<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
return ApiResponseDto.success(SuccessStatus.SIGNUP_SUCCESS, authService.signup(memberRequestDto));
}

@PostMapping("/login")
Expand All @@ -32,4 +34,9 @@ public ApiResponseDto<TokenDto> login(@RequestBody MemberLoginRequestDto memberL
public ApiResponseDto<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
return ApiResponseDto.success(SuccessStatus.REISSUE_SUCCESS, authService.reissue(tokenRequestDto));
}

@GetMapping("/dupl-check")
public ApiResponseDto<String> duplCheck(@RequestBody DuplCheckDto email) {
return ApiResponseDto.success(SuccessStatus.CHECK_DUPL_EMAIL_SUCCESS, authService.duplCheck(email));
}
}
15 changes: 15 additions & 0 deletions src/main/java/org/sophy/sophy/controller/TestController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.sophy.sophy.controller;

import org.sophy.sophy.common.dto.ApiResponseDto;
import org.sophy.sophy.exception.SuccessStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

@GetMapping("/test")
public ApiResponseDto<String> test() {
return ApiResponseDto.success(SuccessStatus.TEST_SUCCESS, SuccessStatus.TEST_SUCCESS.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.sophy.sophy.controller.dto.request;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class DuplCheckDto {
@Email(message = "이메일 형식에 맞지 않습니다.")
@NotNull
String email;
}
8 changes: 7 additions & 1 deletion src/main/java/org/sophy/sophy/exception/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ public enum ErrorStatus {
/**
* 401 UNAUTHORIZED
*/
TOKEN_TIME_EXPIRED_EXCEPTION(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
INVALID_ACCESS_TOKEN_EXCEPTION(HttpStatus.UNAUTHORIZED, "유효하지 않은 액세스 토큰입니다."),
REFRESH_TOKEN_TIME_EXPIRED_EXCEPTION(HttpStatus.UNAUTHORIZED, "만료된 리프레시 토큰입니다."),
/**
* 404 NOT FOUND
*/
NOT_FOUND_USER_EXCEPTION(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다"),
NOT_FOUND_SAVE_IMAGE_EXCEPTION(HttpStatus.NOT_FOUND, "이미지 저장에 실패했습니다"),
NOT_FOUND_IMAGE_EXCEPTION(HttpStatus.NOT_FOUND, "이미지 이름을 찾을 수 없습니다"),

/**
* 409 CONFLICT
*/
ALREADY_EXIST_USER_EXCEPTION(HttpStatus.CONFLICT, "이미 존재하는 유저입니다"),

/**
* 500 INTERNAL SERVER ERROR
*/
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/org/sophy/sophy/exception/SuccessStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public enum SuccessStatus {
*/
LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공했습니다."),
REISSUE_SUCCESS(HttpStatus.OK, "토큰 재발행에 성공했습니다."),
CHECK_DUPL_EMAIL_SUCCESS(HttpStatus.OK, "사용 가능한 이메일 주소입니다."),
TEST_SUCCESS(HttpStatus.OK, "Test :: OK"),
/*
* 201 created
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.sophy.sophy.exception.model;

import org.sophy.sophy.exception.ErrorStatus;

public class ExistEmailException extends SophyException {

public ExistEmailException(ErrorStatus errorStatus, String message) {
super(errorStatus, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.sophy.sophy.exception.model;

import org.sophy.sophy.exception.ErrorStatus;

public class ExpiredRefreshTokenException extends SophyException {

public ExpiredRefreshTokenException(ErrorStatus errorStatus, String message) {
super(errorStatus, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import org.sophy.sophy.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Repository
@Transactional
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/org/sophy/sophy/jwt/JwtExceptionFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.sophy.sophy.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.JwtException;
import org.sophy.sophy.common.dto.ApiResponseDto;
import org.sophy.sophy.exception.ErrorStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtExceptionFilter extends OncePerRequestFilter {

@Autowired
private ObjectMapper objectMapper;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException exception) {
setErrorResponse(HttpStatus.UNAUTHORIZED, response, exception);
}
}

public void setErrorResponse(HttpStatus status, HttpServletResponse res, Throwable ex) throws IOException {
res.setStatus(status.value());
res.setContentType("application/json; charset=UTF-8");

res.getWriter().write(objectMapper.writeValueAsString(ApiResponseDto.error(ErrorStatus.INVALID_ACCESS_TOKEN_EXCEPTION, ex.getMessage())));
}
}
16 changes: 11 additions & 5 deletions src/main/java/org/sophy/sophy/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.sophy.sophy.jwt;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
Expand All @@ -23,17 +25,21 @@ public class JwtFilter extends OncePerRequestFilter {
// 실제 필터링 로직은 doFilterInternal 에 들어감
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException{

// 1. Request Header에서 토큰을 꺼냄
String jwt = resolveToken(request);

// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으롤 Authentication을 가져와서 SecurityContext에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
//Context에 저장할 때 auth를 설정하며 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
try {
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
//Context에 저장할 때 auth를 설정하며 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (ExpiredJwtException e) { //access token이 만료되었을 때
throw new JwtException("만료된 액세스 토큰입니다.");
}

filterChain.doFilter(request, response);
Expand Down
14 changes: 8 additions & 6 deletions src/main/java/org/sophy/sophy/jwt/TokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 10;
// private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 30;
// private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;

private final Key key;

Expand Down Expand Up @@ -78,7 +80,7 @@ public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);

if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
throw new JwtException("권한 정보가 없는 토큰입니다.");
}

//클레임에서 권한 정보 가져오기
Expand Down Expand Up @@ -115,13 +117,13 @@ public boolean validateToken(String token) {
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
throw new JwtException("잘못된 JWT 서명입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
throw new JwtException("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalStateException e) {
log.info("JWT 토큰이 잘못되었습니다.");
throw new JwtException("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
20 changes: 15 additions & 5 deletions src/main/java/org/sophy/sophy/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package org.sophy.sophy.service;

import lombok.RequiredArgsConstructor;
import org.sophy.sophy.controller.dto.request.DuplCheckDto;
import org.sophy.sophy.controller.dto.request.MemberLoginRequestDto;
import org.sophy.sophy.controller.dto.request.MemberRequestDto;
import org.sophy.sophy.controller.dto.request.TokenRequestDto;
import org.sophy.sophy.controller.dto.response.MemberResponseDto;
import org.sophy.sophy.controller.dto.response.TokenDto;
import org.sophy.sophy.domain.Member;
import org.sophy.sophy.exception.ErrorStatus;
import org.sophy.sophy.exception.model.ExistEmailException;
import org.sophy.sophy.infrastructure.MemberRepository;
import org.sophy.sophy.jwt.TokenProvider;
import org.springframework.data.redis.core.RedisTemplate;
Expand All @@ -33,13 +36,22 @@ public class AuthService {
@Transactional
public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
throw new RuntimeException("이미 가입되어 있는 유저입니다.");
throw new ExistEmailException(ErrorStatus.ALREADY_EXIST_USER_EXCEPTION, ErrorStatus.ALREADY_EXIST_USER_EXCEPTION.getMessage());
}

Member member = memberRequestDto.toMember(passwordEncoder);
return MemberResponseDto.of(memberRepository.save(member));
}

@Transactional
public String duplCheck(DuplCheckDto email) {

if (memberRepository.existsByEmail(email.getEmail())) {
throw new ExistEmailException(ErrorStatus.ALREADY_EXIST_USER_EXCEPTION, ErrorStatus.ALREADY_EXIST_USER_EXCEPTION.getMessage());
}
return "사용 가능한 이메일입니다.";
}

@Transactional
public TokenDto login(MemberLoginRequestDto memberLoginRequestDto) {
// 1. Login ID/PW 를 기반으로 AuthenticationToken 생성
Expand All @@ -64,11 +76,9 @@ public TokenDto login(MemberLoginRequestDto memberLoginRequestDto) {
}

@Transactional
public TokenDto reissue(TokenRequestDto tokenRequestDto) {
public TokenDto reissue(TokenRequestDto tokenRequestDto){
// 1. Refresh Token 검증
if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
}
tokenProvider.validateToken(tokenRequestDto.getRefreshToken());

// 2. Access Token 에서 Member ID 가져오기
Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
Expand Down

0 comments on commit eef2a5c

Please sign in to comment.