Asp.Net Core's What & Why

소개

이 슬로그의 목적은 웹 어플리케이션에서 사용되는 용어와 개념들을 알아 보기 위한 것입니다.

설명을 위해, Asp.Net Core 웹어플리케이션 프로젝트를 사용하지만, 프로젝트의 완성이 목적이 아니라, 프로젝트 진행 각 단계 마다 삼천포로 빠져서, 그 단계와 관련된 개념을 설명하는 것이 목적입니다.

선수 지식

  1. 간혹 있을 수 있는 예제 코드는 C#으로 작성될 것이기에, C#에 대한 이해가 있어야 합니다.
  2. 대부분 윈도우 환경을 가정합니다.
  3. 주로 비주얼 스튜디오 2022 버전을 기준으로 설명될 것입니다.

Https 에 대한 구성

닷넷에서 제공하는 웹 어플리케이션을 만들 수 있는 도구는 아래와 같은 것들이 있고, 비주얼 스튜디오에서 개발한다면, 이 중에 하나를 선택하는 것으로부터 개발은 시작됩니다.

image

이중에 어떤 것을 선택해도, 프로젝트 설정의 마지막 단계는 아래와 같은 [추가 정보] 설정입니다.

image

빨간 색 박스의 Https 에 대한 구성이 이 글의 주제입니다.

Https 통신

Https 통신은 Http 메시지가 암호화되어 송/수신되는 방식 쯤으로 간단히 정의하겠습니다.

[Https 에 대한 구성] 옵션은 웹어플리케이션이 Https 통신을 할 것인지, 말 것인지를 결정하는 것입니다.
이 옵션이 선택되면 Https 통신을 위한 기반 자원들이 설정되고, 그에 상응하는 뼈대 코드가 프로젝트에 삽입되게 됩니다.

Https 통신을 위한 기반 자원 중에 가장 중요한 것은 "SSL 인증서"입니다. 왜냐하면, Https 통신 과정은 "SSL 인증서"를 교환하는 절차가 포함되어 있기 때문입니다.

그래서, 상용 서비스를 하기 위해서는 반드시 필요한 항목이고, 유료로 구매해야 합니다.

개발용 인증서

SSL 인증서를 개발 시점부터 유료로 구매하여 사용하는 것은 부담이 될 수 있습니다.
대부분은 Https 통신을 시작하기 위한 구색을 갖추기 위해 무료인 인증서를 사용하게 됩니다.

무료인 인증서는 보통 OpenSSL 이라는 도구를 사용하여 생성하는데, 닷넷은 개발용 인증서를 간편하게 제공합니다.

[Https 에 대한 구성] 옵션이 선택되면, 비주얼 스튜디오는 개발 PC에 "개발용 인증서"가 있는 지 검색하고, 없다면 새롭게 생성합니다. 비주얼 스튜디오가 생성한 개발용 인증서는 모든 Asp.Net Core 웹 어플리케이션 프로젝트가 공유하도록 설정되어 있습니다. .

비주얼 스튜디오가 생성한 개발용 인증서의 실물을 확인하는 방법은:
(윈도우 기준)

  1. 윈도우 검색 창에 "인증서"를 입력하면
    검색 결과 최상단에 "사용자 인증서 관리"라는 제어판 항목이 노출됩니다.
    image

  2. 인증서 관리자를 클릭하면, 아래와 같이 인증서 관리자가 실행됩니다.
    image

위 창의 왼쪽 분면의 최 상단에 있는 “개인용” 폴더를 확장하면, “인증서” 폴더가 나오는데, 이곳이 비주얼 스튜디오가 생성한 개발용 인증서가 저장되는 곳입니다.
image
위 그림에서, 빨간 색 박스로 표시된 인증서가, 생성된 개발용 인증서 실물입니다.

이 폴더의 인증서들은 대부분 발급자와 발급 대상자가 다르지만, 개발용 인증서는 이들이 모두 “localhost” 로 동일하게 표시되어 있습니다.

이는 자신의 증명서를 자기 도작을 찍어서 발행한 것과 유사합니다. 이렇게 자신을 위해 발급한 인증서를 "자가 서명 인증서(Self-signed Certificate)"이라고 부르는데, 개발용 인증서들이 보편적으로 갖는 특징입니다.

인증서는 Https 통신이 시작되면, 서버에서 브라우저로 전달되고, 브라우저는 이 인증서를 검증합니다.
브라우저가 인증서를 검증하는 일차적인 이유는, 현재 접속 중인 도메인(URL)의 소유자가 제공된 인증서의 "발급 대상자"와 일치하는지 확인하기 위한 것입니다.

즉, 통신 상대방이 진짜 URL 소유자(origin)인지 아닌 지 확인하기 위함입니다.

origin 임이 확인되면, 서버와 브라우저는 향후 주고 받을 메시지를 암호화할 비밀키를 공유합니다.
그 다음부터는, 송신측은 공유된 비밀키로 메시지를 암호화한 후 송신하고, 수신측은 비밀키로 복원해서 메시지를 확인합니다.

이 것이 Https 를 통해 이뤄지는 암호화 통신의 대략입니다.

신뢰할 수 있는 루트 인증기관

인증서 저장 폴더 중에 가장 중요한 곳은 [신뢰할 수 있는 루트 인증기관] 입니다.
image

이 폴더는 발급 기관 정보를 저장하는 특수 폴더가 아니라, 그냥 똑 같이 인증서들을 저장하는 곳입니다.

그런데, 여기에 있는 인증서들을 자세히 보면, 개발용 인증서와 같이 발급자와 발급 대상자가 모두 동일한, 자가 서명 인증서임을 알 수 있습니다.

보통 인증서는 발급자와 발급 대상자가 다른 게 정상이지만, 발급자를 인증해 줄 상위 발급자가 더 이상 없기 때문에, 스스로에게 인증서를 발급하는 것입니다. 이러한 형식으로 발급된 인증서를 "루트 인증서"라고, 루트 인증서의 발급자를 "루트 발급자(기관)"라고 합니다.

개발용 인증서도 형식적으로는 루트 인증서이고, 개발용 인증서의 발급자인 localhost 도 루트 발급자입니다.

그러나, 개발용 인증서의 발급자 “localhost” 와 이 폴더에 있는 발급자들은 급이 다릅니다.
이들은 보편적으로 많은 운영체제들이 신뢰하는 회사-조직으로, 이들이 발급한 루트 인증서는 운영체제가 설치될 때, 혹은 나중에라도 자유롭게 이 폴더에 모셔집니다. 그래서 폴더 이름도 [신뢰할 수 있는 루트 인증 기관]입니다.

이들이 발급한 인증서는 Https 통신에서 대부분 검증을 통과하게 됩니다. 그래서, 돈을 주고 이들에게 사이트에 대한 인증서를 발급 받아야 하는 것입니다.

그런데, 이 폴더가 듣보잡 발급자인 "localhost"를 "신뢰하는 루트 인증기관"으로 격상시키는 방법에 대한 실마리를 제공합니다.

비주얼 스튜디오가 생성한 개발용 인증서의 사본을 이 폴더에 넣기만 하면, 그 인증서의 발행자인 "localhost"가 신뢰할 수 있는 루트 발급자로 둔갑하게 되는 것이죠.

사실, 이 것이 무료인 개발용 인증서를 사용해도, 아무 문제 없이 https 통신을 할 수 있게 되는 원리입니다.

브라우저는 https 통신을 개시할 때, 서버로부터 받은 인증서의 발급자가 신뢰할 수 있는 지 확인하는데, 확인의 근거는 그 인증서의 발급자가 [신뢰할 수 있는 루트 인증 기관]에 있기만 하면 되는 것입니다.

다만 이러한 효과는 그 PC에만 해당되는 얘기입니다.
만약, 개발용 인증서를 전세계 모든 PC의 [신뢰할 수 있는 루트 인증 기관] 폴더에 설치할 수 있다면, 유료 인증서와 다른 점은 없습니다.

윈도우의 경우, 개발용 인증서를 [신뢰할 수 있는 루트 인증 기관] 폴더로 옮기는 작업은 인증서 관리자의 내보내기, 가져오기 기능을 통해 처리할 수 있습니다

만약, 비주얼 스튜디오에서 프로젝트를 생성하고, 그 프로젝트로 인해 개발용 인증서가 생성되는 경우라면, 이 프로젝트를 최초로 실행할 때, 비주얼 스튜디오는 아래의 질문을 합니다.
image

“일단 개발용 인증서를 하나 생성하기는 했는데, 브라우저가 이 인증서를 신뢰하게 만들려면 [신뢰할 ..] 폴더에 넣어야 돼. 넣을까?” 라는 의미입니다.

[예]를 선택하면, 비주얼 스튜디오는 개발용 인증서의 사본을 [신뢰할 수 있는 루트 인증기관] 폴더에 저장하려고 합니다.

그런데, 운영체제에 인증서를 설치하는 것은 중요한 보안 행위이므로, 아래와 같은 운영 체제의 보안 경고가 한번 더 뜹니다.
image

다시 [예]를 선택하면, 개발용 인증서가 [신뢰할 수 있는..] 폴더에 복사됩니다.
image

실행된 웹앱은 개인용-보증서 폴더에 있는 개발용 인증서를 https 통신 개시 시점에 브라우저에게 보냅니다. 이를 받은 브라우저는

  1. 인증서의 발급자가 [신뢰할 수 있는…] 폴더에 있는 발급자인지를 확인합니다.
  2. 확인이 되면, 개발용 인증서를 신뢰하고, 그 인증서의 발급 대상자인 localhost를 origin이라고 판단합니다.

사실 하나의 인증서로 북치고 장구치고 하는 것이죠.

그 결과로 https 통신은 개시되고, 우리의 코드 결과를 화면에 보여주는 것입니다.

유효 기한

개발용 인증서의 만료 날짜를 보시면 유효기간이 1년 짜리입니다.

만약, 이전에 웹 어플리케이션 프로젝트를 생성한 적이 있고, 그 때 발급된 개발용 인증서의 유효기간이 만료되지 않았다면, 개발용 인증서 생성 단계는 생략됩니다.

그리고, 인증서를 신뢰할 것이냐는 질문도 하지 않습니다. 이미, [신뢰할 수 있는…] 폴더에 사본이 들어가 있을 테니까요.

개발용 인증서는 웹 어플리케이션 프로젝트를 처음 생성하거나, 기존에 발급된 인증서의 유효기간이 경과된 경우에만 새로이 생성됩니다.

브라우저가 발급자를 신뢰하지 않는 경우

브라우저가 수신한 인증서의 발급자를 신뢰하지 않는 경우를 살펴보기 위해, [신뢰할 수 있는…] 폴더에서 방금 복사된 인증서를 삭제합니다. 삭제 방법은 인증서 관리자에서, 인증서를 선택하고 delete 키를 누르거나, 우클릭>삭제를 하거나, 관리자 메뉴의 삭제 버튼, 아무거나 다 됩니다.

인증서를 삭제하는 것도 보안 문제라, 삭제 시에도 운영제체는 보안 경고를 보여줍니다.
보안 경고에서, [예]를 선택하면 비로소 삭제됩니다.

인증서의 실제 사용자는 브라우저인데, 브라우저는 실행할 때 신뢰하는 인증서를 캐싱합니다.
따라서, 브라우저 캐싱에서 삭제하지 않으면, 아무런 효과가 없습니다. 브라우저 캐싱에서 삭제하려면, 모든 브라우저를 닫고 재실행하여, 새롭게 캐싱하도록 만들면 됩니다.

이제 앱을 실행해보면, 인증서를 신뢰하겠냐는 질문을 다시 하는데, 이번에는 [아니오]를 선택합니다.
image

이제 발급자 localhost는 브라우저가 신뢰하지 않는 발급자로 전락했기에, 개발용 인증서를 보낸 서버에 대한 대우가 완전히 달라집니다.
image

메시지의 내용은 섬뜩하지만, 신뢰할 수 없는 “듣보잡” 발급자 "localhost"가 발행한 인증서를 받았다는 의미에 지나지 않습니다.

이 화면에서 [고급] 버튼을 누르면, 위험을 감수하고 https 통신을 계속 진행할 수 있는 옵션을 선택할 수 있습니다.(생략)

Https 로 구성된 앱이 (개발용)인증서를 찾을 수 없는 경우

이번에는 개인용-인증서 폴더에 저장된 개발용 인증서를 지워 웹앱이 인증서를 못 찾게 해봅니다.
image

이 상태에서 앱을 다시 실행하면, 아래와 같은 예외가 발생합니다.
image

사실, 이 예외 메시지가 개발용 인증서에 관한 실질을 잘 보여줍니다.

Https 엔드 포인트를 설정할 수 없음. 그 이유는:
서버 인증서도 찾을 수 없고, 기본적인 개발용 인증서도 찾을 수 없거나 유효기간이 만료됨.
개발용 인증서를 생성하려면, 명령 프롬프트에 ‘dotnet dev-certs https’ 를 실행해라.
(윈도우 맥OS 한정) 생성과 동시에 신뢰하려면, 'dotnet dev-certs https --trust’를 실행해라.
자세한 사항은 Enforce HTTPS in ASP.NET Core | Microsoft Learn 으로 가라

위 링크를 따라가 보면 예외 메시지와 비슷한 내용을 보여 주는 문서가 있습니다.

사실, "Https 에 대한 구성"을 선택하면, 비주얼 스튜디오는 ‘dotnet dev-certs https’ 콘솔 명령을 실행하는 것 뿐입니다. 이때, 아주 잠깐 동안 콘솔창이 열렸다가 순식 간에 사라지는 것을 볼 수도 있습니다.

콘솔창을 열러, 시키는 대로 동일한 명령을 실행하고,
image

인증서 관리자를 다시 보면, 인증서가 다시 생성되었음을 볼 수 있습니다.
image

이제 앱을 다시 실행해 보면, 이전과 같은 예외가 발생하지 않고, 개발용 인증서를 신뢰하냐는 질문 단계로 넘어갑니다.

이때, 아무것도 누르지 않고, 앱을 종료한 하고, 모든 브라우저의 창을 닫습니다.
그 다음, 다시 생성된 인증서를 복사해서, [신뢰할 수 있는…] 폴더에 붙여 넣기 합니다.

그 다음, 다시 앱을 실행해 보면, 신뢰할거냐는 질문 단계도 건너 띄고 index 뷰가 정상적으로 표시됩니다.

원격 개발 환경

만약 서버앱이 실행되는 PC와 브라우저가 실행되는 PC 가 다른 경우에는 서버 PC의 개발용 인증서 사본을 브라우저 PC의 [신뢰할 수 있는…] 폴더에 저장하기만 하면 됩니다.

서버 PC가 윈도우인 경우, 인증서 관리자의 내보내기를 선택하면, 아래와 같이 인증서 내보내기 마법사가 실행되고, 아래 그림 대로 순차적으로 따라하면 됩니다.
image

개인키를 내보내지 않는 선택을 하고,
image

파일 형식은 X.509 형식으로,
image

내보낼 폴더를 선택하고, 파일 이름을 선택하면,
image

아래와 같이 인증서 사본 파일이 복사됩니다.
image

이 파일을 USB에 저장하든, 메일로 보내든 브라우저 PC에 저장한 후, 인증서 관리자를 통해 [신뢰할 수 있는…] 폴더로 가져오기를 하면 됩니다.

그런데, 원격 환경에서 개발용 인증서를 사용할 때는 몇 가지 보안 문제가 있는데, 이에 관해서는 위에 링크된 문서에서 설명하고 있습니다.

16개의 좋아요

인증서

인증서란 인증서 소유자에 대한 확인서와 같습니다.

인증서
일련번호 : 20220921 - 1
발급 대상자 : www.biden.com
발급 대상자는 진실함을 확인함.
발급자 : www.mmbbcc.com

발급 대상자가 인증서를 통해 확인되는 소유자입니다.


인증서와 관련해서는 크게 두 가지 보안 문제가 있습니다.

첫 번째 문제는 인증서의 위조입니다.
인증서가 발급된 후, 인증서의 내용 중 일부, 주로 발급 대상자를 위조하는 것입니다.

인증서
일련번호 : 20220921 - 1
발급 대상자 : www.nalimyeon.com
발급 대상자는 진실함을 확인함.
발급자 : www.mmbbcc.com

두 번째는 발급자의 권위입니다.
발급자에게 신망이 없는 경우, 인증서는 문서 쪼가리에 지나지 않습니다.

인증서
일련번호 : 20220923 - 1
발급 대상자 : www.nalimyeon.com
발급 대상자는 진실함을 확인함.
발급자 : www.mmbbnn.com

인증서는 이러한 보안 위험에 대한 검증 장치를 포함하고 있는데, "디지털 서명"이라는 것이 사용됩니다.

디지털 서명

디지털 서명은 수기 서명을 디지털 방식으로 변환한 것으로, 해시 알고리즘과 공개키 암호화 방식을 사용합니다.

해시 알고리즘

해시 알고리즘은 수학적 알고리즘으로 가변 길이 문자열을 단일한 길이의 문자열로 변환하는 알고리즘입니다.

예를 들어, 100 글자를 출력하는 해시 알고리즘이 있다면, 한 글자를 입력하든 책 한권을 입력하든 100글자로 변환합니다.

해시 알고리즘은:

  • 입력과 출력이 고유한 관계임을 보증합니다.
    예를 들어, 책 한권에서 마침표 하나만 빠져도, 출력은 달라집니다.
    (참고, 실제로는 거의 대부분 고유하지만, 서로 다른 입력이 하나의 출력으로 나올 가능성이 없지는 않습니다.)

  • 동일한 입력에 대해 항상 동일한 출력을 제공합니다.

  • 출력으로부터 입력을 유추할 수 없습니다.

이러한 특징을 인증서에 적용해 보면, 동일한 해시 알고리즘을 사용할 경우, 인증서로부터 얻은 해시 값은 언제나 같을 것입니다.

만약, 인증서가 생성될 때 최초로 얻은 해시값과 인증서가 발급된 후 다른 사람이 얻은 해시값이 다른 경우가 발생한다면, 누군가 인증서의 내용을 위조한 것으로 판단할 수 있습니다.

인증서는 이러한 위조를 누구나 확인할 수 있도록, 인증서 발급자가 사용한 해시 알고리즘과 발급자가 얻은 최초의 해시값을 포함합니다.
image
image

인증서 지문

인증서에 포함된 해시값은 사실 해시 알고리즘의 결과물 그대로가 아닙니다.

왜냐하면, 인증서 위조자가 발급자의 해시값을 지우고, 위조된 인증서로부터 얻은 해시값을 삽입한다면, 그 다음부터 얻을 수 있는 해시값은 위조자의 해시값과 항상 동일하기 때문에 위조 여부를 검증하지 못합니다.

따라서, 인증서 발급자는 최초로 얻은 해시값을 암호화하여 인증서에 삽입하는데, 이 암호화된 해시값이 인증서의 “지문” 항목에 저장되는 것입니다.

해시값을 지문으로 변환할 때 사용되는 암호화 방식은 공개키 알고리즘입니다.

공개키 알고리즘

비대칭키 방식으로도 불리는 이 암호화 알고리즘은 평문을 암호문으로 변환하는 키와 암호문을 평문으로 복원하는 키가 분리된 암호화 방식입니다.

여기에서, key 는 비밀번호와 같은 의미입니다.

공개키 방식에서는,

평문을 암호문으로 변환하는데 사용하는 키를 개인 키(Private Key)라고 부르고,
암호문을 평문으로 환원하는 키를 공개키(Public Key)라고 부릅니다.

개인 키와 공개 키는 항상 고유한 쌍을 이루도록, 알고리즘이 보증합니다.

키의 이름에서 알 수 있듯이, 개인키는 외부로 노출하지 않는 비밀번호이고, 공개키는 외부로 노출하는 비밀번호입니다. 비밀번호의 일부를 외부로 노출하는 특징 때문에 공개키 암호화 방식으로 부릅니다.

다시 인증서로 돌아와서, 인증서 발급자는 지문을 생성할 때, 사용했던 공개키 알고리즘과
image

지문을 평문 해시값으로 복원하는데 필요한 공개키를 인증서에 첨부합니다.
image

참고로, 인증서 항목의 이름을 보면, 해시값을 암호화할 때 사용했던 알고리즘을 "서명 알고리즘"으로 부르고 있는데, 이를 통해 인증서에서는 해시값을 암호화하는 것을 "디지털 서명"이라고 부른다는 것을 알 수 있습니다.

위조 검증

인증서 수취인이 인증서를 검증하는 방식은

  1. 인증서에 표시된 해시 알고리즘을 사용하여, 인증서의 해시값을 구합니다.
  2. 인증서에 표시된 공개키 알고리즘과 공개키를 사용하여, 지문을 복원해서 원래 해시값을 구합니다.

이 두 해시값이 같다면, 인증서는 발급시점부터 지금까지 변조된 적이 없다는 의미가 됩니다.

그런데, 다시 위조 가능성에 대해 알아 보겠습니다.

위조자가 인증서를 위조한 다음, 위조된 인증서의 해시값을 구하고, 이를 자신만의 개인키로 서명한 다음, 위조 개인키와 쌍을 이루는 위조 공개키를 인증서에 넣으면, 여전히 위조 가능성은 열려 있다고 할 수 있습니다.

발급자의 권위

사실, SSL 인증서를 통한 인증 시스템을 떠받치는 핵심은 인증서 자체가 아니라, 발급자의 권위라고 할 수 있습니다.

일반적으로 신뢰 받는 루트 인증 기관이 발행한 루트 인증서의 유통 경로는 매우 짧습니다.
여기에서, 신뢰란 발급자와 운영체제 사이의 신뢰를 가리킵니다.

신뢰받는 기관들은 자신들의 루트 인증서가 오염되지 않은 채 운영체제에게 전달됨을 보증하고, 운영체제는 전달 받은 루트 인증서가 위조되지 않음을 보증합니다.

이러한 루트 발급자들은 그 운영체제에서 실행되는 모든 프로그램으로부터 지지를 받게 됩니다.

“니들이 발급한 것은 믿을 수 있지”

즉, 이렇게 유통된 루트 인증서에 명시된 발급자만이 발급자로서의 권위를 갖게 되는 것입니다.

인증서에 삽입된 지문, 공개키, 공개키 알고리즘은 누구나 볼 수 있습니다. 또한, 이들을 사용하여 지문을 원래의 해시값으로 복원하는 것도 매우 쉬운 일입니다.

그러나, 이 모든 것들을 다 알 수 있어도 딱 하나 알 수 없는 것이 있는데, 그것은 서명에 사용된 "개인키"입니다.

공개키 알고리즘은 평문-개인키-암호문-공개키의 조합이 유일함을 보장합니다. 이로 인해,
아래의 위조는 권위 있는 루트 발급자의 원래 공개키로 인해 위조가 금방 드러나게 됩니다.

위조자가 인증서를 위조한 다음, 위조된 인증서의 해시값을 구하고, 이를 자신만의 개인키로 서명한 다음, 위조 개인키와 쌍을 이루는 위조 공개키를 인증서에 넣으면, 여전히 위조 가능성은 열려 있다고 할 수 있습니다.

이는, 위조자는 인증서 자체를 위조할 수 있을 지는 몰라도, 그 권위까지 차용하지는 못한다는 의미입니다.

이전 글에서 자주 언급된 [신뢰할 수 있는 루트 인증기관] 폴더는 권위 있는 발급자들의 서명 정보를 모아 놓는 곳이라 할 수 있습니다.

개별 운영체제의 관리자는 자신이 개인적으로 지지하는 루트 발급자의 루트 인증서를 이 폴더에 등록함으로써, 그 인증서에 권위를 부여하는 것입니다.
그 결과로, 그 운영체제에서 실행되는 브라우저는 그 발급자를 신뢰하게 되고, Https 서버로부터 받은 인증서가 그 발급자가 발행한 것이 확인되면, 그 인증서를 인정하게 되는 것입니다.

10개의 좋아요

Identity


직역을 하면 "정체" 혹은 "신원" 혹은 "신분"으로 표현할 수 있습니다.

“너는 누구냐?”
행인 1 : “난 율도국의 왕인 홍길동이다”

“저 사람 누구야?”
■■ 1 : “쟤 율도국의 왕 홍길동이잖아”

정체를 묻는 우리의 질문에 누가 답변을 하든, 그 답변은 답변자의 주장입니다.

우리는 처음 만난 길거리 행인보다는 오랜 친구를 더 신뢰합니다.
그러나, 더 신뢰하는 친구의 답변일지라도, 주장에 불과하다는 사실에는 변함이 없습니다.

Claim

이름이 홍길동인 사람에 관한 주장은 사실 여러 세부 주장의 집합이라고 할 수 있습니다.

주장 1 : “이름은 홍길동임!!”
주장 2 : “국적은 율도국임!!”
주장 3 : “계급은 왕임!!”

각 주장은 제목과 내용으로 구분해서 형식화할 수 있습니다.

[제목] : [내용]
이름 : 홍길동
계급 : 왕
소속 : 율도국

주장을 데이터 구조로 표현하면, 제목을 "키"로, 내용을 "값"으로 보유한 키-값 쌍이 가장 적절할 것입니다.

claim : {
   {key} : {value}
}

Claims Idendity

어떤 사람이 가진 신분은 아래와 같이 키-값 쌍인 주장 객체의 집합으로 표현할 수 있습니다.

왕 신분 : {
    "이름":"홍길동", 
    "영토":"율도국"
}

주장의 집합으로 표현된 신분을 "주장 신분(Claims Identity)"이라고 합니다.
주장 신분은 주장이라는 정보 매개체를 통해 표현된 신분이라는 의미입니다.

Principal

주장의 대상을 가리키는 것으로 주장의 "주체(Pricipal)"이라고 합니다.
주장을 나타내는 아래의 문장에서 문장의 주어가 주체입니다.

율도국의 왕인 홍길동이다

율도국의 왕 홍길동이잖아

Claims Principal

주장 주체는 주장들의 집합으로 표현된 주체입니다.
이 집합에 포함된 주장들은 모두 주체를 대상으로 합니다.

외형적으로는 앞서 보았던 주장 신분(Claims Idendity)과 완전히 동일한 형태입니다.

다만, 주장 신분은 주장 집합을 "신분(Idendity)"으로 바라보고, 주장 주체(Claims Principal)는 그 "신분"의 소유자(Principal)로 바라본다는 관점적 차이만 있습니다.

만약 주체가 하나의 신분만 가진다면, 주장 주체 - 주장 신분 - 주장 집합은 모두 동일합니다.

홍길동 주체 : 
{
    이름 : 홍길동,
    계급 : 왕,
    국적 : 율도국,
}

관계

지금까지 등장했던 신분과 관련한 개념들을 객체로 표현하면 아래와 같을 것입니다.

주체는 개념적으로 하나 이상의 신분을 가집니다.

class Principal
{
    List<Identity> Identities;
}

class Identity 
{
    List<Claim> Claims;
}

주장 주체는,

대표 신분을 나타내는 "주장 집합"과,
추가적인 신분을 나타내는 "주장 신분 집합"의 합성체로 나타낼 수 있습니다.

class ClaimsPrincipal
{
    List<Claims> Claims;
    List<ClaimsIdentity> Identities;
}
class ClaimsIdendity : Identity
{
    List<Claim> Claims;
}

class Claim
{
    object Key;
    object Value;
}

Role 에서 Claim 으로의 전환

Asp.Net 에서는 인증과 관련하여 과거에는 Role 정보를 바탕으로 했으나, 시대적 흐름이 Claim 기준으로 옮겨 가면서, Asp.Net Core 부터는 Claim 기준으로 인증을 수행하는 방식으로 변경된 것 같습니다.

이러한 변화의 과정 중에 닷넷의 인증에 사용된 Role 과 관련한 객체들이 Claim 과 관련한 객체들 (ClaimsPrincipal 또는 ClaimsIdentity) 로 대체된 듯 보입니다.

닷넷의 유구한 역사를 계속 지켜보지 않았다면, 닷넷의 인증과 관련한 검색 결과에 포함된 레거시 정보나, 최신 문서에 포함된 레거시 키워드를 솎아 내는 것은 결코 쉽지 않습니다.

특히, 2019 년부터 닷넷을 시작한 저는 그러한 감각이 전혀 없었기 때문에 닷넷 문서만으로 닷넷의 인증 시스템을 이해하는 것이 결코 쉽지는 않았습니다.

그러나, 시대적 변화로 인해 인증에 사용된 닷넷 객체들에도 변화가 있었다는 사실을 알고 나니, 닷넷 문서를 이해하는 것이 조금은 수월해졌습니다.

이 글은 그 Claim 에 관한 것입니다.

JWT Claims

주장 기반의 인증 (신분 확인 체계)이 시대적 흐름이라고 언급했는데, 그 흐름의 가장 큰 원인은 주장이 더 유연한 정보 매개체이기 때문입니다.

이러한 유연성에는 과거에 사용하던 Role 정보도 주장으로 표현할 수 있기에, 하방 호환성 유지라는 장점도 포함됩니다.

현재 인증 정보 매개체로 많이 사용하는 JWT 도 주장을 기반한 개념입니다.

RFC 7519 - JSON Web Token (JWT) (ietf.org)

이 문서에는 표준 JWT에 포함되는 몇 가지 주장을 사전에 정의해 놓았습니다.

  1. Registered Claim Names

보시다시피, JWT 의 메타 정보에 관한 것입니다.

JWT 표준을 준수하는 토큰은 위에 나열된 메타 정보를 토큰에 포함시킬 때는 등록된 주장 제목(Registered Claim Names)을 쓰라는 의미입니다.

표준에 등록된 주장 중 하나를 살펴 보면,

4.1.1. “iss” (Issuer) Claim
The “iss” (issuer) claim identifies the principal that issued the JWT. The processing of this claim is generally application specific. The “iss” value is a case-sensitive string containing a StringOrURI value. Use of this claim is OPTIONAL.

4.1.1 iss(토큰 발행자) 주장
iss 주장은 JWT 를 발행한 주체 식별용이다. 이 주장은 보통 어플리케이션에 따라 달리 취급된다. 이 주장의 값은 대소문자를 구별하는 문자열로, 문자열 또는 Uri 값이다. 이 주장을 토큰에 포함하는 것은 선택적이다.


주장, 신분, 주체.. 이러한 개념이 C# 유저에게는 어떤 소용이 있을까요?

HttpContext.User

Asp.Net Core 의 인증/인가 미들웨어는 HttpContext.User 에 담긴 정보를 기준으로 인증과 인가를 수행합니다.

참고로, 인증/인가 미들웨어에 대해 짤막하게 살펴보면,

  • 인증 미들웨어는 시크릿(비밀 번호 입력 등)으로 인증을 통과한 사용자의 정보를 이 속성에 저장합니다. 설정에 따라 속성의 값은 쿠키 또는 JWT 토큰 등과 같은 실물 저장 매체에 연결됩니다.

  • 인가 미들웨어는 이 속성에 저장된 사용자의 정보를 바탕으로 특정 엔드포인트에 대한 접근 허용 여부를 결정하게 됩니다.

이 속성의 자료형은 아래와 같습니다.

public abstract ClaimsPrincipal User { get; set; }

어디서 많이 본 것이죠?

ClaimsPrincipal 객체가 가지는 대표적인 속성 두개를 살펴보면,

public virtual IEnumerable<Claim> Claims { get; }
public virtual IEnumerable<ClaimsIdentity> Identities { get; }

이들도 많이 본 것입니다.

이를 통해, Asp.Net Core는 주장 기반 - 사용자 정보를 담는 컨테이너로 주장 객체를 사용하고, 이 객체에 포함된 정보를 바탕으로 - 인증/인가를 수행함을 알 수 있습니다.

이러한 주장 기반 인증을 지원하는 객체들은 사실 아래의 네임스페이스에 다 모아져 있습니다.

image

인증 설정

Asp.Net Core 프로젝트를 생성할 때, 마지막에 나타나는 추가 설정 창의 [인증 유형] 항목은
image

인증과 관련한 제반 뼈대 코드를 프로젝트에 삽입 시킵니다.

이 뼈대 코드들은 결국엔 HttpContext.User 로 귀결되기에, 주장 객체를 기반합니다.
Asp.Net Core 의 인증 유형 처리자가 뒤에서 무슨 일을 벌이건, 사용자 정보를 잘 정리해서 ClaimsPrincipal 객체로 제공한다는 의미입니다.


Identity

다시 첫 제목으로 돌아 왔습니다.

혹자는 그런 말을 합니다.

“웹앱은 뭐… 로그인과 기타 등등이지”

그만큼 사용자 인증과 인증을 위한 사용자 정보 관리가 힘들다는 의미일 것입니다.

사용자 (인증) 정보는 악의적 공격의 주 대상이라, 비지니스 로직 못지 않은 보안 코드가 필요하고, 또한 우리나라의 법도 사용자의 개인 정보를 관리하는 웹 사이트는 암호화 통신 = https 를 사용하는 것을 강제할 정도이니, User 클래스를 정의하는 순간 법적인 문제도 고려해야 합니다.

닷넷도 사용자 정보 관리에 관한 많은 솔루션을 제공합니다.

그런데, 이러한 솔루션에 관한 문서들의 공통점은 “Idendity” 라는 키워드가 끼어 있다는 점입니다.
Asp.Net Core 자체에서 사용자를 관리하는 방식이든, OAuth 를 이용하는 방식이든, 이 단어가 항상 나타납니다.

그러나, 이들 문서에 나타난 "Identity"라는 단어는, 단순히 특정 객체를 의미하는 것이 아닌, 인증과 관련한 통합 시스템, 혹은 통합 시스템을 제공하는 외부 서비스를 지칭할 때가 많은데, 여기에는 수많은 개념과 객체가 난무하고, 때로는 유료 서비스를 적극 추천하기도 합니다.

이러한 닷넷의 인증 솔루션을 마스터하기를 원하신다면, 아래의 검색 키워드를 적극 사용하시면 됩니다.

C# Indentity

그러나, 사용자 관리를 위해 좀 더 현실적이고 경제적인 방법을 찾으신다면, 아래에 나열된 순서 대로 키워드 검색을 하실 것을 추천합니다.

C# HttpContext.User
C# Authentication Middleware,
C# SignIn
C# SignOut
C# Authentication Scheme,
C# Authorization Middleware
C# Authorization Policy
C# Cookie vs Session

13개의 좋아요

내용이 잘 정리되서 한번에 읽혔어요:+1:

3개의 좋아요

Entity Framework Core

닷넷의 대표적인 Full-ORM(Object Relational Mapper) 으로, 닷넷 개발 도구 사용자라면 반드시 한 번 은 사용할 일이 있는 라이브러리입니다.

Entity

데이터 저장소는 데이터를 의미 있는 “세트(Set)” 단위로 묶어서 저장합니다.
이렇게 하나로 묶인 데이터 묶음을 "엔티티(Entity)"라고 부릅니다.

하나의 엔티티는 다른 엔티티와 연관되어(Assoicated) 있을 수 있는데, 이를 (엔티티) 관계(Relatioinship)이라고 부릅니다. 이 관계는 보통 종속성(Princial-Dependent)과 복수성(Multiplicity, 혹은 Cardinality)을 통해 표현됩니다.

엔티티 관계는 객체 지향 프로그래밍의 일반적인 객체 (이하 "오브젝트"라 칭함) 사이의 관계와는 다릅니다.

오브젝트는 보통 상속(Inheritance)과 결합(Association)이라는 두 가지의 관계를 맺고, 결합은 다시, 합성(Composition)과 집성(Aggregation)이라는 특이 케이스가 있습니다..

우리의 코드가 메모리에 생성한 인스턴스들은 오브젝트의 관계를 가지고 있는 반면, 데이터 베이스에 저장된 데이터 묶음은 엔티티 관계를 맺고 있습니다.

코드가 저장소의 데이터와 협업하기 위해는, 오브젝트의 관계를 엔티티 관계로 변환(하거나, 혹은 그 반대로 변환)해야 하는데, 이를 오브젝트-관계(Object-Relationship) 맵핑이라고 부릅니다.
(이하 "관계 맵핑"으로 줄여서 부릅니다)

이러한 맵핑을 도와 주는 도구를 ORM(Object Relational Mapper) 라고 부릅니다.
ORM이 하는 역할은

  • 메모리에 존재하는 오브젝트의 인스턴스를 데이터로 변환하여 저장소에 저장하거나,
  • 저장된 데이터를 오브젝트의 인스턴스로 변환하여 메모리에 할당하는 것입니다.

DbContext

EF Core 라이브러리가 ORM 을 추상화한 객체의 이름입니다.

참고로, 프로그래밍 세계에서 "컨텍스트(Context)"라는 단어는 보통 "독립적인 실행 줄기"라는 의미를 가지고 있는데, DbContext 는 관계 맵핑이 진행되는 실행 줄기를 나타냅니다.

사실 이 객체는 마틴 파울러의 “Unit of Work” 개념을 나타내는 추상 레이어로, 관계 맵핑, 관계 변화 추적, 저장까지 단일 업무로 수행합니다.

이러한 "단위성"은 어플리케이션의 로직 코드에서 관계 맵핑 코드를 들어 내주는 장점이 있는 반면, 동시성(Concurrency) 문제에 취약해지는 폐단도 있습니다.

DbContext의 동시성 문제는 특히 UI 스레드와 함께 사용될 때 자주 나타나기에 항상 특별한 주의를 해야 하는 부분으로, 잠시 후에 자세히 언급하겠습니다.

관계 맵핑

오브젝트 관계를 엔티티 관계로 변환하는 작업은 사실 명확하지 않으며, 정답이 없다고도 할 수 있습니다.

예를 들어,

상속 관계인 두 오브젝트 Parent, Child가 있습니다.

두 오브젝트의 인스턴스들은 각 속성에 값을 저장하고 있습니다.
이 값들을 데이터 베이스에 어떻게 저장하면 좋을 지 고민해보시기 바랍니다.

쉬운 문제였다면, 이 경우는 어떨까요?

Child는 Heart와 합성 관계를, School 과는 집성 관계를 맺고 있습니다.

이것도 쉬운 문제였다면, 이 경우는 어떨까요?

Child는 School 에서는 Student의 신분을 갖고, Class에서 Teacher 와 결합점이 있습니다.

위 질문에 어떤 대답을 했더라도, 그것이 맞다 혹은 틀리다 할 수 없습니다.

그런데, 그 대답에 엔티티 관계 설정의 핵심 고려 사항인 **평탄화(Normalization)**가 고려되지 않았다면, 결코 맞다고 할 수 없습니다. (평탄화는 이 글의 범위를 넘어서는 것이니, 각자 검색해보시기를)

Convention

ORM은 위의 질문들에 가장 적정하다고 여겨지는 답변을 이미 가지고 있는데, 이를 맵핑 관습(Convention)이라고 합니다. 대부분의 경우, 관습 만으로도 문제 없는 맵핑이 가능합니다.

사용자가 관습이 맘에 들지 않는 경우, 관습과 다른 맵핑 규칙을 설정하는 것도 가능합니다. 이 경우, DbContext는 사용자가 설정한 규칙을 관습에 우선하여 적용합니다.

그런데 ORM에는 널리 알려진 격언이 있다는 것을 기억하시면 좋겠습니다.

전부 설정하려 들지 마라

가급적 관습에 맞기고, 관습과 다른 규칙을 최소화하라는 선배님들의 조언입니다.
우리야 땡큐죠.

개인적으로, 저는 관습을 적용할 때 문제가 발생하는 곳만 해결하는 규칙 만을 별도로 정의하곤 합니다.

데이터 베이스 관리 도구

EF Core를 단순한 ORM이 아닌, Full-ORM 으로 부르는 이유는, 단순한 맵핑을 넘어서 데이터 베이스를 관리하는 도구도 함께 제공하기 때문입니다.

EF Core가 제공하는 데이터 베이스 관리 도구(이하 "관리 도구"로 칭함)는 닷넷 코어 CLI 도구에 설치할 수도 있고, 비주얼 스튜디오를 사용할 경우 "누겟 패키지 매니저 콘솔"에서 기본적으로 제공합니다.

저는 소스 코드에 따라 비주얼 스튜디오와 비주얼 스튜디오 코드를 병행 사용하기 때문에, 닷넷 코어 CLI 도구에 설치하는 것을 선호합니다.

관리 도구는 전역 설정으로 한 번만 설치하면 됩니다. 아래의 링크에 설치하는 방법이 나와 있습니다.

EF Core 도구 참조(.NET CLI) - EF Core | Microsoft Learn

관리 도구를 사용하려면, dotnet 드라이버를 호출할 때 “ef” 태그를 추가하고, 그 다음에 이 도구가 제공하는 기능을 덧붙입니다.

~\ dotnet ef migrations …
~\ dotnet ef database …

코드 우선 vs 데이터 베이스 우선

관리 도구를 사용하면, 개발 컨텍스트의 구분은 아래와 같이 확장됩니다.

(데이터 베이스) 디자인 타임 => 컴파일 타임 => 런 타임

관리 도구는 디자인 타임에 사용할 수 있는데, 데이터 베이스 관리 방법으로 아래 두 가지 방식을 제공합니다.

  1. DbContext + 엔티티 클래스 => 데이터 베이스 생성
    소스 코드로 작성한 클래스를 바탕으로 실물 데이터베이스를 생성합니다.

  2. 데이터 베이스 => DbContext + 엔티티 클래스 생성
    기존에 이미 사용 중인 데이터베이스를 리버스 엔지니어링을 통해 DbContext와 엔티티 클래스를 생성합니다.

첫 번째를 "코드 우선 접근법"이라고 하고, 두 번째를 "데이터 베이스 우선 접근법"이라고 합니다.

위 접근법들의 구분은 데이터 베이스와 DbContext 사이에 동기화가 필요한 프로젝트 생성 시에만 의미가 있습니다. 동기화 된 이후에는 코드 우선 접근법만 적용되기 때문입니다.

즉, DbContext와 실물 데이터 베이스가 동기화가 이뤄지면, DbConext를 통해 데이터 베이스를 관리하는 패턴이 계속됩니다.

데이터 베이스 우선 접근법을 채택할 때는 아래의 EF 도구가 사용됩니다.
(Usage 항목을 보시면 됩니다)
image

제 개인적으로 기존의 데이터 베이스에 대한 작업을 한 경험이 없기도 하고, 프로젝트 진행 과정 중에는 "코드 우선 접근법"이 사용되기 때문에, 아래의 내용은 모두 "코드 우선 접근법"을 가정합니다.

Blazor Server 앱

예제를 위해 비주얼 스튜디오에서 블레이저 서버 앱 탬플릿을 통해 프로젝트를 생성합니다.
이 때 [인증 유형]을 "개별 인증"으로 선택합니다.

주의!! : 예제로 개별 인증이 설정된 블레이저 서버앱을 선정한 이유는 DbContext의 동시성 문제를 확인하기 위함입니다. 이 글을 보면서, 따라 하시는 분들(이 있으려나..)은 위에 언급한 대로 프로젝트를 생성하시기 바랍니다.

참고로, 제가 생성한 프로젝트는 .Net 7.0 에 기반한 앱입니다.
image

구현 제공자

EF의 DbContext 는 사실 추상 레이어입니다.
개발자는 추상을 통해 구현을 소비하기만 할 뿐입니다.

개발자가 소비할 DbContext 구현은 각각의 데이터 베이스 제품이 제공하는데, 이들을 "데이터 베이스 제공자"라고 부릅니다.

앞서 생성한 프로젝트에는 이 제공자로 SQL 서버(제품)이 선택되었을 알 수 있습니다.

image

SQL 서버 구현이 선택된 이유는, "개별 인증"이 설정된 프로젝트는 Asp.Net Core Identity 라이브러리가 포함되는데, 비주얼 스튜디오는 이 라이브러리가 사용할 기본 데이터 베이스로 localDb(라는 데이터 베이스 제품)를 사용하도록 설정하기 때문입니다.

“localDb” 는 비주얼 스튜디오가 설치될 때 함께 설치되는 소형/무료 데이터 베이스인데, 마이크로소프트가 제공하는 SQL Server 제품군 중에 하나 입니다. (SQL Server Express 와 같은 등급입니다)

이런 이유로, ApplicationDbContext 에 대한 구현은 SQL Server 가 제공하는 것을 사용하도록 설정된 것입니다.

DbContext 설정

EF 는 Full-ORM 이기에, 설정도 두 가지로 구분됩니다.
하나는 이 객체와 동기화될 데이터 베이스에 관한 설정이고, 다른 하나는 관계 맵핑에 관한 설정입니다.

EF 는 각 설정을 위한 헬퍼 객체로 아래의 두 가지 종류의 객체들을 제공합니다.

  1. 데이터 베이스에 관한 설정
  • DbContextOptions (DbContextOptions<T>),
  • DbContextOptionsBuilder (DbContextOptionBuilder<T>)
  1. 관계 맵핑에 관한 설정
  • ModelBuilder

우선 데이터 베이스 설정에 관한 것을 알아 보도록 하겠습니다.

데이터 베이스 설정

생성된 프로젝트에서 DbContext 에 동기화할 데이터 베이스를 설정하는 방법을 참조할 만한 코드는 아래의 딱 한 줄입니다.

// program.cs 에서
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

위의 코드에서 connectionString 은 appsetting.json 파일에 있습니다.

// appsettings.json 에서
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-DbContextSetup-40b054cc-657b-43b9-b8b3-e31ff1786815;Trusted_Connection=True;MultipleActiveResultSets=true"
  },

위 파일에 적인 연결 문자열의 내용은, 로컬에서 실행 중인 localDb 서버의 “aspnet-DbContextSetup” 데이터 베이스에 접속함을 의미합니다.
이 데이터 베이스 인스턴스가 ApplicationDbContext 에 동기화될 대상입니다.

만약, 이 이름을 가진 데이터 베이스가 서버에 존재하지 않는다면, 디자인 타임에 이를 생성할 수 있습니다. 이 경우, 연결 문자열에 나타난 (데이터 베이스 서버) 로그인 계정이, 데이터 베이스를 생성할 권한이 없다면 디자인 타임에 에러가 발생합니다.

그러나, localDb 제품은 “계정 관리” 같은 고급 기능 따위는 없기에, 계정 관련 문제는 없습니다.

그런데, Program.cs 의 코드 중, AddDbContext<T> 메서드는 SQL Server 제공자가 정의한 (IServiceCollections 의) 확장 메서드로 아래와 같은 코드를 간략하게 만들어 놓은 것에 지나지 않습니다.

builder.Services.AddScoped<ApplicationDbContext>(services =>
{
    var dbOptionsBuilder = services.GetRequired<DbContextOptionsBuilder<ApplicationDbContext>>();

    dbOptionsBuilder.UseSqlServer(connectionString);
    return new ApplicationDbContext(dbOptionsBuilder.Options);
});

즉, 동기화될 데이터 베이스에 관한 사항을 DbContext 의 생성자를 통해 제공하고 있습니다.

생성자를 통한 데이터 베이스 설정

생성자를 통한 데이터 베이스 설정은 DbContext 소비자에게 데이터 베이스 설정 권한을 부여합니다.

앞서 생성한 블레이저 프로젝트는 DbContext의 소비자에 해당합니다.
블레이저 개발자는 앱이 사용할 데이터 베이스를 이 생성자를 통해 맘대로 변경할 수 있습니다.

그런데, 블레이저 프로젝트는 UI 계층을 포함하고 있습니다.
웹 애플리케이션에서는 UI 계층은 보통 front-end 로 분류되고, 데이터 베이스는 back-end 로 분류됩니다.

Asp.Net Core 는 Full-stack 도구이기 때문에, 하나의 프로젝트에 프론트와 백엔드의 코드가 섞여 있는 형태가 됩니다.

생성한 프로젝트에서는 데이터 베이스에 관해 더 이상 참고할 만한 코드가 없기도 하고, 백앤드 코드를 별도의 프로젝트로 분리해서 관리한다는 명목 하에 데이터 베이스에 관한 코드를 별도의 프로젝트로 이관하도록 하겠습니다.

컨텍스트 내부에서 설정

솔루션에 클래스 라이브러리 프로젝트를 하나 더 생성하고, 생성된 프로젝트에 아래처럼 누겟 패키지를 설치합니다.
image

보시다시피, Npgsql 패키지를 선택했는데, 이는 PostgreSQL 데이터 베이스를 위한 데이터 베이스 제공자입니다.

즉, 이번에는 개발용인 localDb를 사용하지 않고, (무료) 상업용인 데이터베이스인 PostgreSQL을 사용하도록 한 것 입니다.

생성된 프로젝트에 DataDbContext 클래스를 생성하고 내용을 아래와 같이 변경합니다.

using DbContextSetup.PostgreSQL.Entities;
using Microsoft.EntityFrameworkCore;

namespace DbContextSetup.PostgreSQL;

public class DataDbContext : DbContext
{
    //생성자 없음 => 생성자를 통한 데이터 베이스 설정 차단.

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // DbContext.ObConfiguring()은 하는 일 없음
        // base.OnConfiguring(optionsBuilder);

        var connectionString =
            "User ID=postgres;Password=....;Host=localhost;Port=5432;Database=applicationdata";

        optionsBuilder.UseNpgsql(connectionString);
    }
}

위의 코드를 보면 아시겠지만, DbContext.OnConfiguring 메서드를 재정의하고, 그 내부에서 데이터 베이스 설정을 수행합니다.

또한, 데이터 베이스 설정을 받아 들이는 생성자를 정의하지 않고 있는데, 이는 이 객체의 소비자로부터 데이터 베이스 설정 권한을 박탈하는 효과가 있습니다.

이로 인해, 블레이저 개발자는 기존처럼 데이터 베이스를 선택할 할 수 없고, 데이터 베이스가 이미 설정된 DbContext 만을 사용할 수 있게 됩니다.

// 블레이저 프로젝트의 program.cs 에서

// 에러남
// builder.Services.AddDbContext<DataDbContext>(options =>
//    options.UseSqlServer(connectionString));

builder.Services.AddDbContext<DataDbContext>();

참고로, DataDbContext 클래스에 데이터 베이스 설정을 받아 들이는 생성자를 정의한다 하더라도, OnConfiguring 재정의를 통한 설정이 우선권을 갖기 때문에 박탈의 효과는 동일합니다.

의존성 해상도

데이터 베이스 설정을 동와주는 핼퍼 객체에는 제너릭 버전과 비 제너릭 버전이 있습니다.

  • DbContextOptions (DbContextOptions<T>),
  • DbContextOptionBuilder (DbContextOptionBuilder<T>)

블레이저 프로젝트의 ApplicationDbContext는 제너릭 버전을 사용하고 있고,

public class ApplicationDbContext : IdentityDbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
// ...

추가한 데이터 베이스 프로젝트는 비 제너릭 버전을 사용하고 있습니다.

public class DataDbContext : DbContext
{
    //...
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // ...

제너릭 버전의 존재 이유는 의존성 주입기가 주입할 옵션 객체를 좀 더 명확하게 하기 위함인데, 복수의 DbContext 를 사용할 때는 의존성 주입기가 실수하지 않도록, 제너릭 버전을 사용하는 편이 좋습니다.

그러나, 복수의 DbContext 를 사용한다 할지라도, 의존성 주입기의 도움 없는 설정 - DbContext 내부에서 설정하면 제너릭 버전을 사용할 필요가 없습니다.

사실, OnConfiguring 메서드는 제너릭 버전의 옵션을 받아 들이는 오버라이드가 정의되어 있지 않아, 사용하려고 해도 사용할 수 없도록 만들어 놨습니다. (양놈들의 주도 면밀함이란…)

글이 길어져, 관계 맵핑 설정에 관해서는 다른 글에서 다루겠습니다.

9개의 좋아요

인증/인가

참 어렵고 광범위한 주제입니다만, Asp.Net Core 는 관련 인프라가 잘 되어 있어서, 쉽게 생각하면 쉬운 주제이기도 합니다.

다만, 비닷넷 프레임워크와의 협업을 위해서는 인프라의 밑바탕에 흐르는 원리에 대한 이해가 필요하기에, 공부한 것을 정리해 봅니다.

주체는 서버

인증(Authentication)/인가(Authorization) 에서 핵심은 인가입니다. 인증은 인가를 위한 재료에 불과합니다.

인가는 서버의 데이터 보안 문제를 해결하는 방식입니다.

클라이언트 앱 - 프론트 엔드 웹앱, 데스크탑 앱, 모바일 앱 - 에서의 인증/인가를 위한 코드는 서버의 인증/인가의 결과를 사용하거나, 요구 사항을 따르는 것 뿐 입니다.

만약, “블레이저 와즘앱에서 JWT 토큰 인증” 이란 글이나 영상이 있다면, 그 내용은 서버가 발행한 JWT 토큰을 클라이언트 코드에서 어떻게 이용할 수 있는 지에 대한 내용으로 이해해야 하지, 인증/인가를 클라이언트에서 수행(JWT 의 발행)하는 것으로 오해하면 안됩니다.

만약, 그러한 코드를 클라이언트에 넣게 되면, 관련 시크릿이 상대적으로 보안이 취약한 클라이언트 코드에 남게 되어, 인증/인가가 의미가 없게 됩니다.

클라이언트 앱은 서버의 보안에 종속적이라는 점을 전제하는 게 좋습니다.

JWT 인증 / 쿠키 인증

많은 글에서 JWT 인증이 쿠키 인증의 차 세대 혹은 상위 버전이라는 뉘앙스를 풍기는 경향이 있습니다.

그러나, 많은 전문가들은 B2C 시스템에서는 JWT 를 인증에 사용하는 것이 좋은 선택이 아니라고 얘기합니다. B2C 란, 클라이언트가 개별 소비자인 경우로, 모바일 앱, 데스크탑 앱, 웹앱이 여기에 해당합니다.

좋은 선택이 아닌 이유는, 토큰은 한번 발행되면 유효 기간 이전에 무효화나 취소가 불가능하기 때문입니다.

쿠키는 유저 에이전트(브라우저)와 서버 사이에서만 관리되는데, 서버가 저장하라고 하면, 브라우저는 저장하고, 서버가 지우라고 하면 지웁니다. 따라서, 쿠키의 무효화가 토큰보다는 용이합니다.

악의적인 공격자는 보통 소비자를 겨냥하는데, 악의적인 공격자에 토큰이 넘어 간 경우, 서버는 이에 대항하는 게 어렵습니다.

대항하는 방법은 리프레시 토큰을 추가로 발급하거나, 서버가 발행한 토큰 전체 혹은 블랙리스트 형태로 일부를 서버에 저장해 두고, 들어오는 요청마다 저장한 토큰과 요청에 포함된 토큰을 비교하는 것이 전부라 할 수 있습니다.

어떻게 하든, 서버에서 관리하지 않아도 된다는 JWT의 가장 큰 장점이 사라지게 됩니다. 뿐만 아니라, 매 요청 시마다 저장된 토큰과 비교해야 해서 서버의 성능 저하를 피할 수 없습니다.

그런데, B2B에서는 얘기가 다릅니다.

예를 들어, 기업 A의 서버가 다른 기업들의 서버에게 데이터를 제공하는 경우, 데이터를 제공 받을 서버들은 기업 A에게 익명적이지 않을 확률이 높습니다.

또한, 기업 A가 발행한 JWT는 확인된 기업들에게만 전달되기에, 토큰이 소비자에게 넘어 가서 공격자에게 탈취당하거나, 공격자가 기업들의 서버에서 토큰을 탈취할 확률은 낮다고 할 수 있습니다.

즉 B2B에서는 JWT의 단점이 사라지고, 고유의 장점만 부각되게 됩니다.

우리의 서비스가 일반 소비자와 기업 소비자를 모두 커버한다면, 전자는 쿠키를, 후자는 JWT를 사용하는 인증 수단 2중화를 채택하는 것이 좋은 선택이라고 합니다.

그래도 JWT를 써야 한다면

JWT는 페이로드가 있어, 인터넷을 통해 데이터를 주고 받고자 할 때 유용하기는 합니다.

JWT에 데이터를 담아서 보내는 이유는 토큰 발행자가 아니라 수취인을 보호하기 위함입니다. JWT의 수취인은 "토큰이 발행자가 보낸 그대로"인지 아닌 지를 검증할 수 있어, 페이로드의 내용을 신뢰할 수 있기 때문입니다.

그러나, JWT의 페이로드는 누구나 볼 수 있어서, 일반 텍스트로 전송하는 것과 하등 차이가 없습니다.

따라서, JWT를 무언가 전달하려는 용도로 쓴다면, 일반 소비자와 격리된 경로로만 유통됨을 보장하는 게 좋습니다.

닷넷 클라이언트의 쿠키 핸들링

쿠키는 기본적으로 브라우저 환경을 가정합니다.
그런데, 닷넷의 HttpClient 는 쿠키를 지원합니다.

HttpClientHandler.CookieContainer Property (System.Net.Http) | Microsoft Learn

이를 이용하면, 쿠키를 인증 수단으로 사용하는 어떤 서버에도 닷넷 앱은 문제 없이 접속이 가능합니다.

예제로 쿠키 인증을 하는 서버로, 미니멀 API 앱 코드입니다.

        var builder = WebApplication.CreateBuilder(args);

        string AuthScheme = "cookie";

        builder.Services.AddAuthentication(AuthScheme)
            .AddCookie(AuthScheme);

        var app = builder.Build();

        app.UseAuthentication();

        app.MapGet("/secret", (HttpContext http) =>
        {
            var name = http.User.FindFirstValue("name");

            if (name == null)
            {
                http.Response.StatusCode = 403;
                return "Know the dark force";
            }

            http.Response.StatusCode = 200;
            return $"{name}, I am your father!";
        });

        app.MapGet("/login", async (HttpContext http) =>
        {
            var nameClaim = new Claim("name", "Luke");
            var claimIdenty = new ClaimsIdentity([nameClaim], AuthScheme);
            var claimPrincipal = new ClaimsPrincipal(claimIdenty);
            await http.SignInAsync(claimPrincipal);
            return "Logged in";
        });

        app.MapGet("/logout", async (HttpContext http) =>
        {
            await http.SignOutAsync(AuthScheme);
            return "Logged out";
        });
    
        app.Run();

그리고, 클라이언트로 쿠키를 사용하는 콘솔 앱입니다.

using System.Net;


var handler = new HttpClientHandler() 
{ 
    UseDefaultCredentials = true,
    CookieContainer = new CookieContainer(),
};

var http = new HttpClient(handler)
{
    BaseAddress = new Uri("http://localhost:5011"),
};


while (true)
{
    Console.Clear();
    Console.WriteLine();
    Console.WriteLine("Type 'l' to login, 'o' to logout, 's' to get secret or 'x' to exit");
    
    var key = Console.ReadKey(true).KeyChar;

    switch (key)
    {
        case 'l':
            var message = await http.GetStringAsync("/login");
            Console.Write(message);
            Console.ReadKey(true);
            break;
        case 'o':
            message = await http.GetStringAsync("/logout");
            Console.Write(message);
            Console.ReadKey(true);
            break;
        case 's':
            var response = await http.GetAsync("/secret");
            Console.Write(await response.Content.ReadAsStringAsync());
            Console.ReadKey(true);
            break;
        case 'x':
            Environment.Exit(0);
            break;
    }
}

결과

  • 로그인 전 ‘s’ 입력

Type ‘l’ to login, ‘o’ to logout, ‘s’ to get secret or ‘x’ to exit
Know the dark force

  • 로그인 후, ‘s’ 입력

Type ‘l’ to login, ‘o’ to logout, ‘s’ to get secret or ‘x’ to exit
Luke, I am your father!

8개의 좋아요

잘읽어 보겠습니다.

4개의 좋아요

인증/인가 2

인증/인가에서 가장 중요한 것은 인가라고 말씀드렸습니다.
인가는 서버의 데이터 보호 행위인데, 여러 가지 구현 방식이 있을 수 있습니다.

준비물

API를 실행하고, 테스트를 위해, VS Code 플러그인 중, Thunder Client 를 설치했습니다.

Lightweight Rest API Client Extension for VS Code - Thunder Client

가장 간단한 방식

아래의 코드는 미니멀 API 코드로, user-secret 엔드 포인트는 사용자의 비밀 데이터를 보호하고 있습니다. 이를 열람하기 위해서는 엔드 포인트에 비밀키를 제출해야 합니다.

namespace AspNetAuth;

class User(string name, string secret)
{
    public string Name => name;
    public string Secret { get; set; } = secret;
}
using Microsoft.AspNetCore.Mvc;
namespace AspNetAuth;

public class ManualAuth
{
    static readonly List<User> s_userRepo =
    [
        new User("Luke", "Darth Vader is your father"),
        new User("Leia", "Luke is your brother"),
    ];

    public static void Run(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        var app = builder.Build();

        // 보호할 데이터
        app.MapGet("/user-secret", ([FromHeader]string credential, HttpContext http) =>
        {
            var user = s_userRepo.FirstOrDefault(u => u.Name == credential);

            // credential 검증 실패
            if (user == null) return Results.BadRequest(); // 400

            return Results.Ok(user.Secret);
        });

        app.Run();
    }
}
  1. 비밀키를 제출하지 않을 때

코드의 의도 대로, 400 상태 코드를 반환했습니다.

            // credential 불만족 
            if (user == null)
            {
                return Results.BadRequest(null); // 400
            }
  1. 비밀키를 제출했을 때,

위의 코드는 무척 간단합니다.
엔드포인트가 비밀키를 확인하고, 확인되면 보호된 데이터를 제공합니다.

인증 티켓 방식

그러나, 클라이언트는 데이터를 열람할 때마다, 비밀키를 제출해야 하는 단점이 있습니다.
비밀키가 자주 제출될 수록 보안 위험도 올라가기 때문에, 최초로 한 번만 비밀키를 제공하면, 일정 시간 이내에는 다시 제출하는 일이 없게 만들어 보안 위험을 낮출 필요가 있습니다.

이를 위해, 서버는 비밀키가 최초로 검증되면, 서버만이 알 수 있는 인증 티켓을 발행하고, 클라이언트는 그 다음 요청 시마다, 비밀키 대신에 인증 티켓을 서버에 제출하도록 만듭니다.

서버가 요청에 포함된 인증 티켓을 확인하는 과정을 인증(Authentication)이라고 합니다.

인증 티켓 발급을 구현하기 위한 아래의 코드를, ManualAuth.Run 메서드의 app.Run(); 코드 바로 위에 삽입니다.

        const string COOKIE_KEY = "starwars";

        // 보호할 데이터2
        app.MapGet("/secret", (HttpContext http) =>
        {
            var cookieValue = http.Request.Cookies[COOKIE_KEY];

            // 인증 티켓 없음
            if(cookieValue == null)  return Results.Unauthorized(); // 401
            
            var user = s_userRepo.FirstOrDefault(u => u.Name == cookieValue);

            // 실효되거나, 위도/도용된 인증 티켓 
            if(user == null)
            {
                http.Response.Cookies.Delete(COOKIE_KEY);
                return Results.BadRequest();
            }

            return Results.Ok(user.Secret);            
        });

        app.MapGet("/login", ([FromHeader]string credential, HttpContext http) =>
        {
            var user = s_userRepo.FirstOrDefault(x => x.Name == credential);

            if (user == null)  return Results.BadRequest();

            // 인증 티켓 발급
            http.Response.Cookies.Append(COOKIE_KEY, user.Name);
            return Results.Ok($"Welcome, {user.Name}");
        });

        app.MapGet("/logout", (HttpContext http) =>
        {
            if (http.Request.Cookies.ContainsKey(COOKIE_KEY))
            {
                http.Response.Cookies.Delete(COOKIE_KEY);
                return Results.Ok("Good bye~~");
            }
            return Results.BadRequest();
        });

참고
401 Unauthorized 는 이름과 달리 Unauthenticated 의 의미로 사용됩니다.
401 Unauthorized - HTTP | MDN (mozilla.org)

위 코드는 인증 티켓을 쿠키에 저장하는데, 클라이언트가 신경 쓰지 않아도 자동으로 처리되는 장점이 있습니다.

  1. 로그인

쿠키도 클라이언트(Thunder Client)가 잘 받아서 보관하고 있습니다.

  1. 비밀 데이터 요청

인증 티켓 암호화

현재 인증 티켓은 평문이라 보안성이 없습니다. 이를 암호화하여 보안성을 높입니다.

암호화는 Asp.Net Core 에서 제공하는 IDataProtector 객체를 서비스 컨테이너에 등록하여 사용합니다.

builder.Services.AddSingleton<IDataProtector>( _ => 
   DataProtectionProvider.Create("ManualAuth").CreateProtector("auth ticket"));

이 객체를 인증 티켓의 발급과 검증에 사용합니다.

        app.MapGet("/login", ([FromHeader]string credential, HttpContext http, 
             [FromServices]IDataProtector protector) =>
        {
            var user = s_userRepo.FirstOrDefault(x => x.Name == credential);

            if (user == null)
            {
                return Results.BadRequest();
            }

            // 인증 티켓 발급
            //http.Response.Cookies.Append(COOKIE_KEY, user.Name);

            // 암호화된 티켓 발급
            var ticket = protector.Protect(user.Name);

            // 티켓을 서버가 관리
            http.Response.Cookies.Append(COOKIE_KEY, ticket);

            // 티켓을 클라이언트가 관리 (JWT의 관리 방식)
            //return Results.Ok(ticket);
            // or
            //http.Response.Headers.Append("access-token", ticket);

            return Results.Ok($"Welcome, {user.Name}");
        });

참고
인증 티켓을 쿠키에 넣어 응답하면, 티켓의 관리 주체는 서버가 됩니다.
물론 사용자가 브라우저의 쿠키를 지울 수도 있기는 하지만, 그 경우를 제외하곤 쿠키에 보관된 티켓은 항상 서버로 전달되며, 서버는 이를 변경하거나 지울 수 있습니다.
이에 반해, 티켓을 서버가 응답 해더나 바디에 넣으면, 이 티켓의 관리는 클라이언트가 해야 합니다.
요청에 이 티켓을 넣을 지 말지, 넣는 경우 헤더에 넣을지 바디에 넣을 지를 결정해야 합니다.

        // 보호할 데이터2
        app.MapGet("/secret", (HttpContext http, [FromServices] IDataProtector protector) =>
        {
            var cookieValue = http.Request.Cookies[COOKIE_KEY];

            // 인증 수단 없음
            if(cookieValue == null)
            {
                return Results.Unauthorized(); // 401
            }

            cookieValue = protector.Unprotect(cookieValue);
            var user = s_userRepo.FirstOrDefault(u => u.Name == cookieValue);

            // 위조되거나, 도용된 쿠키 
            if(user == null)
            {
                http.Response.Cookies.Delete(COOKIE_KEY);
                return Results.BadRequest();
            }            

            return Results.Ok(user.Secret);            
        });

로그인으로 인증 티켓이 암호화되어 있는 지 확인합니다.

티켓의 내용은 암호화되어 있기 때문에, 오로지 서버(의 데이터 프로텍터)만이 그 내용을 확인할 수 있습니다.

참고: IDataProtector 인스턴스는 암호화/복호화가 인스턴스 별로 독립적이라, 다른 인스턴스가 암호화한 내용을 복호화할 수 없습니다.

인증 미들웨어

현재의 구현은 인증이 엔드 포인트(/secret)에 포함되어 있습니다.

만약, 보호가 필요한 엔드 포인트가 늘어난다면, 인증 행위가 여러 엔드포인트로 분산되어 관리가 쉽지 않습니다.

따라서, 인증을 한 곳으로 모을 필요가 있는데, Asp.Net Core 는 이를 위해 미들웨어와 ClaimsPrincipal 객체를 지원합니다.

우선, 쿠키에 포함된 인증 티켓을 다루는 핸들러를 정의합니다.

class CookieAuthenticator
{
    private readonly IDataProtector _protector;

    public CookieAuthenticator(IDataProtector protector)
    {
        _protector = protector;
    }

    public void Authenticate(ref HttpContext http, string authType, List<User> userRepo)
    {
        var cookieValue = http.Request.Cookies[authType];

        if(cookieValue == null)
        {
            return ;
        }

        cookieValue = _protector.Unprotect(cookieValue);
        var user = userRepo.FirstOrDefault(u => u.Name == cookieValue);

       // HttpContext.User 설정
        if (user != null)
        {
            var nameClaim = new Claim(nameof(user.Name), user.Name);
            var claimIdenty = new ClaimsIdentity([nameClaim], authType);
            var claimPrincipal = new ClaimsPrincipal(claimIdenty);
            http.User = claimPrincipal;
        }
    }
}

이 서비스를 컨테이너에 등록하고, 이 서비스를 이용하는 미들웨어를 정의합니다.

        // 쿠키 인증 서비스
        builder.Services.AddSingleton<CookieAuthenticator>();

        var app = builder.Build();

        // 인증 미들웨어
        app.Use((http, next) =>
        {
            var authenticator = http.RequestServices.GetRequiredService<CookieAuthenticator>();
            authenticator.Authenticate(ref http, COOKIE_KEY, s_userRepo);
            return next();
        });

인증이 완료되면, HttpContext.User 에 Name 클래임이 있는 CliamsPrincipal 객체가 할당됩니다.
엔드 포인트는 이 객체를 검사하여, 사용자를 알아 낼 수 있습니다.

        // 보호할 데이터2
        app.MapGet("/secret", (HttpContext http) =>
        {
            var userName = http.User.FindFirstValue(nameof(User.Name));

            var user = s_userRepo.FirstOrDefault(u => u.Name == userName);

            // 위조되거나, 도용된 쿠키 
            if(user == null)
            {
                http.Response.Cookies.Delete(COOKIE_KEY);
                return Results.BadRequest();
            }            

            return Results.Ok(user.Secret);            
        });

잘 동작하니, 동작 확인은 생략하겠습니다.

인증 인프라

인증 서비스와 인증 미들웨어의 코드를 잘 보시면, Asp.Net Core 가 제공하는 인증 관련 인프라인 IAuthenticationService 와 AuthenticationMiddleware 를 등록하는 과정과 동일함을 알 수 있을 것입니다.

        // 쿠키 인증 서비스
        builder.Services.AddAuthentication("cookie")
            .AddCookie("cookie");
        var app = builder.Build();

        // 인증 미들웨어
        app.UseAuthentication();

위 인프라의 내부 구조는 직접 구현한 것보다 보다 정교하고 좀더 구조적이다는 차이만 있을 뿐 그 원리는 동일합니다.

다음 글, 인가 구현

8개의 좋아요

정리안하신다니 결국 하시는군요 ㅎ!

4개의 좋아요

양질의 글 너무 감사드립니다 :+1: :+1: :+1: :+1:

3개의 좋아요

인증/인가 3

인가의 과정에 대해 알아 봅니다.
그런데, 이전 글의 예제는 인가를 설명하기에 약간 부족한 면이 있어, 수정하는 게 좋을 듯합니다.

우선, 사용자 객체를 다시 정의합니다.

class User(string name, string secret, int credentialHash, string skill)
{
    public string Name => name;
    public int CredentialHash => credentialHash;
    public string Secret { get; set; } = secret;
    public string Skill => skill;
}

사용자 저장소도 업데이트 합니다.

    static readonly List<User> s_userRepo =
    [
        new User("Luke", "Darth Vader is your father", "1234".GetHashCode(), "Jedi"),
        new User("Leia", "Luke is your brother", "5678".GetHashCode(), "Jedi"),
        new User("Han", "", "9123".GetHashCode(), "Pilot"),
    ];

로긴 엔드 포인트도 해시 코드를 검증하는 코드로 변경합니다.

        app.MapGet("/login", ([FromHeader]string credential, HttpContext http, [FromServices]IDataProtector protector) =>
        {
            if(string.IsNullOrWhiteSpace(credential))
            {
                return Results.BadRequest();
            }

            var credentialHash = credential.GetHashCode();

            var user = s_userRepo.FirstOrDefault(x => x.CredentialHash == credentialHash);

            if (user == null)
            {
                return Results.BadRequest();
            }

            // 암호화된 티켓 발급
            var ticket = protector.Protect(user.Name);

            // 티켓을 서버가 관리
            http.Response.Cookies.Append(COOKIE_KEY, ticket);

            // 티켓을 클라이언트가 관리 (JWT의 관리 방식)
            //return Results.Ok(ticket);
            // or
            //http.Response.Headers.Append("access-token", ticket);
            
            return Results.Ok($"Welcome, {user.Name}");
        });

그리고, 인증 서비스에, 클레임을 추가합니다.

    public void Authenticate(ref HttpContext http, string authType, List<User> userRepo)
    {
        var cookieValue = http.Request.Cookies[authType];

        if(cookieValue == null)
        {
            return ;
        }

        cookieValue = _protector.Unprotect(cookieValue);
        var user = userRepo.FirstOrDefault(u => u.Name == cookieValue);

        // 사용자 확인
        if (user != null)
        {
            var nameClaim = new Claim(nameof(user.Name), user.Name);
            var skillClaim = new Claim(nameof(user.Skill),user.Skill); // 스킬 추가
            var claimIdenty = new ClaimsIdentity([nameClaim, skillClaim], authType);
            var claimPrincipal = new ClaimsPrincipal(claimIdenty);
            http.User = claimPrincipal;
        }
    }

마지막으로 secret 엔드 포인트에도 자격을 검증하는 코드를 삽입합니다.

        // 보호할 데이터2
        app.MapGet("/secret", (HttpContext http) =>
        {
            var userName = http.User.FindFirstValue(nameof(User.Name));

            // 인증되지 않은 요청
            if (userName == null)
            {
                http.Response.StatusCode = 401;
                return "";
            }

            var skill = http.User.FindFirstValue(nameof(User.Skill));

            // 권한 없는 요청
            if (skill != "Jedi")
            {
                http.Response.StatusCode = 403;
                return "";
            }

            var user = s_userRepo.FirstOrDefault(u => u.Name == userName);

            // 위조/도용된 쿠키 
            if (user == null)
            {
                http.Response.StatusCode = 400;
                http.Response.Cookies.Delete(COOKIE_KEY);

                return "";
            }

            return s_userRepo.FirstOrDefault(u => u.Name == userName)?.Secret;   
            
        });

인가

인가는 보호된 데이터에 대한 접근 자격이 있는 지를 결정하는 것입니다.
인가를 위해서는 요청을 보낸 사람의 정보를 알아야 하는데, 모르는 경우 자격 유무를 따질 필요도 없습니다.

            var userName = http.User.FindFirstValue(nameof(User.Name));

            // 인증되지 않은 요청
            if (userName == null)
            {
                http.Response.StatusCode = 401;
                return "";
            }

요청이 인증되었으면, 자격 요건을 검증합니다.

            var skill = http.User.FindFirstValue(nameof(User.Skill));

            // 권한 없는 요청
            if (skill != "Jedi")
            {
                http.Response.StatusCode = 403;
                return "";
            }

인가 미들웨어

그러나, 현재의 엔드 포인트는 인가를 직접 수행하고 있는데, 보호된 엔드 포인트가 여러 개라면, 인가 코드가 여러 곳에 분산되어 관리가 쉽지 않습니다. 이를 미들웨어로 집중시킵니다.

인가 미들웨어의 책임은 인가된 요청만 엔드 포인트로 전달하는 것입니다.

아래의 인가 미들웨어 코드를 인증 미들웨어 다음에 넣습니다.
인가 미들웨어는 인증 미들웨어가 설정한 HttpContext.User 의 값을 사용하기에, 이 순서는 반드시 지켜져야 합니다.

        // 인가 미들웨어
        app.Use((http, next) =>
        {
            var path = http.Request.Path;  
            if (path.StartsWithSegments("/secret") is false)
                return next();

            var userName = http.User.FindFirstValue(nameof(User.Name));

            // 인증되지 않은 요청
            if (userName == null)
            {
                http.Response.StatusCode = 401;
                return Task.CompletedTask;
            }

            var skill = http.User.FindFirstValue(nameof(User.Skill));

            // 권한 없는 요청
            if (skill != "Jedi")
            {
                http.Response.StatusCode = 403;
                return Task.CompletedTask;
            }

            var user = s_userRepo.FirstOrDefault(u => u.Name == userName);

            // 위조/도용된 쿠키 
            if (user == null)
            {
                http.Response.StatusCode = 400;
                http.Response.Cookies.Delete(COOKIE_KEY);

                return Task.CompletedTask; 
            }

            return next();
        });

이제 secret 엔드 포인트는 인가된 요청만을 받는 것이 보장되기, 자신의 임무에만 충실할 수 있습니다.

        // 보호할 데이터2
        app.MapGet("/secret", (HttpContext http) =>
        {
            var userName = http.User.FindFirstValue(nameof(User.Name));
            return s_userRepo.FirstOrDefault(u => u.Name == userName)?.Secret;                    
        });

인가 서비스

현재의 인가 미들웨어는 “/secret” 엔드 포인트에 대한 인가 만을 처리합니다.
보호되는 엔드 포인트가 늘어나거나, 인가 방식이 추가되는 경우, 관련 미들웨어들이 늘어나게 됩니다.
이 경우, 모든 요청의 처리 경로(요청 파이프 라인)가 늘어나서 서버의 성능에 좋지 않고, 코드 관리도 쉽지 않습니다.

이러한 폐단을 막기 위해, 인가를 처리하는 별도의 서비스를 등록합니다.

class CookieAuthorizer
{
    // 사이트 전체 보호 설정
    public bool IsGlobal { get; set; } = false;

    // key: path,
    readonly Dictionary<string, List<Func<ClaimsPrincipal, int?>>> _requirements = [];

    public CookieAuthorizer()
    {
        _requirements["/secret"] =
        [
            (p) =>
            {
                var userName = p.FindFirstValue(nameof(User.Name));

                // 인증되지 않은 요청
                if (userName == null)
                {
                    return 401;
                }
                return null;
            },
            (p) =>
            {
                var skill = p.FindFirstValue(nameof(User.Skill));

                if (skill == "Jedi")
                    return null;

                // 권한 없는 요청
                return 403;
            },
        ];
    }

    // 인가 성공: null, 실패: 에러 코드
    public int? Authorize(HttpContext http, string path)
    {
        if (_requirements.ContainsKey(path) is false)
            return null;

        var filter = _requirements[path].FirstOrDefault(r => r.Invoke(http.User).HasValue);

        return filter?.Invoke(http.User);
    }
}

이를 서비스 컨테이너에 등록합니다.

        // 쿠키 인가 서비스
        builder.Services.AddScoped<CookieAuthorizer>();

인가 미들웨어가 이 서비스에 의존하도록 수정합니다.

        // 인가 미들웨어
        app.Use((http, next) =>
        {
            var path = http.Request.Path;
            var authorizationService = http.RequestServices.GetRequiredService<CookieAuthorizer>();

            var failCode = authorizationService.Authorize(http, path);

            if(failCode.HasValue)
            {
                http.Response.StatusCode = failCode.Value;
                return Task.CompletedTask;
            }
       
            // 사이트 전체를 보호한다면
            if (authorizationService.IsGlobal)
            {
                var userName = http.User.FindFirstValue(nameof(User.Name));
                var user = s_userRepo.FirstOrDefault(u => u.Name == userName);

                // 위조/도용된 티켓
                if (user == null)
                {
                    http.Response.StatusCode = 400;
                    http.Response.Cookies.Delete(COOKIE_KEY);

                    return Task.CompletedTask;
                }
            }

            return next();
        });

인가의 설정

사이트 제작자는 보호가 필요한 엔드 포인트와 요건을 인가 서비스의 생성자를 통해 지정하는 구조입니다.

    public CookieAuthorizer()
    {
        _requirements["/secret"] =
        [
            (p) =>
            {
                var userName = p.FindFirstValue(nameof(User.Name));

                // 인증되지 않은 요청
                if (userName == null)
                {
                    return 401;
                }
                return null;
            },
            (p) =>
            {
                var skill = p.FindFirstValue(nameof(User.Skill));

                if (skill == "Jedi")
                    return null;

                // 권한 없는 요청
                return 403;
            },
        ];
    }

인가 인프라

위 코드의 구조는 Asp.Net Core의 인가 인프라 사용과 비슷합니다.
인프라를 설정하는 방식이 동일하고,

보호될 엔드 포인트를 별도로 지정한다는 점에도 동일합니다.
다만, 인프라는 Attribute 를 통해 지정한다는 차이점이 있습니다.

근본적으로 데이터 제공과 데이터 보호를 체계적으로 분리하는 구조인 것입니다.

SPA (Single Page Application)

위의 방식은 사실 전통적인 Http 세션을 기반으로 설계된 모델입니다.
닷넷을 기준으로 말하면, Asp.Net Core MVC, Asp.Net Core Web App, Asp.Net Core Wep Api 에서만 사용이 가능한 방식으로, 모든 메시지가 요청 파이프 라인(미들웨어 집합)를 통과하는 것을 가정합니다.

그러나, 블레이저 서버는 Asp.Net Core 앱이기는 하지만, 페이지 접속만 파이프 라인을 통과하고, 그 이후부터는 웹소켓을 사용하기 때문에, 요청 파이프 라인을 통한 인증/인가가 엄밀하게 동작하지 않습니다.

가장 위험한 예로, 티켓의 유효기간이 만료되었지만, 웹소켓 통신은 계속 유지되는 경우입니다.
블레이저 서버는 이 문제를 일정 시간 마다, 티켓을 재검증하는 방식으로 해결합니다. (8.0 은 아직 자세히 안봐서 꼭 그렇다고 할 수 없습니다.)

참고로, 닷넷의 다른 SPA 앱인 블레이저 와즘 앱의 경우, 클라이언트 앱이기 때문에, 인증/인가의 소비자이지 생산자가 아닙니다. 즉 서버로 부터 티켓을 받는 입장입니다.

그리고, 브라우저 내부에서 동작하기에, 티켓이 쿠키로 응답된 경우, 브라우저가 자동으로 처리합니다.
바디로 응답된 경우에도, 브라우저의 로컬/세션 저장소에 저장해놓고 사용하면 됩니다.

WPF, 윈폼, 마우이 등, 다른 형태로 클라이언트를 제작하는 경우에도, HttpClient 객체가 브라우저의 인프라와 유사하게 제공하기 때문에, 큰 문제가 아닙니다.

다만, 주의해야 할 점은 클라이언트와 API 모두를 작성할 때, 인증/인가를 클라이언트에서 처리하고, API는 아무것도 하지 않는 구조로 만들면 안된다는 점입니다.

이런 구조는 서버 코드와 클라이언트 코드를 혼동해서 생긴 문제로, 닷넷의 와즘 관련 문서에서도 수시로 주의하라는 당부하고 있으니, 잘 기억해야 합니다.

3개의 좋아요

근데이미지는저만깨지나요?

1개의 좋아요

전 안깨지네욤 ㅎㅎ