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/기기 정보 기반 토큰 제어 등 보안을 강화하는 방향으로 확장도 가능하다.

+ Recent posts