diff --git a/src/main/java/Gotcha/common/config/SecurityConfig.java b/src/main/java/Gotcha/common/config/SecurityConfig.java index 2a04a739..927628ee 100644 --- a/src/main/java/Gotcha/common/config/SecurityConfig.java +++ b/src/main/java/Gotcha/common/config/SecurityConfig.java @@ -56,6 +56,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/v1/auth/guest/sign-up").authenticated() .requestMatchers(PUBLIC_ENDPOINTS).permitAll() .requestMatchers(ADMIN_ENDPOINTS).hasAnyRole(String.valueOf(Role.ADMIN)) .anyRequest().authenticated() diff --git a/src/main/java/Gotcha/common/config/SecurityFilterConfig.java b/src/main/java/Gotcha/common/config/SecurityFilterConfig.java index ddd4bf32..04633d71 100644 --- a/src/main/java/Gotcha/common/config/SecurityFilterConfig.java +++ b/src/main/java/Gotcha/common/config/SecurityFilterConfig.java @@ -22,7 +22,7 @@ public class SecurityFilterConfig { @Bean public JwtAuthenticationFilter authenticationFilter() { - return new JwtAuthenticationFilter(userDetailsService, guestDetailsService, tokenProvider, blackListTokenService); + return new JwtAuthenticationFilter(userDetailsService, guestDetailsService, tokenProvider, blackListTokenService,objectMapper); } @Bean diff --git a/src/main/java/Gotcha/common/exception/exceptionCode/GlobalExceptionCode.java b/src/main/java/Gotcha/common/exception/exceptionCode/GlobalExceptionCode.java index 9bed9b45..edb3dbaf 100644 --- a/src/main/java/Gotcha/common/exception/exceptionCode/GlobalExceptionCode.java +++ b/src/main/java/Gotcha/common/exception/exceptionCode/GlobalExceptionCode.java @@ -8,7 +8,8 @@ public enum GlobalExceptionCode implements ExceptionCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러입니다. 서버 팀에 연락주세요."), FIELD_VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "필드 검증 오류입니다."), - CSRF_INVALID(HttpStatus.FORBIDDEN, "CSRF 토큰이 올바르지 않습니다."); + CSRF_INVALID(HttpStatus.FORBIDDEN, "CSRF 토큰이 올바르지 않습니다."), + USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "해당 사용자를 찾을 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/Gotcha/common/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/Gotcha/common/jwt/filter/JwtAuthenticationFilter.java index b7cf423b..691b3e58 100644 --- a/src/main/java/Gotcha/common/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/Gotcha/common/jwt/filter/JwtAuthenticationFilter.java @@ -1,10 +1,14 @@ package Gotcha.common.jwt.filter; import Gotcha.common.constants.SecurityConstants; +import Gotcha.common.exception.ExceptionRes; +import Gotcha.common.exception.exceptionCode.ExceptionCode; +import Gotcha.common.exception.exceptionCode.GlobalExceptionCode; import Gotcha.common.jwt.token.BlackListTokenService; import Gotcha.common.jwt.exception.JwtExceptionCode; import Gotcha.common.jwt.token.TokenProvider; import Gotcha.domain.user.entity.Role; +import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -18,6 +22,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.util.AntPathMatcher; @@ -33,6 +38,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final UserDetailsService guestDetailsService; private final TokenProvider tokenProvider; private final BlackListTokenService blackListTokenService; + private final ObjectMapper objectMapper; + private static final String SPECIAL_CHARACTERS_PATTERN = "[`':;|~!@#$%()^&*+=?/{}\\[\\]\\\"\\\\\"]+$"; private final AntPathMatcher antPathMatcher = new AntPathMatcher(); @@ -41,12 +48,14 @@ public JwtAuthenticationFilter( @Qualifier("userDetailsServiceImpl") UserDetailsService userDetailsService, @Qualifier("guestDetailsService") UserDetailsService guestDetailsService, TokenProvider tokenProvider, - BlackListTokenService blackListTokenService + BlackListTokenService blackListTokenService, + ObjectMapper objectMapper ) { this.userDetailsService = userDetailsService; this.guestDetailsService = guestDetailsService; this.tokenProvider = tokenProvider; this.blackListTokenService = blackListTokenService; + this.objectMapper = objectMapper; } @@ -56,7 +65,8 @@ public JwtAuthenticationFilter( protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String accessTokenHeader = request.getHeader(ACCESS_HEADER_VALUE); - if(isPublicResource(request.getRequestURI())) { + if (isPublicResource(request.getRequestURI()) + && !request.getRequestURI().equals("/api/v1/auth/guest/sign-up")) { filterChain.doFilter(request, response); return; } @@ -65,23 +75,34 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throw new AuthenticationServiceException(JwtExceptionCode.ACCESS_TOKEN_NOT_FOUND.getMessage()); } - String accessToken = resolveAccessToken(response, accessTokenHeader); + try{ + String accessToken = resolveAccessToken(response, accessTokenHeader); - String role = tokenProvider.getRole(accessToken); - UserDetails userDetails; - if (role.equals(String.valueOf(Role.GUEST))) { - Long guestId = tokenProvider.getUserId(accessToken); - userDetails = guestDetailsService.loadUserByUsername(guestId.toString()); - } - else{ - String username = tokenProvider.getUsername(accessToken); - userDetails = userDetailsService.loadUserByUsername(username); - } + String role = tokenProvider.getRole(accessToken); + UserDetails userDetails; + if (role.equals(String.valueOf(Role.GUEST))) { + Long guestId = tokenProvider.getUserId(accessToken); + userDetails = guestDetailsService.loadUserByUsername(guestId.toString()); + } + else{ + String username = tokenProvider.getUsername(accessToken); + userDetails = userDetailsService.loadUserByUsername(username); + } + + Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); - Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } catch (UsernameNotFoundException e){ + log.warn("[사용자 인증 실패] {}", e.getMessage()); + + ExceptionCode exceptionCode = GlobalExceptionCode.USER_NOT_FOUND; - filterChain.doFilter(request, response); + response.setStatus(exceptionCode.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + + objectMapper.writeValue(response.getWriter(), ExceptionRes.from(exceptionCode)); + } } private String resolveAccessToken(HttpServletResponse response, String accessTokenGetHeader) throws IOException { diff --git a/src/main/java/Gotcha/domain/auth/api/AuthApi.java b/src/main/java/Gotcha/domain/auth/api/AuthApi.java index ce5263c4..9a2266c5 100644 --- a/src/main/java/Gotcha/domain/auth/api/AuthApi.java +++ b/src/main/java/Gotcha/domain/auth/api/AuthApi.java @@ -1,5 +1,6 @@ package Gotcha.domain.auth.api; +import Gotcha.common.jwt.auth.SecurityUserDetails; import Gotcha.domain.auth.dto.EmailCodeVerifyReq; import Gotcha.domain.auth.dto.EmailReq; import Gotcha.domain.auth.dto.SignInReq; @@ -13,6 +14,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -69,6 +71,60 @@ public interface AuthApi { }) ResponseEntity signUp(@Valid @RequestBody SignUpReq signUpReq); + @Operation(summary = "게스트 회원가입", description = "게스트가 회원으로 전환하기 위한 API") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 전환 성공", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(value = """ + { + "expiredAt": "2025-04-10T06:57:45", + "accessToken": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QGdtYWlsLmNvbSIsInJvbGUiOiJVU0VSIiwidXNlcklkIjo1LCJpc3MiOiJnb3RjaGEhIiwiaWF0IjoxNzQ0MjY2NDY1LCJleHAiOjE3NDQyNjgyNjV9.u8RTE1VFsxZjQNB_dsc3ibSKqoHQGbC9-ppbOQUvzVY" + } + """) + })), + @ApiResponse(responseCode = "422", description = "유효성검사 실패", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "notBlank", value = """ + { + "email": "이메일은 필수 입력 값입니다.", + "password": "비밀번호는 필수 입력 값입니다.", + "passwordCheck": "비밀번호 확인은 필수 입력 값입니다.", + "nickname": "닉네임은 필수 입력 값입니다." + } + """), + @ExampleObject(name = "patternError", value = """ + { + "password": "비밀번호는 영문, 숫자, 특수문자를 포함하여 8~16자여야 합니다.", + "passwordCheck": "비밀번호 확인은 영문, 숫자, 특수문자를 포함하여 8~16자여야 합니다.", + "nickname": "닉네임은 한글, 영문, 숫자 조합의 2~6자리여야 합니다.", + "email": "유효한 이메일 형식이 아닙니다." + } + """) + })), + @ApiResponse(responseCode = "400", description = "Bad Request", + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "필드 검증 실패", value = """ + { + "status": "BAD_REQUEST", + "message": "필드 검증 오류입니다.", + "fields": { + "password": "비밀번호가 일치하지 않습니다.", + "nickname": "닉네임 중복 확인이 완료되지 않았습니다.", + "email": "이메일 인증이 완료되지 않았습니다." + } + } + """), + @ExampleObject(name = "게스트 아님", value = """ + { + "status": "BAD_REQUEST", + "message": "게스트가 아닙니다." + } + """) + })), + }) + ResponseEntity guestSignUp(@Valid @RequestBody SignUpReq signUpReq, + @AuthenticationPrincipal SecurityUserDetails userDetails); + @Operation(summary = "로그인", description = "로그인 API") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공", diff --git a/src/main/java/Gotcha/domain/auth/controller/AuthController.java b/src/main/java/Gotcha/domain/auth/controller/AuthController.java index a66f84f2..5a7cb054 100644 --- a/src/main/java/Gotcha/domain/auth/controller/AuthController.java +++ b/src/main/java/Gotcha/domain/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import Gotcha.common.api.SuccessRes; import Gotcha.common.exception.CustomException; +import Gotcha.common.jwt.auth.SecurityUserDetails; import Gotcha.common.jwt.exception.JwtExceptionCode; import Gotcha.common.mail.MailCodeService; import Gotcha.common.util.CookieUtil; @@ -19,6 +20,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.CookieValue; @@ -31,6 +33,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; import static Gotcha.common.jwt.token.JwtProperties.ACCESS_HEADER_VALUE; import static Gotcha.common.jwt.token.JwtProperties.REFRESH_COOKIE_VALUE; @@ -53,6 +56,14 @@ public ResponseEntity signUp(@Valid @RequestBody SignUpReq signUpReq) { return createTokenRes(tokenDto, tokenDto.autoSignIn()); } + @PostMapping("/guest/sign-up") + public ResponseEntity guestSignUp(@Valid @RequestBody SignUpReq signUpReq, + @AuthenticationPrincipal SecurityUserDetails userDetails) { + TokenDto tokenDto = authService.guestSignUp(signUpReq, userDetails); + + return createTokenRes(tokenDto, tokenDto.autoSignIn()); + } + @PostMapping("/sign-in") public ResponseEntity signIn(@Valid @RequestBody SignInReq signInReq) { TokenDto tokenDto = authService.signIn(signInReq); diff --git a/src/main/java/Gotcha/domain/auth/dto/SignUpReq.java b/src/main/java/Gotcha/domain/auth/dto/SignUpReq.java index 59b6b666..4075e7d8 100644 --- a/src/main/java/Gotcha/domain/auth/dto/SignUpReq.java +++ b/src/main/java/Gotcha/domain/auth/dto/SignUpReq.java @@ -40,4 +40,13 @@ public User toEntity(String encodePassword) { .role(Role.USER) .build(); } + + public User toEntityFromGuest(String encodePassword, User guest){ + return User.builder() + .email(email) + .password(encodePassword) + .nickname(nickname) + .role(Role.USER) + .build(); + } } diff --git a/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java b/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java index b103fff9..f406340b 100644 --- a/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java +++ b/src/main/java/Gotcha/domain/auth/exception/AuthExceptionCode.java @@ -8,7 +8,8 @@ public enum AuthExceptionCode implements ExceptionCode { INVALID_USERNAME_AND_PASSWORD(HttpStatus.NOT_FOUND, "아이디 또는 비밀번호가 유효하지 않습니다."), - INVALID_USERID(HttpStatus.NOT_FOUND,"존재하지 않는 사용자입니다."); + INVALID_GUEST(HttpStatus.BAD_REQUEST, "게스트가 아닙니다."), + INVALID_USERID(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/Gotcha/domain/auth/service/AuthService.java b/src/main/java/Gotcha/domain/auth/service/AuthService.java index d1e22855..39918620 100644 --- a/src/main/java/Gotcha/domain/auth/service/AuthService.java +++ b/src/main/java/Gotcha/domain/auth/service/AuthService.java @@ -2,6 +2,7 @@ import Gotcha.common.exception.CustomException; import Gotcha.common.exception.FieldValidationException; +import Gotcha.common.jwt.auth.SecurityUserDetails; import Gotcha.common.jwt.token.JwtHelper; import Gotcha.common.util.RedisUtil; import Gotcha.domain.auth.dto.SignInReq; @@ -20,6 +21,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.UUID; import static Gotcha.common.jwt.token.JwtProperties.TOKEN_PREFIX; @@ -37,24 +39,28 @@ public class AuthService { private final RedisUtil redisUtil; @Transactional - public TokenDto signUp(SignUpReq signUpReq) { - Map fieldErrors = new HashMap<>(); - - if(!signUpReq.validatePasswordMatch()){ - fieldErrors.put("password", "비밀번호가 일치하지 않습니다."); + public TokenDto guestSignUp(SignUpReq signUpReq, SecurityUserDetails userDetails){ + if (!userDetails.getRole().equals(Role.GUEST)) { + throw new CustomException(AuthExceptionCode.INVALID_GUEST); } - if(!redisUtil.existed(NICKNAME_VERIFY_KEY_PREFIX+signUpReq.nickname())){ - fieldErrors.put("nickname", "닉네임 중복 확인이 완료되지 않았습니다."); - } + validateSignUpInfo(signUpReq); - if (!redisUtil.existed(EMAIL_VERIFY_KEY_PREFIX + signUpReq.email())) { - fieldErrors.put("email", "이메일 인증이 완료되지 않았습니다."); - } + Long guestId = userDetails.getId(); - if (!fieldErrors.isEmpty()) { - throw new FieldValidationException(fieldErrors); - } + User guest = Optional.ofNullable((User) redisUtil.getData(GUEST_KEY_PREFIX + guestId)) + .orElseThrow(()-> new CustomException(AuthExceptionCode.INVALID_USERID)); + + String encodePassword = passwordEncoder.encode(signUpReq.password()); + User createdUser = userRepository.save(signUpReq.toEntityFromGuest(encodePassword, guest)); + + redisUtil.deleteData(GUEST_KEY_PREFIX + guestId); + return jwtHelper.createToken(createdUser, false); + } + + @Transactional + public TokenDto signUp(SignUpReq signUpReq) { + validateSignUpInfo(signUpReq); redisUtil.deleteData(NICKNAME_VERIFY_KEY_PREFIX+signUpReq.nickname()); redisUtil.deleteData(EMAIL_VERIFY_KEY_PREFIX + signUpReq.email()); @@ -109,4 +115,24 @@ public TokenDto guestSignIn(){ public TokenDto reissueAccessToken(String refreshToken) { return jwtHelper.reissueToken(refreshToken); } + + public void validateSignUpInfo(SignUpReq signUpReq){ + Map fieldErrors = new HashMap<>(); + + if(!signUpReq.validatePasswordMatch()){ + fieldErrors.put("password", "비밀번호가 일치하지 않습니다."); + } + + if(!redisUtil.existed(NICKNAME_VERIFY_KEY_PREFIX+signUpReq.nickname())){ + fieldErrors.put("nickname", "닉네임 중복 확인이 완료되지 않았습니다."); + } + + if (!redisUtil.existed(EMAIL_VERIFY_KEY_PREFIX + signUpReq.email())) { + fieldErrors.put("email", "이메일 인증이 완료되지 않았습니다."); + } + + if (!fieldErrors.isEmpty()) { + throw new FieldValidationException(fieldErrors); + } + } }