320 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			320 lines
		
	
	
		
			12 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
 | |
| ) : 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);
 | |
| 
 | |
|         return challenge is null
 | |
|             ? NotFound("Auth challenge was not found.")
 | |
|             : 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
 | |
|         {
 | |
|             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();
 | |
|             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();
 | |
|     }
 | |
| } |