From a9d25a149262f9ddb6b7e65033270a5f8322d420 Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 11 Dec 2024 22:12:53 +0000 Subject: [PATCH 1/2] WIP OAuth token grant --- Astral.ApiServer/Astral.ApiServer.csproj | 4 -- Astral.ApiServer/Constants/OAuthGrantTypes.cs | 16 +++++++ .../Controllers/OAuthController.cs | 29 +++++++++++++ .../Models/TokenGrantRequestModel.cs | 43 +++++++++++++++++++ .../Models/TokenGrantResponseModel.cs | 37 ++++++++++++++++ Astral.Services/Services/UserService.cs | 8 ++-- 6 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 Astral.ApiServer/Constants/OAuthGrantTypes.cs create mode 100644 Astral.ApiServer/Controllers/OAuthController.cs create mode 100644 Astral.ApiServer/Models/TokenGrantRequestModel.cs create mode 100644 Astral.ApiServer/Models/TokenGrantResponseModel.cs diff --git a/Astral.ApiServer/Astral.ApiServer.csproj b/Astral.ApiServer/Astral.ApiServer.csproj index 7f8c717..4b02de7 100644 --- a/Astral.ApiServer/Astral.ApiServer.csproj +++ b/Astral.ApiServer/Astral.ApiServer.csproj @@ -21,10 +21,6 @@ - - - - diff --git a/Astral.ApiServer/Constants/OAuthGrantTypes.cs b/Astral.ApiServer/Constants/OAuthGrantTypes.cs new file mode 100644 index 0000000..2e7c827 --- /dev/null +++ b/Astral.ApiServer/Constants/OAuthGrantTypes.cs @@ -0,0 +1,16 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +namespace Astral.ApiServer.Constants; + +/// +/// Available grant types for auth token requests. +/// +public static class OAuthGrantTypes +{ + /// + /// Password grant type. + /// + public const string Password = "password"; +} diff --git a/Astral.ApiServer/Controllers/OAuthController.cs b/Astral.ApiServer/Controllers/OAuthController.cs new file mode 100644 index 0000000..8c701e3 --- /dev/null +++ b/Astral.ApiServer/Controllers/OAuthController.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.ApiServer.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Astral.ApiServer.Controllers; + +/// +/// OAuth authentication controller. +/// +[Produces("application/json")] +[Consumes("application/x-www-form-urlencoded")] +[Route("oauth")] +public class OAuthController : ControllerBase +{ + /// + /// Grant token request. + /// + /// Instance of . + [HttpPost("token")] + [AllowAnonymous] + public Task GrantToken([FromForm] TokenGrantRequestModel tokenGrantRequest) + { + throw new NotImplementedException(); + } +} diff --git a/Astral.ApiServer/Models/TokenGrantRequestModel.cs b/Astral.ApiServer/Models/TokenGrantRequestModel.cs new file mode 100644 index 0000000..9c9104d --- /dev/null +++ b/Astral.ApiServer/Models/TokenGrantRequestModel.cs @@ -0,0 +1,43 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Microsoft.AspNetCore.Mvc; + +namespace Astral.ApiServer.Models; + +/// +/// Oauth token grant request. +/// +public class TokenGrantRequestModel +{ + /// + /// The grant type of the request. + /// + [FromForm(Name = "grant_type")] + public string GrantType { get; set; } + + /// + /// Refresh token. + /// + [FromForm(Name = "refresh_token")] + public string RefreshToken { get; set; } + + /// + /// Username. + /// + [FromForm(Name = "username")] + public string Username { get; set; } + + /// + /// Password. + /// + [FromForm(Name = "password")] + public string Password { get; set; } + + /// + /// Scope. + /// + [FromForm(Name = "scope")] + public string Scope { get; set; } +} diff --git a/Astral.ApiServer/Models/TokenGrantResponseModel.cs b/Astral.ApiServer/Models/TokenGrantResponseModel.cs new file mode 100644 index 0000000..b36bf1f --- /dev/null +++ b/Astral.ApiServer/Models/TokenGrantResponseModel.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using System.Text.Json.Serialization; + +namespace Astral.ApiServer.Models; + +/// +/// OAuth Grant Request Response. +/// +public class TokenGrantResponseModel +{ + /// + /// The granted access token. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + /// + /// The granted refresh token. + /// + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + /// + /// When it expires (ticks). + /// + [JsonPropertyName("expires_in")] + public long ExpiresIn { get; set; } + + /// + /// Granted token type. + /// + [JsonPropertyName("token_type")] + public string TokenType { get; set; } +} diff --git a/Astral.Services/Services/UserService.cs b/Astral.Services/Services/UserService.cs index 1b1d747..6828bd1 100644 --- a/Astral.Services/Services/UserService.cs +++ b/Astral.Services/Services/UserService.cs @@ -38,13 +38,13 @@ public class UserService : IUserService /// /// Initializes a new instance of the class. /// - /// Instance of . - /// Instance of . - /// Instance of . - /// Instance of . /// Instance of . + /// Instance of . + /// Instance of . /// Instance of . + /// Instance of . /// Instance of . + /// Instance of . /// Instance of . /// Instance of . public UserService( From 0de089b8f008880487e8397f4687a5b0ede4c21c Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 14 Dec 2024 16:31:17 +0000 Subject: [PATCH 2/2] Oauth with username/password --- Alveus.ruleset | 2 +- Astral.ApiServer/Constants/ApiErrorCodes.cs | 21 +++ .../{OAuthGrantTypes.cs => OAuthGrantType.cs} | 6 +- .../Controllers/BaseApiController.cs | 98 ++++++++++ .../Controllers/OAuthController.cs | 54 +++++- .../Models/Common/ErrorResultModel.cs | 8 +- Astral.ApiServer/Models/Common/ResultModel.cs | 6 +- .../Models/TokenGrantResponseModel.cs | 37 ---- .../Models/TokenGrantResultModel.cs | 95 ++++++++++ Astral.Core/Constants/TokenScope.cs | 26 +++ Astral.Core/Constants/UserRole.cs | 19 +- Astral.Core/Constants/UserState.cs | 31 +++ Astral.Core/Entities/RefreshToken.cs | 58 ++++++ Astral.Core/Entities/User.cs | 9 +- Astral.Core/Extensions/UserRoleExtensions.cs | 28 +++ .../IRefreshTokenRepository.cs | 19 ++ Astral.DAL/Migrations/2024-12-09.04-users.sql | 3 +- .../2024-12-14.01-refreshTokens.sql | 10 + .../Repositories/RefreshTokenRepository.cs | 35 ++++ Astral.Services/Constants/ClaimIds.cs | 31 +++ .../Constants/ServiceErrorCodes.cs | 2 +- Astral.Services/Dtos/BaseAuthenticationDto.cs | 18 ++ ...eUserDto.cs => PasswordAuthenticateDto.cs} | 11 +- Astral.Services/Dtos/SessionDto.cs | 58 ++++++ .../Exceptions/UserSuspendedException.cs | 23 +++ .../Interfaces/IAuthenticationService.cs | 20 ++ Astral.Services/Profiles/AutomapperProfile.cs | 2 +- .../Services/AuthenticationService.cs | 178 ++++++++++++++++++ .../Services/InitialUserService.cs | 2 +- Astral.Services/Services/UserService.cs | 7 +- .../Validators/AuthenticateUserValidator.cs | 4 +- 31 files changed, 841 insertions(+), 80 deletions(-) create mode 100644 Astral.ApiServer/Constants/ApiErrorCodes.cs rename Astral.ApiServer/Constants/{OAuthGrantTypes.cs => OAuthGrantType.cs} (66%) create mode 100644 Astral.ApiServer/Controllers/BaseApiController.cs delete mode 100644 Astral.ApiServer/Models/TokenGrantResponseModel.cs create mode 100644 Astral.ApiServer/Models/TokenGrantResultModel.cs create mode 100644 Astral.Core/Constants/TokenScope.cs create mode 100644 Astral.Core/Constants/UserState.cs create mode 100644 Astral.Core/Entities/RefreshToken.cs create mode 100644 Astral.Core/Extensions/UserRoleExtensions.cs create mode 100644 Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs create mode 100644 Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql create mode 100644 Astral.DAL/Repositories/RefreshTokenRepository.cs create mode 100644 Astral.Services/Constants/ClaimIds.cs create mode 100644 Astral.Services/Dtos/BaseAuthenticationDto.cs rename Astral.Services/Dtos/{AuthenticateUserDto.cs => PasswordAuthenticateDto.cs} (60%) create mode 100644 Astral.Services/Dtos/SessionDto.cs create mode 100644 Astral.Services/Exceptions/UserSuspendedException.cs create mode 100644 Astral.Services/Interfaces/IAuthenticationService.cs create mode 100644 Astral.Services/Services/AuthenticationService.cs diff --git a/Alveus.ruleset b/Alveus.ruleset index eefa5a6..240d57b 100644 --- a/Alveus.ruleset +++ b/Alveus.ruleset @@ -16,7 +16,7 @@ - + diff --git a/Astral.ApiServer/Constants/ApiErrorCodes.cs b/Astral.ApiServer/Constants/ApiErrorCodes.cs new file mode 100644 index 0000000..c32cedd --- /dev/null +++ b/Astral.ApiServer/Constants/ApiErrorCodes.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +namespace Astral.ApiServer.Constants; + +/// +/// Collection of API error codes. +/// +public static class ApiErrorCodes +{ + /// + /// Attempted to authenticate with an unsupported grant type. + /// + public const string UnsupportedGrantType = "unsupported-grant-type"; + + /// + /// Attempted to authenticate with an unsupported token scope. + /// + public const string UnsupportedTokenScope = "unsupported-token-scope"; +} diff --git a/Astral.ApiServer/Constants/OAuthGrantTypes.cs b/Astral.ApiServer/Constants/OAuthGrantType.cs similarity index 66% rename from Astral.ApiServer/Constants/OAuthGrantTypes.cs rename to Astral.ApiServer/Constants/OAuthGrantType.cs index 2e7c827..191deca 100644 --- a/Astral.ApiServer/Constants/OAuthGrantTypes.cs +++ b/Astral.ApiServer/Constants/OAuthGrantType.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. // @@ -7,10 +7,10 @@ namespace Astral.ApiServer.Constants; /// /// Available grant types for auth token requests. /// -public static class OAuthGrantTypes +public enum OAuthGrantType { /// /// Password grant type. /// - public const string Password = "password"; + Password } diff --git a/Astral.ApiServer/Controllers/BaseApiController.cs b/Astral.ApiServer/Controllers/BaseApiController.cs new file mode 100644 index 0000000..6d9325b --- /dev/null +++ b/Astral.ApiServer/Controllers/BaseApiController.cs @@ -0,0 +1,98 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using System.Net; +using System.Net.Sockets; +using Astral.ApiServer.Models.Common; +using Astral.Core.Constants; +using Microsoft.AspNetCore.Mvc; + +namespace Astral.ApiServer.Controllers; + +/// +/// Base API controller with common methods. +/// +[Produces("application/json")] +[Consumes("application/x-www-form-urlencoded")] +public class BaseApiController : ControllerBase +{ + /// + /// Return a failure status with no data. + /// + /// Instance of . + protected IActionResult FailureResult() + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + return new JsonResult(new ResultModel + { + Success = false, + }); + } + + /// + /// Return a failure result with additional information. + /// + /// Api error code. + /// Api error message. + /// Instance of . + protected IActionResult FailureResult(string errorCode, string message) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + return new JsonResult(new ErrorResultModel + { + Error = errorCode, + Message = message, + }); + } + + /// + /// Returns a failure result indicating the body is missing from the request. + /// + /// Instance of . + protected IActionResult MissingBodyResult() + { + return FailureResult(CoreErrorCodes.NoDataProvided, "Missing request body"); + } + + /// + /// Return a success status with no data. + /// + /// Instance of . + protected IActionResult SuccessResult() + { + Response.StatusCode = (int)HttpStatusCode.OK; + return new JsonResult(new ResultModel + { + Success = true, + }); + } + + /// + /// Fetch IP address of requesting agent. + /// + /// Request origin's IP address. + 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; + } +} diff --git a/Astral.ApiServer/Controllers/OAuthController.cs b/Astral.ApiServer/Controllers/OAuthController.cs index 8c701e3..0b9b94f 100644 --- a/Astral.ApiServer/Controllers/OAuthController.cs +++ b/Astral.ApiServer/Controllers/OAuthController.cs @@ -2,7 +2,11 @@ // Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. // +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; /// /// OAuth authentication controller. /// -[Produces("application/json")] -[Consumes("application/x-www-form-urlencoded")] [Route("oauth")] -public class OAuthController : ControllerBase +public class OAuthController : BaseApiController { + private readonly IAuthenticationService _authenticationService; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of . + public OAuthController(IAuthenticationService authenticationService) + { + _authenticationService = authenticationService; + } + /// /// Grant token request. /// /// Instance of . [HttpPost("token")] [AllowAnonymous] - public Task GrantToken([FromForm] TokenGrantRequestModel tokenGrantRequest) + public async Task 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(); } } diff --git a/Astral.ApiServer/Models/Common/ErrorResultModel.cs b/Astral.ApiServer/Models/Common/ErrorResultModel.cs index 66907b4..23f3a30 100644 --- a/Astral.ApiServer/Models/Common/ErrorResultModel.cs +++ b/Astral.ApiServer/Models/Common/ErrorResultModel.cs @@ -9,14 +9,8 @@ namespace Astral.ApiServer.Models.Common; /// /// Error result model. /// -public class ErrorResultModel +public class ErrorResultModel : ResultModel { - /// - /// Status: failure. - /// - [JsonPropertyName("status")] - public const string Status = "failure"; - /// /// Error code. /// diff --git a/Astral.ApiServer/Models/Common/ResultModel.cs b/Astral.ApiServer/Models/Common/ResultModel.cs index 8f30ee4..6f0fca8 100644 --- a/Astral.ApiServer/Models/Common/ResultModel.cs +++ b/Astral.ApiServer/Models/Common/ResultModel.cs @@ -12,8 +12,8 @@ namespace Astral.ApiServer.Models.Common; public class ResultModel { /// - /// Either "success" or "failure". + /// Indicate success. /// - [JsonPropertyName("status")] - public string Status { get; set; } + [JsonPropertyName("success")] + public bool Success { get; set; } } diff --git a/Astral.ApiServer/Models/TokenGrantResponseModel.cs b/Astral.ApiServer/Models/TokenGrantResponseModel.cs deleted file mode 100644 index b36bf1f..0000000 --- a/Astral.ApiServer/Models/TokenGrantResponseModel.cs +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. -// - -using System.Text.Json.Serialization; - -namespace Astral.ApiServer.Models; - -/// -/// OAuth Grant Request Response. -/// -public class TokenGrantResponseModel -{ - /// - /// The granted access token. - /// - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } - - /// - /// The granted refresh token. - /// - [JsonPropertyName("refresh_token")] - public string RefreshToken { get; set; } - - /// - /// When it expires (ticks). - /// - [JsonPropertyName("expires_in")] - public long ExpiresIn { get; set; } - - /// - /// Granted token type. - /// - [JsonPropertyName("token_type")] - public string TokenType { get; set; } -} diff --git a/Astral.ApiServer/Models/TokenGrantResultModel.cs b/Astral.ApiServer/Models/TokenGrantResultModel.cs new file mode 100644 index 0000000..331462e --- /dev/null +++ b/Astral.ApiServer/Models/TokenGrantResultModel.cs @@ -0,0 +1,95 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using System.Text.Json.Serialization; +using Astral.ApiServer.Models.Common; +using Astral.Core.Extensions; +using Astral.Services.Dtos; + +namespace Astral.ApiServer.Models; + +/// +/// OAuth Grant Request Response. +/// +public class TokenGrantResultModel : ResultModel +{ + /// + /// Initializes a new instance of the class. + /// + public TokenGrantResultModel() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Instance of to create from. + 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(); + } + + /// + /// The granted access token. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + /// + /// When it expires (ticks). + /// + [JsonPropertyName("expires_in")] + public long ExpiresIn { get; set; } + + /// + /// The granted refresh token. + /// + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + /// + /// The session scope. + /// + [JsonPropertyName("scope")] + public string Scope { get; set; } + + /// + /// Granted token type. + /// + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + /// + /// When it was created (ticks). + /// + [JsonPropertyName("created_at")] + public long CreatedAt { get; set; } + + /// + /// The user's id. + /// + [JsonPropertyName("account_id")] + public string AccountId { get; set; } + + /// + /// The user's name. + /// + [JsonPropertyName("account_name")] + public string AccountName { get; set; } + + /// + /// The user's roles. + /// + [JsonPropertyName("account_roles")] + public List AccountRoles { get; set; } +} diff --git a/Astral.Core/Constants/TokenScope.cs b/Astral.Core/Constants/TokenScope.cs new file mode 100644 index 0000000..42e807d --- /dev/null +++ b/Astral.Core/Constants/TokenScope.cs @@ -0,0 +1,26 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +namespace Astral.Core.Constants; + +/// +/// Token scopes. +/// +public enum TokenScope +{ + /// + /// Owner token scope. + /// + Owner, + + /// + /// Domain token scope. + /// + Domain, + + /// + /// Place token scope. + /// + Place +} diff --git a/Astral.Core/Constants/UserRole.cs b/Astral.Core/Constants/UserRole.cs index b4c40fd..af271ba 100644 --- a/Astral.Core/Constants/UserRole.cs +++ b/Astral.Core/Constants/UserRole.cs @@ -7,24 +7,15 @@ namespace Astral.Core.Constants; /// /// Available user roles. /// -public static class UserRole +public enum UserRole { /// - /// User role. + /// Basic user. /// - public const string User = "user"; + User, /// - /// Administrator role. + /// Administrator. /// - public const string Admin = "admin"; - - /// - /// Available values. - /// - public static readonly string[] AvailableRoles = - [ - User, - Admin - ]; + Admin } diff --git a/Astral.Core/Constants/UserState.cs b/Astral.Core/Constants/UserState.cs new file mode 100644 index 0000000..bb695eb --- /dev/null +++ b/Astral.Core/Constants/UserState.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +namespace Astral.Core.Constants; + +/// +/// Available user states. +/// +public enum UserState +{ + /// + /// Normal activated user. + /// + Normal, + + /// + /// Suspended account. + /// + Suspended, + + /// + /// Banned account. + /// + Banned, + + /// + /// Awaiting email activation. + /// + AwaitingEmailActivation, +} diff --git a/Astral.Core/Entities/RefreshToken.cs b/Astral.Core/Entities/RefreshToken.cs new file mode 100644 index 0000000..419cfa6 --- /dev/null +++ b/Astral.Core/Entities/RefreshToken.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.Core.Attributes.EntityAnnotation; +using Astral.Core.Constants; + +namespace Astral.Core.Entities; + +/// +/// Refresh token entity. +/// +[TableMapping("refreshTokens")] +public class RefreshToken +{ + /// + /// Token identifier. + /// + [PrimaryKey] + [ColumnMapping("token")] + public string Token { get; set; } + + /// + /// User id the token belongs to. + /// + [ColumnMapping("userId")] + public Guid UserId { get; set; } + + /// + /// Ip address of the agent the token granted to. + /// + [ColumnMapping("ipAddress")] + public string IpAddress { get; set; } + + /// + /// Session agent role. + /// + [ColumnMapping("role")] + public UserRole Role { get; set; } + + /// + /// Session token scope. + /// + [ColumnMapping("scope")] + public TokenScope Scope { get; set; } + + /// + /// When the token expires. + /// + [ColumnMapping("expires")] + public DateTime Expires { get; set; } + + /// + /// When the token was created. + /// + [ColumnMapping("createdAt")] + public DateTime CreatedAt { get; set; } +} diff --git a/Astral.Core/Entities/User.cs b/Astral.Core/Entities/User.cs index 7497d55..57e9a4a 100644 --- a/Astral.Core/Entities/User.cs +++ b/Astral.Core/Entities/User.cs @@ -3,6 +3,7 @@ // 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; } + /// + /// State of the user's account. + /// + [ColumnMapping("userState")] + public UserState State { get; set; } + /// /// The ip address of the creator. /// @@ -71,7 +78,7 @@ public class User /// The user's role. /// [ColumnMapping("role")] - public string UserRole { get; set; } + public UserRole Role { get; set; } /// /// Group for this user's connections. diff --git a/Astral.Core/Extensions/UserRoleExtensions.cs b/Astral.Core/Extensions/UserRoleExtensions.cs new file mode 100644 index 0000000..a3a109c --- /dev/null +++ b/Astral.Core/Extensions/UserRoleExtensions.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.Core.Constants; + +namespace Astral.Core.Extensions; + +/// +/// extensions. +/// +public static class UserRoleExtensions +{ + /// + /// Get accessible roles of a user role. + /// + /// This . + /// Collection of accessible . + public static UserRole[] GetAccessibleRoles(this UserRole userRole) + { + return userRole switch + { + UserRole.User => [UserRole.User], + UserRole.Admin => [UserRole.User, UserRole.Admin], + _ => [] + }; + } +} diff --git a/Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs b/Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs new file mode 100644 index 0000000..8e5c5b7 --- /dev/null +++ b/Astral.Core/RepositoryInterfaces/IRefreshTokenRepository.cs @@ -0,0 +1,19 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.Core.Entities; + +namespace Astral.Core.RepositoryInterfaces; + +/// +/// Repository. +/// +public interface IRefreshTokenRepository : IGenericRepository +{ + /// + /// Revoke all tokens for the provided user id. + /// + /// User id. + Task RevokeAllForUser(Guid userId); +} diff --git a/Astral.DAL/Migrations/2024-12-09.04-users.sql b/Astral.DAL/Migrations/2024-12-09.04-users.sql index 0f04776..d3f6641 100644 --- a/Astral.DAL/Migrations/2024-12-09.04-users.sql +++ b/Astral.DAL/Migrations/2024-12-09.04-users.sql @@ -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, diff --git a/Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql b/Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql new file mode 100644 index 0000000..f99f93c --- /dev/null +++ b/Astral.DAL/Migrations/2024-12-14.01-refreshTokens.sql @@ -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 +); diff --git a/Astral.DAL/Repositories/RefreshTokenRepository.cs b/Astral.DAL/Repositories/RefreshTokenRepository.cs new file mode 100644 index 0000000..019780f --- /dev/null +++ b/Astral.DAL/Repositories/RefreshTokenRepository.cs @@ -0,0 +1,35 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.Core.Entities; +using Astral.Core.Infrastructure; +using Astral.Core.RepositoryInterfaces; +using Dapper; +using Injectio.Attributes; + +namespace Astral.DAL.Repositories; + +/// +[RegisterScoped] +public class RefreshTokenRepository : BaseRepository, IRefreshTokenRepository +{ + /// + /// Initializes a new instance of the class. + /// + /// Instance of . + public RefreshTokenRepository(IDbConnectionProvider db) + : base(db) + { + } + + /// + public async Task RevokeAllForUser(Guid userId) + { + await WithDatabaseAsync(async connection => await connection.ExecuteAsync( + $"DELETE FROM {Table} WHERE userId = @pUserId", new + { + pUserId = userId, + })); + } +} diff --git a/Astral.Services/Constants/ClaimIds.cs b/Astral.Services/Constants/ClaimIds.cs new file mode 100644 index 0000000..3829dc0 --- /dev/null +++ b/Astral.Services/Constants/ClaimIds.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +namespace Astral.Services.Constants; + +/// +/// Collection of claim ids. +/// +public static class ClaimIds +{ + /// + /// User Id claim. + /// + public const string UserId = "user-id"; + + /// + /// Username claim. + /// + public const string UserName = "user-name"; + + /// + /// User Role claim. + /// + public const string Role = "role"; + + /// + /// Token scope. + /// + public const string Scope = "scope"; +} diff --git a/Astral.Services/Constants/ServiceErrorCodes.cs b/Astral.Services/Constants/ServiceErrorCodes.cs index 38547d6..e9b91e0 100644 --- a/Astral.Services/Constants/ServiceErrorCodes.cs +++ b/Astral.Services/Constants/ServiceErrorCodes.cs @@ -5,7 +5,7 @@ namespace Astral.Services.Constants; /// -/// Collection of standard error codes. +/// Collection of standard error codes. /// public static class ServiceErrorCodes { diff --git a/Astral.Services/Dtos/BaseAuthenticationDto.cs b/Astral.Services/Dtos/BaseAuthenticationDto.cs new file mode 100644 index 0000000..f4bc6e0 --- /dev/null +++ b/Astral.Services/Dtos/BaseAuthenticationDto.cs @@ -0,0 +1,18 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using System.Net; + +namespace Astral.Services.Dtos; + +/// +/// Common members across authentication dtos. +/// +public class BaseAuthenticationDto +{ + /// + /// IP address of the requesting agent. + /// + public IPAddress IpAddress { get; set; } +} diff --git a/Astral.Services/Dtos/AuthenticateUserDto.cs b/Astral.Services/Dtos/PasswordAuthenticateDto.cs similarity index 60% rename from Astral.Services/Dtos/AuthenticateUserDto.cs rename to Astral.Services/Dtos/PasswordAuthenticateDto.cs index e60e885..6f87a3c 100644 --- a/Astral.Services/Dtos/AuthenticateUserDto.cs +++ b/Astral.Services/Dtos/PasswordAuthenticateDto.cs @@ -1,13 +1,15 @@ -// +// // Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. // +using Astral.Core.Constants; + namespace Astral.Services.Dtos; /// /// Authentication (login) request dto. /// -public class AuthenticateUserDto +public class PasswordAuthenticateDto : BaseAuthenticationDto { /// /// Agent's username. @@ -18,4 +20,9 @@ public class AuthenticateUserDto /// Agent's password. /// public string Password { get; set; } + + /// + /// The scope for the token. + /// + public TokenScope Scope { get; set; } } diff --git a/Astral.Services/Dtos/SessionDto.cs b/Astral.Services/Dtos/SessionDto.cs new file mode 100644 index 0000000..97c6c9c --- /dev/null +++ b/Astral.Services/Dtos/SessionDto.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.Core.Constants; + +namespace Astral.Services.Dtos; + +/// +/// Session information. +/// +public class SessionDto +{ + /// + /// User id. + /// + public Guid UserId { get; set; } + + /// + /// User name. + /// + public string UserName { get; set; } + + /// + /// Access token. + /// + public string AccessToken { get; set; } + + /// + /// Refresh token. + /// + public string RefreshToken { get; set; } + + /// + /// Created at. + /// + public DateTime CreatedAt { get; set; } + + /// + /// Access token expiry (UTC). + /// + public DateTime AccessTokenExpires { get; set; } + + /// + /// Refresh token expiry (UTC). + /// + public DateTime RefreshTokenExpires { get; set; } + + /// + /// The session token scope. + /// + public TokenScope Scope { get; set; } + + /// + /// The session user role. + /// + public UserRole Role { get; set; } +} diff --git a/Astral.Services/Exceptions/UserSuspendedException.cs b/Astral.Services/Exceptions/UserSuspendedException.cs new file mode 100644 index 0000000..663112f --- /dev/null +++ b/Astral.Services/Exceptions/UserSuspendedException.cs @@ -0,0 +1,23 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using System.Net; +using Astral.Core.Exceptions; +using Astral.Services.Constants; + +namespace Astral.Services.Exceptions; + +/// +/// Thrown when the logging in user is suspended. +/// +public class UserSuspendedException : ServiceException +{ + /// + /// Initializes a new instance of the class. + /// + public UserSuspendedException() + : base(ServiceErrorCodes.SuspendedUser, "You are suspended from logging in", HttpStatusCode.Unauthorized) + { + } +} diff --git a/Astral.Services/Interfaces/IAuthenticationService.cs b/Astral.Services/Interfaces/IAuthenticationService.cs new file mode 100644 index 0000000..f3bb6ce --- /dev/null +++ b/Astral.Services/Interfaces/IAuthenticationService.cs @@ -0,0 +1,20 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.Services.Dtos; + +namespace Astral.Services.Interfaces; + +/// +/// Provide methods to authenticate new sessions. +/// +public interface IAuthenticationService +{ + /// + /// Authenticate using username/password pair. + /// + /// Instance of . + /// Instance of . + Task AuthenticateSession(PasswordAuthenticateDto passwordAuthenticateDto); +} diff --git a/Astral.Services/Profiles/AutomapperProfile.cs b/Astral.Services/Profiles/AutomapperProfile.cs index 51cc1a5..fef364d 100644 --- a/Astral.Services/Profiles/AutomapperProfile.cs +++ b/Astral.Services/Profiles/AutomapperProfile.cs @@ -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() .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id)) diff --git a/Astral.Services/Services/AuthenticationService.cs b/Astral.Services/Services/AuthenticationService.cs new file mode 100644 index 0000000..f9546d9 --- /dev/null +++ b/Astral.Services/Services/AuthenticationService.cs @@ -0,0 +1,178 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +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; + +/// +[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 _passwordAuthValidator; + private readonly JwtOptions _jwtOptions; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of . + /// Instance of . + /// Instance of . + /// Instance of . + /// Instance of . + /// Instance of . + public AuthenticationService( + ICryptographyService cryptographyService, + ITransactionProvider transactionProvider, + IUserRepository userRepository, + IRefreshTokenRepository refreshTokenRepository, + IValidator passwordAuthValidator, + IOptions jwtOptions) + { + _cryptographyService = cryptographyService; + _transactionProvider = transactionProvider; + _userRepository = userRepository; + _refreshTokenRepository = refreshTokenRepository; + _passwordAuthValidator = passwordAuthValidator; + _jwtOptions = jwtOptions.Value; + } + + /// + public async Task 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; + } + + /// + /// Generate a new access token and refresh token for user. + /// + /// Instance of . + /// Instance of . + /// The IP address of the agent requesting a user access token. + /// Instance of . + private async Task GenerateAccessTokens( + User user, + TokenScope scope, + IPAddress ipAddress) + { + var handler = new JwtSecurityTokenHandler(); + + var claims = new List + { + 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; + } +} diff --git a/Astral.Services/Services/InitialUserService.cs b/Astral.Services/Services/InitialUserService.cs index c357b02..ee5d2c7 100644 --- a/Astral.Services/Services/InitialUserService.cs +++ b/Astral.Services/Services/InitialUserService.cs @@ -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); diff --git a/Astral.Services/Services/UserService.cs b/Astral.Services/Services/UserService.cs index 6828bd1..3d0b0ba 100644 --- a/Astral.Services/Services/UserService.cs +++ b/Astral.Services/Services/UserService.cs @@ -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(); diff --git a/Astral.Services/Validators/AuthenticateUserValidator.cs b/Astral.Services/Validators/AuthenticateUserValidator.cs index bbb15b4..f28bfc8 100644 --- a/Astral.Services/Validators/AuthenticateUserValidator.cs +++ b/Astral.Services/Validators/AuthenticateUserValidator.cs @@ -9,10 +9,10 @@ using Injectio.Attributes; namespace Astral.Services.Validators; /// -/// Validation for . +/// Validation for . /// [RegisterScoped] -public class AuthenticateUserValidator : AbstractValidator +public class AuthenticateUserValidator : AbstractValidator { private const int MinimumUsernameLength = 4;