ASP.NET Core Identity 기반 JWT Refresh Token 통합 및 테스트 가이드
이 문서에서는 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.cs
의 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 = "유효하지 않은 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 파일 기반 전수 테스트