OAuth token grant #3

Merged
mike merged 2 commits from 2024-12-11-2-user-authentication into main 2024-12-14 17:30:53 +01:00
32 changed files with 925 additions and 43 deletions

View file

@ -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 -->

View file

@ -21,10 +21,6 @@
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.1.0"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Astral.Services\Astral.Services.csproj"/>
</ItemGroup>

View 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";
}

View 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
}

View 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;
}
}

View 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();
}
}

View file

@ -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>

View file

@ -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; }
}

View 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; }
}

View 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; }
}

View 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
}

View file

@ -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";
/// <summary>
/// Administrator role.
/// </summary>
public const string Admin = "admin";
/// <summary>
/// Available values.
/// </summary>
public static readonly string[] AvailableRoles =
[
User,
/// <summary>
/// Administrator.
/// </summary>
Admin
];
}

View 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,
}

View 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; }
}

View file

@ -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.

View 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],
_ => []
};
}
}

View 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);
}

View file

@ -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,

View 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
);

View 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,
}));
}
}

View 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";
}

View 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; }
}

View file

@ -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; }
}

View 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; }
}

View 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)
{
}
}

View 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);
}

View file

@ -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))

View 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;
}
}

View file

@ -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);

View file

@ -38,13 +38,13 @@ public class UserService : IUserService
/// <summary>
/// Initializes a new instance of the <see cref="UserService" /> class.
/// </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="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="userGroupService">Instance of <see cref="IUserGroupService" />.</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="logger">Instance of <see cref="ILogger" />.</param>
public UserService(
@ -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();

View file

@ -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;