API 인증 방식을 구현할 때 많이 사용하는 JWT 에 Refresh Token을 붙여서 보다 안정적인 인증 시스템을 만들어보자.

JWT + Refresh Token 구조 요약

  • 로그인 성공 시 Access Token Refresh Token을 발급
  • Access Token은 만료 시간이 짧음 (ex. 30분)
  • Refresh Token은 길게 설정 (ex. 1일)
  • 클라이언트가 Access Token 만료로 401을 받으면, Refresh Token으로 새 Access Token을 요청
  • Refresh Token도 만료되었으면 다시 로그인

1. 로그인 - 토큰 발급

[HttpPost("signin")]
public async Task<IActionResult> SignIn(SignModel req)
{
    var user = await _db.Users.FirstOrDefaultAsync(u => u.UserId == req.UserID && u.Password == req.Password);
    
    //User 체크 로직 적용
    
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.NameIdentifier, user.UserId),
        new Claim(ClaimTypes.Sid, user.UserIdx.ToString()),
        new Claim(ClaimTypes.Name, user.UserName),
        // 필요한 claim 추가
    };

    var accessToken = new JwtSecurityTokenHandler().WriteToken(CreateToken(claims));
    var refreshToken = GenerateRefreshToken();

    int.TryParse(_configuration["JWT:RefreshTokenValidityInDays"], out int refreshTokenValidityInDays);
    var refreshExpiry = _db.GetKoreaDate().AddDays(refreshTokenValidityInDays);

    var tokenEntity = await _db.UserTokens.FirstOrDefaultAsync(t => t.UserIdx == user.UserIdx);
    if (tokenEntity == null)
    {
        tokenEntity = new UserToken
        {
            UserIdx = user.UserIdx,
            AccessToken = accessToken,
            RefreshToken = refreshToken,
            RefreshTokenExpiryTime = refreshExpiry
        };
        _db.UserTokens.Add(tokenEntity);
    }
    else
    {
        tokenEntity.AccessToken = accessToken;
        tokenEntity.RefreshToken = refreshToken;
        tokenEntity.RefreshTokenExpiryTime = refreshExpiry;
    }

    await _db.SaveChangesAsync();

    var token = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
    var expiration = DateTimeOffset.FromUnixTimeSeconds((long)token.Payload.Exp!).LocalDateTime;

    return Ok(new
    {
        Token = accessToken,
        RefreshToken = refreshToken,
        Expiration = expiration,
        Code = "0",
        Msg = "success"
    });
}

 

2. 토큰 재발급 (Refresh Token)

[HttpPost("refresh-token")]
public async Task<IActionResult> RefreshToken(TokenModel tokenModel)
{
    if (tokenModel == null)
        return BadRequest("잘못된 요청입니다.");

    var principal = GetPrincipalFromExpiredToken(tokenModel.AccessToken);
    if (principal == null)
        return BadRequest("잘못된 액세스 토큰입니다.");

    var userIdx = principal.FindFirst(ClaimTypes.Sid)?.Value;
    if (!int.TryParse(userIdx, out int uid))
        return BadRequest("사용자 정보를 찾을 수 없습니다.");

    var userToken = await _db.UserTokens.FirstOrDefaultAsync(t => t.UserIdx == uid);
    if (userToken == null || userToken.RefreshToken != tokenModel.RefreshToken)
        return BadRequest("잘못된 리프레시 토큰입니다.");

    if (userToken.RefreshTokenExpiryTime <= DateTime.Now)
        return BadRequest("리프레시 토큰이 만료되었습니다.");

    var newAccessToken = new JwtSecurityTokenHandler().WriteToken(CreateToken(principal.Claims.ToList()));
    var newRefreshToken = GenerateRefreshToken();

    int.TryParse(_configuration["JWT:RefreshTokenValidityInDays"], out int refreshTokenValidityInDays);
    userToken.AccessToken = newAccessToken;
    userToken.RefreshToken = newRefreshToken;
    userToken.RefreshTokenExpiryTime = _db.GetKoreaDate().AddDays(refreshTokenValidityInDays);

    await _db.SaveChangesAsync();

    return Ok(new
    {
        AccessToken = newAccessToken,
        RefreshToken = newRefreshToken
    });
}

 

3. 리프레시 토큰 초기화 (로그아웃 또는 강제 만료)

[Authorize]
[HttpPost("revoke/{id}")]
public async Task<IActionResult> Revoke(string id)
{
    var user = await _db.Users.FirstOrDefaultAsync(u => u.UserId == id);
    if (user == null)
        return BadRequest("잘못된 사용자 정보입니다.");

    var token = await _db.UserTokens.FirstOrDefaultAsync(t => t.UserIdx == user.UserIdx);
    if (token != null)
    {
        _db.UserTokens.Remove(token);
        await _db.SaveChangesAsync();
    }

    return NoContent();
}

 

4. 토큰 생성 및 유효성 검사 메서드

private JwtSecurityToken CreateToken(List<Claim> authClaims)
{
    var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"]));
    int.TryParse(_configuration["JWT:TokenValidityInMinutes"], out int tokenValidityInMinutes);

    return new JwtSecurityToken(
        issuer: _configuration["JWT:ValidIssuer"],
        audience: _configuration["JWT:ValidAudience"],
        expires: _db.GetKoreaDate().AddMinutes(tokenValidityInMinutes),
        claims: authClaims,
        signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
    );
}

private static string GenerateRefreshToken()
{
    var randomNumber = new byte[64];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(randomNumber);
    return Convert.ToBase64String(randomNumber);
}

private ClaimsPrincipal? GetPrincipalFromExpiredToken(string? token)
{
    var tokenValidationParameters = new TokenValidationParameters
    {
        ValidateAudience = false,
        ValidateIssuer = false,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"])),
        ValidateLifetime = false
    };

    var tokenHandler = new JwtSecurityTokenHandler();
    var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken);
    if (securityToken is JwtSecurityToken jwtToken &&
        jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase))
    {
        return principal;
    }

    throw new SecurityTokenException("잘못된 토큰입니다.");
}

 

5. appsettings.json 설정

"JWT": {
  "ValidAudience": "https://",
  "ValidIssuer": "https://",
  "Secret": "abcdef",
  "TokenValidityInMinutes": 30,
  "RefreshTokenValidityInDays": 1
}

 

해당 설정 코드는 .NET Core에서 JWT + Refresh Token을 활용한 인증 시스템 구현 방법으로, 외부에 노출되는 API 서비스나 앱 백엔드에서 많이 사용된다.
하지만 모든 시스템에 무조건 Refresh Token이 필요한 것은 아니다. 회사 내에서만 사용하는 내부 시스템이나 보안 부담이 적은 관리자용 시스템 등에서는 Access Token 하나로만 인증을 구성해도 충분한 경우가 많다.

또한 Middleware를 통해 만료된 토큰을 자동으로 감지하고 갱신 처리하는 기능을 구현하거나

토큰을 HttpOnly 쿠키로 저장하거나 IP/기기 정보 기반 토큰 제어 등 보안을 강화하는 방향으로 확장도 가능하다.

유효성 검사는 보통은 JavaScript를 통해 클라이언트에서 미리 체크하거나, 또는 보안상의 이유로 서버에서 다시 한 번 검사한다.

그런데 만약 사용자의 입력이 여러 필드에 걸쳐 동시에 잘못되었고, 
한꺼번에 모든 에러를 사용자에게 보여주는 구조가 필요하다면 IValidatableObject 를 통해 유효성 검사가 가능하다.

 

1. 모델 정의

using System.ComponentModel.DataAnnotations;

public class SaveModel : IValidatableObject
{
    public string ID { get; set; } = null!;   
    public string Name { get; set; } = null!;

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
     	if (string.IsNullOrWhiteSpace(ID))
        {
             yield return new ValidationResult(
                 "ID 값이 필요합니다.",
                 new[] { nameof(ID) }
             );
        }
        if (string.IsNullOrWhiteSpace(Name))
        {
             yield return new ValidationResult(
                 "Name 값이 필요합니다.",
                 new[] { nameof(Name) }
             );
        }
    }
}

 

2. Razor Page 또는 Controller (POST)

public class IndexModel : PageModel
{
    public async Task<JsonResult> OnPostSave([FromForm] SaveModel data)
    {
    	//이 부분은 테스트 용
        if (!ModelState.IsValid)
        {
            var errors = ModelState
                .Where(kvp => kvp.Value.Errors.Count > 0)
                .SelectMany(kvp => kvp.Value.Errors.Select(e => new
                {
                    ErrorMessage = e.ErrorMessage,
                    MemberNames = new[] { kvp.Key }
                }));

            return new JsonResult(new
            {
                Success = false,
                Errors = errors
            });
        }

        return new JsonResult(new { Success = true });
    }
}

 

3. 유효성 처리 JavaScript

    <form id="saveForm">
        <label>ID: <input type="text" name="ID" id="ID" /></label><br />
        <label>Name: <input type="text" name="Name" id="Name" /></label><br />
        <button type="submit">저장</button>
    </form>

    <div id="errorArea" style="color:red; margin-top: 10px;"></div>
        const formElement = document.getElementById("saveForm");

        function markError(field) {
            field.classList.add("error-field");
        }

        function clearErrorMarks() {
            const errorFields = formElement.querySelectorAll(".error-field");
            errorFields.forEach(field => field.classList.remove("error-field"));
        }

        formElement.addEventListener("submit", async function (e) {
            e.preventDefault();
            clearErrorMarks();
            document.getElementById("errorArea").innerHTML = "";

            const formData = new FormData(this);

            const response = await fetch("/Index?handler=Save", {
                method: "POST",
                body: formData
            });

            const data = await response.json();

            if (data.Errors) {
                const errors = data.Errors;

                const errorMessages = `<div>${errors.map(error => `• ${error.ErrorMessage}`).join("<br />")}</div>`;
                document.getElementById("errorArea").innerHTML = errorMessages;

                errors.forEach(error => {
                    if (error.MemberNames && error.MemberNames.length > 0) {
                        error.MemberNames.forEach(fieldName => {
                            const field = formElement.querySelector(`[name="${fieldName}"]`);
                            if (field) {
                                markError(field);
                            }
                        });
                    }
                });
            } else if (!data.Success) {
                document.getElementById("errorArea").innerHTML = "알 수 없는 오류가 발생했습니다.";
            } else {
                console.log("저장 완료!");
                this.reset();
            }
        });

 

IValidatableObject를 이용한 유효성 검사는 자주 쓰이진 않지만 검증 로직이 여러 필드에 걸쳐 복잡하게 얽혀 있을 때
또는 여러 에러를 한 번에 수집해서 처리해야 할 때 유용하다.

보통은 각 필드에 어노테이션을 붙이거나 하나씩 수동으로 추가하는 방식이 많지만
이렇게 Validate() 안에서 조건별로 yield return만 추가해 주면
복잡한 유효성 체크도 훨씬 깔끔하게 정리할 수 있다.

+ Recent posts