Expand Ikar messaging and Android release support

This commit is contained in:
Курнат Андрей
2026-06-20 19:59:53 +03:00
parent 22e19cdfdb
commit e18f5f5ad6
126 changed files with 15172 additions and 2206 deletions
+249 -9
View File
@@ -16,7 +16,9 @@ public sealed class AuthController(
IkarDbContext dbContext,
TokenService tokenService,
PresenceTracker presenceTracker,
PhoneAuthChallengeStore phoneAuthChallengeStore) : ControllerBase
PhoneAuthChallengeStore phoneAuthChallengeStore,
EmailAuthChallengeStore emailAuthChallengeStore,
IEmailCodeSender emailCodeSender) : ControllerBase
{
private readonly PasswordHasher<User> _passwordHasher = new();
@@ -146,12 +148,17 @@ public sealed class AuthController(
var user = await dbContext.Users
.AsNoTracking()
.SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken);
if (user is null)
{
return NotFound("Phone code login is available only for existing accounts. Register by email.");
}
if (user?.IsBlocked == true)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, user is not null);
var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, isRegistered: true);
return Ok(new PhoneCodeChallengeDto(
challenge.Id,
challenge.ExpiresAt,
@@ -186,13 +193,7 @@ public sealed class AuthController(
if (user is null)
{
var displayName = request.DisplayName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(displayName))
{
return ValidationProblem("Display name is required for a new account.");
}
user = await CreatePhoneOnlyUserAsync(normalizedPhoneNumber, request.PhoneNumber, displayName, cancellationToken);
return NotFound("Phone code login is available only for existing accounts. Register by email.");
}
if (user.IsBlocked)
@@ -207,6 +208,147 @@ public sealed class AuthController(
return Ok(session with { User = user.ToDto(presenceTracker) });
}
[HttpPost("request-email-code")]
[AllowAnonymous]
public async Task<ActionResult<EmailCodeChallengeDto>> RequestEmailCode(
RequestEmailCodeRequest request,
CancellationToken cancellationToken)
{
var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email);
if (normalizedEmail is null)
{
return ValidationProblem("Valid email is required.");
}
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
if (normalizedPhoneNumber is null)
{
return ValidationProblem("Valid phone number is required.");
}
var email = request.Email.Trim();
var (user, conflictMessage) = await LoadEmailPhoneUserAsync(
normalizedEmail,
normalizedPhoneNumber,
cancellationToken);
if (conflictMessage is not null)
{
return Conflict(conflictMessage);
}
if (RequiresAllowedRegistrationDomain(user, normalizedEmail) &&
!EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail))
{
return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage);
}
if (user?.IsBlocked == true)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
var challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null);
try
{
await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken);
}
catch
{
emailAuthChallengeStore.Remove(challenge.Id);
throw;
}
return Ok(new EmailCodeChallengeDto(
challenge.Id,
challenge.ExpiresAt,
challenge.IsRegistered));
}
[HttpPost("verify-email-code")]
[AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> VerifyEmailCode(
VerifyEmailCodeRequest request,
CancellationToken cancellationToken)
{
var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email);
if (normalizedEmail is null)
{
return ValidationProblem("Valid email is required.");
}
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
if (normalizedPhoneNumber is null)
{
return ValidationProblem("Valid phone number is required.");
}
var challenge = emailAuthChallengeStore.Consume(
request.ChallengeId?.Trim() ?? string.Empty,
normalizedEmail,
normalizedPhoneNumber,
request.Code?.Trim() ?? string.Empty);
if (challenge is null)
{
return Unauthorized("Invalid or expired verification code.");
}
var (user, conflictMessage) = await LoadEmailPhoneUserAsync(
normalizedEmail,
normalizedPhoneNumber,
cancellationToken);
if (conflictMessage is not null)
{
return Conflict(conflictMessage);
}
if (RequiresAllowedRegistrationDomain(user, normalizedEmail) &&
!EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail))
{
return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage);
}
if (user is null)
{
var displayName = request.DisplayName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(displayName))
{
return ValidationProblem("Display name is required for a new account.");
}
user = await CreateEmailPhoneUserAsync(
normalizedEmail,
request.Email,
normalizedPhoneNumber,
request.PhoneNumber,
displayName,
cancellationToken);
}
if (user.IsBot)
{
return Unauthorized("Bot accounts cannot sign in through the user client.");
}
if (user.IsBlocked)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
await EnsureEmailPhoneLinkedAsync(
user,
normalizedEmail,
request.Email,
normalizedPhoneNumber,
request.PhoneNumber,
cancellationToken);
var session = await tokenService.CreateSessionAsync(
user,
ClientVersionRequestReader.Read(Request),
cancellationToken);
return Ok(session with { User = user.ToDto(presenceTracker) });
}
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken)
@@ -258,6 +400,104 @@ public sealed class AuthController(
return user;
}
private async Task<User> CreateEmailPhoneUserAsync(
string normalizedEmail,
string rawEmail,
string normalizedPhoneNumber,
string rawPhoneNumber,
string displayName,
CancellationToken cancellationToken)
{
var username = await GenerateInternalUsernameAsync(cancellationToken);
var user = new User
{
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = displayName,
Email = rawEmail.Trim(),
NormalizedEmail = normalizedEmail,
PhoneNumber = rawPhoneNumber.Trim(),
NormalizedPhoneNumber = normalizedPhoneNumber
};
user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N"));
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync(cancellationToken);
return user;
}
private async Task<(User? User, string? ConflictMessage)> LoadEmailPhoneUserAsync(
string normalizedEmail,
string normalizedPhoneNumber,
CancellationToken cancellationToken)
{
var users = await dbContext.Users
.Where(x =>
x.NormalizedEmail == normalizedEmail ||
x.NormalizedPhoneNumber == normalizedPhoneNumber)
.ToListAsync(cancellationToken);
var emailUser = users.SingleOrDefault(x => x.NormalizedEmail == normalizedEmail);
var phoneUser = users.SingleOrDefault(x => x.NormalizedPhoneNumber == normalizedPhoneNumber);
if (emailUser is not null && phoneUser is not null && emailUser.Id != phoneUser.Id)
{
return (null, "Email and phone number belong to different accounts.");
}
var user = emailUser ?? phoneUser;
if (user is null)
{
return (null, null);
}
if (!string.IsNullOrWhiteSpace(user.NormalizedEmail) &&
!string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal))
{
return (null, "Phone number is already linked to another email.");
}
if (!string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber) &&
!string.Equals(user.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal))
{
return (null, "Email is already linked to another phone number.");
}
return (user, null);
}
private static bool RequiresAllowedRegistrationDomain(User? user, string normalizedEmail) =>
user is null ||
!string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal);
private async Task EnsureEmailPhoneLinkedAsync(
User user,
string normalizedEmail,
string rawEmail,
string normalizedPhoneNumber,
string rawPhoneNumber,
CancellationToken cancellationToken)
{
var changed = false;
if (string.IsNullOrWhiteSpace(user.NormalizedEmail))
{
user.Email = rawEmail.Trim();
user.NormalizedEmail = normalizedEmail;
changed = true;
}
if (string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber))
{
user.PhoneNumber = rawPhoneNumber.Trim();
user.NormalizedPhoneNumber = normalizedPhoneNumber;
changed = true;
}
if (changed)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private async Task<string> GenerateInternalUsernameAsync(CancellationToken cancellationToken)
{
while (true)