Skip to content
Merged
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
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
54 changes: 40 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,28 @@ 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);
}
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 +115,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);
}
}
}