IDORは認証ではなく認可の失敗だ
한국어 원문으로 표시합니다.
한 줄 요약
인증은 "당신이 누구인지 확인"이고 인가는 "이 사람이 이 자원에 이 동작을 해도 되는지 판단"이다. 대부분의 실무 사고는 후자에서 난다.
왜 이게 필요했나
로그인은 잘 만들어 놓고 사고가 나는 경우가 많습니다. 사용자는 정상적으로 인증됐고 토큰도 유효합니다. 그런데 /api/orders/1042 의 숫자를 1043 으로 바꾸면 남의 주문이 보입니다.
이것이 IDOR(Insecure Direct Object Reference)입니다. 인증은 통과했지만 인가를 안 한 것입니다. "이 토큰의 주인이 이 주문의 소유자인가"를 아무도 확인하지 않았습니다.
어떻게 동작하나
두 질문을 코드에서 분리해서 생각하는 습관이 필요합니다.
인증은 요청 진입점에서 한 번 합니다. 토큰 서명을 검증하고, 만료를 보고, 주체(sub)를 꺼냅니다. 이 단계는 미들웨어로 처리할 수 있고 자원과 무관합니다.
인가는 자원에 접근하는 지점마다 합니다. 그리고 자원의 소유자나 속성을 알아야 판단할 수 있으므로 미들웨어만으로는 부족합니다. "주문 1043 의 owner_id 가 현재 사용자의 sub 와 같은가"는 그 주문을 읽은 뒤에야 판단할 수 있습니다.
여기서 실무의 규칙이 하나 나옵니다. 조회 쿼리 자체에 소유자 조건을 넣는 것입니다. SELECT * FROM orders WHERE id=? AND owner_id=? 로 쓰면 남의 주문은 애초에 결과가 없습니다. 읽은 뒤 비교하는 방식보다 실수하기 어렵습니다.
역할과 권한도 구분할 만합니다. 역할은 사람에 붙는 이름표(예: order-admin)이고, 권한은 동작에 붙는 이름(예: order:refund)입니다. 역할만으로 인가를 짜면 역할 수가 폭발하고, 권한만으로 짜면 관리가 어렵습니다. 실무는 대개 역할에 권한을 묶고 사용자에게 역할을 줍니다.
인가를 어디에 둘 것인가
같은 규칙을 여러 층에 흩어 놓으면 언젠가 어긋납니다. 세 자리 중 하나를 정하고 나머지는 보조로만 씁니다.
| 자리 | 잘하는 것 | 못하는 것 |
|---|---|---|
| 게이트웨이 | 경로·메서드 단위 차단, 인증 검증 | 자원의 소유자를 모른다 |
| 서비스 코드 | 소유자·상태에 따른 판단 | 규칙이 코드에 흩어진다 |
| 정책 엔진(OPA 등) | 규칙을 한곳에 모으고 감사 | 한 번 더 왕복한다 |
실무의 기본형은 게이트웨이에서 인증, 서비스에서 인가 입니다. 게이트웨이는 토큰이 유효한지만 보고, "이 주문을 이 사람이 볼 수 있는가" 는 그 주문을 아는 서비스가 판단합니다.
권한 검사를 잊지 않는 구조
사람의 주의력에 기대면 언젠가 빠뜨립니다. 구조로 막습니다.
기본을 거부로 둡니다. 새 엔드포인트를 만들면 아무 데코레이터도 없을 때 403 이 나야 합니다. 허용이 기본이면 빠뜨린 곳이 곧 구멍입니다.
@router.get("/orders/{oid}")
@requires("order:read") # 없으면 라우터 등록 단계에서 거부
def get_order(oid: int, user=Depends(current_user)):
# 조회 자체에 소유자 조건을 넣는다 — 읽은 뒤 비교하는 것보다 실수하기 어렵다
row = db.one("select * from orders where id=%s and owner_id=%s", (oid, user.sub))
if not row:
raise HTTPException(404) # 403 이 아니라 404 — 존재 여부도 흘리지 않는다
return row
마지막 줄이 중요합니다. 남의 자원에 403 을 주면 "그 번호는 존재한다" 를 알려 주는 것 입니다. 순차 ID 를 쓰는 시스템에서 이것만으로 전체 규모가 새어 나갑니다. 없는 것처럼 404 를 주는 편이 낫습니다.
목록 조회에도 같은 조건이 들어가야 합니다. 상세는 막았는데 목록은 전부 돌려주는 사고가 흔합니다. 그래서 조회 함수 자체를 소유자 인자를 받도록 만들고, 받지 않는 함수는 아예 두지 않는 방법을 씁니다.
다중 테넌트에서 한 겹 더
여러 조직이 한 시스템을 쓰면 owner_id 만으로는 부족합니다. 같은 조직 안의 다른
사람이 볼 수 있어야 하는 자원이 있기 때문입니다. tenant_id 를 모든 표에 넣고,
모든 쿼리에 강제로 붙이는 층 을 둡니다. PostgreSQL 이라면 Row Level Security 로
DB 가 직접 강제하게 할 수도 있습니다.
alter table orders enable row level security;
create policy tenant_isolation on orders
using (tenant_id = current_setting('app.tenant_id')::uuid);
애플리케이션이 실수해도 DB 가 막습니다. 대가는 연결마다 set_config 를 부르는
번거로움과, 커넥션 풀에서 설정이 새지 않게 하는 주의입니다.
현장에서 만나는 모습
토큰에 역할을 담는 것과 매 요청 서버에서 조회하는 것 사이에도 선택이 있습니다. 토큰에 담으면 빠르지만, 역할을 회수해도 토큰이 만료될 때까지 유효합니다. 그래서 액세스 토큰을 짧게 두는 것이 인가 측면에서도 중요합니다.
또 하나 자주 틀리는 곳. 프런트엔드에서 버튼을 숨기는 것은 인가가 아닙니다. UI 는 편의이고, 판단은 반드시 서버에서 다시 해야 합니다. "관리자만 볼 수 있는 화면인데 API 는 열려 있는" 상황이 침투 테스트에서 가장 먼저 나옵니다.
다음 확인에서 볼 것
먼저 퀴즈에서 인증 성공과 자원 인가 성공을 구분할 수 있는지 확인합니다. 다음 모듈에서 OAuth2 그랜트 타입의 지형도를 정리한 뒤 실제 Keycloak 으로 넘어갑니다.