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
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public enum ErrorCode {
USER_COIN_TRANSFER_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "U-006", "코인 송금에 실패하였습니다."),
USER_IMAGE_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "U-007", "해당 유저의 프로필 이미지를 찾을 수 없습니다."),
USER_UPDATE_TOKEN_INVALID_EXCEPTION(HttpStatus.BAD_REQUEST, "U-008", "토큰이 유효하지 않습니다."),
WITHDRAWN_USER_EXCEPTION(HttpStatus.BAD_REQUEST, "U-009", "탈퇴한 유저입니다."),

// Post
POST_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "P-001", "해당 포스트를 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
import hanium.modic.backend.common.property.property.TokenProperty;
import hanium.modic.backend.common.security.principal.AuthenticatedUser;
import hanium.modic.backend.common.security.principal.UserPrincipal;
import hanium.modic.backend.domain.auth.constant.AuthConstant;
import hanium.modic.backend.domain.auth.dto.Token;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.repository.UserEntityRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

Expand Down Expand Up @@ -95,6 +97,12 @@ public void validateToken(final String accessToken) {
}
}

public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(AuthConstant.AUTHORIZATION)).filter(
accessToken -> accessToken.startsWith(AuthConstant.BEARER)
).map(accessToken -> accessToken.replace(AuthConstant.BEARER, ""));
}

public String getType(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(tokenProperty.getSecretKey().getBytes(StandardCharsets.UTF_8)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import java.io.IOException;

import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
Expand All @@ -13,7 +15,6 @@
import hanium.modic.backend.domain.auth.constant.AuthConstant;
import hanium.modic.backend.domain.auth.dto.Token;
import hanium.modic.backend.domain.auth.util.CookieUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
Expand All @@ -26,6 +27,7 @@ public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
private final CookieUtil cookieUtil;

@Override
@Transactional
Expand All @@ -44,8 +46,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo

// 엑세스 토큰과 리프레시 토큰을 응답 헤더와 쿠키에 설정
response.addHeader(AuthConstant.AUTHORIZATION, AuthConstant.BEARER + token.accessToken());
Cookie refreshTokenCookie = CookieUtil.createRefreshCookie(token.refreshToken());
response.addCookie(refreshTokenCookie);
ResponseCookie refreshTokenCookie = cookieUtil.createRefreshCookie(token.refreshToken());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());

// Todo: 리다이렉트 URL을 환경 변수로 관리
response.sendRedirect(AuthConstant.LOCAL_OAUTH_REDIRECT_URI);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll())
.anyRequest().authenticated())
Comment on lines 37 to +38
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

모든 요청에 인증을 요구하면 로그인이 불가능합니다.

.anyRequest().authenticated()로 변경하면 로그인, 회원가입, 이메일 인증 등의 공개 엔드포인트도 인증을 요구하게 되어 사용자가 로그인할 수 없습니다.

다음과 같이 공개 엔드포인트를 명시적으로 허용해야 합니다:

 .authorizeHttpRequests(auth -> auth
+    .requestMatchers("/api/auth/login", "/api/auth/email/**", "/api/auth/signup").permitAll()
+    .requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll()
     .anyRequest().authenticated())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll())
.anyRequest().authenticated())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/auth/email/**", "/api/auth/signup").permitAll()
.requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll()
.anyRequest().authenticated())
🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/security/SecurityConfig.java around
lines 37-38, the current .anyRequest().authenticated() forces authentication for
all endpoints (breaking login/signup/email verification). Update the
authorization config to explicitly permit public endpoints first (e.g.
requestMatchers or ant patterns for /api/auth/**, /api/signup,
/api/verify-email, static/public resources, swagger/actuator if used) with
.permitAll(), and then leave .anyRequest().authenticated() last so only the
remaining endpoints require authentication.

.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package hanium.modic.backend.common.security.principal;

import static hanium.modic.backend.domain.user.enums.UserRole.*;

import java.util.ArrayList;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.enums.UserRole;
import lombok.Data;

@Data
Expand Down Expand Up @@ -58,7 +61,7 @@ public boolean isCredentialsNonExpired() {

@Override
public boolean isEnabled() {
return true;
return user.getUserRole() != WITHDRAWN;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import hanium.modic.backend.common.error.ErrorCode;
import hanium.modic.backend.common.error.exception.AppException;
Expand All @@ -14,6 +15,7 @@
import hanium.modic.backend.domain.auth.service.component.EmailSender;
import hanium.modic.backend.domain.auth.service.dto.EmailDto;
import hanium.modic.backend.domain.user.entity.UserEntity;
import hanium.modic.backend.domain.user.enums.UserRole;
import hanium.modic.backend.domain.user.repository.UserEntityRepository;
import hanium.modic.backend.web.auth.dto.CheckEmailDuplicateResponse;
import hanium.modic.backend.web.auth.dto.LoginResponse;
Expand All @@ -40,10 +42,16 @@ public class AuthService {
private final EmailSender emailSender;

// 로그인 처리
@Transactional
public LoginResponse login(final String email, final String password) {
UserEntity user = userEntityRepository.findByEmail(email)
.orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND_EXCEPTION));

// 탈퇴한 회원이 로그인 시도 시, 로그인 막음
if (user.getUserRole() == UserRole.WITHDRAWN) {
throw new AppException(ErrorCode.WITHDRAWN_USER_EXCEPTION);
}

if (!passwordEncoder.matches(password, user.getPassword())) {
throw new AppException(ErrorCode.USER_PASSWORD_MISMATCH_EXCEPTION);
}
Expand All @@ -59,6 +67,18 @@ public LoginResponse login(final String email, final String password) {
return LoginResponse.from(token);
}

@Transactional
public void logout(final String refreshToken, final String accessToken) {
UserEntity user = jwtTokenProvider.getUser(refreshToken)
.orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND_EXCEPTION));

jwtTokenProvider.setBlackList(refreshToken);
jwtTokenProvider.setBlackList(accessToken);

refreshTokenRepository.deleteById(user.getId());
}

@Transactional
public ReissueResponse reissue(final String refreshToken) {
if (blackListRepository.existsById(refreshToken)) {
throw new AppException(ErrorCode.TOKEN_BLACKLISTED_EXCEPTION);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
package hanium.modic.backend.domain.auth.util;

import jakarta.servlet.http.Cookie;
import org.springframework.http.ResponseCookie;

public class CookieUtil {
public interface CookieUtil {
ResponseCookie createRefreshCookie(final String refreshToken);

private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";

private static final int COOKIE_MAX_AGE = 60 * 60 * 24 * 3;

public static Cookie createRefreshCookie(final String refreshToken) {
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE_NAME, refreshToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
// cookie.setSecure(true); // Todo: https://github.com/Modic-2025/modic_backend/issues/39
cookie.setMaxAge(COOKIE_MAX_AGE);
return cookie;
}
ResponseCookie deleteRefreshCookie();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package hanium.modic.backend.domain.auth.util;

import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;

// 개발 환경용 쿠키 유틸리티
@Component
@Profile({"local", "dev", "test"})
public class DevCookieUtil implements CookieUtil {

private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";

private static final int COOKIE_MAX_AGE = 60 * 60 * 24 * 3;

public ResponseCookie createRefreshCookie(final String refreshToken) {
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(COOKIE_MAX_AGE)
.sameSite("Lax") // 또는 "Strict"
.build();
}

public ResponseCookie deleteRefreshCookie() {
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "")
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(0)
.sameSite("Lax")
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package hanium.modic.backend.domain.auth.util;

import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.Cookie;

// Production 환경용 쿠키 유틸리티
@Component
@Profile({"main"})
public class ProdCookieUtil implements CookieUtil {

private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";

private static final int COOKIE_MAX_AGE = 60 * 60 * 24 * 3;

public ResponseCookie createRefreshCookie(final String refreshToken) {
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(COOKIE_MAX_AGE)
.sameSite("Lax") // 또는 "Strict"
.build();
}

public ResponseCookie deleteRefreshCookie() {
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "")
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(0)
.sameSite("Lax")
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,10 @@ public void updateEmail(String email) {
}
this.email = email;
}

public void softWithdraw() {
this.userRole = UserRole.WITHDRAWN;
this.email = "withdrawn_" + this.id + "_" + java.util.UUID.randomUUID().toString() + "@modic.com";
this.name = "탈퇴회원";
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package hanium.modic.backend.domain.user.enums;

public enum UserRole {
ADMIN, USER
ADMIN,
USER,
WITHDRAWN
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ private void checkDuplicateEmail(final String email) {
}
}

// 회원탈퇴
@Transactional
public void deleteAndLogout(final long id, final String refreshToken, final String accessToken) {
// 소프트 삭제 처리
UserEntity user = userEntityRepository.findById(id)
.orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND_EXCEPTION));
user.softWithdraw();

userEntityRepository.save(user);

// 관련 토큰 삭제
authService.logout(refreshToken, accessToken);
}

// 회원 정보 조회
public UserInfoResponse getUserInfo(UserEntity user) {
Optional<String> userImageUrl = userImageService.createImageGetUrlOptional(user.getId());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package hanium.modic.backend.web.auth.controller;

import static hanium.modic.backend.common.error.ErrorCode.*;

import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.CookieValue;
Expand All @@ -10,7 +14,9 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import hanium.modic.backend.common.jwt.JwtTokenProvider;
import hanium.modic.backend.common.response.AppResponse;
import hanium.modic.backend.common.swagger.ApiErrorMapping;
import hanium.modic.backend.domain.auth.constant.AuthConstant;
import hanium.modic.backend.domain.auth.service.AuthService;
import hanium.modic.backend.domain.auth.util.CookieUtil;
Expand All @@ -23,7 +29,7 @@
import hanium.modic.backend.web.auth.dto.VerifyEmailCodeResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
Expand All @@ -37,6 +43,8 @@
public class AuthController {

private final AuthService authService;
private final CookieUtil cookieUtil;
private final JwtTokenProvider jwtTokenProvider;

@PostMapping("/login")
@Operation(
Expand All @@ -52,12 +60,37 @@ public ResponseEntity<AppResponse<LoginResponse>> login(@RequestBody @Valid Logi
LoginResponse loginResponse = authService.login(request.email(), request.password());

response.addHeader(AuthConstant.AUTHORIZATION, AuthConstant.BEARER + loginResponse.accessToken());
Cookie refreshTokenCookie = CookieUtil.createRefreshCookie(loginResponse.refreshToken());
response.addCookie(refreshTokenCookie);
ResponseCookie refreshTokenCookie = cookieUtil.createRefreshCookie(loginResponse.refreshToken());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());

return ResponseEntity.ok(AppResponse.ok(loginResponse));
}

@PostMapping("/logout")
@Operation(
summary = "로그아웃 API",
description = """
리프레시 토큰을 통해 로그아웃합니다. <br>
로그아웃된 토큰으로 요청시 [C-005] - 차단된 토큰입니다를 반환합니다.
"""
)
@ApiErrorMapping({
USER_NOT_FOUND_EXCEPTION,
})
public ResponseEntity<AppResponse<Void>> logout(
@CookieValue(name = "refreshToken") String refreshToken,
HttpServletRequest request,
HttpServletResponse response
) {
String accessToken = jwtTokenProvider.extractAccessToken(request).get(); // accessToken은 무조건 존재함
authService.logout(refreshToken, accessToken);

ResponseCookie deleteRefreshTokenCookie = cookieUtil.deleteRefreshCookie();
response.addHeader(HttpHeaders.SET_COOKIE, deleteRefreshTokenCookie.toString());

return ResponseEntity.ok(AppResponse.noContent());
}

@PostMapping("/reissue")
@Operation(
summary = "토큰 재발급 API",
Expand All @@ -73,8 +106,8 @@ public ResponseEntity<AppResponse<Void>> reissue(@CookieValue(name = "refreshTok
ReissueResponse reissueResponse = authService.reissue(refreshToken);

response.addHeader(AuthConstant.AUTHORIZATION, AuthConstant.BEARER + reissueResponse.accessToken());
Cookie refreshTokenCookie = CookieUtil.createRefreshCookie(reissueResponse.refreshToken());
response.addCookie(refreshTokenCookie);
ResponseCookie refreshTokenCookie = cookieUtil.createRefreshCookie(reissueResponse.refreshToken());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString());

return ResponseEntity.ok().build();
}
Expand Down
Loading