인증서버 - API Key Management

2023. 8. 7. 00:00ASPNET/ASPNET 7

반응형

일반적으로 back end api 에 접근 하기 위한 여러가지 인증 방식이 있다 

그중에서 api key 를 발급하여 client 에서 특정키를 가지고 있으면 접근 가능하도록 하는 방식을 알아보겠다. 

 

가장많이 쓰이는 방식은 아래와 같이 특정 apikey 를 미리 정의해 두고

해당 key 를 가지고 있는 client 들은 접근 가능하도록 하는 것이다. 

 

apikey = "abcdefg112345"  

 

일반적으로 cloud system 이나 sass(service as a service) 등에서 많이 사용된다. 

 

이것과는 약간다른방식으로 보안을 조금 더 신경쓴 방식있다.

  • public key 를 client 에 발급하고 특정 secret 를 encryption 해서 server 에 전달
  • server 에서는 private key 를 가지고 secret 를 decryption 해서 인증

 

우리는 이 방법을 이용해 보자.

 

일단 기존 작업하던거에서 다음 두 파일을 생성하자

Features/Key/Endpoints.cs

Features/Key/Generate.cs

 

Generate.cs 가 실제 key 파일을 생성하고 db에 저장할 내용이다. 

다음과 같이 key 파일을 생성하자

Key/Generate.cs

public class Generate
{
    public class Command : IRequest<Response>
    {
        public string Id{get;set;}
    }

    public class Response : BaseResponse
    {
        public string? PublicKey { get; set; }
    }

    public class CommandHandler : IRequestHandler<Command, Response>
    {
        private readonly BaseDbContext _dbContext;

        public CommandHandler(BaseDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
        {
            var response = new Response { Result = false };
            using var rsa = RSA.Create();
            var publicKey = rsa.ExportSubjectPublicKeyInfoPem();
            var privateKey = rsa.ExportPkcs8PrivateKeyPem();
            response.PublicKey = publicKey;

            await _dbContext.Keys.AddAsync(new()
            {
                  Id = request.Id,
                  PrivateKey = Encoding.UTF8.GetBytes(privateKey),
                  PublicKey = Encoding.UTF8.GetBytes(publicKey)
            });

            await _dbContext.SaveChangesAsync();
			response.Result = true;
            return response;
        }
    }
}

위에서 BaseDbContext 라고 있는데 이전 강좌들을 봤다면 어떤 내용인지 알것이다. 

아래 강좌에서 ApiServerContext 에 해당하는 것이 BaseDbContext 이다. 

https://yogingang.tistory.com/409

 

Rest API Template 만들기 - EP 07 (EF Core - Migration and Update)

기존 solution 에 프로젝트를 하나 추가 하자. .NET 또는 .NET Standard 용 library 가 있을 것이다. 추가하자 Project 명은 Infrastructure 라고 지정하자. 최신 .net 을 지원하도록 framework 를 설정하자 (현재 시점

yogingang.tistory.com

db 에 저장하기 위한 Persistance Model 의 이름은 Keys 이다. 

이것이 그대로 table 이 된다. 

Models/Key.cs

public class Keys
{
    [Key]
    [MinLength(1), MaxLength(100)]
    public string Id { get; set; }
    public byte[] PrivateKey { get; set; }
    public byte[] PublicKey { get; set; }
}

이제 BaseDbContext 와 SqlServerContext 를 알아보자

BaseDbContext.cs

public partial class BaseDbContext : DbContext
{
    protected readonly IConfiguration _configuration;
    protected int MaxRetryCount = 3;

    public BaseDbContext(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    protected BaseDbContext(IConfiguration configuration, DbContextOptions options) : base(options)
    {
        _configuration = configuration;
    }
    public BaseDbContext(IConfiguration configuration, DbContextOptions<BaseDbContext> options)
        : base(options)
    {
        _configuration = configuration;
    }

    public DbSet<Keys> Keys { get; set; }

    /// <summary>
    /// Do not use Unicode as the default type for string
    /// </summary>
    /// <param name="modelBuilder"></param>
    private void DisableUnicodeToString(ModelBuilder modelBuilder)
    {
        foreach (var property in modelBuilder.Model.GetEntityTypes()
                .SelectMany(t => t.GetProperties())
                .Where(
                       p => p.ClrType == typeof(string)    // Entity is a string
                    && p.GetColumnType() == null           // No column type is set
                ))
        {
            property.SetIsUnicode(false);
        }
    }

    // partial 을 사용하려면 class 자체가 partial 이어야 한다.
    partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
    /// <summary>
    /// database first 로 작업할 경우
    /// db 쪽 table 에 column 을 추가하거나 수정한 후
    /// scaffold 를 통해 모델을 생성하면 새로 모델이 생성되어
    /// 사용자가 추가한 몇가지 코드를 (OnModelCreating) 다시 복사후 옮겨줘야 한다.
    /// 이러한 문제를 해결하기 위해 OnModelCrateingPartial 에 사용자가 정의한
    /// 내용들을 처리 하도록 할 수 있다. 
    /// </summary>
    /// <param name="modelBuilder"></param>
    partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
    {
        //DisableUnicodeToString(modelBuilder);
    }
}

SqlServer 를 사용할 것이므로 (localdb) BaseDbContext 를 상속하여 SqlServerContext 를 구현하자

SqlServerContext.cs

public class SqlServerContext : BaseDbContext
{
    public SqlServerContext(IConfiguration configuration) : base(configuration)
    {
    }
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            string? connectionString = _configuration.GetConnectionString("Server");
            optionsBuilder.UseSqlServer(connectionString,
                            x => x.EnableRetryOnFailure(MaxRetryCount));
        }
    }
}

이제 Extension method 를 이용하여 program.cs 에서 호출 할 것이다. 

DbContextExtensions.cs

public static class DbContextExtensions
{

    public static void AddDataAccessServices(this IServiceCollection self, string connectionString, string dbProvider = "SqlServer", int maxRetryCount = 3)
    {
        if (dbProvider.Equals("MySql", StringComparison.OrdinalIgnoreCase))
        {
            self.AddDbContext<BaseDbContext, MySqlContext>
                (options => options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
                            x => x.EnableRetryOnFailure(maxRetryCount: maxRetryCount)));
        }
        else
        {
            self.AddDbContext<BaseDbContext, SqlServerContext>
                (options => options.UseSqlServer(connectionString,
                            x => x.EnableRetryOnFailure(maxRetryCount: maxRetryCount)));
        }
    }

}

이렇게 하는 이유는 rdbms 를 여러가지 사용할 경우 최소한의 변경으로 app 을 적용하기 위해서 이다. 

이제 Program.cs 를 확인하자

Program.cs

string dbProvider = builder.Configuration.GetValue<string>("DbProvider") ?? string.Empty;
string connectionString = builder.Configuration.GetConnectionString("Server") ?? string.Empty;
builder.Services.AddDataAccessServices(connectionString, dbProvider);

appsettings.json

"DbProvider": "MsSql",
  "ConnectionStrings": {
    "Server": "Data Source=(localdb)\\MSSQLLocalDB;Database=IdentityServer;Trusted_Connection=True;"
  }

자 이런식으로 작업하면 multi db context 를 처리 가능 하다

어쨌든 중요한건 이게 아니니 다음으로 진행하자

 

Generate 를 했으면  이제 Endpoints 에서 호출해보자

Endpoints.cs

public class Endpoints : IEndPoints
{
    public void AddEndPoints(IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("Key").WithOpenApi();
        group.MapPost("generate/rsa", async ([FromBody] Generate.Command request, IMediator mediator) => await mediator.Send(request));
    }
}

 

이제 swagger 로 호출 하면 public key 를 return 하는걸 확인 할 수 있다. 

 

이제 이걸 test 해보자.

아주 간단한 vue3 project 를 만들어서 test 해볼것이다.

vue 관련해서는 일반적인 javascript framework 이므로 간단히 넘어가겠다.

우리는 jsencrypt 라는 library 를 사용할 것이다. 

 

nodejs 를 설치하고 다음을 실행하자

npm create vue@latest

아래와 같이 같단하게 설정하자

아래와 같이 실행하면 간단하게 vue3 spa app 을 생성할 수 있다. 

(이제 우리도 full stak 이다!!)

이 Vue3 Template 을 수정하여 우리가 만든 identity server 가 정상적으로 동작하는지 확인해 보자.

 

axios 설치

 

npm install axios

src/router/index.ts

import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import Register from '../views/Register.vue'
import Main from '../views/Main.vue'
import Login from '../views/Login.vue'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Main',
    component: Main
  },
  {
    path: '/register',
    name: 'Register',
    component: Register
  },
  {
    path: '/login', // 로그인 페이지로 이동할 경로를 추가합니다.
    name: 'Login',
    component: Login
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

src/views/Main.vue

<template>
  <div>
    <h2>메인 페이지</h2>
    <p v-if="isLoggedIn">로그인되었습니다.</p>
    <p v-else>로그인이 필요합니다.</p>
    <button v-if="!isLoggedIn" @click="login">로그인</button>
    <button v-else @click="logout">로그아웃</button>
    <button v-if="isLoggedIn" @click="hello">hello</button>
    <p>{{ message }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'

const isLoggedIn = ref(false)
const message = ref()
const router = useRouter()

// const isLoggedIn = computed(() => {
//   return !!sessionStorage.getItem('token')
// })

// watch(
//   () => sessionStorage.getItem('token'),
//   (newValue) => {
//     isLoggedIn.value = !!newValue
//   }
// )

onMounted(() => {
  isLoggedIn.value = sessionStorage.getItem('token') !== null
})

const login = () => {
  // 로그인 로직을 여기에 추가 (예시로 간단히 isLoggedIn 값을 true로 변경합니다.)
  //isLoggedIn.value = true;
  router.push({ name: 'Login' }) // 로그인 버튼을 누르면 로그인 페이지로 이동합니다.
}

const logout = () => {
  // 로그아웃 로직을 여기에 추가 (예시로 간단히 isLoggedIn 값을 false로 변경합니다.)
  sessionStorage.removeItem('token')
  isLoggedIn.value = false

  router.push({
    name: 'Main',
    params: { type: 'detail' },
    query: { id: 'logout', time: Date.now() }
  })
}

const hello = async () => {
  try {
    const response = await axios.get('https://localhost:7172/authentication', {
      headers: {
        Authorization: `Bearer ${sessionStorage.getItem('token')}`
      }
    })
    message.value = response.data
  } catch (error) {
    console.error('로그인 실패:', error)
    // 로그인 실패 시 오류 처리 로직 구현
  }
}
</script>

src/views/Register.vue

<template>
  <div>
    <h2>회원가입</h2>
    <form @submit.prevent="registerUser">
      <div>
        <label for="username">사용자명</label>
        <input type="text" v-model="username" id="username" required />
      </div>
      <button type="submit">회원가입</button>
    </form>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
import { routeLocationKey } from 'vue-router'
import router from '@/router'

const username = ref('')

const registerUser = async () => {
  try {
    const response = await axios.post('https://localhost:7172/key/generate/rsa', {
      id: username.value
    })
    console.log('회원가입 성공:', response.data)
    sessionStorage.setItem('PublicKey', response.data.publicKey)
    // 회원가입 성공 후 로그인 등 다음 동작 수행
    router.push({ name: 'Login' })
  } catch (error) {
    console.error('회원가입 실패:', error)
    // 오류 처리 로직 구현
  }
}
</script>

이제 typescript 에서  rsa public key 를 이용해서 encrypt, decrypt 를 처리하기 위해 jsencrypt 를 설치하자.

npm install JSEncrypt

Login.vue

<template>
  <div>
    <h2>로그인</h2>
    <form @submit.prevent="loginUser">
      <div>
        <label for="id">id</label>
        <input type="text" v-model="id" id="id" required />
      </div>
      <!-- <div>
        <label for="password">비밀번호</label>
        <input type="password" v-model="password" id="password" required />
      </div> -->
      <button type="submit">로그인</button>
    </form>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
import JSEncrypt from 'JSEncrypt'

const id = ref('')

var encrypt = new JSEncrypt({
  default_key_size: '2048'
})
encrypt.setPublicKey(sessionStorage.getItem('PublicKey') ?? '')
const router = useRouter()

const loginUser = async () => {
  try {
   	const password = encrypt.encrypt(id.value)
    console.log(`password : ${password}`)
    const response = await axios.post('https://localhost:7172/token/generate/jwt', {
      id: id.value,
      password: password
    })
    console.log('로그인 성공:', response.data)
    sessionStorage.setItem('token', response.data.accessToken)

    // 로그인 성공 시 메인 페이지로 이동
    router.push({ name: 'Main' })
  } catch (error) {
    console.error('로그인 실패:', error)
    // 로그인 실패 시 오류 처리 로직 구현
  }
}
</script>

 

IdentityServer 쪽 수정이 필요한데 Token/Generate.cs 에 내용을 수정하자

Features/Token/Generate.cs

public class Generate
{
    public class Command : IRequest<Response>
    {
        public string Id { get; set; }
        public string Password { get; set; }
    }

    public class Response : BaseResponse
    {
        public string AccessToken { get; set; }
    }

    public class CommandHandler : IRequestHandler<Command, Response>
    {
        private readonly BaseDbContext _dbContext;

        public CommandHandler(BaseDbContext dbContext)
        {
            _dbContext = dbContext;
        }
        public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
        {
            var response = new Response { Result = false };

            if (!(await Validation(request.Id, request.Password))) throw new Exception("Password is not valid");


            var tokenString = GenerateJweToken(request.Id);
            response.AccessToken = tokenString;
            response.Result = true;
            return await Task.FromResult(response);
        }

         private async ValueTask<bool> Validation(string id, string password)
        {
            var list = await _dbContext.Keys.Where(k => k.Id == id).ToListAsync();
            if (!list.Any()) return false;

            var item = list.SingleOrDefault();
            using var rsa = RSA.Create();
            rsa.ImportFromPem(Encoding.UTF8.GetString(item.PrivateKey));
            var decryptData = rsa?.Decrypt(Convert.FromBase64String(password), RSAEncryptionPadding.Pkcs1);
            var decryptString = Encoding.UTF8.GetString(decryptData);
            if (!decryptString.Equals(id)) return false;

            return true;

        }

        private string GenerateJweToken(string id)
        {
            var privateSigningKey = JweTokenConfigHelper.PrivateSigningKey;
            var publicEncryptionKey = JweTokenConfigHelper.PublicEncryptionKey;

            // JwtSecurityTokenHandler 의 향상된 버전 JsonWebTokenHandler
            var handler = new JsonWebTokenHandler();

            string token = handler.CreateToken(new SecurityTokenDescriptor
            {
                Audience = "IdentityServer",
                Issuer = "https://localhost:7172",
                Claims = new Dictionary<string, object> { { "sub", id } },

                // private key for signing
                SigningCredentials = new SigningCredentials(
                    privateSigningKey,
                    SecurityAlgorithms.EcdsaSha256),

                // public key for encryption
                EncryptingCredentials = new EncryptingCredentials(
                    publicEncryptionKey,
                    SecurityAlgorithms.RsaOAEP,
                    SecurityAlgorithms.Aes256CbcHmacSha512),
                Expires = DateTime.UtcNow.AddMinutes(10)
            }); ;

            return token;
        }
    }

이제 npm run dev 를 통해 실행해보자

 

사용자명에 1234  같은 아무 문자열이나 넣자. 회원가입을 누르자. 

성공하면 자동으로 Login.vue 로 이동하게 될것이다. 

id 에 1234 를 넣고 로그인 하면 로그인이 처리 될것이다.

이제 다른 브라우저를 뛰워서 같은 주소의 register 로 이동하여서

2345 로 가입 하자. 그리고 똑같이 2345로 로그인 해보자

 

1234 와 2345 는 서로 다른 browser 에서 가입했다. 

각각 자신들의 sessionStorage 에 publickey 를 저장했으므로 값을 공유할 수 없다.

즉 첫번째 browser 에서 2345 로 로그인이 불가능하다

두번째 browser 에서는 1234로 로그인이 불가능 하다. 

 

바로 이점을 활용하여 특정 machine 에 rsa key 를 주입 하고 이를 인증으로 이용할 수 있게 된다. 

 

관련영상

https://youtu.be/B4yrUNgZxS4

반응형