auth changes

This commit is contained in:
2024-03-28 19:17:46 +01:00
Unverified
parent 0d9a42dd24
commit fcca2119a5
45 changed files with 6588 additions and 55 deletions

View File

@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using WatchIt.Shared.Models.Accounts.Authenticate;
using WatchIt.Shared.Models.Accounts.Register;
using WatchIt.WebAPI.Services.Controllers;
namespace WatchIt.WebAPI.Controllers
{
[ApiController]
[Route("api/accounts")]
public class AccountsController(IAccountsControllerService accountsControllerService) : ControllerBase
{
#region METHODS
[HttpPost]
[Route("register")]
[AllowAnonymous]
[ProducesResponseType(typeof(RegisterResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult> Register([FromBody] RegisterRequest data) => await accountsControllerService.Register(data);
[HttpPost]
[Route("authenticate")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthenticateResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> Authenticate([FromBody] AuthenticateRequest data) => await accountsControllerService.Authenticate(data);
[HttpPost]
[Route("authenticate-refresh")]
[Authorize(AuthenticationSchemes = "refresh")]
[ProducesResponseType(typeof(AuthenticateResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult> AuthenticateRefresh() => await accountsControllerService.AuthenticateRefresh();
#endregion
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WatchIt.WebAPI.Services\WatchIt.WebAPI.Services.Controllers\WatchIt.WebAPI.Services.Controllers.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore;
using SimpleToolkit.Extensions;
using System.Security.Cryptography;
using System.Text;
using WatchIt.Database;
using WatchIt.Database.Model.Account;
using WatchIt.Shared.Models;
using WatchIt.Shared.Models.Accounts.Authenticate;
using WatchIt.Shared.Models.Accounts.Register;
using WatchIt.WebAPI.Services.Utility.JWT;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace WatchIt.WebAPI.Services.Controllers
{
public interface IAccountsControllerService
{
Task<RequestResult<RegisterResponse>> Register(RegisterRequest data);
Task<RequestResult<AuthenticateResponse>> Authenticate(AuthenticateRequest data);
Task<RequestResult<AuthenticateResponse>> AuthenticateRefresh();
}
public class AccountsControllerService(IJWTService jwtService, DatabaseContext database) : IAccountsControllerService
{
#region PUBLIC METHODS
public async Task<RequestResult<RegisterResponse>> Register(RegisterRequest data)
{
string leftSalt = StringExtensions.CreateRandom(20);
string rightSalt = StringExtensions.CreateRandom(20);
byte[] hash = ComputeHash(data.Password, leftSalt, rightSalt);
Account account = new Account
{
Username = data.Username,
Email = data.Email,
Password = hash,
LeftSalt = leftSalt,
RightSalt = rightSalt
};
await database.Accounts.AddAsync(account);
await database.SaveChangesAsync();
return RequestResult.Created<RegisterResponse>($"accounts/{account.Id}", account);
}
public async Task<RequestResult<AuthenticateResponse>> Authenticate(AuthenticateRequest data)
{
Account? account = await database.Accounts.FirstOrDefaultAsync(x => string.Equals(x.Email, data.UsernameOrEmail) || string.Equals(x.Username, data.UsernameOrEmail));
if (account is null)
{
return RequestResult.Unauthorized<AuthenticateResponse>("User does not exists");
}
byte[] hash = ComputeHash(data.Password, account.LeftSalt, account.RightSalt);
if (!Enumerable.SequenceEqual(hash, account.Password))
{
return RequestResult.Unauthorized<AuthenticateResponse>("Incorrect password");
}
Task<string> refreshTokenTask = jwtService.CreateRefreshToken(account, true);
Task<string> accessTokenTask = jwtService.CreateAccessToken(account);
await Task.WhenAll(refreshTokenTask, accessTokenTask);
AuthenticateResponse response = new AuthenticateResponse
{
AccessToken = accessTokenTask.Result,
RefreshToken = refreshTokenTask.Result,
};
return RequestResult.Ok(response);
}
public async Task<RequestResult<AuthenticateResponse>> AuthenticateRefresh()
{
}
#endregion
#region PRIVATE METHODS
protected byte[] ComputeHash(string password, string leftSalt, string rightSalt) => SHA512.Create().ComputeHash(Encoding.UTF8.GetBytes($"{leftSalt}{password}{rightSalt}"));
#endregion
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.3" />
<PackageReference Include="SimpleToolkit.Extensions" Version="1.7.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\WatchIt.Database\WatchIt.Database\WatchIt.Database.csproj" />
<ProjectReference Include="..\..\..\WatchIt.Shared\WatchIt.Shared.Models\WatchIt.Shared.Models.csproj" />
<ProjectReference Include="..\WatchIt.WebAPI.Services.Utility\WatchIt.WebAPI.Services.Utility.JWT\WatchIt.WebAPI.Services.Utility.JWT.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WatchIt.WebAPI.Services.Utility.Configuration.Models;
namespace WatchIt.WebAPI.Services.Utility.Configuration
{
public interface IConfigurationService
{
ConfigurationData Data { get; }
}
public class ConfigurationService(IConfiguration configuration) : IConfigurationService
{
#region PROPERTIES
public ConfigurationData Data => configuration.GetSection("WebAPI").Get<ConfigurationData>()!;
#endregion
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Services.Utility.Configuration.Models
{
public class AccessToken
{
public int Lifetime { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Services.Utility.Configuration.Models
{
public class Authentication
{
public string Key { get; set; }
public string Issuer { get; set; }
public RefreshToken RefreshToken { get; set; }
public AccessToken AccessToken { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Services.Utility.Configuration.Models
{
public class ConfigurationData
{
public Authentication Authentication { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Services.Utility.Configuration.Models
{
public class RefreshToken
{
public int Lifetime { get; set; }
public int ExtendedLifetime { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,131 @@
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using System.Text;
using System.Threading.Tasks;
using WatchIt.Database;
using WatchIt.Database.Model.Account;
using WatchIt.WebAPI.Services.Utility.Configuration;
namespace WatchIt.WebAPI.Services.Utility.JWT
{
public interface IJWTService
{
Task<string> CreateAccessToken(Account account);
Task<string> CreateRefreshToken(Account account, bool extendable);
Task<string> ExtendRefreshToken(Account account, Guid id);
}
public class JWTService(IConfigurationService configurationService, DatabaseContext database) : IJWTService
{
#region PUBLIC METHODS
public async Task<string> CreateRefreshToken(Account account, bool extendable)
{
int expirationMinutes = extendable ? configurationService.Data.Authentication.RefreshToken.ExtendedLifetime : configurationService.Data.Authentication.RefreshToken.Lifetime;
DateTime expirationDate = DateTime.UtcNow.AddMinutes(expirationMinutes);
Guid id = Guid.NewGuid();
AccountRefreshToken refreshToken = new AccountRefreshToken
{
Id = id,
AccountId = account.Id,
ExpirationDate = expirationDate,
IsExtendable = extendable
};
database.AccountRefreshTokens.Add(refreshToken);
Task saveTask = database.SaveChangesAsync();
SecurityTokenDescriptor tokenDescriptor = CreateBaseSecurityTokenDescriptor(account, id, expirationDate);
tokenDescriptor.Audience = "refresh";
tokenDescriptor.Subject.AddClaim(new Claim("extend", extendable.ToString()));
string tokenString = TokenToString(tokenDescriptor);
await saveTask;
return tokenString;
}
public async Task<string> ExtendRefreshToken(Account account, Guid id)
{
AccountRefreshToken? token = account.AccountRefreshTokens.FirstOrDefault(x => x.Id == id);
if (token is null)
{
throw new TokenNotFoundException();
}
if (!token.IsExtendable)
{
throw new TokenNotExtendableException();
}
int expirationMinutes = configurationService.Data.Authentication.RefreshToken.ExtendedLifetime;
DateTime expirationDate = DateTime.UtcNow.AddMinutes(expirationMinutes);
token.ExpirationDate = expirationDate;
Task saveTask = database.SaveChangesAsync();
SecurityTokenDescriptor tokenDescriptor = CreateBaseSecurityTokenDescriptor(account, id, expirationDate);
tokenDescriptor.Audience = "refresh";
tokenDescriptor.Subject.AddClaim(new Claim("extend", bool.TrueString));
string tokenString = TokenToString(tokenDescriptor);
await saveTask;
return tokenString;
}
public async Task<string> CreateAccessToken(Account account)
{
DateTime lifetime = DateTime.Now.AddMinutes(configurationService.Data.Authentication.AccessToken.Lifetime);
Guid id = Guid.NewGuid();
SecurityTokenDescriptor tokenDescriptor = CreateBaseSecurityTokenDescriptor(account, id, lifetime);
tokenDescriptor.Audience = "access";
tokenDescriptor.Subject.AddClaim(new Claim("admin", account.IsAdmin.ToString()));
return TokenToString(tokenDescriptor);
}
#endregion
#region PRIVATE METHODS
protected SecurityTokenDescriptor CreateBaseSecurityTokenDescriptor(Account account, Guid id, DateTime expirationTime) => new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Jti, id.ToString()),
new Claim(JwtRegisteredClaimNames.Sub, account.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, account.Email),
new Claim(JwtRegisteredClaimNames.UniqueName, account.Username),
new Claim(JwtRegisteredClaimNames.Exp, expirationTime.ToString()),
}),
Expires = expirationTime,
Issuer = configurationService.Data.Authentication.Issuer,
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configurationService.Data.Authentication.Key)), SecurityAlgorithms.HmacSha512)
};
protected string TokenToString(SecurityTokenDescriptor tokenDescriptor)
{
System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
handler.InboundClaimTypeMap.Clear();
SecurityToken token = handler.CreateToken(tokenDescriptor);
return handler.WriteToken(token);
}
#endregion
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Services.Utility.JWT
{
public class TokenNotExtendableException : Exception
{
public TokenNotExtendableException() : base() { }
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Services.Utility.JWT
{
public class TokenNotFoundException : Exception
{
public TokenNotFoundException() : base() { }
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.1.2" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.1.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\WatchIt.Database\WatchIt.Database.Model\WatchIt.Database.Model.csproj" />
<ProjectReference Include="..\..\..\..\WatchIt.Database\WatchIt.Database\WatchIt.Database.csproj" />
<ProjectReference Include="..\WatchIt.WebAPI.Services.Utility.Configuration\WatchIt.WebAPI.Services.Utility.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Services.Utility.User
{
public class UserService(IHttpContextAccessor accessor)
{
#region PUBLIC METHODS
#endregion
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using WatchIt.Database;
using WatchIt.Shared.Models.Accounts.Authenticate;
namespace WatchIt.WebAPI.Validators.Accounts
{
public class AuthenticateRequestValidator : AbstractValidator<AuthenticateRequest>
{
#region CONSTRUCTOR
public AuthenticateRequestValidator(DatabaseContext database)
{
RuleFor(x => x.UsernameOrEmail).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
#endregion
}
}

View File

@@ -0,0 +1,31 @@
using FluentValidation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WatchIt.Database;
using WatchIt.Shared.Models.Accounts.Register;
namespace WatchIt.WebAPI.Validators.Accounts
{
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
#region CONSTRUCTOR
public RegisterRequestValidator(DatabaseContext database)
{
RuleFor(x => x.Username).MinimumLength(5)
.MaximumLength(50)
.CannotBeIn(database.Accounts, x => x.Username).WithMessage("Username was already used");
RuleFor(x => x.Email).EmailAddress()
.CannotBeIn(database.Accounts, x => x.Email).WithMessage("Email was already used");
RuleFor(x => x.Password).MinimumLength(8)
.Must(x => x.Any(c => Char.IsUpper(c))).WithMessage("Password must contain at least one uppercase letter.")
.Must(x => x.Any(c => Char.IsLower(c))).WithMessage("Password must contain at least one lowercase letter.")
.Must(x => x.Any(c => Char.IsDigit(c))).WithMessage("Password must contain at least one digit.");
}
#endregion
}
}

View File

@@ -0,0 +1,18 @@
using FluentValidation;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WatchIt.WebAPI.Validators
{
public static class CustomValidators
{
public static IRuleBuilderOptions<T, TProperty> CannotBeIn<T, TProperty>(this IRuleBuilder<T, TProperty> ruleBuilder, IEnumerable<TProperty> collection) => ruleBuilder.Must(x => !collection.Any(e => Equals(e, x)));
public static IRuleBuilderOptions<T, TProperty> CannotBeIn<T, TProperty, TCollectionType>(this IRuleBuilder<T, TProperty> ruleBuilder, IEnumerable<TCollectionType> collection, Func<TCollectionType, TProperty> propertyFunc) => ruleBuilder.Must(x => !collection.Select(propertyFunc).Any(e => Equals(e, x)));
public static IRuleBuilderOptions<T, TProperty> MustBeIn<T, TProperty>(this IRuleBuilder<T, TProperty> ruleBuilder, IEnumerable<TProperty> collection) => ruleBuilder.Must(x => collection.Any(e => Equals(e, x)));
public static IRuleBuilderOptions<T, TProperty> MustBeIn<T, TProperty, TCollectionType>(this IRuleBuilder<T, TProperty> ruleBuilder, IEnumerable<TCollectionType> collection, Func<TCollectionType, TProperty> propertyFunc) => ruleBuilder.Must(x => collection.Select(propertyFunc).Any(e => Equals(e, x)));
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\WatchIt.Database\WatchIt.Database\WatchIt.Database.csproj" />
<ProjectReference Include="..\..\WatchIt.Shared\WatchIt.Shared.Models\WatchIt.Shared.Models.csproj" />
</ItemGroup>
</Project>