Skip to content

Commit

Permalink
Merge pull request #11 from FuzzyLab-UVA/auth
Browse files Browse the repository at this point in the history
  • Loading branch information
Kaoticz authored Apr 6, 2024
2 parents 1464142 + a21da43 commit 5d8ca09
Show file tree
Hide file tree
Showing 27 changed files with 677 additions and 89 deletions.
9 changes: 3 additions & 6 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -484,18 +484,15 @@ dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter

#### Visual Studio Specific Rules ####

# CA1822: Mark members as static
dotnet_diagnostic.CA1822.severity = none

# IDE0004: Cast is redundant
dotnet_diagnostic.IDE0004.severity = error

# IDE0058: Expression value is never used
dotnet_diagnostic.IDE0058.severity = none

# IDE0011: Add braces to 'if'/'else' statement
dotnet_diagnostic.IDE0011.severity = none

# IDE0058: Expression value is never used
dotnet_diagnostic.IDE0058.severity = none

# Use primary constructor
dotnet_diagnostic.IDE0290.severity = none

Expand Down
8 changes: 5 additions & 3 deletions ArtwiseAPI/ArtwiseAPI.csproj
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>
6 changes: 0 additions & 6 deletions ArtwiseAPI/ArtwiseAPI.http

This file was deleted.

22 changes: 22 additions & 0 deletions ArtwiseAPI/Common/ApiConstants.cs
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";
}
17 changes: 17 additions & 0 deletions ArtwiseAPI/Common/ApiStatics.cs
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();
}
22 changes: 22 additions & 0 deletions ArtwiseAPI/Common/MediaType.cs
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";
}
80 changes: 80 additions & 0 deletions ArtwiseAPI/Extensions/IServiceCollectionExt.cs
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;
}
}
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 ArtwiseAPI/Features/Authentication/Models/AuthenticationRequest.cs
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)]);
}
}
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 ArtwiseAPI/Features/Authentication/Services/AuthenticationService.cs
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);
}
}
6 changes: 0 additions & 6 deletions ArtwiseAPI/Models/WeatherForecast.cs

This file was deleted.

Loading

0 comments on commit 5d8ca09

Please sign in to comment.