ASP.NET Core Identity 기반 JWT Refresh Token 통합 및 테스트 가이드

  • 11 minutes to read

이 문서에서는 ASP.NET Core MVC 9.0 으로 생성한 Azunt.Web 프로젝트에서 ASP.NET Core Identity + JWT Refresh Token 기반 인증 시스템을 구축하는 방법을 단계별로 다룹니다.
초기 프로젝트 생성부터 실제 API 개발, .http 테스트, Swagger UI 테스트까지 완벽하게 정리하였으며, 완성된 후에는 강력하고 안전한 JWT 기반 인증 시스템이 갖춰지게 됩니다.

개발 환경

  • .NET 9.0 (Preview)
  • ASP.NET Core MVC 프로젝트 (Azunt.Web)
  • Entity Framework Core (SQL Server 기준)
  • ASP.NET Core Identity
  • JWT Bearer 인증
  • Swagger API 문서화 및 테스트
  • .http 파일 수동 API 테스트

1. 프로젝트 생성 및 패키지 설치

1.1. 프로젝트 생성

dotnet new mvc --auth Individual -n Azunt.Web
cd Azunt.Web
  • --auth Individual: ASP.NET Core Identity가 포함된 MVC 템플릿 프로젝트 생성
  • 기본적으로 인증 기능이 내장된 상태로 시작합니다.

1.2. 필수 패키지 설치

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Identity
dotnet add package Swashbuckle.AspNetCore
패키지 이름 설명
Microsoft.AspNetCore.Authentication.JwtBearer JWT 인증
Microsoft.AspNetCore.Identity.EntityFrameworkCore Identity 저장소를 EF Core로 구현
Microsoft.AspNetCore.Identity ASP.NET Core Identity 핵심 기능
Swashbuckle.AspNetCore Swagger API 문서화 및 테스트 도구

2. 프로젝트 구조 준비

2.1. ApplicationUser 엔터티

Models/ApplicationUser.cs

using Microsoft.AspNetCore.Identity;
using System;

namespace Azunt.Web.Models
{
    public class ApplicationUser : IdentityUser
    {
        public string? RefreshToken { get; set; }
        public DateTime? RefreshTokenExpiryTime { get; set; }
    }
}
  • 사용자 기본 정보 + Refresh Token 및 만료 시간 관리

2.2. ApplicationDbContext 수정

Data/ApplicationDbContext.cs

using Azunt.Web.Models;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace Azunt.Web.Data
{
    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

3. JWT 설정

3.1. appsettings.json

appsettings.json 에 JWT 설정 추가

"JwtSettings": {
  "Secret": "YourSuperSecretKeyHere123!",
  "Issuer": "https://azunt-web.com",
  "Audience": "https://azunt-client.com",
  "AccessTokenExpiration": 30,
  "RefreshTokenExpiration": 7
}
  • Secret: 보안을 위해 반드시 환경 변수 또는 Secret Manager로 관리 권장

3.2. Program.cs 환경 변수 사용

var secretKey = Environment.GetEnvironmentVariable("JwtSecret")
                ?? builder.Configuration["JwtSettings:Secret"];

4. Program.cs 구성

Program.cs 전체 구성

using Azunt.Web.Data;
using Azunt.Web.Models;
using Azunt.Web.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var key = Encoding.UTF8.GetBytes(jwtSettings["Secret"]);

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.RequireHttpsMetadata = false;
    options.SaveToken = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = jwtSettings["Issuer"],
        ValidAudience = jwtSettings["Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(key)
    };
});

builder.Services.AddScoped<JwtService>();

builder.Services.AddControllersWithViews();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo { Title = "Azunt.Web API", Version = "v1" });

    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "JWT Authorization header using the Bearer scheme. Enter 'Bearer' followed by your token.",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                },
                Scheme = "oauth2",
                Name = "Bearer",
                In = ParameterLocation.Header
            },
            new List<string>()
        }
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();
IMPORTANT

AddJwtBearer() 메서드와 AddBearerToken() 메서드를 함께 찾아보세요.


5. JWT 서비스 생성

Services/JwtService.cs

using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;

namespace Azunt.Web.Services
{
    public class JwtService
    {
        private readonly IConfiguration _configuration;

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

        public string GenerateAccessToken(IEnumerable<Claim> claims)
        {
            var jwtSettings = _configuration.GetSection("JwtSettings");
            var key = Encoding.UTF8.GetBytes(jwtSettings["Secret"]);

            var tokenDescriptor = new SecurityTokenDescriptor
            {
                Subject = new ClaimsIdentity(claims),
                Expires = DateTime.UtcNow.AddMinutes(Convert.ToDouble(jwtSettings["AccessTokenExpiration"])),
                Issuer = jwtSettings["Issuer"],
                Audience = jwtSettings["Audience"],
                SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
            };

            var tokenHandler = new JwtSecurityTokenHandler();
            var token = tokenHandler.CreateToken(tokenDescriptor);
            return tokenHandler.WriteToken(token);
        }

        public string GenerateRefreshToken()
        {
            return Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
        }
    }
}

6. 모델 클래스 준비

Models/RegisterModel.cs

namespace Azunt.Web.Models
{
    public class RegisterModel
    {
        public string Username { get; set; } = null!;
        public string Email { get; set; } = null!;
        public string Password { get; set; } = null!;
    }
}

Models/LoginModel.cs

namespace Azunt.Web.Models
{
    public class LoginModel
    {
        public string Username { get; set; } = null!;
        public string Password { get; set; } = null!;
    }
}

Models/RefreshTokenRequest.cs

namespace Azunt.Web.Models
{
    public class RefreshTokenRequest
    {
        public string RefreshToken { get; set; } = null!;
    }
}

7. 인증 API 구현

Controllers/AuthController.cs

using Azunt.Web.Models;
using Azunt.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;

namespace Azunt.Web.Controllers
{
    [Route("api/auth")]
    [ApiController]
    public class AuthController : ControllerBase
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly JwtService _jwtService;

        public AuthController(UserManager<ApplicationUser> userManager, JwtService jwtService)
        {
            _userManager = userManager;
            _jwtService = jwtService;
        }

        [HttpPost("register")]
        public async Task<IActionResult> Register([FromBody] RegisterModel model)
        {
            var user = new ApplicationUser { UserName = model.Username, Email = model.Email };
            var result = await _userManager.CreateAsync(user, model.Password);
            if (!result.Succeeded)
                return BadRequest(result.Errors);

            return Ok(new { message = "User registered successfully" });
        }

        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody] LoginModel model)
        {
            var user = await _userManager.FindByNameAsync(model.Username);
            if (user == null || !await _userManager.CheckPasswordAsync(user, model.Password))
                return Unauthorized();

            var accessToken = _jwtService.GenerateAccessToken(new[]
            {
                new Claim(ClaimTypes.Name, user.UserName),
                new Claim(ClaimTypes.NameIdentifier, user.Id)
            });
            var refreshToken = _jwtService.GenerateRefreshToken();

            user.RefreshToken = refreshToken;
            user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
            await _userManager.UpdateAsync(user);

            return Ok(new { accessToken, refreshToken });
        }

        [HttpPost("refresh-token")]
        public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
        {
            var user = await _userManager.Users.FirstOrDefaultAsync(u =>
                u.RefreshToken == request.RefreshToken &&
                u.RefreshTokenExpiryTime > DateTime.UtcNow);

            if (user == null)
                return Unauthorized(new { message = "Invalid refresh token" });

            var newAccessToken = _jwtService.GenerateAccessToken(new[]
            {
                new Claim(ClaimTypes.Name, user.UserName),
                new Claim(ClaimTypes.NameIdentifier, user.Id)
            });

            var newRefreshToken = _jwtService.GenerateRefreshToken();

            user.RefreshToken = newRefreshToken;
            user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
            await _userManager.UpdateAsync(user);

            return Ok(new { accessToken = newAccessToken, refreshToken = newRefreshToken });
        }

        [HttpPost("logout")]
        [Authorize]
        public async Task<IActionResult> Logout()
        {
            var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
            var user = await _userManager.FindByIdAsync(userId);

            if (user == null)
                return Unauthorized();

            user.RefreshToken = null;
            user.RefreshTokenExpiryTime = null;
            await _userManager.UpdateAsync(user);

            return Ok(new { message = "로그아웃 되었습니다. Refresh Token이 무효화되었습니다." });
        }
    }
}

8. .http 파일로 API 테스트

API가 정상적으로 동작하는지 .http 파일을 사용하여 수동으로 테스트할 수 있습니다.

프로젝트 루트에 api.http 파일을 만들어 다음 내용을 추가합니다.

### 회원가입
POST http://localhost:5000/api/auth/register
Content-Type: application/json

{
  "username": "testuser",
  "email": "test@example.com",
  "password": "Test@1234"
}

### 로그인
POST http://localhost:5000/api/auth/login
Content-Type: application/json

{
  "username": "testuser",
  "password": "Test@1234"
}

### 토큰 갱신
POST http://localhost:5000/api/auth/refresh-token
Content-Type: application/json

{
  "refreshToken": "여기에_로그인_응답에서_받은_refreshToken_값_입력"
}

### 로그아웃
POST http://localhost:5000/api/auth/logout
Authorization: Bearer 여기에_로그인_응답에서_받은_accessToken_값_입력

.http 파일은 Visual Studio Code 또는 JetBrains Rider 에서 REST Client 플러그인 설치 시 바로 실행 테스트 가능합니다.


9. 보호된 API 생성

Access Token 이 실제로 사용되는 보호된 엔드포인트를 구현합니다.

Controllers/ProtectedController.cs

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Azunt.Web.Controllers
{
    [Route("api/protected")]
    [ApiController]
    [Authorize] // JWT 인증이 필요합니다
    public class ProtectedController : ControllerBase
    {
        [HttpGet("data")]
        public IActionResult GetProtectedData()
        {
            return Ok(new { message = "이 데이터는 인증된 사용자만 접근할 수 있습니다." });
        }
    }
}

.http 파일에 다음 테스트 요청을 추가합니다.

### 보호된 API 테스트 (AccessToken 필요!)
GET http://localhost:5000/api/protected/data
Authorization: Bearer 여기에_accessToken_입력

Access Token 을 넣지 않으면 401 Unauthorized 응답이 반환됩니다.


10. Refresh Token 재사용 방지

보안을 위해 Refresh Token 은 1회 사용 후 즉시 폐기하는 것이 좋습니다.
우리는 이미 구현한 토큰 갱신 API (refresh-token) 를 개선하여 Refresh Token 을 재사용할 수 없도록 처리합니다.

AuthController.csRefreshToken 메서드를 이렇게 보완합니다.

[HttpPost("refresh-token")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
    var user = await _userManager.Users.FirstOrDefaultAsync(u =>
        u.RefreshToken == request.RefreshToken &&
        u.RefreshTokenExpiryTime > DateTime.UtcNow);

    if (user == null)
        return Unauthorized(new { message = "유효하지 않은 Refresh Token 입니다." });

    // 새로운 Access Token 과 Refresh Token 발급
    var newAccessToken = _jwtService.GenerateAccessToken(new[]
    {
        new Claim(ClaimTypes.Name, user.UserName),
        new Claim(ClaimTypes.NameIdentifier, user.Id)
    });

    var newRefreshToken = _jwtService.GenerateRefreshToken();

    // 기존 Refresh Token 을 즉시 폐기
    user.RefreshToken = newRefreshToken;
    user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
    await _userManager.UpdateAsync(user);

    return Ok(new
    {
        accessToken = newAccessToken,
        refreshToken = newRefreshToken
    });
}

이로써 Refresh Token 은 재사용이 불가능하며, 매 갱신 시마다 새 토큰이 발급됩니다.


11. 최종 테스트 시나리오 (.http)

.http 파일에 전체 시나리오를 정리합니다.

### 회원가입
POST http://localhost:5000/api/auth/register
Content-Type: application/json

{
  "username": "testuser",
  "email": "test@example.com",
  "password": "Test@1234"
}

### 로그인 (Access Token + Refresh Token 발급)
POST http://localhost:5000/api/auth/login
Content-Type: application/json

{
  "username": "testuser",
  "password": "Test@1234"
}

### 보호된 API 테스트 (AccessToken 필요!)
GET http://localhost:5000/api/protected/data
Authorization: Bearer 여기에_accessToken_입력

### 토큰 갱신 (새 Access Token + 새 Refresh Token 발급)
POST http://localhost:5000/api/auth/refresh-token
Content-Type: application/json

{
  "refreshToken": "여기에_refreshToken_입력"
}

### 보호된 API 재테스트 (갱신된 Access Token 사용)
GET http://localhost:5000/api/protected/data
Authorization: Bearer 여기에_새로_발급받은_accessToken_입력

### 로그아웃 (Refresh Token 폐기)
POST http://localhost:5000/api/auth/logout
Authorization: Bearer 여기에_갱신된_accessToken_입력

### 폐기된 Refresh Token 재사용 시도 (실패 예상)
POST http://localhost:5000/api/auth/refresh-token
Content-Type: application/json

{
  "refreshToken": "로그아웃 이전 사용된_refreshToken_값"
}

예상 결과

  • 로그아웃 이후 재사용된 Refresh Token 요청 시 401 Unauthorized 응답

12. 마무리 요약

여기까지 진행한 내용을 정리합니다.

기능 구현 여부
프로젝트 생성 및 패키지 설치 완료
ASP.NET Core Identity 사용자 관리 완료
JWT Access Token / Refresh Token 발급 완료
Refresh Token 기반 Access Token 재발급 완료
Refresh Token 1회성 사용 완료
Swagger UI 테스트 통합 완료
.http 파일 수동 테스트 완료
보호된 API 구현 완료
로그아웃 시 Refresh Token 폐기 완료
토큰 재사용 공격 방지 완료

추가로 할 수 있는 확장 (선택)

  • Access Token 만료 감지 및 프론트엔드 자동 갱신
  • Redis 기반 토큰 블랙리스트 적용 (다중 기기 및 세션 관리)
  • 토큰 발급 / 만료 / 갱신 로그 기록
  • HTTPS 강제 적용 및 배포 (Docker / Azure / AWS)

✅ 결론

이제 Azunt.Web 프로젝트는 실서비스 수준의 JWT 인증 시스템을 갖췄습니다.
.http 파일과 Swagger UI 로 바로 테스트하면서 토큰 기반 인증 흐름을 체계적으로 검증할 수 있습니다.

  • ✅ 토큰 기반 로그인 및 인증
  • ✅ Access Token 만료 후 Refresh Token 으로 안전하게 갱신
  • ✅ Refresh Token 재사용 방지
  • ✅ 인증된 사용자 전용 API 보호
  • ✅ Swagger UI, .http 파일 기반 전수 테스트
VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com