인증 모듈 분리 및 JWT 발급 로직 개선
기존 모놀리식 구조에서 인증 관련 책임을 분리하기 위해 전용 엔드포인트 클래스를 도입합니다. 로그인 요청 시 사용자 자격 증명을 검증한 후, 역할(Role) 및 사용자 정의 클레임을 통합하여 JWT를 생성합니다. 토큰 발급 시 만료 시간과 서명 알고리즘을 명시적으로 구성하여 보안 수준을 강화합니다.
[ApiController]
[Route("api/auth")]
public class AuthEndpoint : ControllerBase
{
private readonly UserManager<IdentityUser> _users;
private readonly RoleManager<IdentityRole> _roles;
private readonly IConfiguration _config;
public AuthEndpoint(UserManager<IdentityUser> users, RoleManager<IdentityRole> roles, IConfiguration config)
{
_users = users;
_roles = roles;
_config = config;
}
[HttpPost("signin")]
public async Task<IActionResult> SignInAsync([FromBody] CredentialDto credentials)
{
var targetUser = await _users.FindByNameAsync(credentials.AccountName);
if (targetUser == null || !await _users.CheckPasswordAsync(targetUser, credentials.Secret))
return Unauthorized();
var identityClaims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, targetUser.Id),
new(ClaimTypes.Name, targetUser.UserName),
new(JwtRegisteredClaimNames.Sub, targetUser.Id),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var assignedRoles = await _users.GetRolesAsync(targetUser);
foreach (var roleName in assignedRoles)
{
identityClaims.Add(new Claim(ClaimTypes.Role, roleName));
var roleEntity = await _roles.FindByNameAsync(roleName);
if (roleEntity != null)
{
var roleSpecificClaims = await _roles.GetClaimsAsync(roleEntity);
identityClaims.AddRange(roleSpecificClaims);
}
}
var customUserClaims = await _users.GetClaimsAsync(targetUser);
identityClaims.AddRange(customUserClaims);
var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["JwtSettings:SigningKey"]));
var signingCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);
var jwtPayload = new JwtSecurityToken(
issuer: _config["JwtSettings:Issuer"],
audience: _config["JwtSettings:Audience"],
claims: identityClaims,
expires: DateTime.UtcNow.AddMinutes(180),
signingCredentials: signingCredentials
);
return Ok(new { AccessToken = new JwtSecurityTokenHandler().WriteToken(jwtPayload), ExpiresAt = jwtPayload.ValidTo });
}
[HttpPost("signup")]
public async Task<IActionResult> SignUpAsync([FromBody] RegistrationDto registration)
{
if (await _users.FindByNameAsync(registration.AccountName) != null)
return Conflict(new { Message = "이미 존재하는 계정입니다." });
var newUser = new IdentityUser
{
UserName = registration.AccountName,
Email = registration.EmailAddress,
SecurityStamp = Guid.NewGuid().ToString()
};
var creationResult = await _users.CreateAsync(newUser, registration.Secret);
return creationResult.Succeeded ? Ok() : BadRequest(creationResult.Errors);
}
}
권한 할당 및 조회 API 재구성
리소스 기반 접근 제어를 위해 엔티티 권한 정의와 사용자/역할 매핑을 처리하는 컨트롤러를 별도로 구성합니다. 기존 라우팅 구조를 RESTful 관점에서 재정의하고, 서비스 계층과의 결합도를 낮추며 요청/응답 모델을 명확히 분리합니다.
[ApiController]
[Route("api/access-control")]
public class AccessControlController : ControllerBase
{
private readonly IPermissionService _permService;
private readonly IUserAccessRepository _userAccess;
private readonly IRoleAccessRepository _roleAccess;
public AccessControlController(IPermissionService permService, IUserAccessRepository userAccess, IRoleAccessRepository roleAccess)
{
_permService = permService;
_userAccess = userAccess;
_roleAccess = roleAccess;
}
[HttpPost("permissions/entities")]
public async Task<IActionResult> DefineEntityPermission([FromBody] EntityPermissionDto dto)
{
var newPermission = new PermissionRecord
{
Identifier = dto.Key,
Title = dto.DisplayName,
Summary = dto.Description,
Category = dto.Group,
Payload = dto.Data,
LinkedResources = dto.ResourceKeys.Select(k => new EntityResource { ResourceKey = k }).ToList()
};
await _permService.RegisterAsync(newPermission);
return CreatedAtAction(nameof(DefineEntityPermission), new { id = newPermission.Identifier }, null);
}
[HttpGet("users/{accountName}/permissions")]
public async Task<IActionResult> GetUserAccessList(string accountName)
{
var accessList = await _userAccess.RetrievePermissionsAsync(accountName);
return Ok(accessList);
}
[HttpGet("roles/{roleTitle}/permissions")]
public async Task<IActionResult> GetRoleAccessList(string roleTitle)
{
var accessList = await _roleAccess.RetrievePermissionsAsync(roleTitle);
return Ok(accessList);
}
[HttpPost("roles/assign")]
public async Task<IActionResult> GrantPermissionToRole([FromQuery] string roleTitle, [FromQuery] string permissionKey)
{
await _roleAccess.AssignAsync(roleTitle, permissionKey);
return NoContent();
}
[HttpPost("users/assign")]
public async Task<IActionResult> GrantPermissionToUser([FromQuery] string accountName, [FromQuery] string permissionKey)
{
await _userAccess.AssignAsync(accountName, permissionKey);
return NoContent();
}
}
인증/인가 미들웨어 결과 처리기 커스터마이징
ASP.NET Core 3.1 이후 버전에서는 필터 기반 권한 검사보다 파이프라인 수준의 IAuthorizationMiddlewareResultHandler를 활용하는 것이 권장됩니다. 엔드포인트 메타데이터에서 컨트롤러 액션 정보를 추출해 고유 권한 키를 비교하고, 인증 실패(401)와 권한 부족(403) 응답을 명확히 분리하여 처리 순서를 보장합니다.
public class CustomAuthResultProcessor : IAuthorizationMiddlewareResultHandler
{
public async Task HandleAsync(RequestDelegate next, HttpContext httpContext, AuthorizationPolicy policy, PolicyAuthorizationResult result)
{
var currentEndpoint = httpContext.GetEndpoint();
var actionMeta = currentEndpoint?.Metadata.GetMetadata<ControllerActionDescriptor>();
if (actionMeta != null)
{
var requiredPermission = actionMeta.GetCustomPermissionKey();
var userPermissions = httpContext.User.FindAll(c => c.Type == CustomClaimTypes.Permission).Select(c => c.Value);
if (!userPermissions.Contains(requiredPermission))
{
await httpContext.ForbidAsync();
return;
}
}
if (result.Challenged)
{
var schemes = policy.AuthenticationSchemes.Any() ? policy.AuthenticationSchemes : new[] { string.Empty };
foreach (var authScheme in schemes)
{
await httpContext.ChallengeAsync(authScheme);
}
return;
}
if (result.Forbidden)
{
var schemes = policy.AuthenticationSchemes.Any() ? policy.AuthenticationSchemes : new[] { string.Empty };
foreach (var authScheme in schemes)
{
await httpContext.ForbidAsync(authScheme);
}
return;
}
await next(httpContext);
}
}
EF Core 저장소 인터셉터를 통한 데이터 수준 권한 검증
ISaveChangesInterceptor를 구현하여 데이터베이스 저장 직전 엔티티 상태(Added, Modified, Deleted)를 감시합니다. 각 상태에 따라 필요한 권한 키를 추출하고, 현재 사용자의 클레임과 교차 검증하여 데이터 조작을 차단하거나 허용합니다. 수정 작업의 경우 변경된 프로퍼티만 선별적으로 검사하여 불필요한 권한 차단을 방지합니다.
public class EntityAccessValidator : ISaveChangesInterceptor
{
private readonly IHttpContextAccessor _httpAccessor;
private readonly IPermissionResolver _permResolver;
public EntityAccessValidator(IHttpContextAccessor httpAccessor, IPermissionResolver permResolver)
{
_httpAccessor = httpAccessor;
_permResolver = permResolver;
}
public async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
var dbContextType = eventData.Context.GetType().Name;
var relevantPermissions = await _permResolver.FetchByContextAsync(dbContextType);
if (relevantPermissions == null || !relevantPermissions.Any())
return result;
var mappedPermissions = relevantPermissions.Select(p => new PermissionModel(p)).ToList();
var tracker = eventData.Context.ChangeTracker;
await ValidateCreationAsync(tracker.Entries().Where(e => e.State == EntityState.Added), mappedPermissions);
await ValidateDeletionAsync(tracker.Entries().Where(e => e.State == EntityState.Deleted), mappedPermissions);
await ValidateModificationAsync(tracker.Entries().Where(e => e.State == EntityState.Modified), mappedPermissions);
return result;
}
private async Task ValidateCreationAsync(IEnumerable<EntityEntry> entries, IEnumerable<PermissionModel> perms)
{
foreach (var entry in entries)
{
var entityTypeName = entry.Metadata.Name;
var targetPerms = perms.Where(p => p.TargetEntity == entityTypeName).ToList();
if (!targetPerms.Any()) continue;
EnsureAuthenticated();
var requiredKeys = targetPerms.Where(p => p.AllowsCreate).Select(p => p.Key);
AssertHasPermission(requiredKeys);
}
}
private async Task ValidateDeletionAsync(IEnumerable<EntityEntry> entries, IEnumerable<PermissionModel> perms)
{
foreach (var entry in entries)
{
var entityTypeName = entry.Metadata.Name;
var targetPerms = perms.Where(p => p.TargetEntity == entityTypeName).ToList();
if (!targetPerms.Any()) continue;
EnsureAuthenticated();
var requiredKeys = targetPerms.Where(p => p.AllowsDelete).Select(p => p.Key);
AssertHasPermission(requiredKeys);
}
}
private async Task ValidateModificationAsync(IEnumerable<EntityEntry> entries, IEnumerable<PermissionModel> perms)
{
foreach (var entry in entries)
{
var entityTypeName = entry.Metadata.Name;
var targetPerms = perms.Where(p => p.TargetEntity == entityTypeName).ToList();
if (!targetPerms.Any()) continue;
EnsureAuthenticated();
var changedFields = entry.Members.Where(m => m.IsModified).Select(m => m.Metadata.Name).ToHashSet();
var validUpdateKeys = new List<string>();
foreach (var perm in targetPerms)
{
var updatableFields = perm.UpdatableMembers.Where(m => changedFields.Contains(m));
if (updatableFields.Any()) validUpdateKeys.Add(perm.Key);
}
AssertHasPermission(validUpdateKeys);
}
}
private void EnsureAuthenticated()
{
var principal = _httpAccessor.HttpContext?.User;
if (principal?.Identity?.IsAuthenticated != true)
throw new AuthenticationException("인증된 세션이 필요합니다.");
}
private void AssertHasPermission(IEnumerable<string> requiredKeys)
{
var userClaims = _httpAccessor.HttpContext.User.FindAll(c => c.Type == CustomClaimTypes.Permission).Select(c => c.Value);
if (!requiredKeys.Intersect(userClaims).Any())
throw new AuthorizationException("해당 데이터 작업에 대한 권한이 부족합니다.");
}
}