Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions src/main/java/Gotcha/common/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 37 additions & 16 deletions src/main/java/Gotcha/common/jwt/filter/JwtAuthenticationFilter.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -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();
Expand All @@ -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;
}


Expand All @@ -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;
}
Expand All @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions src/main/java/Gotcha/domain/auth/api/AuthApi.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 = "로그인 성공",
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/Gotcha/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/Gotcha/domain/auth/dto/SignUpReq.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 42 additions & 14 deletions src/main/java/Gotcha/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -37,24 +39,30 @@ public class AuthService {
private final RedisUtil redisUtil;

@Transactional
public TokenDto signUp(SignUpReq signUpReq) {
Map<String, String> 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);
}
System.out.println(guestId);
Copy link
Contributor

Choose a reason for hiding this comment

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

요 라인 지우는걸 깜빡하신 것 같습니다 ! !

Copy link
Contributor Author

Choose a reason for hiding this comment

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

헉 맞네요!


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());
Expand Down Expand Up @@ -109,4 +117,24 @@ public TokenDto guestSignIn(){
public TokenDto reissueAccessToken(String refreshToken) {
return jwtHelper.reissueToken(refreshToken);
}

public void validateSignUpInfo(SignUpReq signUpReq){
Map<String, String> 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);
}
}
}