✨ Optimized risk detection
🐛 Fix bugs
This commit is contained in:
parent
b69dd659d4
commit
5a0c6dc4b0
@ -428,7 +428,8 @@ public class AccountCurrentController(
|
|||||||
[FromQuery] int offset = 0
|
[FromQuery] int offset = 0
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
|
|
||||||
var query = db.AuthSessions
|
var query = db.AuthSessions
|
||||||
.Include(session => session.Account)
|
.Include(session => session.Account)
|
||||||
@ -438,8 +439,10 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
Response.Headers.Append("X-Total", total.ToString());
|
Response.Headers.Append("X-Total", total.ToString());
|
||||||
|
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||||
|
|
||||||
var sessions = await query
|
var sessions = await query
|
||||||
|
.OrderByDescending(x => x.LastGrantedAt)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@ -481,4 +484,37 @@ public class AccountCurrentController(
|
|||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("sessions/{id:guid}/label")]
|
||||||
|
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.UpdateSessionLabel(currentUser, id, label);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("sessions/current/label")]
|
||||||
|
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -293,7 +293,9 @@ public class AccountService(
|
|||||||
case AccountAuthFactorType.EmailCode:
|
case AccountAuthFactorType.EmailCode:
|
||||||
case AccountAuthFactorType.InAppCode:
|
case AccountAuthFactorType.InAppCode:
|
||||||
var correctCode = await _GetFactorCode(factor);
|
var correctCode = await _GetFactorCode(factor);
|
||||||
return correctCode is not null && string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
|
var isCorrect = correctCode is not null && string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
|
||||||
|
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
|
||||||
|
return isCorrect;
|
||||||
case AccountAuthFactorType.Password:
|
case AccountAuthFactorType.Password:
|
||||||
case AccountAuthFactorType.TimedCode:
|
case AccountAuthFactorType.TimedCode:
|
||||||
default:
|
default:
|
||||||
@ -319,6 +321,22 @@ public class AccountService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
|
||||||
|
{
|
||||||
|
var session = await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||||
|
|
||||||
|
session.Label = label;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{session.Id}");
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteSession(Account account, Guid sessionId)
|
public async Task DeleteSession(Account account, Guid sessionId)
|
||||||
{
|
{
|
||||||
var session = await db.AuthSessions
|
var session = await db.AuthSessions
|
||||||
|
@ -53,7 +53,7 @@ public class AuthController(
|
|||||||
var challenge = new Challenge
|
var challenge = new Challenge
|
||||||
{
|
{
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
||||||
StepTotal = 3,
|
StepTotal = await auth.DetectChallengeRisk(Request, account),
|
||||||
Platform = request.Platform,
|
Platform = request.Platform,
|
||||||
Audiences = request.Audiences,
|
Audiences = request.Audiences,
|
||||||
Scopes = request.Scopes,
|
Scopes = request.Scopes,
|
||||||
@ -205,7 +205,6 @@ public class AuthController(
|
|||||||
[HttpPost("token")]
|
[HttpPost("token")]
|
||||||
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
|
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
|
||||||
{
|
{
|
||||||
Session? session;
|
|
||||||
switch (request.GrantType)
|
switch (request.GrantType)
|
||||||
{
|
{
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
@ -221,7 +220,7 @@ public class AuthController(
|
|||||||
if (challenge.StepRemain != 0)
|
if (challenge.StepRemain != 0)
|
||||||
return BadRequest("Challenge not yet completed.");
|
return BadRequest("Challenge not yet completed.");
|
||||||
|
|
||||||
session = await db.AuthSessions
|
var session = await db.AuthSessions
|
||||||
.Where(e => e.Challenge == challenge)
|
.Where(e => e.Challenge == challenge)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (session is not null)
|
if (session is not null)
|
||||||
|
@ -1,10 +1,70 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Sphere.Auth;
|
||||||
|
|
||||||
public class AuthService(IConfiguration config, IHttpClientFactory httpClientFactory)
|
public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Detect the risk of the current request to login
|
||||||
|
/// and returns the required steps to login.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request context</param>
|
||||||
|
/// <param name="account">The account to login</param>
|
||||||
|
/// <returns>The required steps to login</returns>
|
||||||
|
public async Task<int> DetectChallengeRisk(HttpRequest request, Account.Account account)
|
||||||
|
{
|
||||||
|
// 1) Find out how many authentication factors the account has enabled.
|
||||||
|
var maxSteps = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == account.Id)
|
||||||
|
.Where(f => f.EnabledAt != null)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
// We’ll accumulate a “risk score” based on various factors.
|
||||||
|
// Then we can decide how many total steps are required for the challenge.
|
||||||
|
var riskScore = 0;
|
||||||
|
|
||||||
|
// 2) Get the remote IP address from the request (if any).
|
||||||
|
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var lastActiveInfo = await db.AuthSessions
|
||||||
|
.OrderByDescending(s => s.LastGrantedAt)
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.AccountId == account.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
// Example check: if IP is missing or in an unusual range, increase the risk.
|
||||||
|
// (This is just a placeholder; in reality, you’d integrate with GeoIpService or a custom check.)
|
||||||
|
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||||
|
riskScore += 1;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
||||||
|
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||||||
|
riskScore += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) (Optional) Check how recent the last login was.
|
||||||
|
// If it was a long time ago, the risk might be higher.
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var daysSinceLastActive = lastActiveInfo?.LastGrantedAt is not null
|
||||||
|
? (now - lastActiveInfo.LastGrantedAt.Value).TotalDays
|
||||||
|
: double.MaxValue;
|
||||||
|
if (daysSinceLastActive > 30)
|
||||||
|
riskScore += 1;
|
||||||
|
|
||||||
|
// 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score.
|
||||||
|
// You might choose to make “maxSteps + riskScore” your total required steps,
|
||||||
|
// or clamp it to maxSteps if you only want to require existing available factors.
|
||||||
|
var totalRequiredSteps = maxSteps + riskScore;
|
||||||
|
|
||||||
|
// Clamp the step
|
||||||
|
totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
|
||||||
|
|
||||||
|
return totalRequiredSteps;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> ValidateCaptcha(string token)
|
public async Task<bool> ValidateCaptcha(string token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user