feat: JWT 인증 모듈 구현 (#20)

- 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
This commit is contained in:
SEAN 2026-02-09 14:59:36 +09:00
parent 24e1ccbfef
commit 2d30aaf212
8 changed files with 179 additions and 5 deletions

View File

@ -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<JwtSettings>()!;
services.Configure<JwtSettings>(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;
}
}

View File

@ -1,7 +1,10 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SPMS.API.Extensions;
using SPMS.API.Middlewares; using SPMS.API.Middlewares;
using SPMS.Application.Interfaces;
using SPMS.Domain.Interfaces; using SPMS.Domain.Interfaces;
using SPMS.Infrastructure; using SPMS.Infrastructure;
using SPMS.Infrastructure.Auth;
using SPMS.Infrastructure.Persistence; using SPMS.Infrastructure.Persistence;
using SPMS.Infrastructure.Persistence.Repositories; using SPMS.Infrastructure.Persistence.Repositories;
@ -22,6 +25,11 @@ builder.Services.AddDbContext<AppDbContext>(options =>
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>(); builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<IJwtService, JwtService>();
// JWT 인증/인가
builder.Services.AddJwtAuthentication(builder.Configuration);
builder.Services.AddAuthorizationPolicies();
var app = builder.Build(); var app = builder.Build();
@ -34,11 +42,11 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
} }
var webRoot = app.Environment.WebRootPath; var webRoot = app.Environment.WebRootPath;
Console.WriteLine($"[System] Web Root Path: {webRoot}"); // 로그에 경로 찍어보기 Console.WriteLine($"[System] Web Root Path: {webRoot}");
if (Directory.Exists(webRoot)) if (Directory.Exists(webRoot))
{ {
app.UseStaticFiles(); // 경로가 있으면 파일 서빙 app.UseStaticFiles();
} }
else else
{ {
@ -48,7 +56,13 @@ else
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseRouting(); app.UseRouting();
// [4] 요청 처리 // ── 10. JWT 인증 ──
app.UseAuthentication();
// ── 11. 역할 인가 ──
app.UseAuthorization();
// [엔드포인트 매핑]
app.MapControllers(); app.MapControllers();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");

View File

@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FirebaseAdmin" Version="3.4.0" /> <PackageReference Include="FirebaseAdmin" Version="3.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@ -13,7 +13,8 @@
"SecretKey": "", "SecretKey": "",
"Issuer": "SPMS", "Issuer": "SPMS",
"Audience": "SPMS", "Audience": "SPMS",
"ExpiryMinutes": 10 "ExpiryMinutes": 10,
"RefreshTokenExpiryDays": 7
}, },
"RabbitMQ": { "RabbitMQ": {
"HostName": "", "HostName": "",

View File

@ -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();
}

View File

@ -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;
}

View File

@ -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<JwtSettings> 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<Claim>
{
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);
}
}

View File

@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>