astral-api/Astral.Services/Services/AuthenticationService.cs

178 lines
6.7 KiB
C#

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