using Ease.NetCore.DataAccess; using Ease.NetCore.DataAccess.SQL; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Options; using OnlineSalesAutoCrop.CoreAPI.Models.Global; using OnlineSalesAutoCrop.CoreAPI.Models.Objects.Systems; using OnlineSalesAutoCrop.CoreAPI.Models.Requests.Integrations; using OnlineSalesAutoCrop.CoreAPI.Models.Responses.Integrations; using OnlineSalesAutoCrop.CoreAPI.Services.Contracts.Auth; using System; using System.Data; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; namespace OnlineSalesAutoCrop.CoreAPI.Services.Services.Auth; public class RefreshTokenService : IRefreshTokenService { private readonly AppSettings _settings; public RefreshTokenService(IOptions settings) { _settings = settings.Value; } public async Task AddAsync(InsertRefreshTokenRequest refreshToken) { bool returnValue = false; try { using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode, true); AddAsync(tc, refreshToken); tc.End(); } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return returnValue; } private bool AddAsync( TransactionContext tc, InsertRefreshTokenRequest refreshToken) { bool returnValue = false; try { SqlParameter[] p = [ SqlHelperExtension.CreateInParam(pName: "@UserId", pType: SqlDbType.VarChar, pValue: refreshToken.UserId), SqlHelperExtension.CreateInParam(pName: "@TokenHash", pType: SqlDbType.VarChar, pValue: refreshToken.TokenHash), SqlHelperExtension.CreateInParam(pName: "@IpAddress", pType: SqlDbType.VarChar, pValue: refreshToken.IpAddress), SqlHelperExtension.CreateInParam(pName: "@CreatedAt", pType: SqlDbType.DateTime, pValue: DateTime.Now), SqlHelperExtension.CreateInParam(pName: "@ExpiredAt", pType: SqlDbType.DateTime, pValue: DateTime.Now.AddMinutes(_settings.RefreshTokenDuration)), ]; _ = tc.ExecuteNonQuerySp(spName: "dbo.InsertRefreshToken", parameterValues: p); returnValue = true; } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return returnValue; } public async Task GetByTokenHashAsync(string tokenHash) { RefreshTokenResponse response = new(); try { using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode); try { 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 GetUserByRefreshTokenAsync(string refreshToken) { UserByRefreshTokenResponse response = new(); try { using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode); try { response = await GetUserByRefreshTokenAsync(tc, refreshToken); tc.End(); } catch (Exception ie) { tc?.HandleError(); throw DBCustomError.GenerateCustomError(ie); } } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return response; } private async Task GetByTokenHashAsync(TransactionContext tc, string tokenHash) { RefreshTokenResponse response = new(); try { SqlParameter[] p = [ SqlHelperExtension.CreateInParam(pName: "@RefreshToken", pType: SqlDbType.VarChar, pValue: tokenHash), ]; using (IDataReader dr = await tc.ExecuteReaderSpAsync("dbo.GetRefreshTokenByTokenHash", parameterValues: p)) { if (dr.Read()) { response.UserId = dr.GetInt32(0); response.TokenHash = dr.GetString(1); response.IpAddress = dr.GetString(2); response.ExpiredAt = dr.GetDateTime(3); response.RevokedAt = dr.IsDBNull(4) ? null : dr.GetDateTime(4); } dr.Close(); } } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return response; } private async Task 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 RevokeAllForUserAsync(int userId) { bool returnValue = false; try { using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode, true); try { SqlParameter[] p = [ SqlHelperExtension.CreateInParam(pName: "@UserId", pType: SqlDbType.Int, pValue: userId) ]; _ = await tc.ExecuteNonQuerySpAsync(spName: "dbo.RevokedAllRefreshToken", parameterValues: p); returnValue = true; tc.End(); } catch (Exception ie) { tc?.HandleError(); throw DBCustomError.GenerateCustomError(ie); } } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return returnValue; } public async Task RevokeAsync(RevokedRefreshTokenRequest token) { bool returnValue = false; try { using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode, true); try { returnValue = RevokeAsync(tc, token); tc.End(); } catch (Exception ie) { tc?.HandleError(); throw DBCustomError.GenerateCustomError(ie); } } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return returnValue; } private bool RevokeAsync(TransactionContext tc, RevokedRefreshTokenRequest token) { bool returnValue = false; try { SqlParameter[] p = [ SqlHelperExtension.CreateInParam(pName: "@RefreshToken", pType: SqlDbType.NVarChar, pValue: token.RefreshToken) ]; _ = tc.ExecuteNonQuerySp(spName: "dbo.RevokedAllRefreshToken", parameterValues: p); returnValue = true; } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return returnValue; } 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 GenerateRefreshTokenByUserAsync(GenerateRefreshTokenRequest request) { GenerateRefreshTokenResponse refreshTokenResponse = new(); try { using TransactionContext tc = await TransactionContext.BeginAsync(_settings.DefaultConnection.ConnectionNode, true); try { // Rotate: revoke old token, issue new one RevokeRefreshTokenByUserIdAsync(tc, request.UserId); refreshTokenResponse= await IssueTokensAsync(tc,request.UserId, request.IpAddress); tc.End(); } catch (Exception ie) { tc?.HandleError(); throw DBCustomError.GenerateCustomError(ie); } } catch (Exception e) { throw new InvalidOperationException(e.Message, e); } return refreshTokenResponse; } public async Task 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 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 IssueTokensAsync(TransactionContext tc, int userId, string ipAddress) { string rawRefreshToken = GenerateRowToken(); string tokenHash = HashToken(rawRefreshToken); var refreshToken = new InsertRefreshTokenRequest { UserId = userId, TokenHash = tokenHash, IpAddress = ipAddress, CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddDays(_settings.RefreshTokenDuration) }; AddAsync(tc,refreshToken); return new GenerateRefreshTokenResponse { RefreshToken = tokenHash, ExpireTime = DateTime.UtcNow.AddMinutes(_settings.RefreshTokenDuration) }; } private static string HashToken(string token) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); return Convert.ToBase64String(bytes); } private string GenerateRowToken() { var bytes = new byte[64]; using var rng = RandomNumberGenerator.Create(); rng.GetBytes(bytes); return Convert.ToBase64String(bytes); } }