Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions SS14.Auth.Shared/ModelShared.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,18 +15,29 @@ await sender.SendEmailAsync(address, "Confirm your Space Station 14 account",
$"\n<p><small>If the above link is not working, try this one {HtmlEncoder.Default.Encode(confirmLink)}</small></p>");
}

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.<br />" +
$"\n<p>If this was you: you already have an account - just <a href='{loginUrl}'>log in</a>, " +
"or reset your password from the login page if you've forgotten it.</p>" +
"\n<p>If this wasn't you, you don't need to do anything. Your account is unaffected.</p>" +
$"\n<p><small>If the link above is not working, go to {HtmlEncoder.Default.Encode(loginUrl)}</small></p>");
}

public static async Task SendResetEmail(IEmailSender emailSender, string email, string callbackUrl)
{
await emailSender.SendEmailAsync(
email, "Reset Password",
"A password reset has been requested for your account.<br />" +
$"If you did indeed request this, <a href='{callbackUrl}'>click here</a> to reset your password.<br />" +
"If you did not request this, simply ignore this email." +
$"\n<p><small>If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)}</small></p");
$"\n<p><small>If the above link is not working, try this one {HtmlEncoder.Default.Encode(callbackUrl)}</small></p>");
}

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 };
}
}
132 changes: 68 additions & 64 deletions SS14.Auth/Controllers/AuthApiController.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<SpaceUser> _signInManager;

private readonly Lazy<SpaceUser> _dummyUser = new(() =>
{
var u = new SpaceUser();
u.PasswordHash = new PasswordHasher<SpaceUser>().HashPassword(u, "timing-equalizer");
return u;
});

private string WebBaseUrl => _cfg.GetValue<string>("WebBaseUrl") ?? "";

private const string DuplicateEmailCode = "DuplicateEmail";

public AuthApiController(SpaceUserManager userManager, SignInManager<SpaceUser> signInManager,
SessionManager sessionManager, IEmailSender emailSender, ISystemClock systemClock, IConfiguration cfg)
SessionManager sessionManager, IEmailSender emailSender, TimeProvider systemClock, IConfiguration cfg)
{
_userManager = userManager;
_signInManager = signInManager;
Expand All @@ -45,17 +55,17 @@ public AuthApiController(SpaceUserManager userManager, SignInManager<SpaceUser>
_cfg = cfg;
}

[EnableRateLimiting("authenticate")]
[HttpPost("authenticate")]
public async Task<IActionResult> 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)
Expand All @@ -65,12 +75,13 @@ public async Task<IActionResult> 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));
Expand Down Expand Up @@ -113,57 +124,68 @@ public async Task<IActionResult> 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<IActionResult> 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));
}

var confirmLink = await GenerateEmailConfirmLink(user);

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<IActionResult> ResetPassword(ResetPasswordRequest request)
{
Expand All @@ -185,27 +207,22 @@ public async Task<IActionResult> ResetPassword(ResetPasswordRequest request)
return Ok();
}

// Launcher resend confirmation disabled due to spam risk.
/*
[EnableRateLimiting("resend-confirmation")]
[HttpPost("resendConfirmation")]
public async Task<IActionResult> ResendConfirmation(ResendConfirmationRequest request)
{
var email = request.Email.Trim();

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")]
Expand Down Expand Up @@ -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
}
}
62 changes: 61 additions & 1 deletion SS14.Auth/Startup.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -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<SS14AuthOptions, SS14AuthHandler>("SS14Auth", _ => {});

Expand Down Expand Up @@ -85,6 +143,8 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

app.UseRouting();

app.UseRateLimiter();

app.UseHttpMetrics();

app.UseAuthorization();
Expand All @@ -95,4 +155,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
endpoints.MapMetrics();
});
}
}
}
Loading