add refresh token

This commit is contained in:
dibakor 2026-06-21 16:51:27 +06:00
parent ade62e7b6a
commit c5a333dcef
5 changed files with 284 additions and 1782 deletions

View File

@ -14,9 +14,9 @@ public class IntegrationLoginRequest
[Required, NotNull, StringLength(maximumLength: 150, MinimumLength = 5, ErrorMessage = "Password must be between 1 and 30 characters.")] [Required, NotNull, StringLength(maximumLength: 150, MinimumLength = 5, ErrorMessage = "Password must be between 1 and 30 characters.")]
public string Password { get; set; } public string Password { get; set; }
public string RefreshToken { get; set; }
} }
public class IntegrationRefreshTokenRequest public class IntegrationRefreshTokenRequest
{ {
public string RefreshToken { get; set; } public string RefreshToken { get; set; }
@ -35,7 +35,7 @@ public class RevokedRefreshTokenRequest
public class GenerateRefreshTokenRequest public class GenerateRefreshTokenRequest
{ {
public User User { get; set; } public int UserId { get; set; }
public string IpAddress { get; set; } public string IpAddress { get; set; }
public string RawRefreshToken { get; set; } public string RawRefreshToken { get; set; }
} }

View File

@ -14,10 +14,16 @@ public class IntegrationLoginResponse : ResponseBase
public DateTime AccessTokenExpiry { get; set; } public DateTime AccessTokenExpiry { get; set; }
} }
public class IntegrationRrefreshTokenResponse : ResponseBase
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
}
public class RefreshTokenResponse : ResponseBase public class RefreshTokenResponse : ResponseBase
{ {
public string UserId { get; set; } public int UserId { get; set; }
public string TokenHash { get; set; } public string TokenHash { get; set; }
public string IpAddress { get; set; } public string IpAddress { get; set; }
public DateTime ExpiredAt { get; set; } public DateTime ExpiredAt { get; set; }
@ -31,3 +37,12 @@ public class GenerateRefreshTokenResponse : ResponseBase
public string RefreshToken { get; set; } public string RefreshToken { get; set; }
public DateTime ExpireTime { get; set; } public DateTime ExpireTime { get; set; }
} }
public class UserByRefreshTokenResponse : ResponseBase
{
public int UserId { get; set; }
public string LoginId { get; set; }
public string EmailAddress { get; set; }
public string AuthKey { get; set; }
public bool IsActive { set; get; }
}

View File

@ -10,5 +10,7 @@ public interface IRefreshTokenService
Task<bool> AddAsync(InsertRefreshTokenRequest refreshToken); Task<bool> AddAsync(InsertRefreshTokenRequest refreshToken);
Task<bool> RevokeAsync(RevokedRefreshTokenRequest token); Task<bool> RevokeAsync(RevokedRefreshTokenRequest token);
Task<bool> RevokeAllForUserAsync(int userId); Task<bool> RevokeAllForUserAsync(int userId);
Task<GenerateRefreshTokenResponse> GenerateRefreshToken(GenerateRefreshTokenRequest request); Task<GenerateRefreshTokenResponse> GenerateRefreshTokenByUserAsync(GenerateRefreshTokenRequest request);
Task<string> GenerateRefreshTokenAsync(string refreshToken, string ipAddress);
Task<UserByRefreshTokenResponse> GetUserByRefreshTokenAsync(string refreshToken);
} }

View File

@ -77,7 +77,33 @@ public class RefreshTokenService : IRefreshTokenService
using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode); using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode);
try try
{ {
await GetByTokenHashAsync(tc, tokenHash); response= await GetByTokenHashAsync(tc, tokenHash);
tc.End();
}
catch (Exception ie)
{
tc?.HandleError();
throw DBCustomError.GenerateCustomError(ie);
}
}
catch (Exception e)
{
throw new InvalidOperationException(e.Message, e);
}
return response;
}
public async Task<UserByRefreshTokenResponse> GetUserByRefreshTokenAsync(string refreshToken)
{
UserByRefreshTokenResponse response = new();
try
{
using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode);
try
{
response = await GetUserByRefreshTokenAsync(tc, refreshToken);
tc.End(); tc.End();
} }
catch (Exception ie) catch (Exception ie)
@ -108,7 +134,7 @@ public class RefreshTokenService : IRefreshTokenService
{ {
if (dr.Read()) if (dr.Read())
{ {
response.UserId = dr.GetString(0); response.UserId = dr.GetInt32(0);
response.TokenHash = dr.GetString(1); response.TokenHash = dr.GetString(1);
response.IpAddress = dr.GetString(2); response.IpAddress = dr.GetString(2);
response.ExpiredAt = dr.GetDateTime(3); response.ExpiredAt = dr.GetDateTime(3);
@ -125,6 +151,36 @@ public class RefreshTokenService : IRefreshTokenService
return response; return response;
} }
private async Task<UserByRefreshTokenResponse> GetUserByRefreshTokenAsync(TransactionContext tc, string refreshToken)
{
UserByRefreshTokenResponse response = new();
try
{
SqlParameter[] p =
[
SqlHelperExtension.CreateInParam(pName: "@RefreshToken", pType: SqlDbType.VarChar, pValue: refreshToken),
];
using (IDataReader dr = await tc.ExecuteReaderSpAsync("dbo.GetUserByRefreshToken", parameterValues: p))
{
if (dr.Read())
{
response.UserId = dr.GetInt32(0);
response.LoginId = dr.GetString(1);
response.EmailAddress = dr.IsDBNull(2) ? string.Empty : dr.GetString(2);
response.AuthKey = dr.IsDBNull(3) ? string.Empty : dr.GetString(3);
response.IsActive = dr.IsDBNull(4) ? true : false;
}
dr.Close();
}
}
catch (Exception e)
{
throw new InvalidOperationException(e.Message, e);
}
return response;
}
public async Task<bool> RevokeAllForUserAsync(int userId) public async Task<bool> RevokeAllForUserAsync(int userId)
{ {
bool returnValue = false; bool returnValue = false;
@ -206,7 +262,31 @@ public class RefreshTokenService : IRefreshTokenService
return returnValue; return returnValue;
} }
public async Task<GenerateRefreshTokenResponse> GenerateRefreshToken(GenerateRefreshTokenRequest request)
private bool RevokeRefreshTokenByUserIdAsync(TransactionContext tc, int userId)
{
bool returnValue = false;
try
{
SqlParameter[] p =
[
SqlHelperExtension.CreateInParam(pName: "@UserId", pType: SqlDbType.Int, pValue: userId)
];
_ = tc.ExecuteNonQuerySp(spName: "dbo.RevokedAllRefreshTokenByUserId", parameterValues: p);
returnValue = true;
}
catch (Exception e)
{
throw new InvalidOperationException(e.Message, e);
}
return returnValue;
}
public async Task<GenerateRefreshTokenResponse> GenerateRefreshTokenByUserAsync(GenerateRefreshTokenRequest request)
{ {
GenerateRefreshTokenResponse refreshTokenResponse = new(); GenerateRefreshTokenResponse refreshTokenResponse = new();
try try
@ -214,17 +294,10 @@ public class RefreshTokenService : IRefreshTokenService
using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode, true); using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode, true);
try try
{ {
var tokenHash = HashToken(request.RawRefreshToken);
var storedToken = await GetByTokenHashAsync(tc, tokenHash);
if (storedToken.UserId is not null && !storedToken.IsActive)
throw new UnauthorizedAccessException("Refresh token has expired or been revoked.");
// Rotate: revoke old token, issue new one // Rotate: revoke old token, issue new one
RevokeAsync(tc,new RevokedRefreshTokenRequest() { RefreshToken = storedToken.TokenHash }); RevokeRefreshTokenByUserIdAsync(tc, request.UserId);
refreshTokenResponse= await IssueTokensAsync(tc,request.User, request.IpAddress); refreshTokenResponse= await IssueTokensAsync(tc,request.UserId, request.IpAddress);
tc.End(); tc.End();
} }
@ -243,16 +316,68 @@ public class RefreshTokenService : IRefreshTokenService
return refreshTokenResponse; return refreshTokenResponse;
} }
public async Task<string> GenerateRefreshTokenAsync(string refreshToken, string ipAddress)
{
string refreshTokenResponse = string.Empty;
try
{
using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode, true);
try
{
bool isValid = IsValidRefreshTokenAsync(tc, refreshToken);
if(string.IsNullOrEmpty(refreshToken) && !isValid)
{
throw new Exception($"Refresh token has expired or been revoked- {refreshToken}");
}
var storedToken = await GetByTokenHashAsync(tc, refreshToken);
RevokeRefreshTokenByUserIdAsync(tc, storedToken.UserId);
var result = await IssueTokensAsync(tc, storedToken.UserId, ipAddress);
refreshTokenResponse = result.RefreshToken;
tc.End();
}
catch (Exception ie)
{
tc?.HandleError();
throw DBCustomError.GenerateCustomError(ie);
}
}
catch (Exception e)
{
throw new InvalidOperationException(e.Message, e);
}
return refreshTokenResponse;
}
// ----- private helpers ----- // ----- private helpers -----
private async Task<GenerateRefreshTokenResponse> IssueTokensAsync(TransactionContext tc, User user, string ipAddress) private bool IsValidRefreshTokenAsync(TransactionContext tc, string refreshToken )
{
try
{
string sql = SQLParser.MakeSQL("Select Count(1) FROM RefreshTokens where TokenHash = %s AND RevokedAt is null", refreshToken);
var returnValue = tc.ExecuteScalar(sql);
return Convert.ToInt32(returnValue) > 0 ? true : false;
}
catch(Exception)
{
throw;
}
}
private async Task<GenerateRefreshTokenResponse> IssueTokensAsync(TransactionContext tc, int userId, string ipAddress)
{ {
string rawRefreshToken = GenerateRowToken(); string rawRefreshToken = GenerateRowToken();
string tokenHash = HashToken(rawRefreshToken);
var refreshToken = new InsertRefreshTokenRequest var refreshToken = new InsertRefreshTokenRequest
{ {
UserId = user.UserId, UserId = userId,
TokenHash = HashToken(rawRefreshToken), TokenHash = tokenHash,
IpAddress = ipAddress, IpAddress = ipAddress,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenDuration) ExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenDuration)
@ -262,7 +387,7 @@ public class RefreshTokenService : IRefreshTokenService
return new GenerateRefreshTokenResponse return new GenerateRefreshTokenResponse
{ {
RefreshToken = rawRefreshToken, RefreshToken = tokenHash,
ExpireTime = DateTime.UtcNow.AddMinutes(_settings.RefreshTokenDuration) ExpireTime = DateTime.UtcNow.AddMinutes(_settings.RefreshTokenDuration)
}; };
} }