OAuth token grant #3
32 changed files with 925 additions and 43 deletions
|
@ -16,7 +16,7 @@
|
||||||
<Rule Id="SA1007" Action="Warning"/> <!-- Operator keyword should be followed by space -->
|
<Rule Id="SA1007" Action="Warning"/> <!-- Operator keyword should be followed by space -->
|
||||||
<Rule Id="SA1008" Action="Warning"/> <!-- Opening parenthesis should be spaced correctly -->
|
<Rule Id="SA1008" Action="Warning"/> <!-- Opening parenthesis should be spaced correctly -->
|
||||||
<Rule Id="SA1009" Action="Warning"/> <!-- Closing parenthesis should be spaced correctly -->
|
<Rule Id="SA1009" Action="Warning"/> <!-- Closing parenthesis should be spaced correctly -->
|
||||||
<Rule Id="SA1010" Action="Warning"/> <!-- Opening square brackets should be spaced correctly -->
|
<Rule Id="SA1010" Action="None"/> <!-- Opening square brackets should be spaced correctly -->
|
||||||
<Rule Id="SA1011" Action="Warning"/> <!-- Closing square brackets should be spaced correctly -->
|
<Rule Id="SA1011" Action="Warning"/> <!-- Closing square brackets should be spaced correctly -->
|
||||||
<Rule Id="SA1012" Action="Warning"/> <!-- Opening braces should be spaced correctly -->
|
<Rule Id="SA1012" Action="Warning"/> <!-- Opening braces should be spaced correctly -->
|
||||||
<Rule Id="SA1013" Action="Warning"/> <!-- Closing braces should be spaced correctly -->
|
<Rule Id="SA1013" Action="Warning"/> <!-- Closing braces should be spaced correctly -->
|
||||||
|
|
|
@ -21,10 +21,6 @@
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.1.0"/>
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.1.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Controllers\"/>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Astral.Services\Astral.Services.csproj"/>
|
<ProjectReference Include="..\Astral.Services\Astral.Services.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
21
Astral.ApiServer/Constants/ApiErrorCodes.cs
Normal file
21
Astral.ApiServer/Constants/ApiErrorCodes.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// <copyright file="ApiErrorCodes.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Astral.ApiServer.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collection of API error codes.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApiErrorCodes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Attempted to authenticate with an unsupported grant type.
|
||||||
|
/// </summary>
|
||||||
|
public const string UnsupportedGrantType = "unsupported-grant-type";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempted to authenticate with an unsupported token scope.
|
||||||
|
/// </summary>
|
||||||
|
public const string UnsupportedTokenScope = "unsupported-token-scope";
|
||||||
|
}
|
16
Astral.ApiServer/Constants/OAuthGrantType.cs
Normal file
16
Astral.ApiServer/Constants/OAuthGrantType.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// <copyright file="OAuthGrantType.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Astral.ApiServer.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Available grant types for auth token requests.
|
||||||
|
/// </summary>
|
||||||
|
public enum OAuthGrantType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Password grant type.
|
||||||
|
/// </summary>
|
||||||
|
Password
|
||||||
|
}
|
98
Astral.ApiServer/Controllers/BaseApiController.cs
Normal file
98
Astral.ApiServer/Controllers/BaseApiController.cs
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
// <copyright file="BaseApiController.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using Astral.ApiServer.Models.Common;
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Astral.ApiServer.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base API controller with common methods.
|
||||||
|
/// </summary>
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
|
public class BaseApiController : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Return a failure status with no data.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
|
||||||
|
protected IActionResult FailureResult()
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||||
|
return new JsonResult(new ResultModel
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a failure result with additional information.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="errorCode">Api error code.</param>
|
||||||
|
/// <param name="message">Api error message.</param>
|
||||||
|
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
|
||||||
|
protected IActionResult FailureResult(string errorCode, string message)
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||||
|
return new JsonResult(new ErrorResultModel
|
||||||
|
{
|
||||||
|
Error = errorCode,
|
||||||
|
Message = message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a failure result indicating the body is missing from the request.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
|
||||||
|
protected IActionResult MissingBodyResult()
|
||||||
|
{
|
||||||
|
return FailureResult(CoreErrorCodes.NoDataProvided, "Missing request body");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a success status with no data.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Instance of <see cref="IActionResult"/>.</returns>
|
||||||
|
protected IActionResult SuccessResult()
|
||||||
|
{
|
||||||
|
Response.StatusCode = (int)HttpStatusCode.OK;
|
||||||
|
return new JsonResult(new ResultModel
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch IP address of requesting agent.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Request origin's IP address.</returns>
|
||||||
|
protected IPAddress ClientIpAddress()
|
||||||
|
{
|
||||||
|
IPAddress remoteIpAddress = null;
|
||||||
|
if (Request.Headers.TryGetValue("X-Forwarded-For", out var value))
|
||||||
|
{
|
||||||
|
foreach (var ip in value)
|
||||||
|
{
|
||||||
|
if (IPAddress.TryParse(ip, out var address) &&
|
||||||
|
(address.AddressFamily is AddressFamily.InterNetwork
|
||||||
|
or AddressFamily.InterNetworkV6))
|
||||||
|
{
|
||||||
|
remoteIpAddress = address;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
remoteIpAddress = HttpContext.Connection.RemoteIpAddress?.MapToIPv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteIpAddress;
|
||||||
|
}
|
||||||
|
}
|
73
Astral.ApiServer/Controllers/OAuthController.cs
Normal file
73
Astral.ApiServer/Controllers/OAuthController.cs
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
// <copyright file="OAuthController.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.ApiServer.Constants;
|
||||||
|
using Astral.ApiServer.Models;
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
using Astral.Services.Dtos;
|
||||||
|
using Astral.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Astral.ApiServer.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth authentication controller.
|
||||||
|
/// </summary>
|
||||||
|
[Route("oauth")]
|
||||||
|
public class OAuthController : BaseApiController
|
||||||
|
{
|
||||||
|
private readonly IAuthenticationService _authenticationService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="OAuthController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authenticationService">Instance of <see cref="IAuthenticationService"/>.</param>
|
||||||
|
public OAuthController(IAuthenticationService authenticationService)
|
||||||
|
{
|
||||||
|
_authenticationService = authenticationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grant token request.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokenGrantRequest">Instance of <see cref="TokenGrantRequestModel"/>.</param>
|
||||||
|
[HttpPost("token")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GrantToken([FromForm] TokenGrantRequestModel tokenGrantRequest)
|
||||||
|
{
|
||||||
|
if (tokenGrantRequest is null)
|
||||||
|
{
|
||||||
|
return MissingBodyResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse(tokenGrantRequest.GrantType, true, out OAuthGrantType grantType))
|
||||||
|
{
|
||||||
|
return FailureResult(ApiErrorCodes.UnsupportedGrantType, "Unknown grant type");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse(tokenGrantRequest.Scope, true, out TokenScope tokenScope))
|
||||||
|
{
|
||||||
|
return FailureResult(ApiErrorCodes.UnsupportedTokenScope, "Unknown token scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (grantType)
|
||||||
|
{
|
||||||
|
case OAuthGrantType.Password:
|
||||||
|
var request = new PasswordAuthenticateDto
|
||||||
|
{
|
||||||
|
Username = tokenGrantRequest.Username,
|
||||||
|
Password = tokenGrantRequest.Password,
|
||||||
|
Scope = tokenScope,
|
||||||
|
IpAddress = ClientIpAddress()
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _authenticationService.AuthenticateSession(request);
|
||||||
|
|
||||||
|
return new JsonResult(new TokenGrantResultModel(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FailureResult();
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,14 +9,8 @@ namespace Astral.ApiServer.Models.Common;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error result model.
|
/// Error result model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ErrorResultModel
|
public class ErrorResultModel : ResultModel
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Status: failure.
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("status")]
|
|
||||||
public const string Status = "failure";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error code.
|
/// Error code.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -12,8 +12,8 @@ namespace Astral.ApiServer.Models.Common;
|
||||||
public class ResultModel
|
public class ResultModel
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Either "success" or "failure".
|
/// Indicate success.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("status")]
|
[JsonPropertyName("success")]
|
||||||
public string Status { get; set; }
|
public bool Success { get; set; }
|
||||||
}
|
}
|
||||||
|
|
43
Astral.ApiServer/Models/TokenGrantRequestModel.cs
Normal file
43
Astral.ApiServer/Models/TokenGrantRequestModel.cs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
// <copyright file="TokenGrantRequestModel.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Astral.ApiServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Oauth token grant request.
|
||||||
|
/// </summary>
|
||||||
|
public class TokenGrantRequestModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The grant type of the request.
|
||||||
|
/// </summary>
|
||||||
|
[FromForm(Name = "grant_type")]
|
||||||
|
public string GrantType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh token.
|
||||||
|
/// </summary>
|
||||||
|
[FromForm(Name = "refresh_token")]
|
||||||
|
public string RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Username.
|
||||||
|
/// </summary>
|
||||||
|
[FromForm(Name = "username")]
|
||||||
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Password.
|
||||||
|
/// </summary>
|
||||||
|
[FromForm(Name = "password")]
|
||||||
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scope.
|
||||||
|
/// </summary>
|
||||||
|
[FromForm(Name = "scope")]
|
||||||
|
public string Scope { get; set; }
|
||||||
|
}
|
95
Astral.ApiServer/Models/TokenGrantResultModel.cs
Normal file
95
Astral.ApiServer/Models/TokenGrantResultModel.cs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
// <copyright file="TokenGrantResultModel.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Astral.ApiServer.Models.Common;
|
||||||
|
using Astral.Core.Extensions;
|
||||||
|
using Astral.Services.Dtos;
|
||||||
|
|
||||||
|
namespace Astral.ApiServer.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth Grant Request Response.
|
||||||
|
/// </summary>
|
||||||
|
public class TokenGrantResultModel : ResultModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TokenGrantResultModel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public TokenGrantResultModel()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TokenGrantResultModel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionDto">Instance of <see cref="SessionDto"/> to create from.</param>
|
||||||
|
public TokenGrantResultModel(SessionDto sessionDto)
|
||||||
|
{
|
||||||
|
Success = true;
|
||||||
|
AccessToken = sessionDto.AccessToken;
|
||||||
|
CreatedAt = sessionDto.CreatedAt.Ticks;
|
||||||
|
ExpiresIn = sessionDto.AccessTokenExpires.Ticks;
|
||||||
|
RefreshToken = sessionDto.RefreshToken;
|
||||||
|
Scope = sessionDto.Scope.ToString().ToLowerInvariant();
|
||||||
|
AccountId = sessionDto.UserId.ToString();
|
||||||
|
AccountName = sessionDto.UserName;
|
||||||
|
TokenType = "Bearer";
|
||||||
|
AccountRoles = sessionDto.Role.GetAccessibleRoles().Select(role => role.ToString().ToLowerInvariant()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The granted access token.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When it expires (ticks).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public long ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The granted refresh token.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The session scope.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string Scope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Granted token type.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("token_type")]
|
||||||
|
public string TokenType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When it was created (ticks).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("created_at")]
|
||||||
|
public long CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's id.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("account_id")]
|
||||||
|
public string AccountId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's name.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("account_name")]
|
||||||
|
public string AccountName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's roles.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("account_roles")]
|
||||||
|
public List<string> AccountRoles { get; set; }
|
||||||
|
}
|
26
Astral.Core/Constants/TokenScope.cs
Normal file
26
Astral.Core/Constants/TokenScope.cs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// <copyright file="TokenScope.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Astral.Core.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token scopes.
|
||||||
|
/// </summary>
|
||||||
|
public enum TokenScope
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Owner token scope.
|
||||||
|
/// </summary>
|
||||||
|
Owner,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain token scope.
|
||||||
|
/// </summary>
|
||||||
|
Domain,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Place token scope.
|
||||||
|
/// </summary>
|
||||||
|
Place
|
||||||
|
}
|
|
@ -7,24 +7,15 @@ namespace Astral.Core.Constants;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Available user roles.
|
/// Available user roles.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class UserRole
|
public enum UserRole
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// User role.
|
/// Basic user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string User = "user";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Administrator role.
|
|
||||||
/// </summary>
|
|
||||||
public const string Admin = "admin";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Available values.
|
|
||||||
/// </summary>
|
|
||||||
public static readonly string[] AvailableRoles =
|
|
||||||
[
|
|
||||||
User,
|
User,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Administrator.
|
||||||
|
/// </summary>
|
||||||
Admin
|
Admin
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
31
Astral.Core/Constants/UserState.cs
Normal file
31
Astral.Core/Constants/UserState.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// <copyright file="UserState.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Astral.Core.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Available user states.
|
||||||
|
/// </summary>
|
||||||
|
public enum UserState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Normal activated user.
|
||||||
|
/// </summary>
|
||||||
|
Normal,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suspended account.
|
||||||
|
/// </summary>
|
||||||
|
Suspended,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Banned account.
|
||||||
|
/// </summary>
|
||||||
|
Banned,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Awaiting email activation.
|
||||||
|
/// </summary>
|
||||||
|
AwaitingEmailActivation,
|
||||||
|
}
|
58
Astral.Core/Entities/RefreshToken.cs
Normal file
58
Astral.Core/Entities/RefreshToken.cs
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
// <copyright file="RefreshToken.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.Core.Attributes.EntityAnnotation;
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
|
||||||
|
namespace Astral.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh token entity.
|
||||||
|
/// </summary>
|
||||||
|
[TableMapping("refreshTokens")]
|
||||||
|
public class RefreshToken
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Token identifier.
|
||||||
|
/// </summary>
|
||||||
|
[PrimaryKey]
|
||||||
|
[ColumnMapping("token")]
|
||||||
|
public string Token { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User id the token belongs to.
|
||||||
|
/// </summary>
|
||||||
|
[ColumnMapping("userId")]
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ip address of the agent the token granted to.
|
||||||
|
/// </summary>
|
||||||
|
[ColumnMapping("ipAddress")]
|
||||||
|
public string IpAddress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session agent role.
|
||||||
|
/// </summary>
|
||||||
|
[ColumnMapping("role")]
|
||||||
|
public UserRole Role { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session token scope.
|
||||||
|
/// </summary>
|
||||||
|
[ColumnMapping("scope")]
|
||||||
|
public TokenScope Scope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the token expires.
|
||||||
|
/// </summary>
|
||||||
|
[ColumnMapping("expires")]
|
||||||
|
public DateTime Expires { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the token was created.
|
||||||
|
/// </summary>
|
||||||
|
[ColumnMapping("createdAt")]
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
// </copyright>
|
// </copyright>
|
||||||
|
|
||||||
using Astral.Core.Attributes.EntityAnnotation;
|
using Astral.Core.Attributes.EntityAnnotation;
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
|
||||||
namespace Astral.Core.Entities;
|
namespace Astral.Core.Entities;
|
||||||
|
|
||||||
|
@ -55,6 +56,12 @@ public class User
|
||||||
[ColumnMapping("authSalt")]
|
[ColumnMapping("authSalt")]
|
||||||
public string PasswordSalt { get; set; }
|
public string PasswordSalt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// State of the user's account.
|
||||||
|
/// </summary>
|
||||||
|
[ColumnMapping("userState")]
|
||||||
|
public UserState State { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ip address of the creator.
|
/// The ip address of the creator.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -71,7 +78,7 @@ public class User
|
||||||
/// The user's role.
|
/// The user's role.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ColumnMapping("role")]
|
[ColumnMapping("role")]
|
||||||
public string UserRole { get; set; }
|
public UserRole Role { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Group for this user's connections.
|
/// Group for this user's connections.
|
||||||
|
|
28
Astral.Core/Extensions/UserRoleExtensions.cs
Normal file
28
Astral.Core/Extensions/UserRoleExtensions.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
// <copyright file="UserRoleExtensions.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
|
||||||
|
namespace Astral.Core.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="UserRole"/> extensions.
|
||||||
|
/// </summary>
|
||||||
|
public static class UserRoleExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get accessible roles of a user role.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userRole">This <see cref="UserRole"/>.</param>
|
||||||
|
/// <returns>Collection of accessible <see cref="UserRole"/>.</returns>
|
||||||
|
public static UserRole[] GetAccessibleRoles(this UserRole userRole)
|
||||||
|
{
|
||||||
|
return userRole switch
|
||||||
|
{
|
||||||
|
UserRole.User => [UserRole.User],
|
||||||
|
UserRole.Admin => [UserRole.User, UserRole.Admin],
|
||||||
|
_ => []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
19
Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs
Normal file
19
Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
// <copyright file="IRefreshTokenRepository.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.Core.Entities;
|
||||||
|
|
||||||
|
namespace Astral.Core.RepositoryInterfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="RefreshToken"/> Repository.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRefreshTokenRepository : IGenericRepository<RefreshToken, string>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Revoke all tokens for the provided user id.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
Task RevokeAllForUser(Guid userId);
|
||||||
|
}
|
|
@ -7,9 +7,10 @@ CREATE TABLE users
|
||||||
email TEXT UNIQUE,
|
email TEXT UNIQUE,
|
||||||
authHash TEXT,
|
authHash TEXT,
|
||||||
authSalt TEXT,
|
authSalt TEXT,
|
||||||
|
userState SMALLINT DEFAULT NULL,
|
||||||
lastLoggedIn TIMESTAMP DEFAULT NULL,
|
lastLoggedIn TIMESTAMP DEFAULT NULL,
|
||||||
creatorIp TEXT DEFAULT '0.0.0.0',
|
creatorIp TEXT DEFAULT '0.0.0.0',
|
||||||
role TEXT DEFAULT 'user' NOT NULL,
|
role SMALLINT DEFAULT 0 NOT NULL,
|
||||||
state SMALLINT DEFAULT 0 NOT NULL,
|
state SMALLINT DEFAULT 0 NOT NULL,
|
||||||
connectionGroup UUID DEFAULT NULL,
|
connectionGroup UUID DEFAULT NULL,
|
||||||
friendGroup UUID DEFAULT NULL,
|
friendGroup UUID DEFAULT NULL,
|
||||||
|
|
10
Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql
Normal file
10
Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
CREATE TABLE refreshTokens
|
||||||
|
(
|
||||||
|
token TEXT UNIQUE PRIMARY KEY NOT NULL,
|
||||||
|
userId UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
ipAddress TEXT NOT NULL,
|
||||||
|
role SMALLINT NOT NULL,
|
||||||
|
scope SMALLINT NOT NULL,
|
||||||
|
expires TIMESTAMP NOT NULL,
|
||||||
|
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
35
Astral.DAL/Repositories/RefreshTokenRepository.cs
Normal file
35
Astral.DAL/Repositories/RefreshTokenRepository.cs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
// <copyright file="RefreshTokenRepository.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.Core.Entities;
|
||||||
|
using Astral.Core.Infrastructure;
|
||||||
|
using Astral.Core.RepositoryInterfaces;
|
||||||
|
using Dapper;
|
||||||
|
using Injectio.Attributes;
|
||||||
|
|
||||||
|
namespace Astral.DAL.Repositories;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Astral.Core.RepositoryInterfaces.IRefreshTokenRepository" />
|
||||||
|
[RegisterScoped]
|
||||||
|
public class RefreshTokenRepository : BaseRepository<RefreshToken, string>, IRefreshTokenRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RefreshTokenRepository"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="db">Instance of <see cref="IDbConnectionProvider"/>.</param>
|
||||||
|
public RefreshTokenRepository(IDbConnectionProvider db)
|
||||||
|
: base(db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IRefreshTokenRepository" />
|
||||||
|
public async Task RevokeAllForUser(Guid userId)
|
||||||
|
{
|
||||||
|
await WithDatabaseAsync(async connection => await connection.ExecuteAsync(
|
||||||
|
$"DELETE FROM {Table} WHERE userId = @pUserId", new
|
||||||
|
{
|
||||||
|
pUserId = userId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
31
Astral.Services/Constants/ClaimIds.cs
Normal file
31
Astral.Services/Constants/ClaimIds.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// <copyright file="ClaimIds.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace Astral.Services.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collection of claim ids.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClaimIds
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// User Id claim.
|
||||||
|
/// </summary>
|
||||||
|
public const string UserId = "user-id";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Username claim.
|
||||||
|
/// </summary>
|
||||||
|
public const string UserName = "user-name";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User Role claim.
|
||||||
|
/// </summary>
|
||||||
|
public const string Role = "role";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token scope.
|
||||||
|
/// </summary>
|
||||||
|
public const string Scope = "scope";
|
||||||
|
}
|
18
Astral.Services/Dtos/BaseAuthenticationDto.cs
Normal file
18
Astral.Services/Dtos/BaseAuthenticationDto.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// <copyright file="BaseAuthenticationDto.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace Astral.Services.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Common members across authentication dtos.
|
||||||
|
/// </summary>
|
||||||
|
public class BaseAuthenticationDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// IP address of the requesting agent.
|
||||||
|
/// </summary>
|
||||||
|
public IPAddress IpAddress { get; set; }
|
||||||
|
}
|
|
@ -1,13 +1,15 @@
|
||||||
// <copyright file="AuthenticateUserDto.cs" company="alveus.dev">
|
// <copyright file="PasswordAuthenticateDto.cs" company="alveus.dev">
|
||||||
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
// </copyright>
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
|
||||||
namespace Astral.Services.Dtos;
|
namespace Astral.Services.Dtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authentication (login) request dto.
|
/// Authentication (login) request dto.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthenticateUserDto
|
public class PasswordAuthenticateDto : BaseAuthenticationDto
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Agent's username.
|
/// Agent's username.
|
||||||
|
@ -18,4 +20,9 @@ public class AuthenticateUserDto
|
||||||
/// Agent's password.
|
/// Agent's password.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Password { get; set; }
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The scope for the token.
|
||||||
|
/// </summary>
|
||||||
|
public TokenScope Scope { get; set; }
|
||||||
}
|
}
|
58
Astral.Services/Dtos/SessionDto.cs
Normal file
58
Astral.Services/Dtos/SessionDto.cs
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
// <copyright file="SessionDto.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
|
||||||
|
namespace Astral.Services.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session information.
|
||||||
|
/// </summary>
|
||||||
|
public class SessionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// User id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User name.
|
||||||
|
/// </summary>
|
||||||
|
public string UserName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access token.
|
||||||
|
/// </summary>
|
||||||
|
public string AccessToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh token.
|
||||||
|
/// </summary>
|
||||||
|
public string RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Created at.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Access token expiry (UTC).
|
||||||
|
/// </summary>
|
||||||
|
public DateTime AccessTokenExpires { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh token expiry (UTC).
|
||||||
|
/// </summary>
|
||||||
|
public DateTime RefreshTokenExpires { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The session token scope.
|
||||||
|
/// </summary>
|
||||||
|
public TokenScope Scope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The session user role.
|
||||||
|
/// </summary>
|
||||||
|
public UserRole Role { get; set; }
|
||||||
|
}
|
23
Astral.Services/Exceptions/UserSuspendedException.cs
Normal file
23
Astral.Services/Exceptions/UserSuspendedException.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
// <copyright file="UserSuspendedException.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using Astral.Core.Exceptions;
|
||||||
|
using Astral.Services.Constants;
|
||||||
|
|
||||||
|
namespace Astral.Services.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when the logging in user is suspended.
|
||||||
|
/// </summary>
|
||||||
|
public class UserSuspendedException : ServiceException
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="UserSuspendedException"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public UserSuspendedException()
|
||||||
|
: base(ServiceErrorCodes.SuspendedUser, "You are suspended from logging in", HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
20
Astral.Services/Interfaces/IAuthenticationService.cs
Normal file
20
Astral.Services/Interfaces/IAuthenticationService.cs
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// <copyright file="IAuthenticationService.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using Astral.Services.Dtos;
|
||||||
|
|
||||||
|
namespace Astral.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provide methods to authenticate new sessions.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuthenticationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticate using username/password pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="passwordAuthenticateDto">Instance of <see cref="PasswordAuthenticateDto"/>.</param>
|
||||||
|
/// <returns>Instance of <see cref="SessionDto"/>.</returns>
|
||||||
|
Task<SessionDto> AuthenticateSession(PasswordAuthenticateDto passwordAuthenticateDto);
|
||||||
|
}
|
|
@ -22,7 +22,7 @@ public class AutomapperProfile : Profile
|
||||||
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
|
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
|
||||||
.ForMember(dest => dest.RegistrationDate, opt => opt.MapFrom(src => src.CreatedAt))
|
.ForMember(dest => dest.RegistrationDate, opt => opt.MapFrom(src => src.CreatedAt))
|
||||||
.ForMember(dest => dest.RegistrationDateTicks, opt => opt.MapFrom(src => src.CreatedAt.Ticks))
|
.ForMember(dest => dest.RegistrationDateTicks, opt => opt.MapFrom(src => src.CreatedAt.Ticks))
|
||||||
.ForMember(dest => dest.UserRole, opt => opt.MapFrom(src => src.UserRole));
|
.ForMember(dest => dest.UserRole, opt => opt.MapFrom(src => src.Role.ToString()));
|
||||||
|
|
||||||
CreateMap<UserGroup, UserGroupDto>()
|
CreateMap<UserGroup, UserGroupDto>()
|
||||||
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
|
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
|
||||||
|
|
178
Astral.Services/Services/AuthenticationService.cs
Normal file
178
Astral.Services/Services/AuthenticationService.cs
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
// <copyright file="AuthenticationService.cs" company="alveus.dev">
|
||||||
|
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using Astral.Core.Constants;
|
||||||
|
using Astral.Core.Entities;
|
||||||
|
using Astral.Core.Extensions;
|
||||||
|
using Astral.Core.Infrastructure;
|
||||||
|
using Astral.Core.Options;
|
||||||
|
using Astral.Core.RepositoryInterfaces;
|
||||||
|
using Astral.Services.Constants;
|
||||||
|
using Astral.Services.Dtos;
|
||||||
|
using Astral.Services.Exceptions;
|
||||||
|
using Astral.Services.Interfaces;
|
||||||
|
using FluentValidation;
|
||||||
|
using Injectio.Attributes;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Astral.Services.Services;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[RegisterScoped]
|
||||||
|
public class AuthenticationService : IAuthenticationService
|
||||||
|
{
|
||||||
|
private const int RefreshTokenLength = 128;
|
||||||
|
|
||||||
|
private readonly ICryptographyService _cryptographyService;
|
||||||
|
private readonly ITransactionProvider _transactionProvider;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||||
|
private readonly IValidator<PasswordAuthenticateDto> _passwordAuthValidator;
|
||||||
|
private readonly JwtOptions _jwtOptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AuthenticationService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cryptographyService">Instance of <see cref="ICryptographyService"/>.</param>
|
||||||
|
/// <param name="transactionProvider">Instance of <see cref="ITransactionProvider"/>.</param>
|
||||||
|
/// <param name="userRepository">Instance of <see cref="IUserRepository"/>.</param>
|
||||||
|
/// <param name="refreshTokenRepository">Instance of <see cref="IRefreshTokenRepository"/>.</param>
|
||||||
|
/// <param name="passwordAuthValidator">Instance of <see cref="IValidator{PasswordAuthenticateDto}"/>.</param>
|
||||||
|
/// <param name="jwtOptions">Instance of <see cref="IOptions{JwtOptions}"/>.</param>
|
||||||
|
public AuthenticationService(
|
||||||
|
ICryptographyService cryptographyService,
|
||||||
|
ITransactionProvider transactionProvider,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IRefreshTokenRepository refreshTokenRepository,
|
||||||
|
IValidator<PasswordAuthenticateDto> passwordAuthValidator,
|
||||||
|
IOptions<JwtOptions> jwtOptions)
|
||||||
|
{
|
||||||
|
_cryptographyService = cryptographyService;
|
||||||
|
_transactionProvider = transactionProvider;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_refreshTokenRepository = refreshTokenRepository;
|
||||||
|
_passwordAuthValidator = passwordAuthValidator;
|
||||||
|
_jwtOptions = jwtOptions.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SessionDto> AuthenticateSession(PasswordAuthenticateDto passwordAuthenticateDto)
|
||||||
|
{
|
||||||
|
await _passwordAuthValidator.ValidateAndThrowAsync(passwordAuthenticateDto);
|
||||||
|
|
||||||
|
// Find user from provided username.
|
||||||
|
var user = await _userRepository.FindByUsername(passwordAuthenticateDto.Username);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_cryptographyService.VerifyPassword(
|
||||||
|
passwordAuthenticateDto.Password,
|
||||||
|
user.PasswordSalt,
|
||||||
|
user.PasswordHash))
|
||||||
|
{
|
||||||
|
// TODO store failed logins in future
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (user.State)
|
||||||
|
{
|
||||||
|
case UserState.Suspended:
|
||||||
|
throw new UserSuspendedException();
|
||||||
|
case UserState.Banned:
|
||||||
|
throw new UserBannedException();
|
||||||
|
case UserState.AwaitingEmailActivation:
|
||||||
|
throw new UserNotActivatedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var transaction = await _transactionProvider.BeginTransactionAsync();
|
||||||
|
|
||||||
|
// Record last logged in.
|
||||||
|
// TODO store successful login in future
|
||||||
|
user.LastLoggedIn = DateTime.UtcNow;
|
||||||
|
await _userRepository.UpdateAsync(user);
|
||||||
|
|
||||||
|
// Generate token.
|
||||||
|
var result = await GenerateAccessTokens(user, passwordAuthenticateDto.Scope, passwordAuthenticateDto.IpAddress);
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generate a new access token and refresh token for user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">Instance of <see cref="User"/>.</param>
|
||||||
|
/// <param name="scope">Instance of <see cref="TokenScope"/>.</param>
|
||||||
|
/// <param name="ipAddress">The IP address of the agent requesting a user access token.</param>
|
||||||
|
/// <returns>Instance of <see cref="SessionDto"/>.</returns>
|
||||||
|
private async Task<SessionDto> GenerateAccessTokens(
|
||||||
|
User user,
|
||||||
|
TokenScope scope,
|
||||||
|
IPAddress ipAddress)
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new (ClaimIds.UserId, user.Id.ToString()),
|
||||||
|
new (ClaimIds.UserName, user.Username),
|
||||||
|
new (ClaimIds.Role, user.Role.ToString()),
|
||||||
|
new (ClaimIds.Scope, scope.ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_jwtOptions.SecretKey));
|
||||||
|
|
||||||
|
var accessExpires = _jwtOptions.AccessTokenExpirationDateTime();
|
||||||
|
var refreshExpires = _jwtOptions.RefreshTokenExpirationDateTime();
|
||||||
|
|
||||||
|
var descriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Subject = new ClaimsIdentity(claims),
|
||||||
|
Expires = accessExpires,
|
||||||
|
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature),
|
||||||
|
Audience = _jwtOptions.Audience,
|
||||||
|
Issuer = _jwtOptions.Issuer,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = new SessionDto();
|
||||||
|
|
||||||
|
var token = handler.CreateToken(descriptor);
|
||||||
|
result.UserId = user.Id;
|
||||||
|
result.UserName = user.Username;
|
||||||
|
result.CreatedAt = DateTime.UtcNow;
|
||||||
|
result.AccessToken = handler.WriteToken(token);
|
||||||
|
result.RefreshToken = _cryptographyService.GenerateRandomString(RefreshTokenLength);
|
||||||
|
result.AccessTokenExpires = accessExpires;
|
||||||
|
result.RefreshTokenExpires = refreshExpires;
|
||||||
|
result.Scope = scope;
|
||||||
|
result.Role = user.Role;
|
||||||
|
|
||||||
|
// Make sure the refresh token is unique.
|
||||||
|
while (await _refreshTokenRepository.FindByIdAsync(result.RefreshToken) is not null)
|
||||||
|
{
|
||||||
|
result.RefreshToken = _cryptographyService.GenerateRandomString(RefreshTokenLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store refresh token.
|
||||||
|
await _refreshTokenRepository.AddAsync(new RefreshToken()
|
||||||
|
{
|
||||||
|
Token = result.RefreshToken,
|
||||||
|
UserId = user.Id,
|
||||||
|
Role = user.Role,
|
||||||
|
Scope = scope,
|
||||||
|
Expires = refreshExpires,
|
||||||
|
IpAddress = ipAddress.ToString(),
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,7 +69,7 @@ public class InitialUserService : IInitialUserService
|
||||||
|
|
||||||
// Promote user to admin.
|
// Promote user to admin.
|
||||||
var userEntity = await _userRepository.FindByIdAsync(newUser.Id);
|
var userEntity = await _userRepository.FindByIdAsync(newUser.Id);
|
||||||
userEntity.UserRole = UserRole.Admin;
|
userEntity.Role = UserRole.Admin;
|
||||||
|
|
||||||
await _userRepository.UpdateAsync(userEntity);
|
await _userRepository.UpdateAsync(userEntity);
|
||||||
|
|
||||||
|
|
|
@ -38,13 +38,13 @@ public class UserService : IUserService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserService" /> class.
|
/// Initializes a new instance of the <see cref="UserService" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userRepository">Instance of <see cref="IUserRepository" />.</param>
|
|
||||||
/// <param name="cryptographyService">Instance of <see cref="ICryptographyService" />.</param>
|
|
||||||
/// <param name="userGroupService">Instance of <see cref="IUserGroupService" />.</param>
|
|
||||||
/// <param name="mapper">Instance of <see cref="IMapper" />.</param>
|
|
||||||
/// <param name="createUserValidator">Instance of <see cref="CreateUserValidator" />.</param>
|
/// <param name="createUserValidator">Instance of <see cref="CreateUserValidator" />.</param>
|
||||||
|
/// <param name="cryptographyService">Instance of <see cref="ICryptographyService" />.</param>
|
||||||
|
/// <param name="mapper">Instance of <see cref="IMapper" />.</param>
|
||||||
/// <param name="registrationConfig">Instance of <see cref="IOptions{RegistrationOptions}" />.</param>
|
/// <param name="registrationConfig">Instance of <see cref="IOptions{RegistrationOptions}" />.</param>
|
||||||
|
/// <param name="userGroupService">Instance of <see cref="IUserGroupService" />.</param>
|
||||||
/// <param name="transactionProvider">Instance of <see cref="ITransactionProvider" />.</param>
|
/// <param name="transactionProvider">Instance of <see cref="ITransactionProvider" />.</param>
|
||||||
|
/// <param name="userRepository">Instance of <see cref="IUserRepository" />.</param>
|
||||||
/// <param name="userProfileRepository">Instance of <see cref="IUserProfileRepository" />.</param>
|
/// <param name="userProfileRepository">Instance of <see cref="IUserProfileRepository" />.</param>
|
||||||
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
|
/// <param name="logger">Instance of <see cref="ILogger" />.</param>
|
||||||
public UserService(
|
public UserService(
|
||||||
|
@ -98,7 +98,7 @@ public class UserService : IUserService
|
||||||
EmailAddress = createUser.Email,
|
EmailAddress = createUser.Email,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
CreatorIp = createUser.IpAddress.ToString(),
|
CreatorIp = createUser.IpAddress.ToString(),
|
||||||
UserRole = UserRole.User
|
Role = UserRole.User
|
||||||
};
|
};
|
||||||
|
|
||||||
var salt = _cryptographyService.GenerateSalt();
|
var salt = _cryptographyService.GenerateSalt();
|
||||||
|
@ -115,6 +115,11 @@ public class UserService : IUserService
|
||||||
|
|
||||||
if (!_registrationConfiguration.RequireEmailActivation || createUser.ActivateImmediately)
|
if (!_registrationConfiguration.RequireEmailActivation || createUser.ActivateImmediately)
|
||||||
{
|
{
|
||||||
|
user.State = UserState.Normal;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
user.State = UserState.AwaitingEmailActivation;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var transaction = await _transactionProvider.BeginTransactionAsync();
|
using var transaction = await _transactionProvider.BeginTransactionAsync();
|
||||||
|
|
|
@ -9,10 +9,10 @@ using Injectio.Attributes;
|
||||||
namespace Astral.Services.Validators;
|
namespace Astral.Services.Validators;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validation for <see cref="AuthenticateUserDto" />.
|
/// Validation for <see cref="PasswordAuthenticateDto" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RegisterScoped]
|
[RegisterScoped]
|
||||||
public class AuthenticateUserValidator : AbstractValidator<AuthenticateUserDto>
|
public class AuthenticateUserValidator : AbstractValidator<PasswordAuthenticateDto>
|
||||||
{
|
{
|
||||||
private const int MinimumUsernameLength = 4;
|
private const int MinimumUsernameLength = 4;
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue