331 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			331 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.ComponentModel.DataAnnotations;
 | 
						|
using Microsoft.AspNetCore.Mvc;
 | 
						|
using NodaTime;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using DysonNetwork.Pass.Localization;
 | 
						|
using DysonNetwork.Shared.GeoIp;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using Microsoft.Extensions.Localization;
 | 
						|
using AccountService = DysonNetwork.Pass.Account.AccountService;
 | 
						|
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
 | 
						|
namespace DysonNetwork.Pass.Auth;
 | 
						|
 | 
						|
[ApiController]
 | 
						|
[Route("/api/auth")]
 | 
						|
public class AuthController(
 | 
						|
    AppDatabase db,
 | 
						|
    AccountService accounts,
 | 
						|
    AuthService auth,
 | 
						|
    GeoIpService geo,
 | 
						|
    ActionLogService als,
 | 
						|
    RingService.RingServiceClient pusher,
 | 
						|
    IConfiguration configuration,
 | 
						|
    IStringLocalizer<NotificationResource> localizer,
 | 
						|
    ILogger<AuthController> logger
 | 
						|
) : ControllerBase
 | 
						|
{
 | 
						|
    private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
 | 
						|
 | 
						|
    public class ChallengeRequest
 | 
						|
    {
 | 
						|
        [Required] public ClientPlatform Platform { get; set; }
 | 
						|
        [Required] [MaxLength(256)] public string Account { get; set; } = null!;
 | 
						|
        [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
 | 
						|
        [MaxLength(1024)] public string? DeviceName { get; set; }
 | 
						|
        public List<string> Audiences { get; set; } = new();
 | 
						|
        public List<string> Scopes { get; set; } = new();
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("challenge")]
 | 
						|
    public async Task<ActionResult<SnAuthChallenge>> CreateChallenge([FromBody] ChallengeRequest request)
 | 
						|
    {
 | 
						|
        var account = await accounts.LookupAccount(request.Account);
 | 
						|
        if (account is null) return NotFound("Account was not found.");
 | 
						|
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var punishment = await db.Punishments
 | 
						|
            .Where(e => e.AccountId == account.Id)
 | 
						|
            .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
 | 
						|
            .Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (punishment is not null)
 | 
						|
            return StatusCode(
 | 
						|
                423,
 | 
						|
                $"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
 | 
						|
            );
 | 
						|
 | 
						|
        var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
 | 
						|
        var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
 | 
						|
 | 
						|
        request.DeviceName ??= userAgent;
 | 
						|
 | 
						|
        var device =
 | 
						|
            await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
 | 
						|
 | 
						|
        // Trying to pick up challenges from the same IP address and user agent
 | 
						|
        var existingChallenge = await db.AuthChallenges
 | 
						|
            .Where(e => e.AccountId == account.Id)
 | 
						|
            .Where(e => e.IpAddress == ipAddress)
 | 
						|
            .Where(e => e.UserAgent == userAgent)
 | 
						|
            .Where(e => e.StepRemain > 0)
 | 
						|
            .Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
 | 
						|
            .Where(e => e.Type == Shared.Models.ChallengeType.Login)
 | 
						|
            .Where(e => e.ClientId == device.Id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (existingChallenge is not null)
 | 
						|
        {
 | 
						|
            var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id)
 | 
						|
                .FirstOrDefaultAsync();
 | 
						|
            if (existingSession is null) return existingChallenge;
 | 
						|
        }
 | 
						|
 | 
						|
        var challenge = new SnAuthChallenge
 | 
						|
        {
 | 
						|
            ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
 | 
						|
            StepTotal = await auth.DetectChallengeRisk(Request, account),
 | 
						|
            Audiences = request.Audiences,
 | 
						|
            Scopes = request.Scopes,
 | 
						|
            IpAddress = ipAddress,
 | 
						|
            UserAgent = userAgent,
 | 
						|
            Location = geo.GetPointFromIp(ipAddress),
 | 
						|
            ClientId = device.Id,
 | 
						|
            AccountId = account.Id
 | 
						|
        }.Normalize();
 | 
						|
 | 
						|
        await db.AuthChallenges.AddAsync(challenge);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
 | 
						|
        als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
 | 
						|
            new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
 | 
						|
        );
 | 
						|
 | 
						|
        return challenge;
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("challenge/{id:guid}")]
 | 
						|
    public async Task<ActionResult<SnAuthChallenge>> GetChallenge([FromRoute] Guid id)
 | 
						|
    {
 | 
						|
        var challenge = await db.AuthChallenges
 | 
						|
            .Include(e => e.Account)
 | 
						|
            .ThenInclude(e => e.Profile)
 | 
						|
            .FirstOrDefaultAsync(e => e.Id == id);
 | 
						|
 | 
						|
        if (challenge is null)
 | 
						|
        {
 | 
						|
            logger.LogWarning("GetChallenge: challenge not found (challengeId={ChallengeId}, ip={IpAddress})",
 | 
						|
                id, HttpContext.Connection.RemoteIpAddress?.ToString());
 | 
						|
            return NotFound("Auth challenge was not found.");
 | 
						|
        }
 | 
						|
 | 
						|
        return challenge;
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("challenge/{id:guid}/factors")]
 | 
						|
    public async Task<ActionResult<List<SnAccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
 | 
						|
    {
 | 
						|
        var challenge = await db.AuthChallenges
 | 
						|
            .Include(e => e.Account)
 | 
						|
            .Include(e => e.Account.AuthFactors)
 | 
						|
            .Where(e => e.Id == id)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        return challenge is null
 | 
						|
            ? NotFound("Auth challenge was not found.")
 | 
						|
            : challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList();
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
 | 
						|
    public async Task<ActionResult> RequestFactorCode(
 | 
						|
        [FromRoute] Guid id,
 | 
						|
        [FromRoute] Guid factorId
 | 
						|
    )
 | 
						|
    {
 | 
						|
        var challenge = await db.AuthChallenges
 | 
						|
            .Include(e => e.Account)
 | 
						|
            .Where(e => e.Id == id).FirstOrDefaultAsync();
 | 
						|
        if (challenge is null) return NotFound("Auth challenge was not found.");
 | 
						|
        var factor = await db.AccountAuthFactors
 | 
						|
            .Where(e => e.Id == factorId)
 | 
						|
            .Where(e => e.Account == challenge.Account).FirstOrDefaultAsync();
 | 
						|
        if (factor is null) return NotFound("Auth factor was not found.");
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            await accounts.SendFactorCode(challenge.Account, factor);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            return BadRequest(ex.Message);
 | 
						|
        }
 | 
						|
 | 
						|
        return Ok();
 | 
						|
    }
 | 
						|
 | 
						|
    public class PerformChallengeRequest
 | 
						|
    {
 | 
						|
        [Required] public Guid FactorId { get; set; }
 | 
						|
        [Required] public string Password { get; set; } = string.Empty;
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPatch("challenge/{id:guid}")]
 | 
						|
    public async Task<ActionResult<SnAuthChallenge>> DoChallenge(
 | 
						|
        [FromRoute] Guid id,
 | 
						|
        [FromBody] PerformChallengeRequest request
 | 
						|
    )
 | 
						|
    {
 | 
						|
        var challenge = await db.AuthChallenges
 | 
						|
            .Include(e => e.Account)
 | 
						|
            .Include(authChallenge => authChallenge.Client)
 | 
						|
            .FirstOrDefaultAsync(e => e.Id == id);
 | 
						|
        if (challenge is null) return NotFound("Auth challenge was not found.");
 | 
						|
 | 
						|
        var factor = await db.AccountAuthFactors
 | 
						|
            .Where(f => f.Id == request.FactorId)
 | 
						|
            .Where(f => f.AccountId == challenge.AccountId)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (factor is null) return NotFound("Auth factor was not found.");
 | 
						|
        if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
 | 
						|
        if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
 | 
						|
 | 
						|
        if (challenge.StepRemain == 0) return challenge;
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        if (challenge.ExpiredAt.HasValue && now > challenge.ExpiredAt.Value)
 | 
						|
            return BadRequest();
 | 
						|
 | 
						|
        // prevent reusing the same factor in one challenge
 | 
						|
        if (challenge.BlacklistFactors.Contains(factor.Id))
 | 
						|
            return BadRequest("Auth factor already used.");
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            if (await accounts.VerifyFactorCode(factor, request.Password))
 | 
						|
            {
 | 
						|
                challenge.StepRemain -= factor.Trustworthy;
 | 
						|
                challenge.StepRemain = Math.Max(0, challenge.StepRemain);
 | 
						|
                challenge.BlacklistFactors.Add(factor.Id);
 | 
						|
                db.Update(challenge);
 | 
						|
                als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
 | 
						|
                    new Dictionary<string, object>
 | 
						|
                    {
 | 
						|
                        { "challenge_id", challenge.Id },
 | 
						|
                        { "factor_id", factor.Id }
 | 
						|
                    }, Request, challenge.Account
 | 
						|
                );
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                throw new ArgumentException("Invalid password.");
 | 
						|
            }
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            challenge.FailedAttempts++;
 | 
						|
            db.Update(challenge);
 | 
						|
            als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
 | 
						|
                new Dictionary<string, object>
 | 
						|
                {
 | 
						|
                    { "challenge_id", challenge.Id },
 | 
						|
                    { "factor_id", factor.Id }
 | 
						|
                }, Request, challenge.Account
 | 
						|
            );
 | 
						|
            await db.SaveChangesAsync();
 | 
						|
 | 
						|
            logger.LogWarning("DoChallenge: authentication failure (challengeId={ChallengeId}, factorId={FactorId}, accountId={AccountId}, failedAttempts={FailedAttempts}, factorType={FactorType}, ip={IpAddress}, uaLength={UaLength})",
 | 
						|
                challenge.Id, factor.Id, challenge.AccountId, challenge.FailedAttempts, factor.Type, HttpContext.Connection.RemoteIpAddress?.ToString(), (HttpContext.Request.Headers.UserAgent.ToString() ?? "").Length);
 | 
						|
 | 
						|
            return BadRequest("Invalid password.");
 | 
						|
        }
 | 
						|
 | 
						|
        if (challenge.StepRemain == 0)
 | 
						|
        {
 | 
						|
            AccountService.SetCultureInfo(challenge.Account);
 | 
						|
            await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
 | 
						|
            {
 | 
						|
                Notification = new PushNotification()
 | 
						|
                {
 | 
						|
                    Topic = "auth.login",
 | 
						|
                    Title = localizer["NewLoginTitle"],
 | 
						|
                    Body = localizer["NewLoginBody", challenge.Client?.DeviceName ?? "unknown",
 | 
						|
                        challenge.IpAddress ?? "unknown"],
 | 
						|
                    IsSavable = true
 | 
						|
                },
 | 
						|
                UserId = challenge.AccountId.ToString()
 | 
						|
            });
 | 
						|
            als.CreateActionLogFromRequest(ActionLogType.NewLogin,
 | 
						|
                new Dictionary<string, object>
 | 
						|
                {
 | 
						|
                    { "challenge_id", challenge.Id },
 | 
						|
                    { "account_id", challenge.AccountId }
 | 
						|
                }, Request, challenge.Account
 | 
						|
            );
 | 
						|
        }
 | 
						|
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        return challenge;
 | 
						|
    }
 | 
						|
 | 
						|
    public class TokenExchangeRequest
 | 
						|
    {
 | 
						|
        public string GrantType { get; set; } = string.Empty;
 | 
						|
        public string? RefreshToken { get; set; }
 | 
						|
        public string? Code { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    public class TokenExchangeResponse
 | 
						|
    {
 | 
						|
        public string Token { get; set; } = string.Empty;
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("token")]
 | 
						|
    public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
 | 
						|
    {
 | 
						|
        switch (request.GrantType)
 | 
						|
        {
 | 
						|
            case "authorization_code":
 | 
						|
                var code = Guid.TryParse(request.Code, out var codeId) ? codeId : Guid.Empty;
 | 
						|
                if (code == Guid.Empty)
 | 
						|
                    return BadRequest("Invalid or missing authorization code.");
 | 
						|
                var challenge = await db.AuthChallenges
 | 
						|
                    .Include(e => e.Account)
 | 
						|
                    .Where(e => e.Id == code)
 | 
						|
                    .FirstOrDefaultAsync();
 | 
						|
                if (challenge is null)
 | 
						|
                    return BadRequest("Authorization code not found or expired.");
 | 
						|
                try
 | 
						|
                {
 | 
						|
                    var tk = await auth.CreateSessionAndIssueToken(challenge);
 | 
						|
                    return Ok(new TokenExchangeResponse { Token = tk });
 | 
						|
                }
 | 
						|
                catch (ArgumentException ex)
 | 
						|
                {
 | 
						|
                    return BadRequest(ex.Message);
 | 
						|
                }
 | 
						|
            default:
 | 
						|
                // Since we no longer need the refresh token
 | 
						|
                // This case is blank for now, thinking to mock it if the OIDC standard requires it
 | 
						|
                return BadRequest("Unsupported grant type.");
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("captcha")]
 | 
						|
    public async Task<ActionResult> ValidateCaptcha([FromBody] string token)
 | 
						|
    {
 | 
						|
        var result = await auth.ValidateCaptcha(token);
 | 
						|
        return result ? Ok() : BadRequest();
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpPost("logout")]
 | 
						|
    public IActionResult Logout()
 | 
						|
    {
 | 
						|
        Response.Cookies.Delete(AuthConstants.CookieTokenName, new CookieOptions
 | 
						|
        {
 | 
						|
            Domain = _cookieDomain,
 | 
						|
            HttpOnly = true,
 | 
						|
            Secure = true,
 | 
						|
            SameSite = SameSiteMode.Lax
 | 
						|
        });
 | 
						|
        return Ok();
 | 
						|
    }
 | 
						|
}
 |