diff --git a/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj b/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj
index f292b0e..793a4d9 100644
--- a/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj
+++ b/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/CMSMicroservice.Application/Common/Extensions/UtilExtensions.cs b/src/CMSMicroservice.Application/Common/Extensions/UtilExtensions.cs
new file mode 100644
index 0000000..42c258a
--- /dev/null
+++ b/src/CMSMicroservice.Application/Common/Extensions/UtilExtensions.cs
@@ -0,0 +1,38 @@
+using System.Security.Cryptography;
+
+namespace CMSMicroservice.Application.Common.Extensions;
+public static class UtilExtensions
+{
+ public static string NormalizeIranMobile(this string input)
+ {
+ if (string.IsNullOrWhiteSpace(input)) throw new ArgumentException("mobile is empty");
+ var m = new string(input.Where(char.IsDigit).ToArray());
+
+ if (m.StartsWith("0098")) m = m[4..];
+ else if (m.StartsWith("098")) m = m[3..];
+ else if (m.StartsWith("98")) m = m[2..];
+ if (!m.StartsWith("0")) m = "0" + m;
+
+ if (m.Length != 11 || !m.StartsWith("09"))
+ throw new ArgumentException("شماره موبایل نامعتبر است.");
+ return m;
+ }
+ public static string Generate(int digits = 10, bool firstDigitNonZero = false)
+ {
+ if (digits <= 0) throw new ArgumentOutOfRangeException(nameof(digits));
+
+ var chars = new char[digits];
+
+ // رقم اول
+ if (firstDigitNonZero)
+ chars[0] = (char)('0' + RandomNumberGenerator.GetInt32(1, 10)); // 1..9
+ else
+ chars[0] = (char)('0' + RandomNumberGenerator.GetInt32(10)); // 0..9
+
+ // بقیه ارقام
+ for (int i = 1; i < digits; i++)
+ chars[i] = (char)('0' + RandomNumberGenerator.GetInt32(10));
+
+ return new string(chars);
+ }
+}
diff --git a/src/CMSMicroservice.Application/Common/Interfaces/IApplicationDbContext.cs b/src/CMSMicroservice.Application/Common/Interfaces/IApplicationDbContext.cs
index 3d0f94a..f173703 100644
--- a/src/CMSMicroservice.Application/Common/Interfaces/IApplicationDbContext.cs
+++ b/src/CMSMicroservice.Application/Common/Interfaces/IApplicationDbContext.cs
@@ -2,11 +2,12 @@ namespace CMSMicroservice.Application.Common.Interfaces;
public interface IApplicationDbContext
{
- DbSet Users { get; }
DbSet UserAddresss { get; }
DbSet Packages { get; }
DbSet UserOrders { get; }
DbSet Roles { get; }
DbSet UserRoles { get; }
+ DbSet Users { get; }
+ DbSet OtpTokens { get; }
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
\ No newline at end of file
diff --git a/src/CMSMicroservice.Application/Common/Mappings/OtpTokenProfile.cs b/src/CMSMicroservice.Application/Common/Mappings/OtpTokenProfile.cs
new file mode 100644
index 0000000..96ed714
--- /dev/null
+++ b/src/CMSMicroservice.Application/Common/Mappings/OtpTokenProfile.cs
@@ -0,0 +1,10 @@
+namespace CMSMicroservice.Application.Common.Mappings;
+
+public class OtpTokenProfile : IRegister
+{
+ void IRegister.Register(TypeAdapterConfig config)
+ {
+ //config.NewConfig()
+ // .Map(dest => dest.FullName, src => $"{src.Firstname} {src.Lastname}");
+ }
+}
diff --git a/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommand.cs b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommand.cs
new file mode 100644
index 0000000..dc2181b
--- /dev/null
+++ b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommand.cs
@@ -0,0 +1,9 @@
+namespace CMSMicroservice.Application.OtpTokenCQ.Commands.CreateNewOtpToken;
+public record CreateNewOtpTokenCommand : IRequest
+{
+ //موبایل مقصد
+ public string Mobile { get; init; }
+ //مقصود
+ public string Purpose { get; init; }
+
+}
\ No newline at end of file
diff --git a/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandHandler.cs b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandHandler.cs
new file mode 100644
index 0000000..79dd318
--- /dev/null
+++ b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandHandler.cs
@@ -0,0 +1,83 @@
+using CMSMicroservice.Domain.Events;
+using Microsoft.Extensions.Configuration;
+using System.Security.Cryptography;
+using System.Text;
+namespace CMSMicroservice.Application.OtpTokenCQ.Commands.CreateNewOtpToken;
+public class CreateNewOtpTokenCommandHandler : IRequestHandler
+{
+ private readonly IApplicationDbContext _context;
+ private readonly IConfiguration _cfg;
+
+ public CreateNewOtpTokenCommandHandler(IApplicationDbContext context, IConfiguration cfg)
+ {
+ _context = context;
+ _cfg = cfg;
+ }
+
+ const int CodeLength = 6; // 4-6 مناسب است
+ static readonly TimeSpan Ttl = TimeSpan.FromMinutes(2);
+ static readonly TimeSpan Cooldown = TimeSpan.FromSeconds(60); // فاصله ارسال مجدد
+ public async Task Handle(CreateNewOtpTokenCommand request,
+ CancellationToken cancellationToken)
+ {
+ var mobile = request.Mobile.NormalizeIranMobile();
+ var purpose = request.Purpose?.ToLowerInvariant() ?? "signup";
+
+ // ریتلیمیت ساده: اگر هنوز کدی فعال و تازه داریم، اجازه نده
+ var now = DateTime.Now;
+ var lastActive = await _context.OtpTokens
+ .Where(o => o.Mobile == mobile && o.Purpose == purpose && !o.IsUsed && o.ExpiresAt > now)
+ .OrderByDescending(o => o.Created)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (lastActive is not null && (now - lastActive.Created) < Cooldown)
+ return new CreateNewOtpTokenResponseDto()
+ {
+ Success = false,
+ Message = "لطفاً کمی بعد دوباره تلاش کنید."
+ };
+
+ // تولید کد
+ var code = GenerateNumericCode(CodeLength);
+ var codeHash = Hash(code);
+
+ var entity = new OtpToken
+ {
+ Mobile = mobile,
+ Purpose = purpose,
+ CodeHash = codeHash,
+ ExpiresAt = now.Add(Ttl),
+ Attempts = 0,
+ IsUsed = false
+ };
+ await _context.OtpTokens.AddAsync(entity, cancellationToken);
+ entity.AddDomainEvent(new CreateNewOtpTokenEvent(entity));
+ await _context.SaveChangesAsync(cancellationToken);
+ return new CreateNewOtpTokenResponseDto()
+ {
+ Success = true,
+ Message = "کد ارسال شد.",
+ Code = code
+ };
+ }
+
+ // --- utilها ---
+ private string GenerateNumericCode(int len)
+ {
+ // امنتر از Random(): تولید ارقام با RNG
+ var bytes = new byte[len];
+ RandomNumberGenerator.Fill(bytes);
+ var sb = new StringBuilder(len);
+ foreach (var b in bytes) sb.Append((b % 10).ToString());
+ return sb.ToString();
+ }
+
+ private string Hash(string code)
+ {
+ // HMAC با secret اپ (نیازی به ذخیره salt جدا نیست)
+ var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not set");
+ using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
+ var hash = h.ComputeHash(Encoding.UTF8.GetBytes(code));
+ return Convert.ToHexString(hash);
+ }
+}
diff --git a/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandValidator.cs b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandValidator.cs
new file mode 100644
index 0000000..8ab92fb
--- /dev/null
+++ b/src/CMSMicroservice.Application/OtpTokenCQ/Commands/CreateNewOtpToken/CreateNewOtpTokenCommandValidator.cs
@@ -0,0 +1,18 @@
+namespace CMSMicroservice.Application.OtpTokenCQ.Commands.CreateNewOtpToken;
+public class CreateNewOtpTokenCommandValidator : AbstractValidator
+{
+ public CreateNewOtpTokenCommandValidator()
+ {
+ RuleFor(model => model.Mobile)
+ .NotEmpty();
+ RuleFor(model => model.Purpose)
+ .NotEmpty();
+ }
+ public Func
diff --git a/src/CMSMicroservice.Protobuf/Protos/otptoken.proto b/src/CMSMicroservice.Protobuf/Protos/otptoken.proto
new file mode 100644
index 0000000..ed89925
--- /dev/null
+++ b/src/CMSMicroservice.Protobuf/Protos/otptoken.proto
@@ -0,0 +1,87 @@
+syntax = "proto3";
+
+package otptoken;
+
+import "public_messages.proto";
+import "google/protobuf/empty.proto";
+import "google/protobuf/wrappers.proto";
+import "google/protobuf/duration.proto";
+import "google/protobuf/timestamp.proto";
+import "google/api/annotations.proto";
+
+option csharp_namespace = "CMSMicroservice.Protobuf.Protos.OtpToken";
+
+service OtpTokenContract
+{
+ rpc CreateNewOtpToken(CreateNewOtpTokenRequest) returns (CreateNewOtpTokenResponse){
+ option (google.api.http) = {
+ post: "/CreateNewOtpToken"
+ body: "*"
+ };
+ };
+ rpc VerifyOtpToken(VerifyOtpTokenRequest) returns (VerifyOtpTokenResponse){
+ option (google.api.http) = {
+ post: "/VerifyOtpToken"
+ body: "*"
+ };
+ };
+ rpc GetAllOtpTokenByFilter(GetAllOtpTokenByFilterRequest) returns (GetAllOtpTokenByFilterResponse){
+ option (google.api.http) = {
+ get: "/GetAllOtpTokenByFilter"
+
+ };
+ };
+}
+message CreateNewOtpTokenRequest
+{
+ string mobile = 1;
+ string purpose = 2;
+}
+message CreateNewOtpTokenResponse
+{
+ bool success = 1;
+ string message = 2;
+ google.protobuf.StringValue code = 3;
+}
+message VerifyOtpTokenRequest
+{
+ string mobile = 1;
+ string purpose = 2;
+ string code = 3;
+}
+message VerifyOtpTokenResponse
+{
+ bool success = 1;
+ string message = 2;
+}
+message GetAllOtpTokenByFilterRequest
+{
+ messages.PaginationState pagination_state = 1;
+ google.protobuf.StringValue sort_by = 2;
+ GetAllOtpTokenByFilterFilter filter = 3;
+}
+message GetAllOtpTokenByFilterFilter
+{
+ google.protobuf.Int64Value id = 1;
+ google.protobuf.StringValue mobile = 2;
+ google.protobuf.StringValue purpose = 3;
+ google.protobuf.StringValue code_hash = 4;
+ google.protobuf.Timestamp expires_at = 5;
+ google.protobuf.Int32Value attempts = 6;
+ google.protobuf.BoolValue is_used = 7;
+}
+message GetAllOtpTokenByFilterResponse
+{
+ messages.MetaData meta_data = 1;
+ repeated GetAllOtpTokenByFilterResponseModel models = 2;
+}
+message GetAllOtpTokenByFilterResponseModel
+{
+ int64 id = 1;
+ string mobile = 2;
+ string purpose = 3;
+ string code_hash = 4;
+ google.protobuf.Timestamp expires_at = 5;
+ int32 attempts = 6;
+ bool is_used = 7;
+}
diff --git a/src/CMSMicroservice.Protobuf/Protos/user.proto b/src/CMSMicroservice.Protobuf/Protos/user.proto
index 0af6c13..6deaa50 100644
--- a/src/CMSMicroservice.Protobuf/Protos/user.proto
+++ b/src/CMSMicroservice.Protobuf/Protos/user.proto
@@ -62,10 +62,8 @@ message UpdateUserRequest
int64 id = 1;
google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue last_name = 3;
- string mobile = 4;
- google.protobuf.StringValue national_code = 5;
- google.protobuf.StringValue avatar_path = 6;
- google.protobuf.Int64Value parent_id = 7;
+ google.protobuf.StringValue national_code = 4;
+ google.protobuf.StringValue avatar_path = 5;
}
message DeleteUserRequest
{
@@ -84,6 +82,9 @@ message GetUserResponse
google.protobuf.StringValue national_code = 5;
google.protobuf.StringValue avatar_path = 6;
google.protobuf.Int64Value parent_id = 7;
+ string referral_code = 8;
+ bool is_mobile_verified = 9;
+ google.protobuf.Timestamp mobile_verified_at = 10;
}
message GetAllUserByFilterRequest
{
@@ -100,6 +101,9 @@ message GetAllUserByFilterFilter
google.protobuf.StringValue national_code = 5;
google.protobuf.StringValue avatar_path = 6;
google.protobuf.Int64Value parent_id = 7;
+ google.protobuf.StringValue referral_code = 8;
+ google.protobuf.BoolValue is_mobile_verified = 9;
+ google.protobuf.Timestamp mobile_verified_at = 10;
}
message GetAllUserByFilterResponse
{
@@ -115,4 +119,7 @@ message GetAllUserByFilterResponseModel
google.protobuf.StringValue national_code = 5;
google.protobuf.StringValue avatar_path = 6;
google.protobuf.Int64Value parent_id = 7;
+ string referral_code = 8;
+ bool is_mobile_verified = 9;
+ google.protobuf.Timestamp mobile_verified_at = 10;
}
diff --git a/src/CMSMicroservice.Protobuf/Validator/OtpToken/CreateNewOtpTokenRequestValidator.cs b/src/CMSMicroservice.Protobuf/Validator/OtpToken/CreateNewOtpTokenRequestValidator.cs
new file mode 100644
index 0000000..5633ef5
--- /dev/null
+++ b/src/CMSMicroservice.Protobuf/Validator/OtpToken/CreateNewOtpTokenRequestValidator.cs
@@ -0,0 +1,21 @@
+using FluentValidation;
+using CMSMicroservice.Protobuf.Protos.OtpToken;
+namespace CMSMicroservice.Protobuf.Validator.OtpToken;
+
+public class CreateNewOtpTokenRequestValidator : AbstractValidator
+{
+ public CreateNewOtpTokenRequestValidator()
+ {
+ RuleFor(model => model.Mobile)
+ .NotEmpty();
+ RuleFor(model => model.Purpose)
+ .NotEmpty();
+ }
+ public Func>> ValidateValue => async (model, propertyName) =>
+ {
+ var result = await ValidateAsync(ValidationContext.CreateWithOptions((CreateNewOtpTokenRequest)model, x => x.IncludeProperties(propertyName)));
+ if (result.IsValid)
+ return Array.Empty();
+ return result.Errors.Select(e => e.ErrorMessage);
+ };
+}
diff --git a/src/CMSMicroservice.Protobuf/Validator/OtpToken/GetAllOtpTokenByFilterRequestValidator.cs b/src/CMSMicroservice.Protobuf/Validator/OtpToken/GetAllOtpTokenByFilterRequestValidator.cs
new file mode 100644
index 0000000..07be630
--- /dev/null
+++ b/src/CMSMicroservice.Protobuf/Validator/OtpToken/GetAllOtpTokenByFilterRequestValidator.cs
@@ -0,0 +1,17 @@
+using FluentValidation;
+using CMSMicroservice.Protobuf.Protos.OtpToken;
+namespace CMSMicroservice.Protobuf.Validator.OtpToken;
+
+public class GetAllOtpTokenByFilterRequestValidator : AbstractValidator
+{
+ public GetAllOtpTokenByFilterRequestValidator()
+ {
+ }
+ public Func>> ValidateValue => async (model, propertyName) =>
+ {
+ var result = await ValidateAsync(ValidationContext.CreateWithOptions((GetAllOtpTokenByFilterRequest)model, x => x.IncludeProperties(propertyName)));
+ if (result.IsValid)
+ return Array.Empty();
+ return result.Errors.Select(e => e.ErrorMessage);
+ };
+}
diff --git a/src/CMSMicroservice.Protobuf/Validator/OtpToken/VerifyOtpTokenRequestValidator.cs b/src/CMSMicroservice.Protobuf/Validator/OtpToken/VerifyOtpTokenRequestValidator.cs
new file mode 100644
index 0000000..116aedc
--- /dev/null
+++ b/src/CMSMicroservice.Protobuf/Validator/OtpToken/VerifyOtpTokenRequestValidator.cs
@@ -0,0 +1,23 @@
+using FluentValidation;
+using CMSMicroservice.Protobuf.Protos.OtpToken;
+namespace CMSMicroservice.Protobuf.Validator.OtpToken;
+
+public class VerifyOtpTokenRequestValidator : AbstractValidator
+{
+ public VerifyOtpTokenRequestValidator()
+ {
+ RuleFor(model => model.Mobile)
+ .NotEmpty();
+ RuleFor(model => model.Purpose)
+ .NotEmpty();
+ RuleFor(model => model.Code)
+ .NotEmpty();
+ }
+ public Func>> ValidateValue => async (model, propertyName) =>
+ {
+ var result = await ValidateAsync(ValidationContext.CreateWithOptions((VerifyOtpTokenRequest)model, x => x.IncludeProperties(propertyName)));
+ if (result.IsValid)
+ return Array.Empty();
+ return result.Errors.Select(e => e.ErrorMessage);
+ };
+}
diff --git a/src/CMSMicroservice.Protobuf/Validator/User/UpdateUserRequestValidator.cs b/src/CMSMicroservice.Protobuf/Validator/User/UpdateUserRequestValidator.cs
index f65bc5f..335859e 100644
--- a/src/CMSMicroservice.Protobuf/Validator/User/UpdateUserRequestValidator.cs
+++ b/src/CMSMicroservice.Protobuf/Validator/User/UpdateUserRequestValidator.cs
@@ -8,8 +8,6 @@ public class UpdateUserRequestValidator : AbstractValidator
{
RuleFor(model => model.Id)
.NotNull();
- RuleFor(model => model.Mobile)
- .NotEmpty();
}
public Func>> ValidateValue => async (model, propertyName) =>
{
diff --git a/src/CMSMicroservice.WebApi/Common/Mappings/OtpTokenProfile.cs b/src/CMSMicroservice.WebApi/Common/Mappings/OtpTokenProfile.cs
new file mode 100644
index 0000000..bbbef34
--- /dev/null
+++ b/src/CMSMicroservice.WebApi/Common/Mappings/OtpTokenProfile.cs
@@ -0,0 +1,10 @@
+namespace CMSMicroservice.WebApi.Common.Mappings;
+
+public class OtpTokenProfile : IRegister
+{
+ void IRegister.Register(TypeAdapterConfig config)
+ {
+ //config.NewConfig()
+ // .Map(dest => dest.FullName, src => $"{src.Firstname} {src.Lastname}");
+ }
+}
diff --git a/src/CMSMicroservice.WebApi/Services/OtpTokenService.cs b/src/CMSMicroservice.WebApi/Services/OtpTokenService.cs
new file mode 100644
index 0000000..694661e
--- /dev/null
+++ b/src/CMSMicroservice.WebApi/Services/OtpTokenService.cs
@@ -0,0 +1,27 @@
+using CMSMicroservice.Protobuf.Protos.OtpToken;
+using CMSMicroservice.WebApi.Common.Services;
+using CMSMicroservice.Application.OtpTokenCQ.Commands.CreateNewOtpToken;
+using CMSMicroservice.Application.OtpTokenCQ.Commands.VerifyOtpToken;
+using CMSMicroservice.Application.OtpTokenCQ.Queries.GetAllOtpTokenByFilter;
+namespace CMSMicroservice.WebApi.Services;
+public class OtpTokenService : OtpTokenContract.OtpTokenContractBase
+{
+ private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
+
+ public OtpTokenService(IDispatchRequestToCQRS dispatchRequestToCQRS)
+ {
+ _dispatchRequestToCQRS = dispatchRequestToCQRS;
+ }
+ public override async Task CreateNewOtpToken(CreateNewOtpTokenRequest request, ServerCallContext context)
+ {
+ return await _dispatchRequestToCQRS.Handle(request, context);
+ }
+ public override async Task VerifyOtpToken(VerifyOtpTokenRequest request, ServerCallContext context)
+ {
+ return await _dispatchRequestToCQRS.Handle(request, context);
+ }
+ public override async Task GetAllOtpTokenByFilter(GetAllOtpTokenByFilterRequest request, ServerCallContext context)
+ {
+ return await _dispatchRequestToCQRS.Handle(request, context);
+ }
+}
diff --git a/src/CMSMicroservice.WebApi/appsettings.json b/src/CMSMicroservice.WebApi/appsettings.json
index 7242cd5..9b32709 100644
--- a/src/CMSMicroservice.WebApi/appsettings.json
+++ b/src/CMSMicroservice.WebApi/appsettings.json
@@ -7,6 +7,9 @@
"DefaultConnection": "Data Source=185.252.31.42,2019; Initial Catalog=Foursat;User ID=afrino;Password=87zH26nbqT%;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient"
},
+ "Otp": {
+ "Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
+ },
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {