516 lines
18 KiB
C#
516 lines
18 KiB
C#
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,
|
|
EmailAuthChallengeStore emailAuthChallengeStore,
|
|
IEmailCodeSender emailCodeSender) : 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 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, isRegistered: true);
|
|
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)
|
|
{
|
|
return NotFound("Phone code login is available only for existing accounts. Register by email.");
|
|
}
|
|
|
|
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("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)
|
|
{
|
|
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<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)
|
|
{
|
|
var candidate = $"user_{Guid.NewGuid():N}".ToLowerInvariant();
|
|
var exists = await dbContext.Users
|
|
.AsNoTracking()
|
|
.AnyAsync(x => x.NormalizedUsername == candidate.ToUpperInvariant(), cancellationToken);
|
|
if (!exists)
|
|
{
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
}
|