이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 인증 성공과 인가 성공은 왜 분리해서 설계해야 하는가?
  • 로그인한 사용자가 다른 사용자의 리소스 ID를 넣을 때 서버는 어디서 막아야 하는가?
  • 401, 403, 404 응답과 권한 거부 로그를 어떻게 구분해야 하는가?

개요

Authentication은 “이 요청자가 누구인지 확인했다”는 뜻이다.

Authorization은 “이 요청자가 이 resource에 이 action을 해도 된다”는 뜻이다.

로그인 성공은 인가의 시작점일 뿐이다. 로그인한 사용자가 다른 사용자의 주문, 문서, 결제, 팀 설정을 볼 수 있는 것은 아니다.

권한 취약점의 많은 부분은 이 두 개념을 섞을 때 생긴다. 코드에서는 isAuthenticated()만 확인하고 실제 resource ownership을 확인하지 않는 형태로 드러난다.

공격 시나리오

사용자 1004가 자기 주문을 조회한다.

GET /api/orders/801 HTTP/1.1
Authorization: Bearer member-1004-token

응답을 보고 공격자는 URL의 주문 ID를 바꾼다.

GET /api/orders/991 HTTP/1.1
Authorization: Bearer member-1004-token

서버가 “로그인했는가”만 보고 orders.id = 991로 조회하면 사용자 2002의 주문이 노출된다. 이것이 IDOR의 전형적인 모양이다.

공격 요청은 이상하지 않다. 정상 endpoint, 정상 token, 정상 HTTP method를 쓴다. 바뀐 것은 resource id뿐이다.

취약한 요청/응답 예시

GET /api/users/2002/orders/991 HTTP/1.1
Host: api.example.com
Authorization: Bearer member-1004-token
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "orderId": 991,
  "memberId": 2002,
  "totalPrice": 42000,
  "shippingAddress": "Seoul ..."
}

문제는 bearer token이 틀린 것이 아니다. 인증은 성공했지만 주문의 소유자와 principal의 관계를 확인하지 않았다.

주제별 핵심 판단

  • 인증은 “누구인가”이고 인가는 “이 resource에 이 action을 해도 되는가”다.
  • 인증 성공을 권한 허용으로 사용하면 IDOR와 수평 권한 상승이 생긴다.

개선된 요청/응답 예시

인가 판단은 다음 네 가지를 함께 본다.

  • 개선된 API는 principal을 서버에서 얻고 resource owner와 action 정책을 별도로 확인한다.
  • 거부 응답은 403으로 통일하되 로그에는 owner mismatch나 missing permission을 남긴다.

방어 설계

actor: authenticated member 1004
resource: order 991
action: read
context: normal customer API

이 네 가지가 모여야 decision이 된다.

allow if order.member_id == principal.member_id
deny if order.member_id != principal.member_id

API path도 혼동을 줄인다.

나쁜 형태: GET /api/users/{userId}/orders/{orderId}
나은 형태: GET /api/me/orders/{orderId}

하지만 /me를 쓰는 것만으로 충분하지 않다. 서버는 여전히 orderId와 principal 관계를 확인해야 한다.

응답 코드는 정책에 맞게 정한다.

  • 401: 인증이 없거나 유효하지 않다.
  • 403: 인증은 되었지만 action이 거부된다.
  • 404: resource 존재 여부 노출을 줄이기 위해 “없는 것처럼” 응답한다.

어떤 응답을 쓰든 서버 감사 로그에는 실제 reason을 남겨야 한다.

Spring/HTTP 예시

@GetMapping("/api/me/orders/{orderId}")
public OrderResponse getMyOrder(
        @AuthenticationPrincipal AccountPrincipal principal,
        @PathVariable long orderId) {
 
    return orderQueryService.getOrderForMember(principal.accountId(), orderId);
}
@Transactional(readOnly = true)
public OrderResponse getOrderForMember(long memberId, long orderId) {
    Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
        .orElseThrow(() -> {
            audit.authorizationDenied(memberId, "order", orderId, "read", "ownership_mismatch");
            return new NotFoundException("order not found");
        });
 
    return OrderResponse.from(order);
}

Repository 조건에 principal context가 들어가면 실수 여지가 줄어든다.

SELECT id, member_id, total_price, status
FROM orders
WHERE id = ?
  AND member_id = ?;

운영 로그와 감사 지점

level=WARN event=authorization_denied result=blocked actor_id=1004 resource_type=order resource_id=991 action=read reason=ownership_mismatch response_status=404 trace_id=6f12aa90

로그에는 외부 응답보다 더 정확한 reason을 남긴다. 응답은 404일 수 있어도 내부 reason은 ownership_mismatch여야 사고 분석이 가능하다.

실전 판단 기준

  • 인증 없는 요청은 401인가?
  • 모든 인증된 사용자 허용 규칙이 민감 resource에 닿지 않는지 확인한다.
  • 테스트에는 반드시 정상 사용자와 비소유자 사용자가 함께 있어야 한다.

테스트 포인트

  • 다른 사용자의 주문 ID를 넣으면 403 또는 404인가?
  • 거부 로그에 actor, resource, action, reason이 남는가?
  • Controller가 아니라 Service/Repository에서도 principal context가 쓰이는가?
  • userId를 body나 query로 보내도 서버 권한 결정에 사용하지 않는가?
  • list API와 detail API가 같은 ownership 조건을 적용하는가?

위험 신호!

  • isAuthenticated()만 확인하고 resource owner를 보지 않는다.
  • URL의 userId와 token의 subject가 달라도 요청이 성공한다.
  • 프론트에서 남의 링크를 숨겼으니 충분하다고 말한다.
  • 403/404 응답은 있지만 감사 로그에 decision reason이 없다.
  • list 조회에는 권한 조건이 있는데 detail 조회에는 없다.
  • 관리자 예외 경로가 일반 사용자 경로와 같은 정책 없이 Service를 호출한다.

확인 질문

확인 질문

  • 인증과 인가를 분리해야 하는 이유는 무엇인가?
    • 로그인은 신원 확인일 뿐이고, 각 resource와 action마다 별도의 허용 판단이 필요하기 때문이다.
  • IDOR를 막는 가장 직접적인 DB 조건은 무엇인가?
    • resource id와 principal의 owner 또는 tenant id를 함께 조건으로 조회하는 것이다.
  • 404로 응답해도 감사 로그에 reason을 남겨야 하는 이유는 무엇인가?
    • 외부에는 존재 여부를 숨기더라도 내부에서는 권한 거부와 실제 원인을 추적해야 하기 때문이다.

참고 문서