콘텐츠 대표 이미지 - C#으로 마스터하는 API 보안: OAuth와 JWT로 철벽 인증 시스템 구축하기 🛡️

C#으로 마스터하는 API 보안: OAuth와 JWT로 철벽 인증 시스템 구축하기 🛡️

요즘 개발자라면 필수로 알아야 할 인증과 인가, 친구처럼 쉽고 재미있게 파헤쳐 봐요!

들어가며: "너... 누구냐?" 🤨 현대 앱들의 필수 관문, 인증과 인가

안녕! C#과 함께 멋진 프로그램을 만들어가는 개발자 친구들! 👋
우리가 열심히 만든 서비스, 앱, API가 세상에 나왔다고 상상해봐. 정말 뿌듯하겠지? 그런데 어느 날, 정체불명의 유저가 우리 서비스에 들어와 데이터를 마구잡이로 바꾸고, 심지어 다른 사람의 개인정보까지 훔쳐본다면? 😱 생각만 해도 아찔한 일이야.

바로 이 지점에서 개발자들의 영원한 숙제, '보안'이 등장해. 특히 수많은 서비스들이 데이터를 주고받는 'API의 시대'인 지금, "이 요청을 보낸 사람이 정말 믿을 수 있는 사람인가?" (인증) 그리고 "이 사람이 이 데이터를 볼 자격이 있는가?" (인가)를 확인하는 과정은 선택이 아닌 필수가 되었어.

옛날에는 아이디와 비밀번호만 받아서 서버에 '로그인 상태'를 저장하는 방식(세션-쿠키)을 많이 썼지만, 요즘처럼 모바일 앱, 웹, 다른 서버 등 다양한 클라이언트가 하나의 API 서버에 접속하는 환경에서는 한계가 명확해졌지. 서버가 모든 클라이언트의 로그인 상태를 기억해야 하니 부담도 크고, 확장성도 떨어지거든.

그래서 등장한 슈퍼스타들이 바로 OAuth 2.0과 JWT(Json Web Token)야. 이 둘의 조합은 현대적인 API 보안의 '국룰'이자 '근본'으로 자리 잡았어. 마치 햄버거엔 콜라, 치킨엔 맥주처럼 환상의 짝꿍이지! 🍔🥤

이 글에서는 C#과 .NET 환경에서 OAuth 2.0의 개념을 활용하고, JWT를 통해 어떻게 안전하고 확장성 있는 인증/인가 시스템을 구현할 수 있는지 A부터 Z까지 차근차근 알아볼 거야.
코드가 조금 복잡해 보여도 괜찮아! 왜 이렇게 해야 하는지, 각 코드가 어떤 의미인지 친구한테 설명하듯 친절하게 알려줄게. 자, 그럼 이제 우리 앱의 보안 레벨을 한 단계 업그레이드하러 떠나볼까? 🚀

Part 1: 개념부터 탄탄하게! 인증 vs 인가, 그리고 OAuth 2.0

코드를 짜기 전에, 우리가 뭘 만드는지 정확히 알아야겠지? 용어부터 확실히 정리하고 가자. 이게 헷갈리면 나중에 동료 개발자랑 대화할 때 "아... 그... 거시기..." 하게 될 수도 있으니까! 😉

인증(Authentication) vs 인가(Authorization): 클럽 입구와 VIP 룸

이 두 단어는 정말 자주 헷갈리는데, 클럽에 가는 상황을 생각하면 아주 쉬워.

인증 (Authentication): "당신이 누구인지 증명하세요!"
클럽 입구에서 가드가 신분증을 검사하는 과정이야. "네, 홍길동 님 맞으시네요. 들어오세요." 하고 신원을 확인하는 거지. 웹 서비스에서는 아이디와 비밀번호를 입력해서 로그인하는 과정이 바로 인증이야. 내가 정말 '나'라는 것을 시스템에게 증명하는 거지.

인가 (Authorization): "당신이 무엇을 할 수 있는지 허락합니다!"
클럽에 들어왔다고 해서 모든 곳을 다 갈 수 있는 건 아니지? VIP 룸에 들어가려면 VIP 팔찌가 필요하잖아. 이 팔찌가 있는지 확인하는 과정이 바로 인가야. "아, VIP 팔찌가 있으시네요. VIP 룸으로 들어가실 수 있습니다." 하고 특정 권한을 확인하는 거지. 웹 서비스에서는 로그인한 사용자가 '관리자 페이지'에 접근할 수 있는지, '글쓰기' 버튼을 누를 수 있는지 등을 결정하는 과정이야.

간단히 말해, 인증은 '신원 확인'(Who you are), 인가는 '권한 부여'(What you can do)야. 순서상으로도 인증이 먼저 이루어지고, 그 다음에 인가가 이루어지지. 신원이 확인되어야 권한을 줄 수 있으니까!

OAuth 2.0: 내 비밀번호를 알려주지 않고 권한만 위임하는 법

자, 이제 OAuth 2.0에 대해 알아볼 시간이야. 이름이 좀 어렵게 생겼지만, 사실 우리 일상에 아주 깊숙이 들어와 있어. '구글로 로그인하기', '카카오로 로그인하기' 같은 소셜 로그인이 바로 OAuth 2.0을 활용한 대표적인 예시거든.

상황을 하나 가정해보자. 우리가 '인생샷 편집 앱'을 만들었는데, 이 앱이 사용자의 구글 포토에 있는 사진을 가져와서 편집하는 기능이 필요해. 이때 사용자에게 "구글 아이디와 비밀번호를 저희 앱에 입력해주세요!"라고 하면 어떨까? 😱 사용자는 "내 구글 계정을 통째로 넘기라고? 이 앱을 어떻게 믿어?"라며 바로 앱을 삭제할 거야.

이런 문제를 해결하기 위해 나온 것이 바로 OAuth 2.0 (Open Authorization 2.0)이야. OAuth의 핵심 아이디어는 바로 '권한의 위임(Delegated Authorization)'이야.

즉, 사용자가 자신의 아이디/비밀번호(Credential)를 '인생샷 편집 앱'에 직접 주는 게 아니라, 신뢰할 수 있는 '구글'에게 "저기요, 구글님. 이 '인생샷 편집 앱'이 제 구글 포토 사진 목록을 읽을 수 있도록만 허락해주세요."라고 요청하는 거지. 그러면 구글은 사용자에게 동의를 구한 뒤, '인생샷 편집 앱'에게 구글 포토 접근 권한만 있는 특별한 '접근 토큰(Access Token)'을 발급해줘. '인생샷 편집 앱'은 이 토큰을 이용해서 구글 포토에 접근하는 거야.

마치 호텔 키카드 같아. 키카드는 내 방 문만 열 수 있지, 다른 방이나 호텔 금고를 열 수는 없잖아? 이렇게 딱 필요한 권한만 제한적으로, 그리고 정해진 시간 동안만 부여하는 것이 OAuth 2.0의 핵심이야.

잠깐! 🚨 여기서 많이들 오해하는 게 있어. OAuth 2.0은 '인증(Authentication)' 프로토콜이 아니야. 이름 그대로 '인가(Authorization)'를 위한 프레임워크야. '구글로 로그인'은 OAuth 2.0을 이용해 구글로부터 사용자의 이메일, 이름 같은 기본 정보를 가져오는 '인가' 과정을 거친 후, 우리 서비스가 "아, 이 정보는 구글이 인증한 사용자 정보구나!"라고 믿고 자체적으로 '인증' 처리를 해주는 과정이지. 이 둘을 구분하는 게 중요해!

OAuth 2.0의 주인공들 (Roles)

OAuth 2.0의 동작 방식을 이해하려면 4명의 주인공을 알아야 해.

  • Resource Owner (리소스 소유자): 바로 '사용자', 즉 당신이야. 데이터(구글 포토, 페이스북 친구 목록 등)의 주인이지.
  • Client (클라이언트): '인생샷 편집 앱'처럼 리소스 소유자의 데이터에 접근하려는 애플리케이션이야.
  • Authorization Server (권한 서버): '구글', '카카오'처럼 사용자를 인증하고, 클라이언트에게 접근 토큰(Access Token)을 발급해주는 서버야. 권한을 관리하는 총책임자지.
  • Resource Server (리소스 서버): '구글 포토 API 서버'처럼 실제 사용자 데이터(리소스)를 가지고 있는 서버야. 클라이언트가 접근 토큰을 제시하면, 유효성을 검사하고 데이터를 제공해줘.

보통 권한 서버와 리소스 서버는 같은 회사(예: 구글)에서 운영하는 경우가 많아.

OAuth 2.0의 핵심 시나리오: Authorization Code Grant

OAuth 2.0에는 여러 가지 권한 부여 방식(Grant Type)이 있지만, 웹 애플리케이션에서 가장 표준적이고 안전하게 사용되는 'Authorization Code Grant' 흐름을 간단히 살펴보자. 좀 복잡해 보일 수 있지만, 이 흐름을 이해하면 OAuth의 절반은 이해한 거야!

OAuth 2.0 Authorization Code Grant Flow 사용자 (User) 클라이언트 (App) 권한 서버 (Auth) 리소스 서버 (API) 1. 권한 요청 (e.g., 구글로 로그인) 2. 권한 서버 로그인 페이지로 리다이렉트 3. 로그인 & 권한 동의 4. Authorization Code 발급 (리다이렉트) 5. Code + Client Secret으로 토큰 요청 (백채널) 6. Access Token & Refresh Token 발급 7. Access Token으로 API 요청 8. 데이터 응답
  1. (1~2) 사용자가 우리 앱(클라이언트)에서 '구글로 로그인' 버튼을 누르면, 앱은 사용자를 구글(권한 서버)의 로그인 및 동의 페이지로 보낸다.
  2. (3) 사용자는 구글에 로그인하고, "이 앱이 내 OOO 정보에 접근하는 것을 허용합니다"라는 화면에서 '동의' 버튼을 누른다.
  3. (4) 구글은 사용자를 다시 우리 앱으로 돌려보내면서, 일회용 'Authorization Code'를 쿼리 스트링에 담아준다.
  4. (5) 우리 앱의 '서버'는 이 Authorization Code를 받아서, 자신의 신원을 증명하는 `Client ID`, `Client Secret`과 함께 구글의 토큰 발급 엔드포인트로 보낸다. (이 과정은 사용자 브라우저가 아닌, 서버 간의 통신이라 안전하다)
  5. (6) 구글은 코드가 유효한지 확인하고, 마침내 'Access Token'과 (선택적으로) 'Refresh Token'을 발급해준다.
  6. (7~8) 이제 우리 앱은 이 Access Token을 가지고 구글의 API(리소스 서버)에 당당하게 데이터를 요청하고, 응답을 받을 수 있다!

이 흐름에서 우리가 만들 부분은 바로 '권한 서버'와 '리소스 서버'야. 우리만의 서비스에서 자체적으로 토큰을 발급하고, 그 토큰을 검증해서 API 접근을 제어하는 시스템을 만드는 거지. 이때 Access Token의 형식으로 가장 널리 사용되는 것이 바로 JWT다.

Part 2: JWT 파헤치기 🕵️‍♂️ 디지털 신분증의 모든 것

OAuth 2.0이 권한을 위임하는 '프로세스'에 대한 명세라면, JWT(JSON Web Token)는 그 과정에서 사용되는 '토큰'의 한 종류, 즉 '내용물'에 대한 명세야. 발음은 보통 '조트'라고 해.

JWT란? 스스로를 증명하는 똑똑한 토큰

JWT는 이름 그대로, 필요한 모든 정보를 JSON 객체 형태로 가지고 있는 웹 토큰이야. 가장 큰 특징은 'Stateless'하고 'Self-contained'라는 점.

  • Stateless (무상태): 서버는 클라이언트의 상태를 저장할 필요가 없어. 클라이언트가 요청을 보낼 때마다 JWT를 함께 보내주면, 서버는 그 JWT만 보고도 모든 걸 알 수 있지. 덕분에 서버를 여러 대로 늘리는 스케일 아웃에 매우 유리해.
  • Self-contained (자가 수용적): 토큰 자체가 사용자 정보, 권한, 만료 시간 등 필요한 모든 정보를 담고 있어. 서버는 토큰을 받은 뒤 데이터베이스를 뒤져볼 필요 없이 토큰만으로 사용자를 인증하고 인가할 수 있지. (물론 경우에 따라 DB 조회가 필요할 때도 있어)

JWT는 `xxxxx.yyyyy.zzzzz`처럼 점(.)으로 구분된 세 부분으로 이루어져 있어. 각각 Header(헤더), Payload(페이로드), Signature(서명)라고 불러.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

이 정체불명의 문자열은 사실 각 부분을 Base64Url로 인코딩한 것뿐이야. 암호화가 아니라는 점이 중요해! Base64 디코더에 넣으면 누구나 내용을 볼 수 있어.

1. Header (헤더): 토큰의 명세서

헤더에는 이 토큰을 어떻게 해석해야 하는지에 대한 정보가 담겨 있어. 보통 두 가지 정보가 들어가.

  • typ (Type): 토큰의 타입을 지정해. 당연히 "JWT"겠지?
  • alg (Algorithm): 서명(Signature)을 만들 때 어떤 알고리즘을 사용했는지 알려줘. `HS256` (HMAC SHA-256)이나 `RS256` (RSA SHA-256) 등이 주로 사용돼.
{
  "alg": "HS256",
  "typ": "JWT"
}

2. Payload (페이로드): 진짜 하고 싶은 이야기

페이로드에는 토큰에 담고 싶은 실제 정보, 즉 '조각(Claim)'들이 JSON 형태로 담겨 있어. 이 Claim들은 크게 세 종류로 나눌 수 있어.

  • Registered Claims (등록된 클레임): JWT 사양에 이미 정의된 클레임들이야. 필수는 아니지만, 사용을 권장해.
    • `iss` (Issuer): 토큰 발급자 (e.g., "my-api.com")
    • `sub` (Subject): 토큰의 주제, 보통 사용자의 고유 식별자 (e.g., "user123")
    • `aud` (Audience): 토큰을 사용할 대상 (e.g., "my-app-client")
    • `exp` (Expiration Time): 토큰 만료 시간. Unix Timestamp로 표현돼. 이게 제일 중요해! 만료된 토큰은 거부해야 해.
    • `nbf` (Not Before): 이 시간 전에는 토큰을 처리하지 않음을 의미.
    • `iat` (Issued At): 토큰이 발급된 시간.
    • `jti` (JWT ID): 토큰의 고유 식별자. 재사용 방지(replay attack)에 사용할 수 있어.
  • Public Claims (공개 클레임): 충돌을 방지하기 위해 URI 형식으로 이름을 짓는 클레임이야. 보통은 잘 안 써.
  • Private Claims (비공개 클레임): 발급자와 소비자(클라이언트) 간에 협의된 클레임이야. 우리가 가장 많이 사용하게 될 부분이지. 사용자의 이름, 이메일, 역할(Role) 등을 여기에 담을 수 있어.
{
  "sub": "1101",
  "email": "test@jaenung.net",
  "role": "Admin",
  "iss": "jaenung-auth-server",
  "exp": 1672531199
}
페이로드는 Base64 인코딩일 뿐, 암호화된 게 아니야! 누구나 디코딩해서 내용을 볼 수 있으니, 비밀번호나 주민등록번호 같은 민감한 정보는 절대 페이로드에 담으면 안 돼! 🙅‍♀️

3. Signature (서명): 토큰의 위변조 방지 씰

서명은 JWT의 무결성을 보장하는 가장 중요한 부분이야. "이 토큰은 발급된 이후 아무도 건드리지 않았습니다"라는 것을 증명하는 디지털 인감도장 같은 거지.

서명은 다음과 같은 방식으로 만들어져.

HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )

즉, 인코딩된 헤더와 페이로드를 점(.)으로 합친 문자열을, 헤더에 명시된 알고리즘(`alg`)과 서버만 알고 있는 '비밀 키(Secret Key)'를 사용해서 해싱하는 거야.

클라이언트가 JWT를 포함해서 요청을 보내면, 서버는 똑같은 방식으로 서명을 다시 계산해봐. 그리고 클라이언트가 보낸 토큰의 서명과 서버가 방금 계산한 서명이 일치하는지 비교하는 거지. 만약 누군가 중간에 페이로드의 `role`을 "User"에서 "Admin"으로 바꿨다면, 서명 값이 완전히 달라지기 때문에 서버는 "이 토큰은 위조됐네!" 하고 요청을 바로 거부할 수 있어. 🛡️

이 '비밀 키'는 절대 외부에 노출되면 안 돼. 이게 털리면 누구나 유효한 JWT를 만들어낼 수 있게 되니까, 정말 정말 중요하게 관리해야 해!

Part 3: 실전! C#으로 JWT 인증 시스템 구축하기 🧑‍💻

자, 이제 이론 공부는 충분히 했으니 직접 코드를 만져볼 시간이야! .NET 8 (이전 버전도 거의 동일해) Web API 프로젝트를 기준으로, JWT를 발급하고 검증하는 인증 시스템을 만들어보자.

1단계: 프로젝트 설정 및 NuGet 패키지 설치

먼저, Visual Studio나 `dotnet` CLI를 사용해서 'ASP.NET Core Web API' 프로젝트를 하나 만들어. 그리고 JWT 관련 기능을 사용하기 위해 필요한 NuGet 패키지를 설치해야 해.

패키지 관리자 콘솔에서 아래 명령어를 실행하자.

Install-Package Microsoft.AspNetCore.Authentication.JwtBearer

이 패키지 하나면 JWT 인증을 위한 대부분의 기능이 들어있어. 아주 편리하지? 😉

다음으로, JWT를 만들 때 사용할 비밀 키와 발급자(Issuer), 대상(Audience) 같은 설정값들을 `appsettings.json` 파일에 추가할 거야. 이런 설정들을 코드에 하드코딩하는 건 아주 나쁜 습관이야! 설정 파일로 분리해야 나중에 관리하기가 편해.

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Jwt": {
    "Issuer": "MyAwesomeApp.com",
    "Audience": "MyAwesomeAppClient",
    "SecretKey": "ThisIsMySuperSecretKeyForMyAwesomeApplication12345!"
  }
}
실제 프로덕션 환경에서는 `SecretKey`를 이렇게 평문으로 저장하면 안 돼! Azure Key Vault, AWS Secrets Manager 같은 보안 서비스를 사용하거나, 최소한 사용자 비밀(User Secrets) 또는 환경 변수로 관리해야 해. 지금은 예제니까 간단하게 가자!

2단계: JWT 토큰 발급 API 만들기

이제 사용자가 로그인을 시도했을 때, 인증에 성공하면 JWT를 생성해서 돌려주는 API 엔드포인트를 만들어보자. `Controllers` 폴더에 `AuthController.cs` 같은 파일을 만들고 아래처럼 코드를 작성해봐.

Controllers/AuthController.cs


using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly IConfiguration _configuration;

    public AuthController(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginRequest request)
    {
        // 1. 사용자 인증 (실제로는 DB에서 사용자를 조회해야 함)
        // 여기서는 간단하게 하드코딩된 값과 비교
        if (request.Username == "testuser" && request.Password == "password123")
        {
            // 2. 인증 성공 시, JWT 생성
            var token = GenerateJwtToken("testuser", "User");
            return Ok(new { token });
        }

        return Unauthorized("Invalid credentials");
    }

    private string GenerateJwtToken(string username, string role)
    {
        // 페이로드에 담길 클레임(Claim) 생성
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, username), // 사용자 고유 식별자
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // JWT ID
            new Claim(ClaimTypes.Role, role) // 사용자의 역할
        };

        // appsettings.json에서 비밀 키 가져오기
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"]));
        // 서명 자격 증명 생성
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        // 토큰 만료 시간 설정
        var expires = DateTime.UtcNow.AddMinutes(30); // 예: 30분 후 만료

        // JwtSecurityToken 객체 생성
        var token = new JwtSecurityToken(
            issuer: _configuration["Jwt:Issuer"],
            audience: _configuration["Jwt:Audience"],
            claims: claims,
            expires: expires,
            signingCredentials: creds
        );

        // 토큰을 문자열로 직렬화
        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

public class LoginRequest
{
    public string Username { get; set; }
    public string Password { get; set; }
}

코드가 좀 길어 보이지만, 단계별로 보면 아주 간단해.

  1. 사용자 인증: 실제 앱에서는 데이터베이스에서 `Username`으로 사용자를 찾고, 해싱된 비밀번호를 비교하는 로직이 들어가야 해. 여기서는 간단히 "testuser" / "password123"일 때만 통과하도록 했어.
  2. 클레임 생성: 토큰의 페이로드에 담을 정보를 `Claim` 객체로 만들어. 여기서는 사용자 이름(`Sub`), 역할(`Role`), 그리고 토큰 ID(`Jti`)를 담았어.
  3. 비밀 키 및 서명 자격 증명 준비: `appsettings.json`에서 `SecretKey`를 가져와서 `SymmetricSecurityKey` 객체를 만들어. 그리고 이 키와 `HmacSha256` 알고리즘을 사용해서 `SigningCredentials`를 생성해. 이게 바로 서명을 만드는 데 필요한 재료야.
  4. JwtSecurityToken 생성: 발급자, 대상, 클레임, 만료 시간, 서명 자격 증명을 모두 담아서 `JwtSecurityToken` 객체를 만들어. 이게 JWT의 본체라고 할 수 있어.
  5. 토큰 직렬화: 마지막으로 `JwtSecurityTokenHandler().WriteToken()` 메서드를 사용해서 이 객체를 `xxxxx.yyyyy.zzzzz` 형태의 문자열로 변환해서 반환하면 끝! 🎉

이제 Postman 같은 API 테스트 도구로 `POST /api/auth/login`에 아래와 같은 JSON 바디를 보내봐. 그럼 응답으로 긴 JWT 문자열을 받을 수 있을 거야!

{
    "username": "testuser",
    "password": "password123"
}

3단계: API가 JWT를 검증하도록 설정하기

토큰을 발급했으니, 이제 우리 API가 클라이언트로부터 받은 토큰을 검증하고, 유효한 토큰을 가진 요청만 처리하도록 설정해야 해. 이 설정은 `Program.cs` 파일에서 이루어져. (.NET 5 이하라면 `Startup.cs`의 `ConfigureServices` 메서드)

Program.cs


using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// ▼▼▼▼▼ JWT 인증 설정 추가 ▼▼▼▼▼
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        // 1. 발급자(Issuer) 검증
        ValidateIssuer = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],

        // 2. 수신자(Audience) 검증
        ValidateAudience = true,
        ValidAudience = builder.Configuration["Jwt:Audience"],

        // 3. 토큰 만료 시간 검증
        ValidateLifetime = true,

        // 4. 서명 키 검증
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"])),

        // 토큰 만료 시간의 허용 오차 (Clock Skew)
        ClockSkew = TimeSpan.Zero 
    };
});
// ▲▲▲▲▲ JWT 인증 설정 추가 ▲▲▲▲▲

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// ▼▼▼▼▼ 인증 및 인가 미들웨어 추가 ▼▼▼▼▼
app.UseAuthentication(); // 인증 미들웨어
app.UseAuthorization();  // 인가 미들웨어
// ▲▲▲▲▲ 순서가 매우 중요! ▲▲▲▲▲

app.MapControllers();

app.Run();

여기서도 중요한 부분을 짚어보자.

  • `AddAuthentication` & `AddJwtBearer`: ASP.NET Core의 인증 시스템을 설정하는 부분이야. 기본 인증 스킴으로 `JwtBearer`를 사용하겠다고 알려주는 거지.
  • `TokenValidationParameters`: JWT를 검증할 때 어떤 규칙을 적용할지 상세하게 설정하는 부분이야. 이게 핵심이지!
    • `ValidateIssuer`, `ValidIssuer`: 토큰의 `iss` 클레임이 `appsettings.json`에 설정된 `Issuer`와 일치하는지 확인해.
    • `ValidateAudience`, `ValidAudience`: 토큰의 `aud` 클레임이 설정된 `Audience`와 일치하는지 확인해.
    • `ValidateLifetime`: 토큰의 `exp` 클레임을 보고 만료되었는지 확인해.
    • `ValidateIssuerSigningKey`, `IssuerSigningKey`: 토큰의 서명을 우리가 가진 `SecretKey`로 검증해서 위변조 여부를 확인해. 가장 중요한 검증 과정이야!
  • `app.UseAuthentication()` & `app.UseAuthorization()`: 실제 요청 파이프라인에 인증과 인가 미들웨어를 추가하는 부분이야. 이 두 줄이 없으면 위에서 한 설정이 아무 소용 없어. 그리고 반드시 `UseAuthentication`이 `UseAuthorization`보다 먼저 와야 해! 신원 확인(인증)을 해야 권한 검사(인가)를 할 수 있으니까.

4단계: API 엔드포인트 보호하기 및 사용자 정보 접근

모든 설정이 끝났어! 이제 특정 API를 보호하는 건 정말 식은 죽 먹기야. 그냥 컨트롤러나 액션 메서드 위에 `[Authorize]` 어트리뷰트 하나만 붙여주면 돼.

예를 들어, 날씨 정보를 알려주는 `WeatherForecastController`를 인증된 사용자만 접근할 수 있도록 바꿔보자.

Controllers/WeatherForecastController.cs


using Microsoft.AspNetCore.Authorization; // 추가!
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

[Authorize] // <-- 이 컨트롤러의 모든 액션은 인증이 필요함!
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    // ... (기존 코드 생략) ...

    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get()
    {
        // 인증된 사용자의 정보에 접근하기
        var username = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; // JWT의 'sub' 클레임
        var role = User.FindFirst(ClaimTypes.Role)?.Value; // JWT의 'role' 클레임

        // 이제 username과 role 변수를 비즈니스 로직에 활용할 수 있음
        // 예를 들어, 로그를 남기거나 역할에 따라 다른 데이터를 반환

        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            // ... (기존 코드 생략) ...
        })
        .ToArray();
    }

    [HttpGet("admin-only")]
    [Authorize(Roles = "Admin")] // <-- 'Admin' 역할을 가진 사용자만 접근 가능!
    public IActionResult GetAdminData()
    {
        return Ok("This is a top secret data for admins only!");
    }
}

이제 Postman에서 `GET /weatherforecast`를 요청해봐. 만약 `Authorization` 헤더에 유효한 JWT를 담아 보내지 않으면, `401 Unauthorized` 응답을 받게 될 거야.

헤더를 이렇게 설정하고 다시 요청해봐. (앞서 로그인 API에서 받은 토큰을 `[YOUR_JWT_TOKEN]` 부분에 붙여넣어)

Header Key: `Authorization`
Header Value: `Bearer [YOUR_JWT_TOKEN]`

`Bearer`라는 접두사와 한 칸 띄어쓰기는 약속이니 꼭 지켜줘야 해! 이제 요청을 보내면 `200 OK`와 함께 날씨 데이터를 성공적으로 받아올 수 있을 거야. 🥳

더 나아가, `[Authorize(Roles = "Admin")]`처럼 특정 역할(Role)을 가진 사용자만 접근할 수 있도록 제한할 수도 있어. 우리가 로그인할 때 "User" 역할로 토큰을 만들었기 때문에, `/weatherforecast/admin-only` 엔드포인트에 접근하려고 하면 `403 Forbidden` 응답을 받게 될 거야. 권한이 없다는 뜻이지.

`[Authorize]` 어트리뷰트가 붙은 액션에 들어오면, `ControllerBase`가 가지고 있는 `User` 프로퍼티를 통해 JWT 페이로드에 있던 클레임 정보에 쉽게 접근할 수 있어. `User.Identity.Name`이나 `User.FindFirst(...)` 등을 사용해서 현재 요청을 보낸 사용자가 누구인지, 어떤 역할을 가졌는지 등을 파악하고 비즈니스 로직에 활용할 수 있지. 정말 편리하지?

Part 4: 레벨 업! 🚀 고급 주제 및 보안 강화 전략

지금까지 만든 시스템도 훌륭하지만, 실제 서비스를 운영하려면 몇 가지 더 고려해야 할 점들이 있어. 보안은 언제나 과유불급이니까! 재능 공유 플랫폼 재능넷과 같은 실제 서비스에서는 이런 고급 전략들이 필수적으로 사용되고 있어.

Refresh Token: 짧은 만남과 긴 인연의 조화

우리가 만든 Access Token은 만료 시간이 30분으로 아주 짧아. 이건 보안상 아주 좋은 전략이야. 만약 토큰이 탈취되더라도, 공격자가 사용할 수 있는 시간이 30분으로 제한되니까.

하지만 사용자 입장에서는 어떨까? 30분마다 로그인이 풀리고 다시 아이디/비밀번호를 입력해야 한다면? 끔찍한 사용자 경험(UX)이겠지. 😫

이 딜레마를 해결하기 위해 등장한 것이 바로 Refresh Token이야.

  • Access Token: 수명이 짧다 (수 분 ~ 수 시간). 실제 API 요청에 사용된다. 탈취되어도 피해가 제한적이다.
  • Refresh Token: 수명이 길다 (수 일 ~ 수 개월). 새로운 Access Token을 발급받는 용도로만 사용된다.

전체적인 흐름은 이래.

  1. 최초 로그인 시, 서버는 Access Token과 Refresh Token을 둘 다 발급해준다.
  2. 클라이언트는 두 토큰을 모두 안전한 곳에 저장한다.
  3. API 요청 시에는 Access Token만 사용한다.
  4. Access Token이 만료되어 API 요청이 `401 Unauthorized`로 실패하면, 클라이언트는 가지고 있던 Refresh Token을 `/api/auth/refresh` 같은 특정 엔드포인트로 보낸다.
  5. 서버는 받은 Refresh Token이 유효한지 DB에서 확인한다. (Refresh Token은 반드시 DB에 저장하고 관리해야 해!)
  6. Refresh Token이 유효하면, 새로운 Access Token(과 선택적으로 새로운 Refresh Token)을 발급해서 클라이언트에게 돌려준다.
  7. 클라이언트는 새로 받은 Access Token으로 원래 하려던 API 요청을 다시 시도한다.

이 방식을 사용하면, 사용자는 긴 시간 동안 로그인 상태를 유지하면서도, 실제 데이터 접근에 사용되는 Access Token의 수명은 짧게 유지하여 보안성을 높일 수 있어. 두 마리 토끼를 다 잡는 셈이지! 🐰🐰

Refresh Token 구현 시 고려사항: - Refresh Token은 반드시 DB에 사용자 정보와 함께 저장하고, 만료 시간, 사용 여부 등을 관리해야 해.
- Refresh Token을 사용해 새 Access Token을 발급할 때, 기존 Refresh Token을 무효화하고 새로운 Refresh Token을 발급하는 'Refresh Token Rotation' 전략을 사용하면 보안을 더욱 강화할 수 있어. (탈취된 Refresh Token이 한 번만 사용되도록 제한)

JWT 보안 Best Practices: 우리 앱은 소중하니까!

JWT를 사용하면서 저지르기 쉬운 실수들과, 보안을 강화하기 위한 몇 가지 팁들을 알아보자.

1. 비밀 키는 생명처럼! 🔑
`appsettings.json`에 있던 `SecretKey` 기억나지? 이건 절대 코드 저장소(Git 등)에 올라가면 안 돼. .NET의 'User Secrets' 기능, 클라우드 서비스의 'Key Vault', 'Secrets Manager', 또는 '환경 변수'를 사용해서 안전하게 관리해야 해. 그리고 충분히 길고 복잡한 문자열을 사용해야 하는 건 기본이야!

2. HTTPS는 기본 소양! 🔒
JWT는 그 자체로 암호화된 게 아니기 때문에, HTTP 통신을 사용하면 중간에서 누군가 토큰을 가로챌 수 있어. 모든 API 통신은 반드시 HTTPS(SSL/TLS)를 통해 암호화되어야 해. 이건 JWT뿐만 아니라 모든 웹 통신의 기본 중의 기본이야.

3. `alg: none` 공격에 대비하기
과거 일부 JWT 라이브러리에서는 헤더의 `alg`를 "none"으로 설정하면 서명 검증을 건너뛰는 취약점이 있었어. 다행히 `Microsoft.AspNetCore.Authentication.JwtBearer` 같은 최신 라이브러리들은 기본적으로 `alg: none`을 허용하지 않도록 설정되어 있어. 하지만 항상 사용하는 라이브러리의 보안 권장 사항을 확인하는 습관을 들이는 게 좋아.

4. 토큰 강제 만료(로그아웃) 기능 구현하기
JWT는 한번 발급되면 정해진 만료 시간까지는 유효하다는 특징이 있어. 그래서 사용자가 '로그아웃' 버튼을 눌러도, 서버는 그 토큰이 만료되기 전까지는 유효하다고 판단해. 만약 그 사이에 토큰이 탈취되었다면 문제가 될 수 있지.
이를 해결하기 위해 '블랙리스트' 방식을 사용할 수 있어. 로그아웃 요청이 오면, 해당 토큰의 고유 ID(`jti` 클레임)와 남은 유효시간을 Redis나 DB 같은 저장소에 '무효화된 토큰 목록'으로 저장하는 거야. 그리고 API 요청이 올 때마다 토큰을 검증한 후, 이 블랙리스트에 해당 토큰이 있는지 추가로 확인하는 거지. 약간의 성능 저하가 있지만, 보안을 위해서는 고려해볼 만한 방법이야.

클라이언트에서 토큰은 어디에 저장해야 할까?

서버에서 발급한 토큰을 클라이언트(웹 브라우저)에서 어디에 저장할지는 아주 중요한 문제이고, 개발자들 사이에서도 의견이 분분한 주제야. 대표적인 두 가지 방법을 비교해보자.

1. localStorage / sessionStorage

  • 장점: JavaScript로 접근하기가 매우 쉬워서 사용하기 편리해.
  • 단점: XSS(Cross-Site Scripting) 공격에 매우 취약해. 만약 웹사이트에 악성 스크립트가 주입될 수 있는 취약점이 있다면, 공격자는 `localStorage.getItem('jwt_token')` 같은 코드로 토큰을 쉽게 훔쳐갈 수 있어.

2. HttpOnly, Secure 쿠키

  • 장점: `HttpOnly` 속성을 설정하면 JavaScript에서 쿠키에 접근할 수 없어. XSS 공격으로부터 토큰을 안전하게 보호할 수 있지. `Secure` 속성은 HTTPS 통신에서만 쿠키가 전송되도록 강제해줘.
  • 단점: CSRF(Cross-Site Request Forgery) 공격에 취약할 수 있어. 사용자가 의도하지 않은 요청(예: 비밀번호 변경)을 악성 사이트가 대신 보내는 공격이야. 하지만 이 역시 `SameSite=Strict` 또는 `SameSite=Lax` 같은 쿠키 속성을 설정하고, Anti-CSRF 토큰을 사용하는 방식으로 대부분 방어할 수 있어.
결론적으로, 현재로서는 `HttpOnly`, `Secure`, `SameSite` 속성을 모두 적용한 쿠키에 토큰을 저장하는 방식이 localStorage보다 더 안전한 방법으로 권장되고 있어. Refresh Token은 쿠키에, Access Token은 메모리(변수)에 저장하고 사용하는 하이브리드 방식도 좋은 대안이 될 수 있어.

마치며: 보안은 여정이다 🛤️

와, 정말 긴 여정이었어! 헥헥... 😅
인증과 인가의 기본 개념부터 OAuth 2.0의 원리, JWT의 구조, 그리고 C# .NET으로 직접 인증 시스템을 구현하고 보안을 강화하는 방법까지. 정말 많은 내용을 함께 살펴봤어.

오늘 우리가 배운 내용은 현대적인 웹 서비스와 API를 개발하는 데 있어 필수적인 지식이야. 처음에는 용어도 많고 흐름도 복잡해서 어렵게 느껴질 수 있지만, 직접 코드를 짜보고 Postman으로 토큰을 주고받다 보면 "아하!" 하고 감이 오는 순간이 분명히 있을 거야.

기억해야 할 가장 중요한 점은, 보안은 '한 번 설정하고 끝'이 아니라는 거야. 새로운 취약점은 계속해서 발견되고, 기술은 끊임없이 발전하거든. 항상 최신 보안 동향에 관심을 갖고, 내가 만든 시스템을 더 안전하게 만들 방법은 없을지 고민하는 자세가 훌륭한 개발자를 만드는 밑거름이 될 거야.

오늘 배운 지식을 바탕으로 여러분만의 멋지고 안전한 애플리케이션을 만들어보길 바라! 혹시 개발하다가 막히는 부분이 있거나, 더 깊은 지식을 나누고 싶다면 재능넷 같은 전문가 커뮤니티에서 도움을 구하거나 자신의 지식을 공유해보는 것도 좋은 방법이겠지? 😉

그럼, 모두들 즐거운 코딩 하길 바라! 다음에 더 재미있는 주제로 또 만나자! 안녕! 👋

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.