-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #11 from FuzzyLab-UVA/auth
- Loading branch information
Showing
27 changed files
with
677 additions
and
89 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,14 @@ | ||
<Project Sdk="Microsoft.NET.Sdk.Web"> | ||
|
||
<PropertyGroup> | ||
<VersionPrefix>1.0.0.0</VersionPrefix> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="..\ArtwiseDatabase\ArtwiseDatabase.csproj" /> | ||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> | ||
<PackageReference Include="Kotz.DependencyInjection" Version="2.2.1" /> | ||
<PackageReference Include="Kotz.Extensions" Version="2.2.1" /> | ||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.3" /> | ||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.2" /> | ||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" /> | ||
<ProjectReference Include="..\ArtwiseDatabase\ArtwiseDatabase.csproj" /> | ||
</ItemGroup> | ||
</Project> |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
namespace ArtwiseAPI.Common; | ||
|
||
/// <summary> | ||
/// Defines the API's global constants. | ||
/// </summary> | ||
public static class ApiConstants | ||
{ | ||
/// <summary> | ||
/// Represents the version of the API. | ||
/// </summary> | ||
public const string Version = "v1"; | ||
|
||
/// <summary> | ||
/// Represents the main path of the API endpoints. | ||
/// </summary> | ||
public const string MainEndpoint = $"api/{Version}/[controller]"; | ||
|
||
/// <summary> | ||
/// Represents the location of the security key in the "appsettings.json" file. | ||
/// </summary> | ||
public const string JwtAppSetting = "Jwt:Key"; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
using System.Text.RegularExpressions; | ||
|
||
namespace ArtwiseAPI.Common; | ||
|
||
/// <summary> | ||
/// Defines the API's global <see langword="static"/> properties. | ||
/// </summary> | ||
public sealed partial class ApiStatics | ||
{ | ||
/// <summary> | ||
/// Regex to match a valid e-mail address. | ||
/// </summary> | ||
public static Regex EmailRegex { get; } = GenerateEmailRegex(); | ||
|
||
[GeneratedRegex(@"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", RegexOptions.Compiled)] | ||
private static partial Regex GenerateEmailRegex(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
namespace ArtwiseAPI.Common; | ||
|
||
/// <summary> | ||
/// Defines HTTP media types. | ||
/// </summary> | ||
internal static class MediaType | ||
{ | ||
/// <summary> | ||
/// Defines a response/request in plain JSON text format. | ||
/// </summary> | ||
public const string AppJson = "application/json"; | ||
|
||
/// <summary> | ||
/// Defines a response/request in plain JSON text format (old type, still sometimes used for historical reasons). | ||
/// </summary> | ||
public const string TextJson = "text/json"; | ||
|
||
/// <summary> | ||
/// Defines a response that did not complete successfully. | ||
/// </summary> | ||
public const string ProblemJson = "application/problem+json"; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
using ArtwiseDatabase.Enums; | ||
using Microsoft.AspNetCore.Authentication.JwtBearer; | ||
using Microsoft.AspNetCore.Authorization; | ||
using Microsoft.IdentityModel.Tokens; | ||
using Microsoft.OpenApi.Models; | ||
using System.Security.Claims; | ||
|
||
namespace ArtwiseAPI.Extensions; | ||
|
||
/// <summary> | ||
/// Defines extension methods for <see cref="IServiceCollection"/>. | ||
/// </summary> | ||
internal static class IServiceCollectionExt | ||
{ | ||
/// <summary> | ||
/// Adds the default authentication and authorization policies used by the Artwise API. | ||
/// </summary> | ||
/// <param name="serviceCollection">This service collection.</param> | ||
/// <param name="privateKey">The private key to be used for signature validation.</param> | ||
/// <param name="addSwaggerAuth"><see langword="true"/> to add an authentication button on Swagger, <see langword="false"/> otherwise.</param> | ||
/// <returns>This service collection with the policies added.</returns> | ||
public static IServiceCollection AddArtwiseAuth(this IServiceCollection serviceCollection, byte[] privateKey, bool addSwaggerAuth = true) | ||
{ | ||
if (addSwaggerAuth) | ||
{ | ||
// Add Swagger authentication button | ||
serviceCollection.AddSwaggerGen(swaggerOptions => | ||
{ | ||
var apiSecurityScheme = new OpenApiSecurityScheme() | ||
{ | ||
Name = "Authorization", | ||
Description = "Enter the Bearer Authorization string in the format: `bearer jwt-token-here`", | ||
In = ParameterLocation.Header, | ||
Type = SecuritySchemeType.ApiKey, | ||
Scheme = JwtBearerDefaults.AuthenticationScheme, | ||
Reference = new OpenApiReference() | ||
{ | ||
Id = JwtBearerDefaults.AuthenticationScheme, | ||
Type = ReferenceType.SecurityScheme | ||
} | ||
}; | ||
|
||
swaggerOptions.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, apiSecurityScheme); | ||
|
||
// OpenApiSecurityRequirement is a Dictionary<OpenApiSecurityScheme, IList<string>> | ||
// Any scheme other than "oauth2" or "openIdConnect" must contain an empty IList | ||
swaggerOptions.AddSecurityRequirement(new OpenApiSecurityRequirement() { [apiSecurityScheme] = Array.Empty<string>() }); | ||
}); | ||
} | ||
|
||
serviceCollection | ||
.AddAuthorization(authOptions => | ||
{ | ||
// Require all users to be authenticated | ||
authOptions.FallbackPolicy = new AuthorizationPolicyBuilder() | ||
.RequireAuthenticatedUser() | ||
.Build(); | ||
|
||
// Add authorization policies | ||
// Based on the name of UserPermissions enums (except "Blocked") | ||
foreach (var permission in Enum.GetValues<UserType>()) | ||
authOptions.AddPolicy(permission.ToString(), policy => policy.RequireClaim(ClaimTypes.Role, permission.ToString())); | ||
}) | ||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) | ||
.AddJwtBearer(jwtOptions => | ||
{ | ||
jwtOptions.RequireHttpsMetadata = false; | ||
jwtOptions.SaveToken = true; | ||
jwtOptions.TokenValidationParameters = new TokenValidationParameters() | ||
{ | ||
ValidateIssuerSigningKey = true, | ||
IssuerSigningKey = new SymmetricSecurityKey(privateKey), | ||
ValidateIssuer = false, | ||
ValidateAudience = false | ||
}; | ||
}); | ||
|
||
return serviceCollection; | ||
} | ||
} |
49 changes: 49 additions & 0 deletions
49
ArtwiseAPI/Features/Authentication/Controllers/AuthenticationController.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
using ArtwiseAPI.Common; | ||
using ArtwiseAPI.Features.Authentication.Models; | ||
using ArtwiseAPI.Features.Authentication.Services; | ||
using Microsoft.AspNetCore.Authorization; | ||
using Microsoft.AspNetCore.Http.HttpResults; | ||
using Microsoft.AspNetCore.Mvc; | ||
|
||
namespace ArtwiseAPI.Features.Authentication.Controllers; | ||
|
||
/// <summary> | ||
/// Controller for user authentication. | ||
/// </summary> | ||
[ApiController, Route(ApiConstants.MainEndpoint)] | ||
[Consumes(MediaType.AppJson, MediaType.TextJson)] | ||
public sealed class AuthenticationController : ControllerBase | ||
{ | ||
private readonly AuthenticationService _service; | ||
|
||
/// <summary> | ||
/// Controller for user authentication. | ||
/// </summary> | ||
/// <param name="service">The service that handles user authentication.</param> | ||
public AuthenticationController(AuthenticationService service) | ||
=> _service = service; | ||
|
||
/// <summary> | ||
/// Authenticates a user and returns a session token to them. | ||
/// </summary> | ||
/// <param name="request">The authentication request.</param> | ||
/// <returns>A <see cref="AuthenticationResponse"/> or a <see cref="ProblemHttpResult"/>.</returns> | ||
[HttpPost, AllowAnonymous] | ||
[ProducesResponseType(typeof(AuthenticationResponse), StatusCodes.Status200OK)] | ||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] | ||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] | ||
public async Task<Results<Ok<AuthenticationResponse>, ProblemHttpResult>> AuthenticateAsync([FromBody] AuthenticationRequest request) | ||
{ | ||
try | ||
{ | ||
return TypedResults.Ok(await _service.LoginAsync(request)); | ||
} | ||
catch (InvalidOperationException ex) | ||
{ | ||
return TypedResults.Problem( | ||
statusCode: StatusCodes.Status404NotFound, | ||
detail: ex.Message | ||
); | ||
} | ||
} | ||
} |
22 changes: 22 additions & 0 deletions
22
ArtwiseAPI/Features/Authentication/Models/AuthenticationRequest.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
using ArtwiseAPI.Common; | ||
using System.ComponentModel.DataAnnotations; | ||
|
||
namespace ArtwiseAPI.Features.Authentication.Models; | ||
|
||
/// <summary> | ||
/// Represents a request for user authentication. | ||
/// </summary> | ||
/// <param name="Email">The e-mail of the user.</param> | ||
/// <param name="Password">The password of the user.</param> | ||
public sealed record AuthenticationRequest(string Email, string Password) : IValidatableObject | ||
{ | ||
/// <inheritdoc /> | ||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) | ||
{ | ||
if (!ApiStatics.EmailRegex.IsMatch(Email ?? string.Empty)) | ||
yield return new ValidationResult("E-mail is not valid.", [nameof(Email)]); | ||
|
||
if (string.IsNullOrWhiteSpace(Password)) | ||
yield return new ValidationResult("Password cannot be null, empty, or whitespace.", [nameof(Password)]); | ||
} | ||
} |
9 changes: 9 additions & 0 deletions
9
ArtwiseAPI/Features/Authentication/Models/AuthenticationResponse.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
namespace ArtwiseAPI.Features.Authentication.Models; | ||
|
||
/// <summary> | ||
/// Represents a response when a user successfully authenticates with the API. | ||
/// </summary> | ||
/// <param name="Id">The Id of the user.</param> | ||
/// <param name="SessionToken">The token the user must use for authenticated requests.</param> | ||
/// <param name="ExpiresAt">The date and time the <paramref name="SessionToken"/> expires.</param> | ||
public sealed record AuthenticationResponse(Guid Id, string SessionToken, DateTimeOffset ExpiresAt); |
105 changes: 105 additions & 0 deletions
105
ArtwiseAPI/Features/Authentication/Services/AuthenticationService.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
using ArtwiseAPI.Common; | ||
using ArtwiseAPI.Features.Authentication.Models; | ||
using ArtwiseDatabase; | ||
using ArtwiseDatabase.Entities; | ||
using ArtwiseDatabase.Enums; | ||
using Kotz.DependencyInjection; | ||
using Microsoft.EntityFrameworkCore; | ||
using Microsoft.IdentityModel.Tokens; | ||
using System.IdentityModel.Tokens.Jwt; | ||
using System.Security.Claims; | ||
using BC = BCrypt.Net.BCrypt; | ||
|
||
namespace ArtwiseAPI.Features.Authentication.Services; | ||
|
||
/// <summary> | ||
/// Handles authentication and authorization workloads. | ||
/// </summary> | ||
[Service(ServiceLifetime.Scoped)] | ||
public sealed class AuthenticationService | ||
{ | ||
private readonly ArtwiseDbContext _db; | ||
private readonly IConfiguration _config; | ||
|
||
/// <summary> | ||
/// Handles authentication and authorization workloads. | ||
/// </summary> | ||
/// <param name="db">The database context.</param> | ||
/// <param name="config">The IHost configuration.</param> | ||
public AuthenticationService(ArtwiseDbContext db, IConfiguration config) | ||
{ | ||
_db = db; | ||
_config = config; | ||
} | ||
|
||
/// <summary> | ||
/// Authenticates a user. | ||
/// </summary> | ||
/// <param name="request">The controller request.</param> | ||
/// <returns>The authentication session token.</returns> | ||
/// <exception cref="ArgumentNullException">Occurs when <paramref name="request"/> is <see langword="null"/>.</exception> | ||
/// <exception cref="InvalidOperationException">Occurs when the e-mail or password in the request are invalid.</exception> | ||
public async Task<AuthenticationResponse> LoginAsync(AuthenticationRequest request) | ||
{ | ||
ArgumentNullException.ThrowIfNull(request); | ||
|
||
var expireAt = DateTimeOffset.UtcNow.AddMonths(3); | ||
var dbUser = await _db.Users.FirstOrDefaultAsync(x => x.Email == request.Email); | ||
|
||
return (dbUser is not null && BC.Verify(request.Password, dbUser.PasswordHash)) | ||
? new AuthenticationResponse(dbUser.Id, GenerateSessionToken(dbUser, expireAt), expireAt) | ||
: throw new InvalidOperationException("E-mail or password are invalid."); | ||
} | ||
|
||
/// <summary> | ||
/// Generates a session token for the specified user. | ||
/// </summary> | ||
/// <param name="dbUser">The user to generate the token for.</param> | ||
/// <param name="expiresAt">The date and time the token should expire.</param> | ||
/// <returns>A session token.</returns> | ||
/// <exception cref="ArgumentException"> | ||
/// Occurs when <paramref name="dbUser"/> contains invalid data or when | ||
/// <paramref name="expiresAt"/> is less than the current time. | ||
/// </exception> | ||
public string GenerateSessionToken(UserEntity dbUser, DateTimeOffset expiresAt) | ||
=> GenerateSessionToken(dbUser.Email, dbUser.Type, expiresAt); | ||
|
||
/// <summary> | ||
/// Generates a session token for the specified e-mail. | ||
/// </summary> | ||
/// <param name="email">The e-mail of the user.</param> | ||
/// <param name="userType">The type of the user.</param> | ||
/// <param name="expiresAt">The date and time the token should expire.</param> | ||
/// <returns>A session token.</returns> | ||
/// <exception cref="ArgumentException"> | ||
/// Occurs when <paramref name="email"/> is <see langword="null"/> or whitespace | ||
/// or when <paramref name="expiresAt"/> is less than the current time. | ||
/// </exception> | ||
public string GenerateSessionToken(string email, UserType userType, DateTimeOffset expiresAt) | ||
{ | ||
ArgumentException.ThrowIfNullOrWhiteSpace(email); | ||
|
||
if (expiresAt <= DateTimeOffset.UtcNow) | ||
throw new ArgumentException("Token must expire in the future.", nameof(expiresAt)); | ||
|
||
// Generate the claims (for authorization) | ||
var claims = new Claim[] | ||
{ | ||
new(ClaimTypes.Email, email), | ||
new(ClaimTypes.Role, userType.ToString()) | ||
}; | ||
|
||
// Generate the token | ||
var tokenDescriptor = new SecurityTokenDescriptor() | ||
{ | ||
SigningCredentials = new(new SymmetricSecurityKey(_config.GetValue<byte[]>(ApiConstants.JwtAppSetting)), SecurityAlgorithms.HmacSha256Signature), | ||
Expires = expiresAt.DateTime, | ||
Subject = new(claims) | ||
}; | ||
|
||
var tokenHandler = new JwtSecurityTokenHandler(); | ||
var token = tokenHandler.CreateToken(tokenDescriptor); | ||
|
||
return tokenHandler.WriteToken(token); | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.