Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
33b16b8
[Refactor] #158 관리자용 리프레시토큰 발급 메서드 생성
JJUYAAA Jan 4, 2026
879fe81
[Feat] #158 관리자용 로그인 응답 DTO 생성
JJUYAAA Jan 4, 2026
91c6b73
[Feat] #158 관리자용 토큰 발급 서비스 코드 생성
JJUYAAA Jan 4, 2026
d6ca881
[Refactor] #158 관리자용 토큰 발급 관련 환경변수 추가
JJUYAAA Jan 4, 2026
b719b48
[Refactor] #158 관리자용 토큰 발급 서비스 - AuthServiceFacade에 추가
JJUYAAA Jan 4, 2026
6415657
[Refactor] #158 Redis에 관리자용 토큰 저장하는 로직 추가
JJUYAAA Jan 4, 2026
848cc4c
[Refactor] #158 관리자용 토큰 발급 API - Controller에 추가
JJUYAAA Jan 4, 2026
243f444
[Test] #158 application-test.yml에 관리자용 토큰 발급 관련 환경변수 추가
JJUYAAA Jan 4, 2026
cb8d305
[Test] #158 TestInitializer에 특정한 ID의 유저 생성하는 메서드 추가
JJUYAAA Jan 4, 2026
dca8923
[Test] #158 관리자용 토큰 발급 서비스 테스트코드 작성
JJUYAAA Jan 4, 2026
ca4909e
[Test] #158 AuthControllerTest에 관리자용 토큰 발급 API 테스트 추가
JJUYAAA Jan 4, 2026
ecf97e3
[Test] #158 AuthControllerTest 보강 - 401 반환 테스트
JJUYAAA Jan 4, 2026
274e51a
[Refactor] #158 Role-ADMIN 추가
JJUYAAA Jan 10, 2026
284159f
[Refactor] #158 관리자용 refreshToken 생성 메서드 삭제, 관리자용 accessToken 생성 메서드 추가
JJUYAAA Jan 10, 2026
51f4224
[Refactor] #158 관리자용 로그인 ResponseDTO 수정
JJUYAAA Jan 10, 2026
53d27a8
[Refactor] #158 관리자용 로그인 서비스 코드 수정 - refreshToken 제거
JJUYAAA Jan 10, 2026
7f4d66a
[Refactor] #158 RedisRefreshTokenRepository 수정 - refreshToken 제거
JJUYAAA Jan 10, 2026
a8f2b1c
[Refactor] #158 AdminAllowlistFilter 추가
JJUYAAA Jan 10, 2026
40de914
[Test] #158 AdminLoginServiceImplTest 수정
JJUYAAA Jan 10, 2026
3b12825
[Test] #158 AuthControllerTest 수정
JJUYAAA Jan 10, 2026
64b4e4b
[Refactor] #158 Image 업로드 API - PreAuthorize 수정
JJUYAAA Jan 10, 2026
c25b0f4
[Refactor] #158 ExceptionTranslationFilter 뒤에 AdminAllowListFilter를 추가
JJUYAAA Jan 11, 2026
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 @@ -3,21 +3,22 @@
import com.kuit.findyou.domain.auth.dto.ReissueTokenRequest;
import com.kuit.findyou.domain.auth.dto.ReissueTokenResponse;
import com.kuit.findyou.domain.auth.dto.request.GuestLoginRequest;
import com.kuit.findyou.domain.auth.dto.response.AdminLoginResponse;
import com.kuit.findyou.domain.auth.dto.response.GuestLoginResponse;
import com.kuit.findyou.domain.auth.dto.request.KakaoLoginRequest;
import com.kuit.findyou.domain.auth.dto.response.KakaoLoginResponse;
import com.kuit.findyou.domain.auth.service.AuthServiceFacade;
import com.kuit.findyou.global.common.annotation.CustomExceptionDescription;
import com.kuit.findyou.global.common.exception.CustomException;
import com.kuit.findyou.global.common.response.BaseResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;

import static com.kuit.findyou.global.common.response.status.BaseExceptionResponseStatus.UNAUTHORIZED;
import static com.kuit.findyou.global.common.swagger.SwaggerResponseDescription.*;

@Tag(name = "Login", description = "로그인 관련 API")
Expand All @@ -28,6 +29,9 @@
public class AuthController {
private final AuthServiceFacade authServiceFacade;

@Value("${admin.api.key}")
private String adminApiKey;

@Operation(
summary = "카카오 로그인 API",
description = "카카오 사용자 식별자를 이용해서 유저 정보와 엑세스 토큰을 얻을 수 있습니다. 가입된 회원인지 여부를 반환합니다."
Expand Down Expand Up @@ -58,4 +62,18 @@ public BaseResponse<GuestLoginResponse> guestLogin(@RequestBody GuestLoginReques
public BaseResponse<ReissueTokenResponse> reissueToken(@RequestBody ReissueTokenRequest request){
return BaseResponse.ok(authServiceFacade.reissueToken(request));
}

@Operation(
summary = "서비스 계정 로그인 API (내부 자동화용)",
description = "내부 자동화/관리용 서비스 계정 토큰을 발급합니다. X-ADMIN-KEY 헤더가 필요합니다."
)
@PostMapping("/login/admin")
public BaseResponse<AdminLoginResponse> adminLogin(
@RequestHeader(value = "X-ADMIN-KEY", required = false) String adminKey
Copy link
Collaborator

@JangIkhwan JangIkhwan Jan 7, 2026

Choose a reason for hiding this comment

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

서버 간에 인증을 위해서 client credentials라는 방법을 사용하기도 하던데 이렇게 구현하신 이유가 있나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

외부 다수 클라이언트가 붙는 구조가 아니라 내부 자동화 주체 1개만 인증하면 되는 요구사항이라, 인증 인프라를 과도하게 키우기보다는 로그인 단계에서만 API Key를 사용하고, 이후에는 짧은 만료의 access token으로 요청을 처리해 보안 수준은 유지하면서 구현과 운영 복잡도를 낮추는 방식이 적절하다고 생각했습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

보안 관점에서는 아쉽지만 그렇게 생각하신다면 알겠습니다

) {
if (adminKey == null || adminKey.isBlank() || !adminApiKey.equals(adminKey)) {
throw new CustomException(UNAUTHORIZED);
}
return BaseResponse.ok(authServiceFacade.adminLogin());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.kuit.findyou.domain.auth.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "관리자 로그인 응답 DTO")
public record AdminLoginResponse(
@Schema(description = "관리자 유저 식별자")
Long userId,
Comment on lines +7 to +8
Copy link
Collaborator

Choose a reason for hiding this comment

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

식별자를 반환하는 이유가 있나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

지금은 관리자 허용 액션이 한정되어있어서 userID 를 필요로하지는 않지만, 추후 관리자 기능이 늘어날 것을 대비했을 때 확장성이 더 좋다고 생각합니다. 이 외에도, 관리자 유저가 둘 이상이 되는 경우 디버깅도 유리할 것 같네요.

Copy link
Collaborator

Choose a reason for hiding this comment

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

개인적으로는 API 사용자에게 당장 필요하지 않은 정보는 제공하지 않는 게 낫지 않나 싶지만 알겠습니다...!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

디버깅 측면에서도 식별자가 불필요하다고 생각하시나요?

@Schema(description = "엑세스 토큰")
String accessToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.kuit.findyou.domain.auth.service;

import com.kuit.findyou.domain.auth.dto.response.AdminLoginResponse;

public interface AdminLoginService {
AdminLoginResponse adminLogin();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.kuit.findyou.domain.auth.service;

import com.kuit.findyou.domain.auth.dto.response.AdminLoginResponse;
import com.kuit.findyou.domain.user.model.Role;
import com.kuit.findyou.domain.user.model.User;
import com.kuit.findyou.domain.user.repository.UserRepository;
import com.kuit.findyou.global.common.exception.CustomException;
import com.kuit.findyou.global.jwt.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import static com.kuit.findyou.global.common.response.status.BaseExceptionResponseStatus.USER_NOT_FOUND;

@RequiredArgsConstructor
@Service
public class AdminLoginServiceImpl implements AdminLoginService{
private final JwtUtil jwtUtil;
private final UserRepository userRepository;

@Value("${admin.admin-user-id}")
private Long adminUserId;

@Value("${admin.access-ttl-ms}")
private Long adminAccessTtlMs;

@Override
public AdminLoginResponse adminLogin() {
User user = userRepository.findById(adminUserId)
.orElseThrow(() -> new CustomException(USER_NOT_FOUND));

String accessToken = jwtUtil.createAccessJwt(user.getId(), Role.ADMIN, adminAccessTtlMs);

return new AdminLoginResponse(user.getId(), accessToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.kuit.findyou.domain.auth.dto.ReissueTokenResponse;
import com.kuit.findyou.domain.auth.dto.request.GuestLoginRequest;
import com.kuit.findyou.domain.auth.dto.request.KakaoLoginRequest;
import com.kuit.findyou.domain.auth.dto.response.AdminLoginResponse;
import com.kuit.findyou.domain.auth.dto.response.GuestLoginResponse;
import com.kuit.findyou.domain.auth.dto.response.KakaoLoginResponse;
import lombok.RequiredArgsConstructor;
Expand All @@ -14,6 +15,7 @@
public class AuthServiceFacade {
private final LoginService loginService;
private final ReissueTokenService reissueTokenService;
private final AdminLoginService adminLoginService;

public KakaoLoginResponse kakaoLogin(KakaoLoginRequest request) {
return loginService.kakaoLogin(request);
Expand All @@ -26,4 +28,8 @@ public GuestLoginResponse guestLogin(GuestLoginRequest request) {
public ReissueTokenResponse reissueToken(ReissueTokenRequest request) {
return reissueTokenService.reissueToken(request);
}

public AdminLoginResponse adminLogin() {
return adminLoginService.adminLogin();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class ImageController {

@Operation(summary = "신고글 이미지 업로드 API", description = "멀티파트 이미지 업로드 후 CDN URL 리스트 반환")
@CustomExceptionDescription(IMAGE_UPLOAD)
@PreAuthorize("hasRole('ROLE_USER')")
@PreAuthorize("hasAnyRole('ROLE_USER','ROLE_ADMIN')")
@PostMapping(value = "/upload", consumes = MULTIPART_FORM_DATA_VALUE)
public BaseResponse<ReportImageResponse> uploadImages(@RequestPart(value = "files", required = false) List<MultipartFile> files, @LoginUserId Long userId) {
List<String> urls = imageUploadService.uploadImages(files);
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/kuit/findyou/domain/user/model/Role.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
@Getter
public enum Role {

USER("회원"), GUEST("비회원");
USER("회원"), GUEST("비회원"), ADMIN("관리자");

private final String value;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.kuit.findyou.global.jwt.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpMethod;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
@RequiredArgsConstructor
public class AdminAllowlistFilter extends OncePerRequestFilter {
Comment on lines +17 to +21
Copy link
Collaborator

@JangIkhwan JangIkhwan Jan 11, 2026

Choose a reason for hiding this comment

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

필터를 @component로 등록하셨더라고요. AccessDeniedException은 시큐리티의 ExceptionTranslationFilter에서 잡기 때문에 이 필터 뒤에서 예외를 던져야 해요. 지금 방식처럼 필터를 등록하면 서블릿 컨테이너가 ExceptionTranslationFilter -> AdminAllowListFilter 같은 순서를 보장하지는 않아요. 일단 제 로컬에서는 필터가 우리가 기대한대로 등록되어서 잘 돌아가긴 합니다. 근데 잠재적인 오작동 가능성을 생각하면 시큐리티 설정에서 ExceptionTranslationFilter 뒤에 AdminAllowListFilter를 추가하거나, AuthorizationManager를 등록하는 방법이 있다고 하네요. 저는 급한대로 전자를 추천합니다..!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

추가하겠습니다.


private final AntPathMatcher matcher = new AntPathMatcher();

// ADMIN에게 허용되는 API
private static final List<Allow> ADMIN_ALLOWLIST = List.of(
new Allow(HttpMethod.GET.name(), "/api/v2/reports/protecting-reports/random-s3"),
new Allow(HttpMethod.GET.name(), "/api/v2/reports/missing-reports/random-s3"),
new Allow(HttpMethod.POST.name(), "/api/v2/images/upload")
);

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {

Authentication auth = SecurityContextHolder.getContext().getAuthentication();

if (auth != null && auth.isAuthenticated()) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));

if (isAdmin) {
String method = request.getMethod();
String path = request.getRequestURI();

boolean allowed = ADMIN_ALLOWLIST.stream()
.anyMatch(a -> a.method.equals(method) && matcher.match(a.pathPattern, path));

if (!allowed) {
throw new AccessDeniedException("ADMIN은 허용된 API만 호출할 수 있습니다.");
}
}
}

filterChain.doFilter(request, response);
}

private static class Allow {
final String method;
final String pathPattern;

private Allow(String method, String pathPattern) {
this.method = method;
this.pathPattern = pathPattern;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
BaseErrorResponse body = new BaseErrorResponse(FORBIDDEN);
String message = accessDeniedException.getMessage();
BaseErrorResponse body = (message == null || message.isBlank())
? new BaseErrorResponse(FORBIDDEN)
: new BaseErrorResponse(FORBIDDEN, message);
String json = objectMapper.writeValueAsString(body);

response.setStatus(FORBIDDEN.getCode());
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/com/kuit/findyou/global/jwt/util/JwtUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ public String createAccessJwt(Long userId, Role role) {
.compact();
}

public String createAccessJwt(Long userId, Role role, long expireMs) {
return Jwts.builder()
.claim(JwtClaimKey.USER_ID.getKey(), userId)
.claim(JwtClaimKey.ROLE.getKey(), role.name())
.claim(JwtClaimKey.TOKEN_TYPE.getKey(), JwtTokenType.ACCESS_TOKEN)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expireMs))
.signWith(secretKey)
.compact();
}

public String createRefreshJwt(Long userId) {
return Jwts.builder()
.id(UUID.randomUUID().toString())
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,9 @@ management:
exposure:
include: health, info, prometheus


admin:
admin-user-id: ${ADMIN_USER_ID}
access-ttl-ms: ${ADMIN_ACCESS_TTL_MS}
api:
key: ${ADMIN_API_KEY}
Loading