Update Ikar server and Android client

This commit is contained in:
Курнат Андрей
2026-05-17 22:23:43 +03:00
commit 22e19cdfdb
258 changed files with 51047 additions and 0 deletions
@@ -0,0 +1,275 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Ikar.Server.Data;
using Ikar.Server.Data.Entities;
using Ikar.Server.Infrastructure;
using Ikar.Server.Infrastructure.Auth;
using Ikar.Shared;
namespace Ikar.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public sealed class AuthController(
IkarDbContext dbContext,
TokenService tokenService,
PresenceTracker presenceTracker,
PhoneAuthChallengeStore phoneAuthChallengeStore) : ControllerBase
{
private readonly PasswordHasher<User> _passwordHasher = new();
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> Register(RegisterRequest request, CancellationToken cancellationToken)
{
var username = request.Username?.Trim() ?? string.Empty;
var displayName = request.DisplayName?.Trim() ?? string.Empty;
var password = request.Password ?? string.Empty;
var phoneNumber = request.PhoneNumber?.Trim() ?? string.Empty;
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber);
if (string.IsNullOrWhiteSpace(username) ||
string.IsNullOrWhiteSpace(displayName) ||
password.Length < 6 ||
normalizedPhoneNumber is null)
{
return ValidationProblem("Username, display name, phone number and a password of at least 6 characters are required.");
}
var normalizedUsername = username.ToUpperInvariant();
if (await dbContext.Users.AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken))
{
return Conflict("Username is already taken.");
}
if (await dbContext.Users.AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken))
{
return Conflict("Phone number is already used.");
}
var user = new User
{
Username = username,
NormalizedUsername = normalizedUsername,
DisplayName = displayName,
PhoneNumber = phoneNumber,
NormalizedPhoneNumber = normalizedPhoneNumber
};
user.PasswordHash = _passwordHasher.HashPassword(user, password);
dbContext.Users.Add(user);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
var usernameTaken = await dbContext.Users
.AsNoTracking()
.AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken);
if (usernameTaken)
{
return Conflict("Username is already taken.");
}
var phoneTaken = await dbContext.Users
.AsNoTracking()
.AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken);
if (phoneTaken)
{
return Conflict("Phone number is already used.");
}
throw;
}
var session = await tokenService.CreateSessionAsync(
user,
ClientVersionRequestReader.Read(Request),
cancellationToken);
return Ok(session with { User = user.ToDto(presenceTracker) });
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> Login(LoginRequest request, CancellationToken cancellationToken)
{
var username = request.Username?.Trim() ?? string.Empty;
var password = request.Password ?? string.Empty;
var normalizedUsername = username.ToUpperInvariant();
var user = await dbContext.Users.SingleOrDefaultAsync(
x => x.NormalizedUsername == normalizedUsername,
cancellationToken);
if (user is null)
{
return Unauthorized("Invalid username or password.");
}
if (user.IsBot)
{
return Unauthorized("Bot accounts cannot sign in through the user client.");
}
if (user.IsBlocked)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verification == PasswordVerificationResult.Failed)
{
return Unauthorized("Invalid username or password.");
}
var session = await tokenService.CreateSessionAsync(
user,
ClientVersionRequestReader.Read(Request),
cancellationToken);
return Ok(session with { User = user.ToDto(presenceTracker) });
}
[HttpPost("request-code")]
[AllowAnonymous]
public async Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode(
RequestPhoneCodeRequest request,
CancellationToken cancellationToken)
{
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
if (normalizedPhoneNumber is null)
{
return ValidationProblem("Valid phone number is required.");
}
var user = await dbContext.Users
.AsNoTracking()
.SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken);
if (user?.IsBlocked == true)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, user is not null);
return Ok(new PhoneCodeChallengeDto(
challenge.Id,
challenge.ExpiresAt,
challenge.Code,
challenge.IsRegistered));
}
[HttpPost("verify-code")]
[AllowAnonymous]
public async Task<ActionResult<AuthSessionDto>> VerifyPhoneCode(
VerifyPhoneCodeRequest request,
CancellationToken cancellationToken)
{
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
if (normalizedPhoneNumber is null)
{
return ValidationProblem("Valid phone number is required.");
}
var challenge = phoneAuthChallengeStore.Consume(
request.ChallengeId?.Trim() ?? string.Empty,
normalizedPhoneNumber,
request.Code?.Trim() ?? string.Empty);
if (challenge is null)
{
return Unauthorized("Invalid or expired verification code.");
}
var user = await dbContext.Users.SingleOrDefaultAsync(
x => x.NormalizedPhoneNumber == normalizedPhoneNumber,
cancellationToken);
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);
}
if (user.IsBlocked)
{
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
}
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)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Unauthorized();
}
var session = await tokenService.RefreshSessionAsync(
request.RefreshToken,
ClientVersionRequestReader.Read(Request),
cancellationToken);
if (session is null)
{
return Unauthorized();
}
return Ok(session with { User = session.User with { IsOnline = presenceTracker.IsOnline(session.User.Id) } });
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken);
return NoContent();
}
private async Task<User> CreatePhoneOnlyUserAsync(
string normalizedPhoneNumber,
string rawPhoneNumber,
string displayName,
CancellationToken cancellationToken)
{
var username = await GenerateInternalUsernameAsync(cancellationToken);
var user = new User
{
Username = username,
NormalizedUsername = username.ToUpperInvariant(),
DisplayName = displayName,
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<string> GenerateInternalUsernameAsync(CancellationToken cancellationToken)
{
while (true)
{
var candidate = $"user_{Guid.NewGuid():N}".ToLowerInvariant();
var exists = await dbContext.Users
.AsNoTracking()
.AnyAsync(x => x.NormalizedUsername == candidate.ToUpperInvariant(), cancellationToken);
if (!exists)
{
return candidate;
}
}
}
}