OAuth token grant #3
31 changed files with 841 additions and 80 deletions
|
@ -16,7 +16,7 @@
|
|||
<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="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="SA1012" Action="Warning"/> <!-- Opening braces should be spaced correctly -->
|
||||
<Rule Id="SA1013" Action="Warning"/> <!-- Closing braces should be spaced correctly -->
|
||||
|
|
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";
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
// <copyright file="OAuthGrantTypes.cs" company="alveus.dev">
|
||||
// <copyright file="OAuthGrantType.cs" company="alveus.dev">
|
||||
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||
// </copyright>
|
||||
|
||||
|
@ -7,10 +7,10 @@ namespace Astral.ApiServer.Constants;
|
|||
/// <summary>
|
||||
/// Available grant types for auth token requests.
|
||||
/// </summary>
|
||||
public static class OAuthGrantTypes
|
||||
public enum OAuthGrantType
|
||||
{
|
||||
/// <summary>
|
||||
/// Password grant type.
|
||||
/// </summary>
|
||||
public const string Password = "password";
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -2,7 +2,11 @@
|
|||
// 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;
|
||||
|
||||
|
@ -11,19 +15,59 @@ namespace Astral.ApiServer.Controllers;
|
|||
/// <summary>
|
||||
/// OAuth authentication controller.
|
||||
/// </summary>
|
||||
[Produces("application/json")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
[Route("oauth")]
|
||||
public class OAuthController : ControllerBase
|
||||
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 Task<IActionResult> GrantToken([FromForm] TokenGrantRequestModel tokenGrantRequest)
|
||||
public async Task<IActionResult> GrantToken([FromForm] TokenGrantRequestModel tokenGrantRequest)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
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>
|
||||
/// Error result model.
|
||||
/// </summary>
|
||||
public class ErrorResultModel
|
||||
public class ErrorResultModel : ResultModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Status: failure.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public const string Status = "failure";
|
||||
|
||||
/// <summary>
|
||||
/// Error code.
|
||||
/// </summary>
|
||||
|
|
|
@ -12,8 +12,8 @@ namespace Astral.ApiServer.Models.Common;
|
|||
public class ResultModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Either "success" or "failure".
|
||||
/// Indicate success.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; }
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
// <copyright file="TokenGrantResponseModel.cs" company="alveus.dev">
|
||||
// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Astral.ApiServer.Models;
|
||||
|
||||
/// <summary>
|
||||
/// OAuth Grant Request Response.
|
||||
/// </summary>
|
||||
public class TokenGrantResponseModel
|
||||
{
|
||||
/// <summary>
|
||||
/// The granted access token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The granted refresh token.
|
||||
/// </summary>
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string RefreshToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When it expires (ticks).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_in")]
|
||||
public long ExpiresIn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Granted token type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { 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>
|
||||
/// Available user roles.
|
||||
/// </summary>
|
||||
public static class UserRole
|
||||
public enum UserRole
|
||||
{
|
||||
/// <summary>
|
||||
/// User role.
|
||||
/// Basic user.
|
||||
/// </summary>
|
||||
public const string User = "user";
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// Administrator role.
|
||||
/// Administrator.
|
||||
/// </summary>
|
||||
public const string Admin = "admin";
|
||||
|
||||
/// <summary>
|
||||
/// Available values.
|
||||
/// </summary>
|
||||
public static readonly string[] AvailableRoles =
|
||||
[
|
||||
User,
|
||||
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>
|
||||
|
||||
using Astral.Core.Attributes.EntityAnnotation;
|
||||
using Astral.Core.Constants;
|
||||
|
||||
namespace Astral.Core.Entities;
|
||||
|
||||
|
@ -55,6 +56,12 @@ public class User
|
|||
[ColumnMapping("authSalt")]
|
||||
public string PasswordSalt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// State of the user's account.
|
||||
/// </summary>
|
||||
[ColumnMapping("userState")]
|
||||
public UserState State { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The ip address of the creator.
|
||||
/// </summary>
|
||||
|
@ -71,7 +78,7 @@ public class User
|
|||
/// The user's role.
|
||||
/// </summary>
|
||||
[ColumnMapping("role")]
|
||||
public string UserRole { get; set; }
|
||||
public UserRole Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
authHash TEXT,
|
||||
authSalt TEXT,
|
||||
userState SMALLINT DEFAULT NULL,
|
||||
lastLoggedIn TIMESTAMP DEFAULT NULL,
|
||||
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,
|
||||
connectionGroup 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";
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
namespace Astral.Services.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Collection of standard error codes.
|
||||
/// Collection of standard error codes.
|
||||
/// </summary>
|
||||
public static class ServiceErrorCodes
|
||||
{
|
||||
|
|
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>
|
||||
|
||||
using Astral.Core.Constants;
|
||||
|
||||
namespace Astral.Services.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication (login) request dto.
|
||||
/// </summary>
|
||||
public class AuthenticateUserDto
|
||||
public class PasswordAuthenticateDto : BaseAuthenticationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent's username.
|
||||
|
@ -18,4 +20,9 @@ public class AuthenticateUserDto
|
|||
/// Agent's password.
|
||||
/// </summary>
|
||||
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.RegistrationDate, opt => opt.MapFrom(src => src.CreatedAt))
|
||||
.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>()
|
||||
.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.
|
||||
var userEntity = await _userRepository.FindByIdAsync(newUser.Id);
|
||||
userEntity.UserRole = UserRole.Admin;
|
||||
userEntity.Role = UserRole.Admin;
|
||||
|
||||
await _userRepository.UpdateAsync(userEntity);
|
||||
|
||||
|
|
|
@ -98,7 +98,7 @@ public class UserService : IUserService
|
|||
EmailAddress = createUser.Email,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatorIp = createUser.IpAddress.ToString(),
|
||||
UserRole = UserRole.User
|
||||
Role = UserRole.User
|
||||
};
|
||||
|
||||
var salt = _cryptographyService.GenerateSalt();
|
||||
|
@ -115,6 +115,11 @@ public class UserService : IUserService
|
|||
|
||||
if (!_registrationConfiguration.RequireEmailActivation || createUser.ActivateImmediately)
|
||||
{
|
||||
user.State = UserState.Normal;
|
||||
}
|
||||
else
|
||||
{
|
||||
user.State = UserState.AwaitingEmailActivation;
|
||||
}
|
||||
|
||||
using var transaction = await _transactionProvider.BeginTransactionAsync();
|
||||
|
|
|
@ -9,10 +9,10 @@ using Injectio.Attributes;
|
|||
namespace Astral.Services.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validation for <see cref="AuthenticateUserDto" />.
|
||||
/// Validation for <see cref="PasswordAuthenticateDto" />.
|
||||
/// </summary>
|
||||
[RegisterScoped]
|
||||
public class AuthenticateUserValidator : AbstractValidator<AuthenticateUserDto>
|
||||
public class AuthenticateUserValidator : AbstractValidator<PasswordAuthenticateDto>
|
||||
{
|
||||
private const int MinimumUsernameLength = 4;
|
||||
|
||||
|
|
Loading…
Reference in a new issue