개발노트/Taxi Game 3D

Devlog) Taxi Game 3D) 12) 서버 구현2

username263 2023. 12. 20. 14:43
728x90
SMALL

1. 서버인증 구현

옛날에 직접 서버를 만들었을 때는 서버에서 받은 유저ID를 전달하여 서버에서 어떤 유저가 요청하였는지 구분하고 처리하도록 구현하지만, 이번에는 JWT토큰을 이용하여 처리해보기로 하였습니다.

 

서버에서 JWT토큰을 사용하기 위해 JwtBearer 라이브러리를 추가하였습니다.

 

appsettings.json 파일에 JWT를 사용할 때 필요한 정보를 추가하였습니다.
나중에 다 만들고 테스트할 당시에 Key값이 짧아 토큰생성을 실패하여 에러가 발생하지 않을 정도로 길게 입력하였습니다.

"Jwt": {
  "Issuer": "taxi-game-3d-issuer",
  "Audience": "taxi-game-3d-audience",
  "Key": "!!taxi-game-3d-secret-key-1234567890!!",
  "Lifetime": 600
}

 

appsettings.json 에 입력된 정보를 이용하여 토큰을 생성하는 클래스들을 제작하였습니다.

public class JwtSettings
{
    public required string Issuer { get; set; }
    public required string Audience { get; set; }
    public required string Key { get; set; }
    public double Lifetime { get; set; }

    public SymmetricSecurityKey GenerateKey() =>
        new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Key));

    public static implicit operator TokenValidationParameters(JwtSettings settings) => new()
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidIssuer = settings.Issuer,
        ValidAudience = settings.Audience,
        IssuerSigningKey = settings.GenerateKey()
    };
}
public class TokenService
{
    readonly JwtSettings jwtSettings;

    public TokenService(IOptions<JwtSettings> options)
    {
        jwtSettings = options.Value;
    }

    public (string token, DateTime expireUtc) Generate(UserModel user)
    {
        var securityKey = jwtSettings.GenerateKey();
        var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
        var claims = new Claim[]
        {
            new(JwtRegisteredClaimNames.Sub, user.Id!),
            new(JwtRegisteredClaimNames.Email, user.Email)
        };
        var expires = DateTime.Now.AddSeconds(jwtSettings.Lifetime);
        var token = new JwtSecurityToken(
            jwtSettings.Issuer,
            jwtSettings.Audience,
            claims,
            expires: expires,
            signingCredentials: credentials
        );
        return (
            new JwtSecurityTokenHandler().WriteToken(token),
            expires.ToUniversalTime()
        );
    }
}

 

구현한 JwtSettings, TokenService를 서비스에 등록하였습니다.

builder.Services.Configure<JwtSettings>(
    builder.Configuration.GetSection("Jwt")
);
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = builder
            .Configuration
            .GetSection("Jwt")
            .Get<JwtSettings>()!;
    });

 

처음에는 편하게 기기ID를 계정으로 사용하려고 했지만 욕심이 생겨 이메일을 대신 사용해보기로 하였습니다.
이메일과 비밀번호를 받을 수 있도록 UserModel, UserRepository를 수정하였습니다.

public class UserModel
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string? Id { get; set; }
    public string? Nickname { get; set; }
    public required string Email { get; set; }
    public required string Password { get; set; }
    public long Coin { get; set; }
    public List<string>? Cars { get; set; }
    [BsonElement("CurrentCar")]
    [JsonPropertyName("CurrentCar")]
    public string? CurrentCarId { get; set; }
    [BsonElement("CurrentStage")]
    [JsonPropertyName("CurrentStage")]
    public int CurrentStageIndex { get; set; }
}
public class UserRepository
{
    readonly IMongoCollection<UserModel> users;

    public UserRepository(DatabaseContext context)
    {
        users = context.Users;
    }

    public async Task Create(UserModel model) =>
        await users.InsertOneAsync(model);

    public async Task<UserModel> Get(string id) =>
        await users.Find(e => e.Id == id).FirstOrDefaultAsync();

    public async Task<UserModel> FindByEmail(string email) =>
        await users.Find(e => e.Email == email).FirstOrDefaultAsync();

    public async Task Update(string id, UserModel model) =>
        await users.ReplaceOneAsync(e => e.Id == id, model);

    public async Task Delete(string id) =>
        await users.DeleteOneAsync(e => e.Id == id);
}

 

미리 만들어 두었던 AuthController에 로그인, 회원가입, 토큰갱신 기능을 구현하였습니다.
발급받은 토큰은 DB에 저장하도록 처리하지 않았기 때문에 로그아웃은 구현하지 않았습니다.

[Route("[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
    readonly UserRepository userRepository;
    readonly TokenService tokenService;

    public AuthController(UserRepository userRepository, TokenService tokenService)
    {
        this.userRepository = userRepository;
        this.tokenService = tokenService;
    }

    [HttpPost("LoginEmail")]
    [ProducesResponseType(typeof(LoginResponse), 200)]
    public async Task<ActionResult> LoginWithEmail([FromBody] LoginWithEmailRequest body)
    {
        var user = await userRepository.FindByEmail(body.Email);
        if (user == null)
            return NotFound();

        if (user.Password != body.Password)
            return Forbid();

        var token = tokenService.Generate(user);
        return Ok(new LoginResponse
        {
            Token = token.token,
            ExpireUtc = token.expireUtc
        });
    }

    [HttpPost("CreateEmail")]
    [ProducesResponseType(typeof(LoginResponse), 201)]
    public async Task<ActionResult> CreateWithEmail([FromBody] LoginWithEmailRequest body)
    {
        var user = await userRepository.FindByEmail(body.Email);
        if (user != null)
            return Conflict();

        if (body.Password.Length < 8)
            return Forbid();

        user = new()
        {
            Email = body.Email,
            Password = body.Password
        };
        await userRepository.Create(user);

        var token = tokenService.Generate(user);
        return StatusCode(StatusCodes.Status201Created, new LoginResponse
        {
            Token = token.token,
            ExpireUtc = token.expireUtc
        });
    }

    [HttpGet("RefreshToken")]
    [ProducesResponseType(typeof(LoginResponse), 200)]
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public async Task<ActionResult> RefreshToken()
    {
        var userId = ClaimHelper.FindNameIdentifier(User);
        if (string.IsNullOrEmpty(userId))
            return Unauthorized();

        var user = await userRepository.Get(userId);
        if (user == null)
            return Forbid();

        var token = tokenService.Generate(user);
        return Ok(new LoginResponse
        {
            Token = token.token,
            ExpireUtc = token.expireUtc
        });
    }
}

 

Web Api로 프로젝트를 만들었을 때 자동으로 포함되는 Swagger에서 발급받은 토큰을 이용하여 로그인이 필요한 기능도 테스트할 수 있도록 builder.Services.AddSwaggerGen 메소드를 수정하였습니다.

builder.Services.AddSwaggerGen(options =>
{
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Please enter a valid token",
        Name = "Authorization",
        Type = SecuritySchemeType.Http,
        BearerFormat = "JWT",
        Scheme = "Bearer"
    });
    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type= ReferenceType.SecurityScheme,
                    Id= "Bearer"
                }
            },
            new string[] {}
        }
    });
});

 

 

2. 유저정보 관리 구현

 

로그인 후 유저정보를 읽고, 쓸 때 필요한 기능들을 UserController 구현하였습니다.

  • Get
    • 클라이언트에서 유저정보를 불러올 때 사용
    • 처음 가입해서 보유한 차량이 없을 때 무료차량 1대를 지급한다.
  • SelectCar
    • 게임할 때 사용할 자동차 선택
  • BuyCar
    • 차량 구매
  • EndStage
    • 클라이언트에서 스테이지를 클리어했을 때 호출
    • 수익금을 지급하고, 다음 스테이지를 플레이할 수 있도록 DB 수정
[Route("[controller]")]
[ApiController]
public class UserController : ControllerBase
{
    readonly UserRepository userRepository;
    readonly TemplateService templateService;

    public UserController(UserRepository userRepository, TemplateService templateService)
    {
        this.userRepository = userRepository;
        this.templateService = templateService;
    }


    [HttpGet]
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public async Task<ActionResult> Get()
    {
        var userId = ClaimHelper.FindNameIdentifier(User);
        if (string.IsNullOrEmpty(userId))
            return Unauthorized();

        var user = await userRepository.Get(userId);
        if (user == null)
            return Forbid();

        // 자동차 지급
        if (user.Cars == null || user.Cars.Count == 0)
        {
            var cars = await templateService.GetCars();
            var newCar = cars.FirstOrDefault(e => e.Cost == 0);
            if (newCar != null)
            {
                user.CurrentCarId = newCar?.Id;
                user.Cars = [ user.CurrentCarId! ]; 
            }
        }

        return Ok(new
        {
            user.Nickname,
            user.Coin,
            user.Cars,
            CurrentCar = user.CurrentCarId,
            CurrentStage = user.CurrentStageIndex
        });
    }

    [HttpPut("SelctCar/{carId}")]
    public async Task<ActionResult> SelectCar(string carId)
    {
        var userId = ClaimHelper.FindNameIdentifier(User);
        if (string.IsNullOrEmpty(userId))
            return Unauthorized();

        var user = await userRepository.Get(userId);
        if (user == null)
            return Forbid();

        // 보유하지 않는 차는 선택 불가능
        var exist = user.Cars?.Contains(carId) ?? false;
        if (!exist)
            return NotFound();

        if (user.CurrentCarId != carId)
        {
            user.CurrentCarId = carId;
            await userRepository.Update(user.Id!, user);
        }

        return NoContent();
    }

    [HttpPut("BuyCar/{carId}")]
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public async Task<ActionResult> BuyCar(string carId)
    {
        var userId = ClaimHelper.FindNameIdentifier(User);
        if (string.IsNullOrEmpty(userId))
            return Unauthorized();

        var user = await userRepository.Get(userId);
        if (user == null)
            return Forbid();

        if (user.Cars == null)
            user.Cars = new();

        // 이미 보유한 차는 구매 불가능
        if (user.Cars.Contains(carId))
            return NoContent();

        var carTemps = await templateService.GetCars();
        var carTemp = carTemps.Find(e => e.Id == carId);
        if (carTemp == null)
            return NotFound();

        // 비용 부족
        if (user.Coin < carTemp.Cost)
            return Forbid();

        user.Coin -= carTemp.Cost;
        user.Cars.Add(carId);
        await userRepository.Update(user.Id!, user);

        return NoContent();
    }

    [HttpPut]
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public async Task<ActionResult> EndStage([FromBody] EndStageRequest body)
    {
        var userId = ClaimHelper.FindNameIdentifier(User);
        if (string.IsNullOrEmpty(userId))
            return Unauthorized();

        var user = await userRepository.Get(userId);
        if (user == null)
            return Forbid();

        var stageTemps = await templateService.GetStages();
        // 템플릿에 등록되지 않은 스테이지
        if (body.StageIndex < 0 || body.StageIndex >= stageTemps.Count)
            return BadRequest();

        // 현재 스테이지도 아직 클리어 하지 않았을 경우
        if (body.StageIndex > user.CurrentStageIndex)
            return Forbid();

        // 다음 스테이지 개방
        if (
            body.StageIndex == user.CurrentStageIndex && 
            user.CurrentStageIndex < stageTemps.Count - 1
        )
        {
            ++user.CurrentStageIndex;
        }

        // TODO: 나중에 보상금 검증하는 기능 추가
        user.Coin += body.Coin;
        await userRepository.Update(user.Id!, user);

        return NoContent();
    }
}

 

3. 요금 계산방식 수정

 

위에 방식에서는 클라이언트에서 계산한 요금이 제대로 된 금액이 맞는지 검증할 수 없어 최소한의 검사 기능을 추가해보기로 하였습니다.
요금 계산은 아래 설계한 흐름도처럼 해보기로 하였습니다.

 

요금 계산 공식도 서버에서 계산할 수 있도록 수정하였습니다.

  • m = d * r
    • m : 택시 요금(소수점 뒤는 버림)
    • d
      • 클라이언트 : 승객을 태우고 이동한 거리
      • 서버 : 총 이동거리(템플릿에 미리 입력)
      • r : 거리의 몇 %를 지급할지 설정하는 값(템플릿에 미리 입력)

실제 클라이언트는 모든 구간에서 손님을 태우지 않기 때문에 완벽한 처리방식은 아니지만 작업이 커질 것 같이 때문에 지금은 여기까지만 하고, 언젠가 수정하기로 마음먹었습니다.

유니티에서 콘솔창에 각각의 맵들의 이동경로 거리를 출력하는 테스트 코드를 추가하였습니다.

[ContextMenu("Print PlayerPath Distance")]
void PrintPlayerPathDistance()
{
    Debug.Log(playerPath.path.length);
}

 

 

거리를 확인한 후 스테이지 템플릿에 입력하였습니다.

 

template_generator.py 파일을 수정한 후 DB에 저장된 템플릿 정보를 변경하였습니다.

def generate_stage(file_path, sheet_name):
    book = load_workbook(file_path, data_only=True)
    sheet = book[sheet_name]
    skip = 0
    temp_group = []
    for row in sheet.rows:
        if skip < 1:
            skip += 1
            continue  
        if row[0].value == None:
            continue
        new_temp = {
            'Id': row[0].value,
            'Scene': row[1].value,
            'Distance': row[2].value,
            'FareRate': row[3].value
        }
        temp_group.append(new_temp)
    return temp_group

 

 

서버 프로젝트에서 StageTemplate, UserController를 수정하였습니다.

public class StageTemplate
{
    public string? Id { get; set; }
    public double Distance { get; set; }
    public double FareRate { get; set; }

    [JsonIgnore]
    public double MaxCoin => Distance * FareRate;
}
[HttpPut]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public async Task<ActionResult> EndStage([FromBody] EndStageRequest body)
{
    var userId = ClaimHelper.FindNameIdentifier(User);
    if (string.IsNullOrEmpty(userId))
        return Unauthorized();

    var user = await userRepository.Get(userId);
    if (user == null)
        return Forbid();

    var stageTemps = await templateService.GetStages();
    // 템플릿에 등록되지 않은 스테이지
    if (body.StageIndex < 0 || body.StageIndex >= stageTemps.Count)
        return BadRequest();

    // 현재 스테이지도 아직 클리어하지 않았을 경우
    if (body.StageIndex > user.CurrentStageIndex)
        return Forbid();

    // 클라이언트에서 계산한 요금이 서버에서 계산한 요금보다 더 큰 경우
    if (body.Coin >= stageTemps[user.CurrentStageIndex].MaxCoin)
        return Forbid();

    // 다음 스테이지 개방
    if (
        body.StageIndex == user.CurrentStageIndex && 
        user.CurrentStageIndex < stageTemps.Count - 1
    )
    {
        ++user.CurrentStageIndex;
    }

    user.Coin += body.Coin;
    await userRepository.Update(user.Id!, user);

    return NoContent();
}

 

다음에는 다시 클라이언트 작업을 시작하여 게임을 완성하도록 하겠습니다.

깃 허브 저장소 : taxi-game-3d-unity

728x90
LIST