Optimized risk detection

🐛 Fix bugs
This commit is contained in:
LittleSheep 2025-06-07 18:21:51 +08:00
parent b69dd659d4
commit 5a0c6dc4b0
4 changed files with 137 additions and 24 deletions

View File

@ -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);
}
}
} }

View File

@ -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

View File

@ -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)

View File

@ -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();
// Well 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, youd 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;