290 lines
9.4 KiB
C#
290 lines
9.4 KiB
C#
using GreenHome.Application;
|
|
using GreenHome.Infrastructure.Data;
|
|
using GreenHome.Infrastructure.Services.Usage;
|
|
using GreenHome.Sms.Ippanel;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace GreenHome.Infrastructure.Services.Auth;
|
|
|
|
public sealed class AuthService : IAuthService
|
|
{
|
|
private readonly GreenHomeDbContext _dbContext;
|
|
private readonly ISmsService _smsService;
|
|
private readonly UsageTracker _usageTracker;
|
|
private readonly ILogger<AuthService> _logger;
|
|
private readonly string _tokenSecret;
|
|
private const int CodeExpirationMinutes = 5;
|
|
private const int ResendCooldownSeconds = 120;
|
|
|
|
public AuthService(
|
|
GreenHomeDbContext dbContext,
|
|
ISmsService smsService,
|
|
UsageTracker usageTracker,
|
|
IConfiguration configuration,
|
|
ILogger<AuthService> logger)
|
|
{
|
|
_dbContext = dbContext;
|
|
_smsService = smsService;
|
|
_usageTracker = usageTracker;
|
|
_logger = logger;
|
|
_tokenSecret = configuration["Auth:TokenSecret"] ?? "greenhome-dev-secret-change-me";
|
|
}
|
|
|
|
public async Task<SendCodeResponse> SendVerificationCodeAsync(
|
|
SendCodeRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var mobile = Domain.MobileNormalizer.Normalize(request.Mobile);
|
|
|
|
if (mobile.Length == 0)
|
|
{
|
|
return new SendCodeResponse
|
|
{
|
|
Success = false,
|
|
Message = "شماره موبایل معتبر نیست"
|
|
};
|
|
}
|
|
|
|
var existingUser = await _dbContext.Users
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken);
|
|
|
|
if (existingUser != null && existingUser.IsDisabled)
|
|
{
|
|
return new SendCodeResponse
|
|
{
|
|
Success = false,
|
|
Message = "حساب شما غیرفعال شده است. لطفاً با پشتیبانی تماس بگیرید."
|
|
};
|
|
}
|
|
|
|
var canResend = await CanResendCodeAsync(mobile, cancellationToken);
|
|
if (!canResend)
|
|
{
|
|
var lastCode = await _dbContext.VerificationCodes
|
|
.Where(v => v.Mobile == mobile && !v.IsUsed)
|
|
.OrderByDescending(v => v.CreatedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
if (lastCode != null)
|
|
{
|
|
var secondsRemaining = (int)(ResendCooldownSeconds - (DateTime.UtcNow - lastCode.CreatedAt).TotalSeconds);
|
|
return new SendCodeResponse
|
|
{
|
|
Success = false,
|
|
Message = $"لطفاً {secondsRemaining} ثانیه دیگر دوباره تلاش کنید",
|
|
ResendAfterSeconds = secondsRemaining > 0 ? secondsRemaining : 0
|
|
};
|
|
}
|
|
}
|
|
|
|
var code = GenerateCode();
|
|
|
|
var previousCodes = await _dbContext.VerificationCodes
|
|
.Where(v => v.Mobile == mobile && !v.IsUsed)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
foreach (var prevCode in previousCodes)
|
|
{
|
|
prevCode.IsUsed = true;
|
|
prevCode.UsedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
var verificationCode = new Domain.VerificationCode
|
|
{
|
|
Mobile = mobile,
|
|
Code = code,
|
|
CreatedAt = DateTime.UtcNow,
|
|
ExpiresAt = DateTime.UtcNow.AddMinutes(CodeExpirationMinutes),
|
|
IsUsed = false
|
|
};
|
|
|
|
_dbContext.VerificationCodes.Add(verificationCode);
|
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
try
|
|
{
|
|
await _smsService.SendPatternSmsAsync(new PatternSmsRequest
|
|
{
|
|
Recipients = [mobile],
|
|
PatternCode = "ruvpjx7lajne1dx",
|
|
Variables = new Dictionary<string, string> { { "code", code } }
|
|
}, cancellationToken);
|
|
|
|
if (existingUser != null)
|
|
await _usageTracker.RecordAsync(existingUser.Id, UsageKind.Sms, cancellationToken: cancellationToken);
|
|
|
|
_logger.LogInformation("Verification code sent to {Mobile}", mobile);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send SMS to {Mobile}", mobile);
|
|
}
|
|
|
|
return new SendCodeResponse
|
|
{
|
|
Success = true,
|
|
Message = "کد فعالسازی ارسال شد",
|
|
ResendAfterSeconds = ResendCooldownSeconds
|
|
};
|
|
}
|
|
|
|
public async Task<VerifyCodeResponse> VerifyCodeAsync(
|
|
VerifyCodeRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var mobile = Domain.MobileNormalizer.Normalize(request.Mobile);
|
|
var code = request.Code.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(mobile) || string.IsNullOrWhiteSpace(code) || code.Length != 4)
|
|
{
|
|
return new VerifyCodeResponse
|
|
{
|
|
Success = false,
|
|
Message = "اطلاعات وارد شده معتبر نیست"
|
|
};
|
|
}
|
|
|
|
var verificationCode = await _dbContext.VerificationCodes
|
|
.Where(v => v.Mobile == mobile && v.Code == code && !v.IsUsed && v.ExpiresAt > DateTime.UtcNow)
|
|
.OrderByDescending(v => v.CreatedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
if (verificationCode == null)
|
|
{
|
|
return new VerifyCodeResponse
|
|
{
|
|
Success = false,
|
|
Message = "کد وارد شده معتبر نیست یا منقضی شده است"
|
|
};
|
|
}
|
|
|
|
verificationCode.IsUsed = true;
|
|
verificationCode.UsedAt = DateTime.UtcNow;
|
|
|
|
var user = await _dbContext.Users
|
|
.FirstOrDefaultAsync(u => u.Mobile == mobile, cancellationToken);
|
|
|
|
if (user != null && user.IsDisabled)
|
|
{
|
|
return new VerifyCodeResponse
|
|
{
|
|
Success = false,
|
|
Message = "حساب شما غیرفعال شده است. لطفاً با پشتیبانی تماس بگیرید."
|
|
};
|
|
}
|
|
|
|
if (user == null)
|
|
{
|
|
user = new Domain.User
|
|
{
|
|
Mobile = mobile,
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastLoginAt = DateTime.UtcNow
|
|
};
|
|
_dbContext.Users.Add(user);
|
|
}
|
|
else
|
|
{
|
|
user.LastLoginAt = DateTime.UtcNow;
|
|
}
|
|
|
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
var token = GenerateToken(user.Id);
|
|
|
|
return new VerifyCodeResponse
|
|
{
|
|
Success = true,
|
|
Message = "ورود موفقیتآمیز بود",
|
|
Token = token,
|
|
User = new UserDto
|
|
{
|
|
Id = user.Id,
|
|
Mobile = user.Mobile,
|
|
Name = user.Name,
|
|
Family = user.Family,
|
|
Role = user.Role,
|
|
IsDisabled = user.IsDisabled
|
|
}
|
|
};
|
|
}
|
|
|
|
public async Task<bool> CanResendCodeAsync(string mobile, CancellationToken cancellationToken)
|
|
{
|
|
var normalizedMobile = Domain.MobileNormalizer.Normalize(mobile);
|
|
var lastCode = await _dbContext.VerificationCodes
|
|
.Where(v => v.Mobile == normalizedMobile && !v.IsUsed)
|
|
.OrderByDescending(v => v.CreatedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
if (lastCode == null)
|
|
return true;
|
|
|
|
var elapsed = (DateTime.UtcNow - lastCode.CreatedAt).TotalSeconds;
|
|
return elapsed >= ResendCooldownSeconds;
|
|
}
|
|
|
|
private static string GenerateCode()
|
|
{
|
|
var random = new Random();
|
|
return random.Next(1000, 9999).ToString();
|
|
}
|
|
|
|
public async Task<UserDto?> GetUserByTokenAsync(string token, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
return null;
|
|
|
|
var parts = token.Split('.');
|
|
if (parts.Length != 3)
|
|
return null;
|
|
|
|
if (!int.TryParse(parts[0], out var userId) || !long.TryParse(parts[1], out var expiresAt))
|
|
return null;
|
|
|
|
if (expiresAt < DateTimeOffset.UtcNow.ToUnixTimeSeconds())
|
|
return null;
|
|
|
|
var payload = $"{parts[0]}.{parts[1]}";
|
|
var expectedSignature = Convert.ToBase64String(
|
|
HMACSHA256.HashData(Encoding.UTF8.GetBytes(_tokenSecret), Encoding.UTF8.GetBytes(payload)));
|
|
|
|
if (!CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(expectedSignature),
|
|
Encoding.UTF8.GetBytes(parts[2])))
|
|
return null;
|
|
|
|
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
|
if (user == null)
|
|
return null;
|
|
|
|
if (user.IsDisabled)
|
|
return null;
|
|
|
|
return new UserDto
|
|
{
|
|
Id = user.Id,
|
|
Mobile = user.Mobile,
|
|
Name = user.Name,
|
|
Family = user.Family,
|
|
Role = user.Role,
|
|
IsDisabled = user.IsDisabled
|
|
};
|
|
}
|
|
|
|
private string GenerateToken(int userId)
|
|
{
|
|
var expiresAt = DateTimeOffset.UtcNow.AddDays(30).ToUnixTimeSeconds();
|
|
var payload = $"{userId}.{expiresAt}";
|
|
var signature = Convert.ToBase64String(
|
|
HMACSHA256.HashData(Encoding.UTF8.GetBytes(_tokenSecret), Encoding.UTF8.GetBytes(payload)));
|
|
return $"{payload}.{signature}";
|
|
}
|
|
|
|
}
|