diff --git a/SS14.Auth.Shared/ModelShared.cs b/SS14.Auth.Shared/ModelShared.cs
index 155367e..f16233a 100644
--- a/SS14.Auth.Shared/ModelShared.cs
+++ b/SS14.Auth.Shared/ModelShared.cs
@@ -1,6 +1,6 @@
+using System;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
-using Microsoft.AspNetCore.Authentication;
using SS14.Auth.Shared.Data;
using SS14.Auth.Shared.Emails;
@@ -15,6 +15,17 @@ await sender.SendEmailAsync(address, "Confirm your Space Station 14 account",
$"\n
If the above link is not working, try this one {HtmlEncoder.Default.Encode(confirmLink)}
");
}
+ public static async Task SendAccountExistsEmail(IEmailSender sender, string address, string loginUrl)
+ {
+ await sender.SendEmailAsync(address, "Registration attempt on your Space Station 14 account",
+ "Someone just tried to register a new Space Station 14 account using this email address, " +
+ "but an account already exists for it. No new account was created.
" +
+ $"\nIf this was you: you already have an account - just log in, " +
+ "or reset your password from the login page if you've forgotten it.
" +
+ "\nIf this wasn't you, you don't need to do anything. Your account is unaffected.
" +
+ $"\nIf the link above is not working, go to {HtmlEncoder.Default.Encode(loginUrl)}
");
+ }
+
public static async Task SendResetEmail(IEmailSender emailSender, string email, string callbackUrl)
{
await emailSender.SendEmailAsync(
@@ -22,11 +33,11 @@ await emailSender.SendEmailAsync(
"A password reset has been requested for your account.
" +
$"If you did indeed request this, click here to reset your password.
" +
"If you did not request this, simply ignore this email." +
- $"\nIf the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)}
If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)}");
}
- public static SpaceUser CreateNewUser(string userName, string email, ISystemClock systemClock)
+ public static SpaceUser CreateNewUser(string userName, string email, DateTimeOffset timeOffset)
{
- return new SpaceUser {UserName = userName, Email = email, CreatedTime = systemClock.UtcNow};
+ return new SpaceUser { UserName = userName, Email = email, CreatedTime = timeOffset };
}
}
diff --git a/SS14.Auth/Controllers/AuthApiController.cs b/SS14.Auth/Controllers/AuthApiController.cs
index f079d39..4ff9122 100644
--- a/SS14.Auth/Controllers/AuthApiController.cs
+++ b/SS14.Auth/Controllers/AuthApiController.cs
@@ -1,19 +1,20 @@
-using System;
-using System.Diagnostics;
-using System.Linq;
-using System.Text;
-using System.Text.Json.Serialization;
-using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Internal;
using SS14.Auth.Shared;
using SS14.Auth.Shared.Data;
using SS14.Auth.Shared.Emails;
using SS14.Auth.Shared.Sessions;
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
namespace SS14.Auth.Controllers;
@@ -26,16 +27,25 @@ public class AuthApiController : ControllerBase
{
private readonly SessionManager _sessionManager;
private readonly IEmailSender _emailSender;
- private readonly ISystemClock _systemClock;
+ private readonly TimeProvider _systemClock;
private readonly IConfiguration _cfg;
private readonly SpaceUserManager _userManager;
private readonly SignInManager _signInManager;
+ private readonly Lazy _dummyUser = new(() =>
+ {
+ var u = new SpaceUser();
+ u.PasswordHash = new PasswordHasher().HashPassword(u, "timing-equalizer");
+ return u;
+ });
+
private string WebBaseUrl => _cfg.GetValue("WebBaseUrl") ?? "";
+ private const string DuplicateEmailCode = "DuplicateEmail";
+
public AuthApiController(SpaceUserManager userManager, SignInManager signInManager,
- SessionManager sessionManager, IEmailSender emailSender, ISystemClock systemClock, IConfiguration cfg)
+ SessionManager sessionManager, IEmailSender emailSender, TimeProvider systemClock, IConfiguration cfg)
{
_userManager = userManager;
_signInManager = signInManager;
@@ -45,17 +55,17 @@ public AuthApiController(SpaceUserManager userManager, SignInManager
_cfg = cfg;
}
+ [EnableRateLimiting("authenticate")]
[HttpPost("authenticate")]
public async Task Authenticate(AuthenticateRequest request)
{
// Password may never be null, and only either username OR userID can be used for login, not both.
if (!(request.Username == null ^ request.UserId == null))
- {
- return BadRequest();
- }
-
- // Console.WriteLine(Request.Headers["SS14-Launcher-Fingerprint"]);
- // Console.WriteLine(Request.Headers["User-Agent"]);
+ return BadRequest(new
+ {
+ Error = "invalid_request",
+ Message = "Exactly one of Username or UserId must be provided."
+ });
SpaceUser? user;
if (request.Username != null)
@@ -65,12 +75,13 @@ public async Task Authenticate(AuthenticateRequest request)
else
{
Debug.Assert(request.UserId != null);
-
user = await _userManager.FindByIdAsync(request.UserId!.Value.ToString());
}
if (user == null)
{
+ await _signInManager.CheckPasswordSignInAsync(_dummyUser.Value, request.Password, false);
+
return Unauthorized(new AuthenticateDenyResponse(
new[] { "Invalid login credentials." },
AuthenticateDenyResponseCode.InvalidCredentials));
@@ -113,42 +124,57 @@ public async Task Authenticate(AuthenticateRequest request)
new[] { "" },
AuthenticateDenyResponseCode.TfaRequired));
}
-
+
var verify = await _userManager.VerifyTwoFactorTokenAsync(
user,
_userManager.Options.Tokens.AuthenticatorTokenProvider,
request.TfaCode);
-
+
if (!verify)
{
return Unauthorized(new AuthenticateDenyResponse(
new[] { "" },
AuthenticateDenyResponseCode.TfaInvalid));
}
-
+
// 2FA passed, we're good.
}
-
+
var (token, expireTime) =
await _sessionManager.RegisterNewSession(user, SessionManager.DefaultExpireTime);
return Ok(new AuthenticateResponse(token.AsBase64, user.UserName!, user.Id, expireTime));
}
- // Launcher registration disabled due to spam risk.
- /*
+ [EnableRateLimiting("registration")]
[HttpPost("register")]
public async Task Register(RegisterRequest request)
{
var userName = request.Username.Trim();
var email = request.Email.Trim();
- var user = ModelShared.CreateNewUser(userName, email, _systemClock);
+ var user = ModelShared.CreateNewUser(userName, email, _systemClock.GetUtcNow());
var result = await _userManager.CreateAsync(user, request.Password);
+ var successStatus = _userManager.Options.SignIn.RequireConfirmedEmail
+ ? RegisterResponseStatus.RegisteredNeedConfirmation
+ : RegisterResponseStatus.Registered;
+
if (!result.Succeeded)
{
- var errors = result.Errors.Select(p => p.Description).ToArray();
+ var errors = result.Errors
+ .Where(e => e.Code != DuplicateEmailCode)
+ .Select(e => e.Description)
+ .ToArray();
+
+ if (errors.Length == 0)
+ {
+ var loginUrl = $"{WebBaseUrl}Identity/Account/Login";
+ await ModelShared.SendAccountExistsEmail(_emailSender, email, loginUrl);
+
+ return Ok(new RegisterResponse(successStatus));
+ }
+
return UnprocessableEntity(new RegisterResponseError(errors));
}
@@ -156,14 +182,10 @@ public async Task Register(RegisterRequest request)
await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink);
- var status = _userManager.Options.SignIn.RequireConfirmedAccount
- ? RegisterResponseStatus.RegisteredNeedConfirmation
- : RegisterResponseStatus.Registered;
-
- return Ok(new RegisterResponse(status));
+ return Ok(new RegisterResponse(successStatus));
}
- */
+ [EnableRateLimiting("reset-password")]
[HttpPost("resetPassword")]
public async Task ResetPassword(ResetPasswordRequest request)
{
@@ -185,8 +207,7 @@ public async Task ResetPassword(ResetPasswordRequest request)
return Ok();
}
- // Launcher resend confirmation disabled due to spam risk.
- /*
+ [EnableRateLimiting("resend-confirmation")]
[HttpPost("resendConfirmation")]
public async Task ResendConfirmation(ResendConfirmationRequest request)
{
@@ -194,18 +215,14 @@ public async Task ResendConfirmation(ResendConfirmationRequest re
var user = await _userManager.FindByEmailAsync(email);
- if (user == null)
+ if (user != null && !await _userManager.IsEmailConfirmedAsync(user))
{
- return Ok();
+ var confirmLink = await GenerateEmailConfirmLink(user);
+ await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink);
}
- var confirmLink = await GenerateEmailConfirmLink(user);
-
- await ModelShared.SendConfirmEmail(_emailSender, email, confirmLink);
-
return Ok();
}
- */
[Authorize(AuthenticationSchemes = "SS14Auth")]
[HttpGet("ping")]
@@ -281,40 +298,27 @@ public enum AuthenticateDenyResponseCode
// @formatter:on
}
-public sealed record RegisterRequest(string Username, string Email, string Password)
-{
-}
+public sealed record RegisterRequest(
+ [Required, StringLength(32, MinimumLength = 3)] string Username,
+ [Required, EmailAddress] string Email,
+ [Required, StringLength(100, MinimumLength = 6)] string Password);
-public sealed record ResetPasswordRequest(string Email)
-{
-}
+public sealed record ResetPasswordRequest([Required, EmailAddress] string Email);
-public sealed record ResendConfirmationRequest(string Email)
-{
-}
+public sealed record ResendConfirmationRequest([Required, EmailAddress] string Email);
-public sealed record RegisterResponse(RegisterResponseStatus Status)
-{
-}
+public sealed record RegisterResponse(RegisterResponseStatus Status);
-public sealed record RegisterResponseError(string[] Errors)
-{
-}
+public sealed record RegisterResponseError(string[] Errors);
-public sealed record LogoutRequest(string Token)
-{
-}
+public sealed record LogoutRequest(string Token);
-public sealed record RefreshRequest(string Token)
-{
-}
+public sealed record RefreshRequest(string Token);
-public sealed record RefreshResponse(DateTimeOffset ExpireTime, string NewToken)
-{
-}
+public sealed record RefreshResponse(DateTimeOffset ExpireTime, string NewToken);
public enum RegisterResponseStatus
{
Registered,
RegisteredNeedConfirmation
-}
\ No newline at end of file
+}
diff --git a/SS14.Auth/Startup.cs b/SS14.Auth/Startup.cs
index 817196e..1a03b9b 100644
--- a/SS14.Auth/Startup.cs
+++ b/SS14.Auth/Startup.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -11,6 +12,8 @@
using SS14.Auth.Shared;
using SS14.Auth.Shared.Auth;
using SS14.WebEverythingShared;
+using System;
+using System.Threading.RateLimiting;
namespace SS14.Auth;
@@ -36,6 +39,61 @@ public void ConfigureServices(IServiceCollection services)
.Build());
});
+ services.AddRateLimiter(options =>
+ {
+ options.AddPolicy("registration", httpContext =>
+ {
+ var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ return RateLimitPartition.GetFixedWindowLimiter(ip, _ =>
+ new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 5,
+ Window = TimeSpan.FromMinutes(15),
+ QueueLimit = 0,
+ });
+ });
+
+ options.AddPolicy("resend-confirmation", httpContext =>
+ {
+ var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ return RateLimitPartition.GetFixedWindowLimiter(ip, _ =>
+ new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 2,
+ Window = TimeSpan.FromHours(1),
+ QueueLimit = 0,
+ });
+ });
+
+ options.AddPolicy("authenticate", httpContext => {
+ var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ return RateLimitPartition.GetFixedWindowLimiter(ip, _ =>
+ new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 10,
+ Window = TimeSpan.FromMinutes(1),
+ QueueLimit = 0
+ });
+ });
+
+ options.AddPolicy("reset-password", httpContext => {
+ var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ return RateLimitPartition.GetFixedWindowLimiter(ip, _ =>
+ new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 3,
+ Window = TimeSpan.FromMinutes(15),
+ QueueLimit = 0
+ });
+ });
+
+ options.OnRejected = async (ctx, token) =>
+ {
+ ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
+ await ctx.HttpContext.Response.WriteAsJsonAsync(new { Error = "rate_limited" }, token);
+ };
+ });
+
services.AddAuthentication()
.AddScheme("SS14Auth", _ => {});
@@ -85,6 +143,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.UseRouting();
+ app.UseRateLimiter();
+
app.UseHttpMetrics();
app.UseAuthorization();
@@ -95,4 +155,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
endpoints.MapMetrics();
});
}
-}
\ No newline at end of file
+}
diff --git a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs
index 37e35cc..5a9f12f 100644
--- a/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs
+++ b/SS14.Web/Areas/Admin/Pages/Users/CreateReserved.cshtml.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Buffers.Text;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
@@ -47,7 +47,7 @@ public async Task OnPostAsync()
var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
- var user = ModelShared.CreateNewUser(userName, $"reserved+{userName}@playss14.com", _systemClock);
+ var user = ModelShared.CreateNewUser(userName, $"reserved+{userName}@playss14.com", _systemClock.UtcNow);
user.AdminLocked = true;
user.AdminNotes = "Account reserved via admin panel. If unlocking, change email and password!";
user.EmailConfirmed = true;
diff --git a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
index bfe5e2f..20cbcbd 100644
--- a/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
+++ b/SS14.Web/Areas/Identity/Pages/Account/Register.cshtml.cs
@@ -56,6 +56,7 @@ public RegisterModel(
public class InputModel
{
[Required]
+ [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 3)]
[Display(Name = "Username")]
public string Username { get; set; }
@@ -101,7 +102,7 @@ public async Task OnPostAsync(string returnUrl = null)
if (!await _hCaptcha.ValidateHCaptcha(HCaptchaResponse, ModelState))
return Page();
- var user = ModelShared.CreateNewUser(userName, email, _systemClock);
+ var user = ModelShared.CreateNewUser(userName, email, _systemClock.UtcNow);
var result = await _userManager.CreateAsync(user, Input.Password);
if (result.Succeeded)
{
@@ -150,4 +151,4 @@ public async Task GenerateEmailConfirmLink(
protocol: Request.Scheme);
return callbackUrl;
}
-}
\ No newline at end of file
+}