ASP.NET Core Web API로 Employee-Photo 관리 시스템 구축하기

  • 21 minutes to read

소개

이 글은 ASP.NET Core Web API를 사용해 직원(Employee) 및 관련 사진(Photo) 데이터를 관리하는 시스템을 구축하는 과정을 설명합니다. 프로젝트 생성부터 CRUD 메서드 작성, 데이터베이스 설정, Swagger 통합, 그리고 CORS 설정까지 전반적인 개발 과정을 다룹니다. 모든 소스 코드를 포함하며, 각 단계의 구현 내용을 상세히 설명합니다.

목차

  1. 프로젝트 생성 및 기본 설정
  2. 모델 및 데이터베이스 설정
  3. ViewModel 및 Mapster 설정
  4. Web API 기능 구현
  5. Swagger 및 HTTP 요청 테스트
  6. 소스 코드 전체 보기

1. 프로젝트 생성 및 기본 설정

1-1 ASP.NET Core Web API 프로젝트 생성

.NET CLI를 사용해 Web API 프로젝트를 생성합니다. 강의 기본 방식은 Visual Studio를 사용하는 방식입니다. 프로젝트 이름은 원하는 이름을 사용하세요. 강의에서는 제가 평소 사용하는 DotNetNote, Hawaso, VisualAcademy, Kodee, Azunt 등의 이름을 접두사로 사용합니다.

dotnet new webapi -o VisualAcademy.ApiService
cd VisualAcademy.ApiService

1-2 OpenAPI 및 Swagger 통합

Program.cs에서 Swagger와 OpenAPI를 설정합니다. 이를 통해 API 메타데이터를 자동으로 생성하고 UI를 통해 테스트할 수 있습니다.

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options => 
        options.SwaggerEndpoint("/openapi/v1.json", "Employee API"));
}

Open API 설정

2. 모델 및 데이터베이스 설정

ASP.NET Core Web API에서는 데이터를 구조화하고 저장하기 위해 모델 클래스와 데이터베이스 컨텍스트(DbContext)를 설정해야 합니다. 이번 단계에서는 직원(Employee)과 사진(Photo) 간의 관계를 정의하고, Entity Framework Core를 통해 이를 데이터베이스와 연동할 수 있도록 준비합니다.


2-1 Employee 및 Photo 모델 추가

Employee 모델

Employee 클래스는 직원 정보를 나타내는 모델입니다. 기본 키 Id와 함께, FirstName, LastName 속성을 포함하고 있으며, 하나의 직원이 여러 장의 사진을 가질 수 있도록 Photos라는 컬렉션을 포함하고 있습니다.

public class Employee
{
    public long Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;

    public ICollection<Photo> Photos { get; set; } = new List<Photo>();
}

Photo 모델

Photo 클래스는 사진 정보를 표현합니다. 각 사진은 고유한 IdFileName 속성을 가지며, 특정 직원(Employee)에 연결될 수 있도록 EmployeeId와 탐색 속성 Employee를 포함합니다. 이로써 PhotoEmployee에 대한 외래 키(Foreign Key) 관계를 가지게 됩니다.

public class Photo
{
    public long Id { get; set; }
    public string FileName { get; set; } = string.Empty;

    public long? EmployeeId { get; set; }
    public Employee? Employee { get; set; }
}

EF Core 설치

Entity Framework Core는 ORM(Object-Relational Mapping) 기술로, 개체 모델을 관계형 데이터베이스와 매핑해줍니다. Microsoft.EntityFrameworkCore.SqlServer는 SQL Server를 위한 프로바이더이며, Microsoft.EntityFrameworkCore.Tools는 마이그레이션과 같은 개발 도구를 제공합니다. *.csproj 파일에 다음 패키지를 추가합니다.

<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.6" />

⚠️ 최신 EF Core 버전(여기선 9.0.6)을 사용하는지 확인하세요.


EmployeePhotoDbContext 설정

EmployeePhotoDbContextDbContext를 상속한 클래스이며, EmployeePhoto 엔터티를 데이터베이스에 매핑하는 역할을 합니다. DbSet<T> 속성을 통해 해당 테이블을 정의하고, 생성자에서 DbContextOptions를 받아 기반 클래스에 전달합니다. 이러한 DbSet 프로퍼티는 각각 데이터베이스의 테이블에 해당하며, EF Core가 해당 엔터티에 대한 CRUD 작업을 수행할 수 있도록 합니다.

using Microsoft.EntityFrameworkCore;

namespace Azunt.ApiService.Models;

// DbContext를 상속한 클래스: EF Core가 사용할 데이터베이스 컨텍스트 정의
public class EmployeePhotoDbContext : DbContext
{
    // 매개변수가 없는 기본 생성자 (테스트나 특별한 경우에 사용 가능)
    public EmployeePhotoDbContext() : base()
    {

    }

    // DI를 통해 옵션을 받아 사용하는 생성자 (실제 앱 실행 시 주로 사용)
    public EmployeePhotoDbContext(DbContextOptions<EmployeePhotoDbContext> options)
        : base(options)
    {

    }

    // Employees 테이블에 해당하는 DbSet - 직원 정보를 관리
    public DbSet<Employee> Employees { get; set; }

    // Photos 테이블에 해당하는 DbSet - 사진 정보를 관리
    public DbSet<Photo> Photos { get; set; }
}

이제 DbContext를 통해 EF Core가 EmployeePhoto 엔터티를 데이터베이스에 반영할 수 있게 됩니다.


ConnectionStrings 설정

"ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EmployeePhoto;Trusted_Connection=True;"
}

이 설정은 애플리케이션이 사용할 데이터베이스 연결 문자열을 정의합니다.

  • "DefaultConnection"은 연결 문자열의 이름으로, 코드에서 이 이름으로 접근합니다.
  • "Server=(localdb)\\mssqllocaldb"로컬 개발용 SQL Server 인스턴스를 의미합니다.
  • "Database=EmployeePhoto"는 연결할 데이터베이스 이름입니다.
  • "Trusted_Connection=True"Windows 인증을 사용하여 로그인함을 나타냅니다.

이 설정을 통해 EF Core는 해당 데이터베이스에 연결하여 엔터티를 저장하고 쿼리할 수 있습니다.


실제 데이터베이스 생성 단계 (EF Core 마이그레이션 사용)

DbContext 및 데이터베이스 연결 설정

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<EmployeePhotoDbContext>(options => 
    options.UseSqlServer(connectionString));

이 코드는 appsettings.json 파일에 정의된 데이터베이스 연결 문자열("DefaultConnection")을 불러와 EmployeePhotoDbContext를 서비스 컨테이너에 등록하는 부분입니다.

  • builder.Configuration.GetConnectionString("DefaultConnection")을 통해 연결 문자열을 읽어옵니다.
  • AddDbContext<EmployeePhotoDbContext>는 Entity Framework Core에서 사용할 DbContext를 DI(의존성 주입)로 등록합니다.
  • UseSqlServer(...)는 SQL Server 데이터베이스를 사용하겠다는 설정입니다.

이 설정을 통해 애플리케이션은 EF Core를 통해 EmployeePhotoDbContext를 사용하여 SQL Server 데이터베이스와 연결 및 작업(CRUD 등)을 수행할 수 있습니다.


1. 패키지 매니저 콘솔(Package Manager Console) 열기

  • Visual Studio 상단 메뉴에서 도구 > NuGet 패키지 관리자 > 패키지 관리자 콘솔 클릭

2. 마이그레이션 추가

Add-Migration InitialCreate
  • 이 명령은 Migrations 폴더를 생성하고, 현재 DbContext에 정의된 모델(Employee, Photo)을 기반으로 초기 마이그레이션 파일을 생성합니다.
  • InitialCreate는 마이그레이션 이름이며 자유롭게 설정 가능합니다. (예: CreateEmployeeAndPhotoTables 등)

3. 데이터베이스 생성 및 적용

Update-Database
  • 이 명령은 생성된 마이그레이션을 실제 SQL Server LocalDB에 적용하여 EmployeePhoto라는 데이터베이스를 생성하고,
  • Employees, Photos 테이블을 포함한 스키마를 반영합니다.

결과

  • EmployeePhoto.mdf 파일이 LocalDB에 생성됩니다.

  • SSMS 또는 Visual Studio의 SQL Server Object Explorer를 통해 다음 경로로 확인할 수 있습니다:

    (localdb)\mssqllocaldb > Databases > EmployeePhoto > Tables > dbo.Employees, dbo.Photos
    

변경사항이 있을 경우

  • 모델이 변경되면 위 과정을 반복합니다:
Add-Migration AddNewFieldToPhoto
Update-Database

3. ViewModel 및 Mapster 설정

3-1 ViewModel 생성

EmployeeViewModel

public record EmployeeViewModel(long Id, string FirstName, string LastName);

PhotoViewModel

public record PhotoViewModel(long Id, string FileName, long? EmployeeId);

3-2 Mapster 설치 및 설정

Mapster는 개체 간 매핑을 간소화합니다. NuGet 패키지를 설치합니다.

dotnet add package Mapster

Mapster를 사용해 ViewModel과 모델 간 변환을 설정합니다.

4. Web API 기능 구현

4-1 GET: 모든 직원 조회

[HttpGet]
public async Task<IActionResult> Get()
{
    var employees = await _context.Employees
        .ProjectToType<EmployeeViewModel>()
        .ToListAsync();
    return Ok(employees);
}

4-2 GET: 특정 직원 조회

[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
    var employee = await _context.Employees
        .Include(e => e.Photos)
        .FirstOrDefaultAsync(e => e.Id == id);

    if (employee == null)
        return NotFound();

    var response = employee.Adapt<EmployeeViewModel>();
    return Ok(response);
}

4-3 POST: 새로운 직원 추가

[HttpPost]
public async Task<IActionResult> Post([FromBody] EmployeeViewModel value)
{
    var employee = value.Adapt<Employee>();
    _context.Employees.Add(employee);
    await _context.SaveChangesAsync();

    var response = employee.Adapt<EmployeeViewModel>();
    return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}

4-4 PUT: 직원 정보 수정

[HttpPut("{id}")]
public async Task<IActionResult> Put(long id, [FromBody] EmployeeViewModel model)
{
    var employee = await _context.Employees.FindAsync(id);

    if (employee == null)
        return NotFound();

    employee.FirstName = model.FirstName;
    employee.LastName = model.LastName;

    _context.Entry(employee).State = EntityState.Modified;
    await _context.SaveChangesAsync();

    return NoContent();
}

4-5 DELETE: 직원 및 관련 데이터 삭제

[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
    var employee = await _context.Employees
        .Include(e => e.Photos)
        .FirstOrDefaultAsync(e => e.Id == id);

    if (employee == null)
        return NotFound();

    _context.Photos.RemoveRange(employee.Photos);
    _context.Employees.Remove(employee);
    await _context.SaveChangesAsync();

    return NoContent();
}

5. Swagger 및 HTTP 요청 테스트

5-1 Swagger 테스트

Swagger UI를 통해 API 요청을 테스트할 수 있습니다. launchSettings.json에서 Swagger를 기본 시작 페이지로 설정합니다.

5-2 HTTP 요청 샘플

아래는 EmployeeControllerTest.http 파일을 사용한 HTTP 요청 예제입니다.

GET {{HostAddress}}/api/employee
GET {{HostAddress}}/api/employee/1

POST {{HostAddress}}/api/employee
Content-Type: application/json
{
  "FirstName": "John",
  "LastName": "Doe"
}

PUT {{HostAddress}}/api/employee/3
Content-Type: application/json
{
  "Id": 3,
  "FirstName": "Alice Updated",
  "LastName": "Johnson Updated"
}

DELETE {{HostAddress}}/api/employee/3

6. 소스 코드 전체 보기

위 소스 코드는 각 파일별로 제공되었으며, 전체 프로젝트는 GitHub 리포지토리를 통해 확인할 수 있습니다.

이 프로젝트는 ASP.NET Core Web API를 사용해 RESTful 서비스를 구축하는 실전 예제입니다. Mapster를 활용한 개체 매핑과 Swagger UI를 통한 테스트를 통해 개발자 경험을 크게 개선할 수 있습니다.

전체 소스 목록

VisualAcademy.ApiService\VisualAcademy.ApiService.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\VisualAcademy.ServiceDefaults\VisualAcademy.ServiceDefaults.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Mapster" Version="7.4.0" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.1.0" />
  </ItemGroup>

</Project>

VisualAcademy.ApiService\Models\Employee.cs

namespace VisualAcademy.ApiService.Models;

public class Employee
{
    public long Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;

    // Photos와의 관계 설정(일대다)
    public ICollection<Photo> Photos { get; set; } = new List<Photo>();
}

VisualAcademy.ApiService\Models\Photo.cs

namespace VisualAcademy.ApiService.Models;

public class Photo
{
    public long Id { get; set; }
    public string FileName { get; set; } = string.Empty;

    // Employee와의 관계 설정(다대일)
    public long? EmployeeId { get; set; }
    public Employee? Employee { get; set; }
}

VisualAcademy.ApiService\Models\EmployeePhotoDbContext.cs

using Microsoft.EntityFrameworkCore;

namespace VisualAcademy.ApiService.Models
{
    public class EmployeePhotoDbContext : DbContext
    {
        public EmployeePhotoDbContext() : base()
        {
            
        }

        public EmployeePhotoDbContext(DbContextOptions<EmployeePhotoDbContext> options)
            : base(options) 
        {
            
        }

        public DbSet<Employee> Employees { get; set; }
        public DbSet<Photo> Photos { get; set; }
    }
}

VisualAcademy.ApiService\ViewModels\EmployeeViewModel.cs

namespace VisualAcademy.ApiService.ViewModels;

public record EmployeeViewModel(long Id, string FirstName, string LastName);

VisualAcademy.ApiService\ViewModels\PhotoViewModel.cs

namespace VisualAcademy.ApiService.ViewModels;

public record PhotoViewModel(long Id, string FileName, long? EmployeeId);

VisualAcademy.ApiService\appsettings.json

{
    "ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EmployeePhoto;Trusted_Connection=True;"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    },
    "AllowedHosts": "*"
}

VisualAcademy.ApiService\Program.cs

using VisualAcademy.ApiService.Models;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add service defaults & Aspire client integrations.
builder.AddServiceDefaults();

// Add services to the container.
builder.Services.AddProblemDetails();

builder.Services.AddControllers();

// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowAllOrigins", policy =>
    {
        policy.AllowAnyOrigin()
              .AllowAnyMethod()
              .AllowAnyHeader();
    });
});

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<EmployeePhotoDbContext>(options => 
    options.UseSqlServer(connectionString));

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseExceptionHandler();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options => 
        options.SwaggerEndpoint("/openapi/v1.json", "weather api"));
}

app.UseCors("AllowAllOrigins");

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapDefaultEndpoints();

app.MapControllers();

app.Run();

VisualAcademy.ApiService\Controllers\EmployeeController.cs

using VisualAcademy.ApiService.Models;
using VisualAcademy.ApiService.ViewModels;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860

namespace VisualAcademy.ApiService.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class EmployeeController : ControllerBase
    {
        private readonly EmployeePhotoDbContext _context;

        public EmployeeController(EmployeePhotoDbContext context)
        {
            _context = context;
        }

        #region GetAll
        // GET: api/Employee
        // 모든 직원 목록을 조회
        [HttpGet]
        [ProducesResponseType(typeof(IEnumerable<EmployeeViewModel>), StatusCodes.Status200OK)] // 성공 시 반환 타입과 HTTP 상태 코드를 명시
        public async Task<IActionResult> Get()
        {
            // 직원 데이터를 EmployeeViewModel로 변환하여 반환
            var employees = await _context.Employees
                //.Select(e => new EmployeeViewModel(e.Id, e.FirstName, e.LastName))
                .ProjectToType<EmployeeViewModel>()
                .ToListAsync();

            return Ok(employees);
        }
        #endregion

        #region GetById
        // GET api/<EmployeeController>/5
        // 특정 ID의 직원 정보를 조회
        [HttpGet("{id}")]
        [ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status200OK)] // 200: 성공적으로 데이터를 반환
        [ProducesResponseType(StatusCodes.Status404NotFound)] // 404: 요청한 직원이 없을 경우
        public async Task<IActionResult> Get(int id)
        {
            // 직원 정보 및 관련된 사진 데이터를 포함하여 조회
            var employee = await _context.Employees
                .Include(e => e.Photos)
                .FirstOrDefaultAsync(e => e.Id == id);

            if (employee == null)
            {
                return NotFound(); // 직원이 없으면 404 반환 
            }

            //var response = new EmployeeViewModel(employee.Id, employee.FirstName, employee.LastName);
            var response = employee.Adapt<EmployeeViewModel>();

            return Ok(response); // 직원 데이터를 반환
        }
        #endregion

        #region POST
        // POST api/<EmployeeController>
        // 새로운 직원을 생성
        [HttpPost]
        [ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status201Created)] // 201: 생성된 리소스를 반환
        [ProducesResponseType(StatusCodes.Status400BadRequest)] // 400: 잘못된 요청일 경우
        public async Task<IActionResult> Post([FromBody] EmployeeViewModel value)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState); // 유효성 검사 실패 시 400 반환
            }

            // 요청 데이터를 Employee 엔터티로 변환하여 저장
            //var employee = new Employee
            //{
            //    FirstName = value.FirstName,
            //    LastName = value.LastName,
            //};
            var employee = value.Adapt<Employee>();

            _context.Employees.Add(employee);
            await _context.SaveChangesAsync();

            //var response = new EmployeeViewModel(employee.Id, employee.FirstName, employee.LastName);
            var response = employee.Adapt<EmployeeViewModel>();

            return CreatedAtAction(nameof(Get), new { id = response.Id }, response); // 생성된 직원 정보 반환
        }
        #endregion

        #region PUT
        // PUT api/<EmployeeController>/5
        // 기존 직원 정보를 업데이트
        [HttpPut("{id}")]
        [ProducesResponseType(StatusCodes.Status204NoContent)] // 204: 성공적으로 업데이트하고 콘텐츠 없음
        [ProducesResponseType(StatusCodes.Status404NotFound)] // 404: 요청한 직원이 없을 경우
        [ProducesResponseType(StatusCodes.Status400BadRequest)] // 400: 잘못된 요청일 경우
        public async Task<IActionResult> Put(long id, [FromBody] EmployeeViewModel model)
        {
            if (!ModelState.IsValid)
                return BadRequest(ModelState); // 유효성 검사 실패 시 400 반환

            var employee = await _context.Employees.FindAsync(id);

            if (employee == null)
                return NotFound(); // 직원이 없으면 404 반환

            employee.FirstName = model.FirstName;
            employee.LastName = model.LastName;

            _context.Entry(employee).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!EmployeeExists(id))
                    return NotFound(); // 업데이트 중 직원이 삭제되었을 경우 404 반환

                throw;
            }

            return NoContent(); // 성공 시 204 반환
        } 

        private bool EmployeeExists(long id)
        {
            return _context.Employees.Any(e => e.Id == id);
        }
        #endregion

        #region DELETE
        // DELETE api/<EmployeeController>/5
        // 특정 직원 데이터를 삭제
        [HttpDelete("{id}")]
        [ProducesResponseType(StatusCodes.Status204NoContent)] // 204: 성공적으로 삭제하고 콘텐츠 없음
        [ProducesResponseType(StatusCodes.Status404NotFound)] // 404: 요청한 직원이 없을 경우
        public async Task<IActionResult> Delete(long id)
        {
            // 직원 정보와 관련된 사진 데이터를 포함하여 조회
            var employee = await _context.Employees
                .Include(e => e.Photos)
                .FirstOrDefaultAsync(e => e.Id == id);

            if (employee == null)
                return NotFound(); // 직원이 없으면 404 반환

            // 직원 데이터와 관련된 사진 데이터를 먼저 삭제
            _context.Photos.RemoveRange(employee.Photos);

            // 직원 데이터 삭제
            _context.Employees.Remove(employee);
            await _context.SaveChangesAsync();

            return NoContent(); // 성공 시 204 반환
        } 
        #endregion
    }
}

VisualAcademy.ApiService\Controllers\EmployeeControllerTest.http

@HostAddress = https://localhost:7312

### 

GET {{HostAddress}}/api/employee
Accept: application/json

###

GET {{HostAddress}}/api/employee/1
Accept: application/json

###

POST {{HostAddress}}/api/employee
Content-Type: application/json

{
  "FirstName": "John",
  "LastName": "Doe"
}

###

POST {{HostAddress}}/api/employee
Content-Type: application/json

{
  "FirstName": "Jane",
  "LastName": "Doe"
}

###

PUT {{HostAddress}}/api/employee/3
Content-Type: application/json

{
  "Id": 3,
  "FirstName": "Alice Updated",
  "LastName": "Johnson Updated"
}

###

DELETE {{HostAddress}}/api/employee/3

### 

Web API 인증

ASP.NET Core Web API에 고정된 이메일과 암호를 사용하는 Basic 인증 구현

고정된 이메일과 암호를 사용하는 Basic 인증은 간단한 보안 메커니즘을 제공합니다. 아래에서는 VisualAcademy.ApiService 프로젝트에 Basic 인증을 구현하는 방법을 단계별로 설명합니다.

1. Basic 인증 미들웨어 구현

1-1 인증 핸들러 클래스 작성

ASP.NET Core의 인증 미들웨어를 사용하려면 AuthenticationHandler를 상속받아 커스텀 핸들러를 작성합니다.

BasicAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;

namespace VisualAcademy.ApiService.Security
{
    public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
    {
        private const string FixedEmail = "admin@visualacademy.com"; // 고정 이메일
        private const string FixedPassword = "securepassword"; // 고정 비밀번호

        public BasicAuthenticationHandler(
            IOptionsMonitor<AuthenticationSchemeOptions> options,
            ILoggerFactory logger,
            UrlEncoder encoder,
            ISystemClock clock)
            : base(options, logger, encoder, clock)
        {
        }

        protected override Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            // Authorization 헤더 확인
            if (!Request.Headers.ContainsKey("Authorization"))
            {
                return Task.FromResult(AuthenticateResult.Fail("Authorization header missing."));
            }

            try
            {
                var authHeader = Request.Headers["Authorization"].ToString();
                if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
                {
                    return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header."));
                }

                var encodedCredentials = authHeader.Substring("Basic ".Length).Trim();
                var decodedBytes = Convert.FromBase64String(encodedCredentials);
                var decodedCredentials = Encoding.UTF8.GetString(decodedBytes);

                // 이메일과 암호 분리
                var credentials = decodedCredentials.Split(':');
                if (credentials.Length != 2)
                {
                    return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format."));
                }

                var email = credentials[0];
                var password = credentials[1];

                // 고정된 이메일과 암호 비교
                if (email != FixedEmail || password != FixedPassword)
                {
                    return Task.FromResult(AuthenticateResult.Fail("Invalid email or password."));
                }

                // 인증 성공 시 ClaimsPrincipal 생성
                var claims = new[] { new Claim(ClaimTypes.Name, email) };
                var identity = new ClaimsIdentity(claims, Scheme.Name);
                var principal = new ClaimsPrincipal(identity);
                var ticket = new AuthenticationTicket(principal, Scheme.Name);

                return Task.FromResult(AuthenticateResult.Success(ticket));
            }
            catch
            {
                return Task.FromResult(AuthenticateResult.Fail("Error occurred during authentication."));
            }
        }
    }
}

1-2 인증 스키마 등록

Program.cs에서 커스텀 인증 핸들러를 등록하고 스키마를 설정합니다.

Program.cs
using VisualAcademy.ApiService.Security;

var builder = WebApplication.CreateBuilder(args);

// 인증 스키마 추가
builder.Services.AddAuthentication("BasicAuthentication")
    .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("BasicAuthentication", null);

var app = builder.Build();

// 인증 미들웨어 추가
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

2. 인증 요구 사항 적용

2-1 컨트롤러 또는 액션에 [Authorize] 속성 추가

Authorize 속성을 사용해 인증을 요구합니다. Basic 인증이 설정된 컨트롤러는 인증되지 않은 사용자의 요청을 거부합니다.

EmployeeController.cs
using Microsoft.AspNetCore.Authorization;

namespace VisualAcademy.ApiService.Controllers
{
    [Authorize] // Basic 인증을 요구
    [Route("api/[controller]")]
    [ApiController]
    public class EmployeeController : ControllerBase
    {
        private readonly EmployeePhotoDbContext _context;

        public EmployeeController(EmployeePhotoDbContext context)
        {
            _context = context;
        }

        // 기존 메서드...
    }
}

2-2 특정 액션에만 인증 적용

컨트롤러 전체가 아닌 특정 액션에만 인증을 요구하려면 액션 메서드에 [Authorize]를 적용합니다.

[HttpGet]
[Authorize]
public async Task<IActionResult> Get()
{
    var employees = await _context.Employees.ToListAsync();
    return Ok(employees);
}

3. HTTP 요청 테스트

3-1 인증 헤더 추가

Basic 인증 요청에는 Authorization 헤더가 필요합니다. 인증 정보를 Base64로 인코딩해 전달합니다.

예제: admin@visualacademy.com:securepassword를 Base64로 인코딩
echo -n "admin@visualacademy.com:securepassword" | base64
# YWRtaW5Aa29kZWUuY29tOnNlY3VyZXBhc3N3b3Jk

3-2 HTTP 요청 예제

GET 요청 (성공)
GET {{HostAddress}}/api/employee
Authorization: Basic YWRtaW5Aa29kZWUuY29tOnNlY3VyZXBhc3N3b3Jk
GET 요청 (실패 - 잘못된 비밀번호)
GET {{HostAddress}}/api/employee
Authorization: Basic YWRtaW5Aa29kZWUuY29tOmJhZHBhc3N3b3Jk

4. 결과

4-1 성공 응답

인증이 성공하면 API가 정상적으로 데이터를 반환합니다.

[
    {
        "id": 1,
        "firstName": "John",
        "lastName": "Doe"
    },
    {
        "id": 2,
        "firstName": "Jane",
        "lastName": "Smith"
    }
]

4-2 실패 응답

인증이 실패하면 API는 401 Unauthorized 상태 코드를 반환합니다.

{
    "title": "Unauthorized",
    "status": 401,
    "detail": "Invalid email or password."
}

5. 주의사항

  1. 보안 고려:

    • 고정된 이메일과 암호는 테스트 환경에서만 사용하며, 프로덕션에서는 데이터베이스 기반 인증 또는 OAuth2 등의 보안 메커니즘을 사용하세요.
    • HTTPS를 활성화하여 네트워크 전송 중 데이터를 암호화하세요.
  2. 기본 인증 확장 가능성:

    • 고정된 이메일과 비밀번호 대신 사용자 데이터를 데이터베이스에서 검증하도록 확장할 수 있습니다.

이 구현은 간단한 Basic 인증 메커니즘을 설명하며, 보다 안전하고 확장 가능한 방식으로 발전시킬 수 있습니다. Basic 인증은 인증 메커니즘의 기초를 이해하는 데 유용하며, 다양한 시나리오에서 활용될 수 있습니다.

VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com