[서버캠퍼스 1기] 개발 중 고민했던 것들 1 (인증, 임시 정보, Redis)

Oak_Cassia·2023년 5월 16일
0

패스워드 보안

암호를 어떻게 데이터베이스에 저장해야할까 고민했습니다. 패스워드를 평문으로 저장하는 것은 정보 유출 시 악용될 위험이 있습니다.

이 문제를 해결하기 위해, 복호화가 불가능한 해시 함수를 사용하여 패스워드를 저장하면 보안성이 향상될 것이라 생각했습니다. 그럼에도 공격자가 지속적으로 시도하면서 해시 값을 추론할 수 있으며, 해시 충돌을 이용하여 로그인 키를 획득할 수 있습니다.

Salt, Stretching

이러한 문제를 완화하기 위해, 솔트(salt)와 스트레칭(stretching) 기법을 사용합니다. 솔트는 고유한 데이터를 패스워드에 추가함으로써, 동일한 패스워드라도 다른 해시 값을 생성합니다. 이를 통해 레인보우 테이블 공격 등의 대응이 가능합니다. 스트레칭은 해시 결과 값을 다시 해싱하는 과정을 반복함으로써, 공격자의 시도 횟수를 늘리고 시간을 소모시키는 것 입니다. 이 두 기법을 함께 사용하면 패스워드 저장의 보안성을 더욱 높일 수 있습니다.

레인보우 테이블 공격이란?
레인보우 테이블은 해시함수로 만든 결과물을 저장한 테이블로 가능한 경우의 수를 엄청 많이 저장하여 대조를 통해 평문을 알아내는 방법

SHA-256

SHA-256은 입력 데이터를 256비트 길이의 고정된 크기의 해시 값으로 변환합니다. 이 과정에서 복호화가 불가능한 원칙이 적용되기 때문에, 원본 데이터를 완전히 복원하는 것은 매우 어렵습니다. 이러한 특성 때문에, SHA-256은 패스워드 저장, 블록체인, 전자 서명 등 다양한 분야에서 사용되고 있습니다.

Base64

바이트 배열을 만든 뒤에 Base64를 사용하여 문자열로 변환했습니다. Base64는 용량이 커지는 단점이 있지만 간편하게 바이너리 데이터를 ASCII 문자열로 변환하여 전송하고 저장할 수 있다는 장점이 있습니다.


토큰을 어떻게 저장할까?

토큰은 인증에서 중요한 역할을 합니다. 일반적으로, 사용자가 시스템에 로그인하면, 서버는 사용자를 식별하는 토큰을 생성합니다. 인증된 사용자는 토큰을 가지고 있어야 하며, 이후에 서버에 요청을 보낼 때마다 토큰을 포함하여 보냅니다. 서버는 토큰을 사용하여 사용자를 식별하고, 해당 사용자에게 적절한 서비스를 제공합니다.

AccountID : Token

처음에는 AccountID를 키로 사용하고 토큰을 값으로 저장하는 방식을 고려했습니다. 그러나 이 방법은 클라이언트로부터 요청이 들어왔을 때 AccountDB에 접근하여 AccountID를 알아내야 합니다. 이 방식은 비효율적이었습니다.

Token : AccountID

따라서 토큰을 키로 사용하고 AccountID를 값으로 저장하는 방법을 고려했습니다. 이 방법은 합리적으로 보였지만, 키 중복 여부를 확인하기 위해 매번 Redis에서 검색을 수행해야 했습니다.

Email : Json

다시 한 번 고민해본 결과, SQLKATA에서 클래스로 데이터를 받는 것이 생각이 났습니다. CloudStructures(redis)에서도 클래스로 값을 읽고 쓸 수 있는지 확인했습니다. 결론적으로 JSON 형식으로 데이터를 저장함으로써 이 문제를 해결할 수 있었습니다.

{
  "Email" :"gnlcks@123.com",
  "AccountId" : "1",
  "GameUserId" : "1",
  "token" :"asdf1234"
}

또한 계정 생성과 로그인을 제외한 모든 요청에서 필요로 하기 때문에 미들웨어에서 토큰 인증을 하도록 하였습니다.

유저 상태 저장

게임 진행에 따른 사용자의 상태(로비, 던전 스테이지, PVP 등)에 따라 각기 다른 작업을 수행하게 됩니다.
클라이언트로부터 요청이 들어올 때마다 사용자의 현재 상태를 검증하고, 해당 상태에서 수행 가능한 작업인지를 확인해야 합니다. 예를 들어, 던전에서 메일 확인이나 아이템 수령과 같은 작업은 허용되지 않을 예정입니다.

이에 따라 사용자의 상태를 어떻게 저장할지에 대한 고민이 생겼습니다. 매번 요청이 들어올 때마다 인증된 사용자인지 확인하기 위해 Redis에서 아래의 UserAuth 데이터를 불러오게 됩니다. 그렇다면, 이 데이터에 상태(State)를 추가하여 Redis에 저장할지, 혹은 새로운 키를 생성하여 사용자의 상태를 별도로 저장할지를 검토해 보았습니다.

public class UserAuth
{
    public String Email { get; set; }
    public String AuthToken { get; set; }
    public Int32 GameUserId { get; set; }
    public Int32 PlayerId { get; set; }
    
    // 유저 상태?
    public SateCode UserState { get; set; }
    
}

상태를 저장하는 방법에 따라 상태를 읽고 쓰는 시점이 달라집니다. 사용자의 상태는 계정 생성 및 로그인 요청을 제외한 모든 요청에서 사용되어야 합니다. 반면, 상태를 쓰는 시점은 로그인, 던전 입장, 던전 종료에서만 변경됩니다.

상태 정보를 별도로 분리하게 되면, 모든 요청에서 두 번의 Redis 접근이 필요하게 됩니다. 대신 상태 변경 시 상대적으로 적은 양의 데이터를 쓸 수 있습니다.

반면, 상태 정보를 분리하지 않는 경우, 모든 요청에서 한 번의 Redis 접근을 합니다. 그러나 상태 변경 시 위의 UserAuth 데이터를 불필요하게 읽어야 하는 단점이 있습니다.

위의 두 가지 상황을 고려한 결과, 후자의 방식을 선택하게 되었습니다. 이유는 모든 요청이 두 번 Redis에 접근하는 것이 로그인, 던전 진입 및 종료 시 불필요한 데이터를 추가로 읽고 쓰는 것보다 더 큰 비용이 들기 때문입니다.

스테이지 진행 정보

스테이지 진행에 관한 정보는 임시 데이터로 Redis에 저장합니다.
현재 사용하는 CloudStructures에서 RedisString을 사용하면 저장하려는 객체를 Json으로 직렬화 후 저장할 수 있습니다.
그러나 게임 플레이 중 아이템을 획득하거나 적을 처치하는 요청이 발생할 때, 수치 1을 증가하기 위해 전체 문자열을 읽고 수정한 뒤 다시 저장하는데, 이 방식은 비효율적이라고 판단하였습니다.

그래서 Redis의 해시를 활용하여 키와 필드를 사용해 값을 저장하는 방법을 사용했습니다. CloudStructures에서는 RedisDictionary를 사용하면 됩니다.
데이터를 문자열로 입력하는 것보다 파싱하는 과정이 복잡해지긴 하지만 필드의 값을 증가 시키기 위해 모든 데이터를 읽고 다시 쓸 필요가 없습니다.

public async Task<ErrorCode> InitializeStageDataAsync(String key, List<StageItem> items, List<StageNpc> npcs, Int32 stageLevel)
{
	var itemKeys = items.Select(item => MemoryDatabaseKeyUtility.MakeStageItemKey(item.ItemCode));
	var npcKeys = npcs.Select(npc => MemoryDatabaseKeyUtility.MakeStageNpcKey(npc.NpcCode));
	var list = itemKeys.Concat(npcKeys)
		.Select(key => new KeyValuePair<String, Int32>(key, 0))
		.ToList();
        
	list.Add(new KeyValuePair<String, Int32>(MemoryDatabaseKeyUtility.MakeStageLevelKey(stageLevel), stageLevel));

	try
	{
		var redis = new RedisDictionary<String, Int32>(_redisConnection, key, _stageExpireTime);
                // 문제가 되는 부분
		await redis.SetAsync(list);
		return ErrorCode.None;
	}
	catch (Exception e)
	{
		_logger.ZLogErrorWithPayload(new{ErrorCode= ErrorCode.InitializeStageDataFailException, Key=key}, "InitializeStageDataFailException");
		return ErrorCode.InitializeStageDataFailException;
	}
}

NpcCodeItemCode에 접두사를 추가해서 해당 키가 어떤 코드의 키인지를 쉽게 알 수 있도록 하였습니다. 이러한 접두사는 다음과 같습니다. "UItem_", "UNpc_"

아이템 획득, 또는 처치 요청이 발생하면 다음 과정을 처리합니다.

  1. 유저의 상태 확인 (Lobby, InStage)
  2. StageLevel코드에 해당하는 최대치 로드
  3. Redis에 저장된 Count 로드
  4. 최대치를 넘지 않는다면 Increment
  5. 클라이언트에 결과 반환

해결되지 않은 문제
CludStructures의 RedisDictionary에서 SetAsync를 사용하여 개별 필드를 생성하는 것은 bool 값을 반환하여 어떠한 이슈를 초래하지 않습니다. 그러나 HMSET의 기능을 수행하는, 여러 필드를 저장하는 SetAsync는 반환 값을 제공하지 않아 세부적인 오류 검증이 불가능합니다.

생각해낸 해결방법
Exist를 사용해서 값을 구하면 됩니다... (왜 바로 생각이 안났지?)

profile
rust로 뭐할까

0개의 댓글