From 2d30aaf2123320092e72ac980759f4c4cd394b9e Mon Sep 17 00:00:00 2001 From: SEAN Date: Mon, 9 Feb 2026 14:59:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EA=B5=AC=ED=98=84=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IJwtService 인터페이스 (Application Layer) - JwtSettings POCO (Options Pattern) - JwtService 구현 (Access Token 생성/검증, Refresh Token 생성) - AddJwtAuthentication/AddAuthorizationPolicies 확장 메서드 - Program.cs에 인증/인가 미들웨어 등록 (파이프라인 순서 10~11번) - NuGet: System.IdentityModel.Tokens.Jwt, Microsoft.AspNetCore.Authentication.JwtBearer --- .../Extensions/AuthenticationExtensions.cs | 55 +++++++++++++ SPMS.API/Program.cs | 20 ++++- SPMS.API/SPMS.API.csproj | 3 +- SPMS.API/appsettings.json | 3 +- SPMS.Application/Interfaces/IJwtService.cs | 10 +++ SPMS.Application/Settings/JwtSettings.cs | 12 +++ SPMS.Infrastructure/Auth/JwtService.cs | 80 +++++++++++++++++++ .../SPMS.Infrastructure.csproj | 1 + 8 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 SPMS.API/Extensions/AuthenticationExtensions.cs create mode 100644 SPMS.Application/Interfaces/IJwtService.cs create mode 100644 SPMS.Application/Settings/JwtSettings.cs create mode 100644 SPMS.Infrastructure/Auth/JwtService.cs diff --git a/SPMS.API/Extensions/AuthenticationExtensions.cs b/SPMS.API/Extensions/AuthenticationExtensions.cs new file mode 100644 index 0000000..8217ae0 --- /dev/null +++ b/SPMS.API/Extensions/AuthenticationExtensions.cs @@ -0,0 +1,55 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using SPMS.Application.Settings; + +namespace SPMS.API.Extensions; + +public static class AuthenticationExtensions +{ + public static IServiceCollection AddJwtAuthentication( + this IServiceCollection services, + IConfiguration configuration) + { + var jwtSettings = configuration.GetSection(JwtSettings.SectionName).Get()!; + + services.Configure(configuration.GetSection(JwtSettings.SectionName)); + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(jwtSettings.SecretKey)), + ClockSkew = TimeSpan.Zero + }; + }); + + return services; + } + + public static IServiceCollection AddAuthorizationPolicies(this IServiceCollection services) + { + services.AddAuthorization(options => + { + options.AddPolicy("SuperOnly", policy => + policy.RequireRole("Super")); + + options.AddPolicy("ManagerOrAbove", policy => + policy.RequireRole("Super", "Manager")); + }); + + return services; + } +} diff --git a/SPMS.API/Program.cs b/SPMS.API/Program.cs index 851b595..7aeebdd 100644 --- a/SPMS.API/Program.cs +++ b/SPMS.API/Program.cs @@ -1,7 +1,10 @@ using Microsoft.EntityFrameworkCore; +using SPMS.API.Extensions; using SPMS.API.Middlewares; +using SPMS.Application.Interfaces; using SPMS.Domain.Interfaces; using SPMS.Infrastructure; +using SPMS.Infrastructure.Auth; using SPMS.Infrastructure.Persistence; using SPMS.Infrastructure.Persistence.Repositories; @@ -22,6 +25,11 @@ builder.Services.AddDbContext(options => builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// JWT 인증/인가 +builder.Services.AddJwtAuthentication(builder.Configuration); +builder.Services.AddAuthorizationPolicies(); var app = builder.Build(); @@ -34,11 +42,11 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } var webRoot = app.Environment.WebRootPath; -Console.WriteLine($"[System] Web Root Path: {webRoot}"); // 로그에 경로 찍어보기 +Console.WriteLine($"[System] Web Root Path: {webRoot}"); if (Directory.Exists(webRoot)) { - app.UseStaticFiles(); // 경로가 있으면 파일 서빙 + app.UseStaticFiles(); } else { @@ -48,7 +56,13 @@ else app.UseHttpsRedirection(); app.UseRouting(); -// [4] 요청 처리 +// ── 10. JWT 인증 ── +app.UseAuthentication(); + +// ── 11. 역할 인가 ── +app.UseAuthorization(); + +// [엔드포인트 매핑] app.MapControllers(); app.MapFallbackToFile("index.html"); diff --git a/SPMS.API/SPMS.API.csproj b/SPMS.API/SPMS.API.csproj index a4d95a8..73bfcf7 100644 --- a/SPMS.API/SPMS.API.csproj +++ b/SPMS.API/SPMS.API.csproj @@ -10,7 +10,8 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SPMS.API/appsettings.json b/SPMS.API/appsettings.json index cffd873..b70ce76 100644 --- a/SPMS.API/appsettings.json +++ b/SPMS.API/appsettings.json @@ -13,7 +13,8 @@ "SecretKey": "", "Issuer": "SPMS", "Audience": "SPMS", - "ExpiryMinutes": 10 + "ExpiryMinutes": 10, + "RefreshTokenExpiryDays": 7 }, "RabbitMQ": { "HostName": "", diff --git a/SPMS.Application/Interfaces/IJwtService.cs b/SPMS.Application/Interfaces/IJwtService.cs new file mode 100644 index 0000000..24d74aa --- /dev/null +++ b/SPMS.Application/Interfaces/IJwtService.cs @@ -0,0 +1,10 @@ +using System.Security.Claims; + +namespace SPMS.Application.Interfaces; + +public interface IJwtService +{ + string GenerateAccessToken(long adminId, string role, string? serviceCode = null); + ClaimsPrincipal? ValidateAccessToken(string token); + string GenerateRefreshToken(); +} diff --git a/SPMS.Application/Settings/JwtSettings.cs b/SPMS.Application/Settings/JwtSettings.cs new file mode 100644 index 0000000..73ad285 --- /dev/null +++ b/SPMS.Application/Settings/JwtSettings.cs @@ -0,0 +1,12 @@ +namespace SPMS.Application.Settings; + +public class JwtSettings +{ + public const string SectionName = "JwtSettings"; + + public string SecretKey { get; set; } = string.Empty; + public string Issuer { get; set; } = string.Empty; + public string Audience { get; set; } = string.Empty; + public int ExpiryMinutes { get; set; } = 60; + public int RefreshTokenExpiryDays { get; set; } = 7; +} diff --git a/SPMS.Infrastructure/Auth/JwtService.cs b/SPMS.Infrastructure/Auth/JwtService.cs new file mode 100644 index 0000000..fb5229b --- /dev/null +++ b/SPMS.Infrastructure/Auth/JwtService.cs @@ -0,0 +1,80 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using SPMS.Application.Interfaces; +using SPMS.Application.Settings; + +namespace SPMS.Infrastructure.Auth; + +public class JwtService : IJwtService +{ + private readonly JwtSettings _settings; + + public JwtService(IOptions settings) + { + _settings = settings.Value; + } + + public string GenerateAccessToken(long adminId, string role, string? serviceCode = null) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SecretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, adminId.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), + new(ClaimTypes.Role, role) + }; + + if (!string.IsNullOrEmpty(serviceCode)) + claims.Add(new Claim("ServiceCode", serviceCode)); + + var token = new JwtSecurityToken( + issuer: _settings.Issuer, + audience: _settings.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_settings.ExpiryMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public ClaimsPrincipal? ValidateAccessToken(string token) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SecretKey)); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = _settings.Issuer, + ValidAudience = _settings.Audience, + IssuerSigningKey = key, + ClockSkew = TimeSpan.Zero + }; + + try + { + var handler = new JwtSecurityTokenHandler(); + return handler.ValidateToken(token, validationParameters, out _); + } + catch + { + return null; + } + } + + public string GenerateRefreshToken() + { + var randomBytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes); + } +} diff --git a/SPMS.Infrastructure/SPMS.Infrastructure.csproj b/SPMS.Infrastructure/SPMS.Infrastructure.csproj index 0979260..e66d498 100644 --- a/SPMS.Infrastructure/SPMS.Infrastructure.csproj +++ b/SPMS.Infrastructure/SPMS.Infrastructure.csproj @@ -16,6 +16,7 @@ + -- 2.45.1