diff --git a/AutobusApi.Api/AutobusApi.Api.csproj b/AutobusApi.Api/AutobusApi.Api.csproj
index 29337ba..c67fb11 100644
--- a/AutobusApi.Api/AutobusApi.Api.csproj
+++ b/AutobusApi.Api/AutobusApi.Api.csproj
@@ -7,21 +7,20 @@
-
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
-
diff --git a/AutobusApi.Api/Controllers/BaseController.cs b/AutobusApi.Api/Controllers/BaseController.cs
new file mode 100644
index 0000000..39137b3
--- /dev/null
+++ b/AutobusApi.Api/Controllers/BaseController.cs
@@ -0,0 +1,12 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AutobusApi.Api.Controllers;
+
+[ApiController]
+[Route("[controller]")]
+public class BaseController : ControllerBase
+{
+ private IMediator _mediator;
+ protected IMediator Mediator => _mediator ??= HttpContext.RequestServices.GetService();
+}
diff --git a/AutobusApi.Api/Controllers/IdentityController.cs b/AutobusApi.Api/Controllers/IdentityController.cs
new file mode 100644
index 0000000..efb2864
--- /dev/null
+++ b/AutobusApi.Api/Controllers/IdentityController.cs
@@ -0,0 +1,35 @@
+using AutobusApi.Application.Common.Models.Identity;
+using AutobusApi.Application.Identity.Commands.Register;
+using AutobusApi.Application.Identity.Commands.RenewAccessToken;
+using AutobusApi.Application.Identity.Commands.RevokeRefreshToken;
+using AutobusApi.Application.Identity.Queries.Login;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AutobusApi.Api.Controllers;
+
+public class IdentityController : BaseController
+{
+ [HttpPost("register")]
+ public async Task Register([FromBody] RegisterCommand command, CancellationToken cancellationToken)
+ {
+ await Mediator.Send(command, cancellationToken);
+ }
+
+ [HttpPost("login")]
+ public async Task Login([FromBody] LoginQuery query, CancellationToken cancellationToken)
+ {
+ return await Mediator.Send(query, cancellationToken);
+ }
+
+ [HttpPost("renewAccessToken")]
+ public async Task RenewAccessToken([FromBody] RenewAccessTokenCommand command, CancellationToken cancellationToken)
+ {
+ return await Mediator.Send(command, cancellationToken);
+ }
+
+ [HttpPost("revokeRefreshToken")]
+ public async Task RevokeRefreshToken([FromBody] RevokeRefreshTokenCommand command, CancellationToken cancellationToken)
+ {
+ await Mediator.Send(command, cancellationToken);
+ }
+}
diff --git a/AutobusApi.Api/Middlewares/GlobalExceptionHandlerMiddleware.cs b/AutobusApi.Api/Middlewares/GlobalExceptionHandlerMiddleware.cs
new file mode 100644
index 0000000..9e67ff6
--- /dev/null
+++ b/AutobusApi.Api/Middlewares/GlobalExceptionHandlerMiddleware.cs
@@ -0,0 +1,129 @@
+using AutobusApi.Application.Common.Exceptions;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AutobusApi.Api.Middlewares;
+
+public class GlobalExceptionHandlerMiddleware : IMiddleware
+{
+ private readonly Dictionary> _exceptionHandlers;
+
+ public GlobalExceptionHandlerMiddleware()
+ {
+ // Register known exception types and handlers.
+ _exceptionHandlers = new()
+ {
+ { typeof(ValidationException), HandleValidationException },
+ { typeof(RegistrationException), HandleRegistrationException },
+ { typeof(LoginException), HandleLoginException },
+ { typeof(RenewAccessTokenException), HandleRenewAccessTokenException },
+ { typeof(RevokeRefreshTokenException), HandleRevokeRefreshTokenException },
+ };
+ }
+
+ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
+ {
+ try
+ {
+ await next(context);
+ }
+ catch (Exception exception)
+ {
+ var exceptionType = exception.GetType();
+
+ if (_exceptionHandlers.ContainsKey(exceptionType))
+ {
+ await _exceptionHandlers[exceptionType].Invoke(context, exception);
+ return;
+ }
+
+ await HandleUnhandledExceptionException(context, exception);
+ }
+ }
+
+ private async Task HandleValidationException(HttpContext context, Exception exception)
+ {
+ var ex = (ValidationException) exception;
+
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/problem+json";
+
+ await context.Response.WriteAsJsonAsync(new HttpValidationProblemDetails(ex.Errors)
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
+ Detail = "Check provided information."
+ });
+ }
+
+ private async Task HandleRegistrationException(HttpContext context, Exception exception)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/problem+json";
+
+ await context.Response.WriteAsJsonAsync(new ProblemDetails()
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
+ Title = "Registration failed.",
+ Detail = "Check your credentials."
+ });
+ }
+
+ private async Task HandleLoginException(HttpContext context, Exception exception)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/problem+json";
+
+ await context.Response.WriteAsJsonAsync(new ProblemDetails()
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
+ Title = "Login failed.",
+ Detail = "Provided email and/or password are invalid."
+ });
+ }
+
+ private async Task HandleRenewAccessTokenException(HttpContext context, Exception exception)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/problem+json";
+
+ await context.Response.WriteAsJsonAsync(new ProblemDetails()
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
+ Title = "Access token renewal failed.",
+ Detail = "Check validity of your refresh token."
+ });
+ }
+
+ private async Task HandleRevokeRefreshTokenException(HttpContext context, Exception exception)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/problem+json";
+
+ await context.Response.WriteAsJsonAsync(new ProblemDetails()
+ {
+ Status = StatusCodes.Status400BadRequest,
+ Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
+ Title = "Refresh token revocation failed.",
+ Detail = "Check validity of your refresh token."
+ });
+ }
+
+ private async Task HandleUnhandledExceptionException(HttpContext context, Exception exception)
+ {
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ context.Response.ContentType = "application/problem+json";
+
+ await context.Response.WriteAsJsonAsync(new ProblemDetails()
+ {
+ Status = StatusCodes.Status500InternalServerError,
+ Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1",
+ Title = "One or more internal server errors occured.",
+ Detail = "Report this error to service's support team.",
+ });
+
+ await Console.Error.WriteLineAsync(exception.StackTrace);
+ }
+}
diff --git a/AutobusApi.Api/Program.cs b/AutobusApi.Api/Program.cs
index 979fc04..7a14efe 100644
--- a/AutobusApi.Api/Program.cs
+++ b/AutobusApi.Api/Program.cs
@@ -1,16 +1,37 @@
-using AutoubsApi.Persistence.Contexts;
-using Microsoft.EntityFrameworkCore;
+using AutobusApi.Infrastructure;
+using AutobusApi.Application;
+using AutobusApi.Api.Middlewares;
+using AutoubsApi.Infrastructure.Data;
+using AutobusApi.Infrastructure.Identity;
var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddDbContext(options =>
- options.UseNpgsql(
- builder.Configuration.GetConnectionString("DefaultConnection"),
- npgsqOptions => npgsqOptions.UseNetTopologySuite()
- ));
+builder.Services.AddInfrastructure(builder.Configuration);
+builder.Services.AddApplication();
+
+builder.Services.AddControllers();
+
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddTransient();
var app = builder.Build();
+// Initialize database
+var scope = app.Services.CreateScope();
+var dbContext = scope.ServiceProvider.GetRequiredService();
+var identityDbContext = scope.ServiceProvider.GetRequiredService();
+DbInitializer.Initialize(dbContext, identityDbContext);
+app.UseAuthentication();
+
+app.MapControllers();
+
+app.UseSwagger();
+app.UseSwaggerUI();
+
+app.UseMiddleware();
app.Run();
+
+public partial class Program { }
diff --git a/AutobusApi.Api/appsettings.Development.json b/AutobusApi.Api/appsettings.Development.json
index 4b6d56e..7075fba 100644
--- a/AutobusApi.Api/appsettings.Development.json
+++ b/AutobusApi.Api/appsettings.Development.json
@@ -1,5 +1,12 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=10.0.0.20:5432;Database=autobus;Username=postgres;Password=12345678"
+ },
+ "Jwt": {
+ "Issuer": "",
+ "Audience": "",
+ "IssuerSigningKey": "a2c98dec80787a4e85ffb5bcbc24f7e4cc014d8a4fe43e9520480a50759164bc",
+ "AccessTokenValidityInMinutes": "5",
+ "RefreshTokenValidityInDays": "15",
}
}
diff --git a/AutobusApi.Application/AutobusApi.Application.csproj b/AutobusApi.Application/AutobusApi.Application.csproj
index 6db0075..b821f29 100644
--- a/AutobusApi.Application/AutobusApi.Application.csproj
+++ b/AutobusApi.Application/AutobusApi.Application.csproj
@@ -8,6 +8,16 @@
+
+ 12.0.1
+
+
+ 11.8.0
+
+
+
+ 7.0.13
+
diff --git a/AutobusApi.Application/Common/Behaviours/ValidationBehaviour.cs b/AutobusApi.Application/Common/Behaviours/ValidationBehaviour.cs
new file mode 100644
index 0000000..67f676c
--- /dev/null
+++ b/AutobusApi.Application/Common/Behaviours/ValidationBehaviour.cs
@@ -0,0 +1,43 @@
+using FluentValidation;
+using MediatR;
+using ValidationException = AutobusApi.Application.Common.Exceptions.ValidationException;
+
+namespace AutobusApi.Application.Common.Behaviours;
+
+public class ValidationBehaviour : IPipelineBehavior
+ where TRequest : notnull
+{
+ private readonly IEnumerable> _validators;
+
+ public ValidationBehaviour(IEnumerable> validators)
+ {
+ _validators = validators;
+ }
+
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ if (_validators.Any())
+ {
+ var context = new ValidationContext(request);
+
+ var validationResults = await Task.WhenAll(
+ _validators.Select(v =>
+ v.ValidateAsync(context, cancellationToken)));
+
+ var failures = validationResults
+ .Where(r => r.Errors.Any())
+ .SelectMany(r => r.Errors)
+ .ToList();
+
+ if (failures.Any())
+ {
+ throw new ValidationException(failures);
+ }
+ }
+
+ return await next();
+ }
+}
diff --git a/AutobusApi.Application/Common/Exceptions/LoginException.cs b/AutobusApi.Application/Common/Exceptions/LoginException.cs
new file mode 100644
index 0000000..af414c0
--- /dev/null
+++ b/AutobusApi.Application/Common/Exceptions/LoginException.cs
@@ -0,0 +1,7 @@
+namespace AutobusApi.Application.Common.Exceptions;
+
+public class LoginException : Exception
+{
+ public LoginException(string? message)
+ : base(message) { }
+}
diff --git a/AutobusApi.Application/Common/Exceptions/RegistrationException.cs b/AutobusApi.Application/Common/Exceptions/RegistrationException.cs
new file mode 100644
index 0000000..b3c1ab7
--- /dev/null
+++ b/AutobusApi.Application/Common/Exceptions/RegistrationException.cs
@@ -0,0 +1,7 @@
+namespace AutobusApi.Application.Common.Exceptions;
+
+public class RegistrationException : Exception
+{
+ public RegistrationException(string? message)
+ : base(message) { }
+}
diff --git a/AutobusApi.Application/Common/Exceptions/RenewAccessTokenException.cs b/AutobusApi.Application/Common/Exceptions/RenewAccessTokenException.cs
new file mode 100644
index 0000000..9721212
--- /dev/null
+++ b/AutobusApi.Application/Common/Exceptions/RenewAccessTokenException.cs
@@ -0,0 +1,7 @@
+namespace AutobusApi.Application.Common.Exceptions;
+
+public class RenewAccessTokenException : Exception
+{
+ public RenewAccessTokenException(string? errorMessage)
+ : base(errorMessage) { }
+}
diff --git a/AutobusApi.Application/Common/Exceptions/RevokeRefreshTokenException.cs b/AutobusApi.Application/Common/Exceptions/RevokeRefreshTokenException.cs
new file mode 100644
index 0000000..397cc3e
--- /dev/null
+++ b/AutobusApi.Application/Common/Exceptions/RevokeRefreshTokenException.cs
@@ -0,0 +1,7 @@
+namespace AutobusApi.Application.Common.Exceptions;
+
+public class RevokeRefreshTokenException : Exception
+{
+ public RevokeRefreshTokenException(string? errorMessage)
+ : base(errorMessage) { }
+}
diff --git a/AutobusApi.Application/Common/Exceptions/ValidationException.cs b/AutobusApi.Application/Common/Exceptions/ValidationException.cs
new file mode 100644
index 0000000..3332b93
--- /dev/null
+++ b/AutobusApi.Application/Common/Exceptions/ValidationException.cs
@@ -0,0 +1,22 @@
+using FluentValidation.Results;
+
+namespace AutobusApi.Application.Common.Exceptions;
+
+public class ValidationException : Exception
+{
+ public ValidationException()
+ : base("One or more validation failures have occurred.")
+ {
+ Errors = new Dictionary();
+ }
+
+ public ValidationException(IEnumerable failures)
+ : this()
+ {
+ Errors = failures
+ .GroupBy(f => f.PropertyName, f => f.ErrorMessage)
+ .ToDictionary(fg => fg.Key, fg => fg.ToArray());
+ }
+
+ public IDictionary Errors { get; }
+}
diff --git a/AutobusApi.Application/Common/Interfaces/IApplicationDbContext.cs b/AutobusApi.Application/Common/Interfaces/IApplicationDbContext.cs
new file mode 100644
index 0000000..fe2137e
--- /dev/null
+++ b/AutobusApi.Application/Common/Interfaces/IApplicationDbContext.cs
@@ -0,0 +1,55 @@
+using AutobusApi.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace AutobusApi.Application.Common.Interfaces;
+
+public interface IApplicationDbContext
+{
+ DbSet Countries { get; }
+
+ DbSet Regions { get; }
+
+ DbSet Cities { get; }
+
+ DbSet Addresses { get; }
+
+ DbSet RouteAddresses { get; }
+
+ DbSet Routes { get; }
+
+ DbSet RouteAddressDetails { get; }
+
+ DbSet VehicleEnrollments { get; }
+
+ DbSet Vehicles { get; }
+
+ DbSet Buses { get; }
+
+ DbSet Aircraft { get; }
+
+ DbSet Trains { get; }
+
+ DbSet TrainCarriages { get; }
+
+ DbSet Carriages { get; }
+
+ DbSet Companies { get; }
+
+ DbSet Employees { get; }
+
+ DbSet EmployeeDocuments { get; }
+
+ DbSet vehicleEnrollmentEmployees { get; }
+
+ DbSet ApplicationUsers { get; }
+
+ DbSet TicketGroups { get; }
+
+ DbSet Tickets { get; }
+
+ DbSet TicketDocuments { get; }
+
+ DbSet Reviews { get; }
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/AutobusApi.Application/Common/Interfaces/IIdentityService.cs b/AutobusApi.Application/Common/Interfaces/IIdentityService.cs
new file mode 100644
index 0000000..675f88c
--- /dev/null
+++ b/AutobusApi.Application/Common/Interfaces/IIdentityService.cs
@@ -0,0 +1,14 @@
+using AutobusApi.Application.Common.Models.Identity;
+
+namespace AutobusApi.Application.Common.Interfaces;
+
+public interface IIdentityService
+{
+ Task RegisterAsync(string email, string password, CancellationToken cancellationToken);
+
+ Task LoginAsync(string email, string password, CancellationToken cancellationToken);
+
+ Task RenewAccessTokenAsync(string refreshToken, CancellationToken cancellationToken);
+
+ Task RevokeRefreshTokenAsync(string refreshToken, CancellationToken cancellationToken);
+}
diff --git a/AutobusApi.Application/Common/Models/Identity/Roles.cs b/AutobusApi.Application/Common/Models/Identity/Roles.cs
new file mode 100644
index 0000000..1449951
--- /dev/null
+++ b/AutobusApi.Application/Common/Models/Identity/Roles.cs
@@ -0,0 +1,6 @@
+namespace AutobusApi.Application.Common.Models.Identity;
+
+public enum Roles
+{
+ User = 0,
+}
diff --git a/AutobusApi.Application/Common/Models/Identity/TokensModel.cs b/AutobusApi.Application/Common/Models/Identity/TokensModel.cs
new file mode 100644
index 0000000..dacc7a8
--- /dev/null
+++ b/AutobusApi.Application/Common/Models/Identity/TokensModel.cs
@@ -0,0 +1,16 @@
+namespace AutobusApi.Application.Common.Models.Identity;
+
+public class TokensModel
+{
+ public TokensModel(
+ string accessToken,
+ string refreshToken)
+ {
+ AccessToken = accessToken;
+ RefreshToken = refreshToken;
+ }
+
+ public string AccessToken { get; set; }
+
+ public string RefreshToken { get; set; }
+}
diff --git a/AutobusApi.Application/DependencyInjection.cs b/AutobusApi.Application/DependencyInjection.cs
new file mode 100644
index 0000000..7896730
--- /dev/null
+++ b/AutobusApi.Application/DependencyInjection.cs
@@ -0,0 +1,23 @@
+using System.Reflection;
+using AutobusApi.Application.Common.Behaviours;
+using FluentValidation;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AutobusApi.Application;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddApplication(this IServiceCollection services)
+ {
+ services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
+
+ services.AddMediatR(configuration =>
+ {
+ configuration.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
+ configuration.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
+ });
+
+ return services;
+ }
+}
diff --git a/AutobusApi.Application/Identity/Commands/Register/RegisterCommand.cs b/AutobusApi.Application/Identity/Commands/Register/RegisterCommand.cs
new file mode 100644
index 0000000..9d054fd
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/Register/RegisterCommand.cs
@@ -0,0 +1,10 @@
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Commands.Register;
+
+public record RegisterCommand : IRequest
+{
+ public required string Email { get; set; }
+
+ public required string Password { get; set; }
+}
diff --git a/AutobusApi.Application/Identity/Commands/Register/RegisterCommandHandler.cs b/AutobusApi.Application/Identity/Commands/Register/RegisterCommandHandler.cs
new file mode 100644
index 0000000..ab6414b
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/Register/RegisterCommandHandler.cs
@@ -0,0 +1,21 @@
+using AutobusApi.Application.Common.Interfaces;
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Commands.Register;
+
+public class RegisterCommandHandler : IRequestHandler
+{
+ private readonly IIdentityService _identityService;
+
+ public RegisterCommandHandler(IIdentityService identityService)
+ {
+ _identityService = identityService;
+ }
+
+ public async Task Handle(
+ RegisterCommand command,
+ CancellationToken cancellationToken)
+ {
+ await _identityService.RegisterAsync(command.Email, command.Password, cancellationToken);
+ }
+}
diff --git a/AutobusApi.Application/Identity/Commands/Register/RegisterCommandValidator.cs b/AutobusApi.Application/Identity/Commands/Register/RegisterCommandValidator.cs
new file mode 100644
index 0000000..ec3171a
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/Register/RegisterCommandValidator.cs
@@ -0,0 +1,22 @@
+using FluentValidation;
+
+namespace AutobusApi.Application.Identity.Commands.Register;
+
+public class RegisterCommandValidator : AbstractValidator
+{
+ public RegisterCommandValidator()
+ {
+ RuleFor(v => v.Email)
+ .NotEmpty().WithMessage("Email address is required.")
+ .Matches(@"\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b").WithMessage("Email address is invalid.");
+
+ RuleFor(v => v.Password)
+ .NotEmpty().WithMessage("Password is required.")
+ .MinimumLength(8).WithMessage("Password must be at least 8 characters long.")
+ .MaximumLength(64).WithMessage("Password must be at most 64 characters long.")
+ .Matches(@"(?=.*[A-Z]).*").WithMessage("Password must contain at least one uppercase letter.")
+ .Matches(@"(?=.*[a-z]).*").WithMessage("Password must contain at least one lowercase letter.")
+ .Matches(@"(?=.*[\d]).*").WithMessage("Password must contain at least one digit.")
+ .Matches(@"(?=.*[!@#$%^&*()]).*").WithMessage("Password must contain at least one of the following special charactters: !@#$%^&*().");
+ }
+}
diff --git a/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommand.cs b/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommand.cs
new file mode 100644
index 0000000..8ff3b62
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommand.cs
@@ -0,0 +1,9 @@
+using AutobusApi.Application.Common.Models.Identity;
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Commands.RenewAccessToken;
+
+public record RenewAccessTokenCommand : IRequest
+{
+ public required string RefreshToken { get; set; }
+}
diff --git a/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommandHandler.cs b/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommandHandler.cs
new file mode 100644
index 0000000..c24c400
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommandHandler.cs
@@ -0,0 +1,22 @@
+using AutobusApi.Application.Common.Interfaces;
+using AutobusApi.Application.Common.Models.Identity;
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Commands.RenewAccessToken;
+
+public class RenewAccessTokenCommandHandler : IRequestHandler
+{
+ private readonly IIdentityService _identityService;
+
+ public RenewAccessTokenCommandHandler(IIdentityService identityService)
+ {
+ _identityService = identityService;
+ }
+
+ public async Task Handle(
+ RenewAccessTokenCommand command,
+ CancellationToken cancellationToken)
+ {
+ return await _identityService.RenewAccessTokenAsync(command.RefreshToken, cancellationToken);
+ }
+}
diff --git a/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommandValidator.cs b/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommandValidator.cs
new file mode 100644
index 0000000..226f846
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/RenewAccessToken/RenewAccessTokenCommandValidator.cs
@@ -0,0 +1,12 @@
+using FluentValidation;
+
+namespace AutobusApi.Application.Identity.Commands.RenewAccessToken;
+
+public class RenewAccessTokenCommandValidator : AbstractValidator
+{
+ public RenewAccessTokenCommandValidator()
+ {
+ RuleFor(v => v.RefreshToken)
+ .NotEmpty();
+ }
+}
diff --git a/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommand.cs b/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommand.cs
new file mode 100644
index 0000000..20ce4e0
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommand.cs
@@ -0,0 +1,8 @@
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Commands.RevokeRefreshToken;
+
+public record RevokeRefreshTokenCommand : IRequest
+{
+ public required string RefreshToken { get; set; }
+}
diff --git a/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommandHandler.cs b/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommandHandler.cs
new file mode 100644
index 0000000..dfa9915
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommandHandler.cs
@@ -0,0 +1,21 @@
+using AutobusApi.Application.Common.Interfaces;
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Commands.RevokeRefreshToken;
+
+public class RevokeRefreshTokenCommandHandler : IRequestHandler
+{
+ private readonly IIdentityService _identityService;
+
+ public RevokeRefreshTokenCommandHandler(IIdentityService identityService)
+ {
+ _identityService = identityService;
+ }
+
+ public async Task Handle(
+ RevokeRefreshTokenCommand command,
+ CancellationToken cancellationToken)
+ {
+ await _identityService.RevokeRefreshTokenAsync(command.RefreshToken, cancellationToken);
+ }
+}
diff --git a/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommandValidator.cs b/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommandValidator.cs
new file mode 100644
index 0000000..3e580dd
--- /dev/null
+++ b/AutobusApi.Application/Identity/Commands/RevokeRefreshToken/RevokeRefreshTokenCommandValidator.cs
@@ -0,0 +1,12 @@
+using FluentValidation;
+
+namespace AutobusApi.Application.Identity.Commands.RevokeRefreshToken;
+
+public class RevokeRefreshTokenCommandValidator : AbstractValidator
+{
+ public RevokeRefreshTokenCommandValidator()
+ {
+ RuleFor(v => v.RefreshToken)
+ .NotEmpty();
+ }
+}
diff --git a/AutobusApi.Application/Identity/Queries/Login/LoginQuery.cs b/AutobusApi.Application/Identity/Queries/Login/LoginQuery.cs
new file mode 100644
index 0000000..79dcfb5
--- /dev/null
+++ b/AutobusApi.Application/Identity/Queries/Login/LoginQuery.cs
@@ -0,0 +1,11 @@
+using AutobusApi.Application.Common.Models.Identity;
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Queries.Login;
+
+public record LoginQuery : IRequest
+{
+ public required string Email { get; set; }
+
+ public required string Password { get; set; }
+}
diff --git a/AutobusApi.Application/Identity/Queries/Login/LoginQueryHandler.cs b/AutobusApi.Application/Identity/Queries/Login/LoginQueryHandler.cs
new file mode 100644
index 0000000..09638da
--- /dev/null
+++ b/AutobusApi.Application/Identity/Queries/Login/LoginQueryHandler.cs
@@ -0,0 +1,22 @@
+using AutobusApi.Application.Common.Interfaces;
+using AutobusApi.Application.Common.Models.Identity;
+using MediatR;
+
+namespace AutobusApi.Application.Identity.Queries.Login;
+
+public class LoginQueryHandler : IRequestHandler
+{
+ private readonly IIdentityService _identityService;
+
+ public LoginQueryHandler(IIdentityService identityService)
+ {
+ _identityService = identityService;
+ }
+
+ public async Task Handle(
+ LoginQuery query,
+ CancellationToken cancellationToken)
+ {
+ return await _identityService.LoginAsync(query.Email, query.Password, cancellationToken);
+ }
+}
diff --git a/AutobusApi.Application/Identity/Queries/Login/LoginQueryValidator.cs b/AutobusApi.Application/Identity/Queries/Login/LoginQueryValidator.cs
new file mode 100644
index 0000000..06560d5
--- /dev/null
+++ b/AutobusApi.Application/Identity/Queries/Login/LoginQueryValidator.cs
@@ -0,0 +1,16 @@
+using FluentValidation;
+
+namespace AutobusApi.Application.Identity.Queries.Login;
+
+public class LoginQueryValidator : AbstractValidator
+{
+ public LoginQueryValidator()
+ {
+ RuleFor(v => v.Email)
+ .NotEmpty().WithMessage("Email address is required.")
+ .EmailAddress().WithMessage("Email address is invalid.");
+
+ RuleFor(v => v.Password)
+ .NotEmpty().WithMessage("Password is required.");
+ }
+}
diff --git a/AutobusApi.Infrastructure/AutobusApi.Infrastructure.csproj b/AutobusApi.Infrastructure/AutobusApi.Infrastructure.csproj
index d297ac0..fc64e66 100644
--- a/AutobusApi.Infrastructure/AutobusApi.Infrastructure.csproj
+++ b/AutobusApi.Infrastructure/AutobusApi.Infrastructure.csproj
@@ -7,13 +7,20 @@
-
-
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
-
diff --git a/AutobusApi.Infrastructure/DbInitializer.cs b/AutobusApi.Infrastructure/DbInitializer.cs
new file mode 100644
index 0000000..69d1561
--- /dev/null
+++ b/AutobusApi.Infrastructure/DbInitializer.cs
@@ -0,0 +1,59 @@
+using AutobusApi.Domain.Enums;
+using AutobusApi.Infrastructure.Identity;
+using AutoubsApi.Infrastructure.Data;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace AutobusApi.Infrastructure;
+
+public static class DbInitializer
+{
+ public static void Initialize(ApplicationDbContext dbContext, ApplicationIdentityDbContext identityDbContext)
+ {
+ if (dbContext.Database.IsRelational())
+ {
+ var domainAppliedMigrations = dbContext.Database.GetAppliedMigrations();
+ var identityAppliedMigrations = identityDbContext.Database.GetAppliedMigrations();
+
+ if (domainAppliedMigrations.Count() == 0)
+ {
+ dbContext.Database.Migrate();
+ InitializeDomain(dbContext);
+ }
+
+ if (identityAppliedMigrations.Count() == 0)
+ {
+ identityDbContext.Database.Migrate();
+ InitializeIdentity(identityDbContext);
+ }
+ }
+ else
+ {
+ dbContext.Database.EnsureCreated();
+ InitializeDomain(dbContext);
+
+ identityDbContext.Database.EnsureCreated();
+ InitializeIdentity(identityDbContext);
+ }
+ }
+
+ private static void InitializeDomain(ApplicationDbContext dbContext)
+ {
+
+ }
+
+ private static void InitializeIdentity(ApplicationIdentityDbContext identityDbContext)
+ {
+ foreach (var role in Enum.GetValues(typeof(IdentityRoles)).Cast())
+ {
+ identityDbContext.Roles.Add(new IdentityRole
+ {
+ Name = role.ToString(),
+ NormalizedName = role.ToString().ToUpper(),
+ ConcurrencyStamp = Guid.NewGuid().ToString()
+ });
+ }
+
+ identityDbContext.SaveChanges();
+ }
+}
diff --git a/AutobusApi.Infrastructure/DependencyInjection.cs b/AutobusApi.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..05cb8c2
--- /dev/null
+++ b/AutobusApi.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,89 @@
+using System.Text;
+using AutobusApi.Application.Common.Interfaces;
+using AutobusApi.Infrastructure.Identity;
+using AutobusApi.Infrastructure.Services;
+using AutoubsApi.Infrastructure.Data;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+
+namespace AutobusApi.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddApplicationDbContext(configuration);
+ services.AddIdentity(configuration);
+ services.AddServices();
+ services.AddAuthentication();
+
+ return services;
+ }
+
+ private static IServiceCollection AddApplicationDbContext(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddDbContext(options =>
+ {
+ options.UseNpgsql(
+ configuration.GetConnectionString("DefaultConnection"),
+ npgsqOptions => npgsqOptions.UseNetTopologySuite()
+ );
+ });
+
+ services.AddScoped();
+
+ return services;
+ }
+
+ private static IServiceCollection AddIdentity(this IServiceCollection services, IConfiguration configuration)
+ {
+
+ services.AddDbContext(options =>
+ {
+ options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
+ });
+
+ services.AddIdentity>()
+ .AddEntityFrameworkStores()
+ .AddDefaultTokenProviders();
+
+ return services;
+ }
+
+ private static IServiceCollection AddServices(this IServiceCollection services)
+ {
+ services.AddScoped();
+
+ return services;
+ }
+
+ private static IServiceCollection AddAuthenticationWithJwt(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
+ })
+ .AddJwtBearer(options =>
+ {
+ options.SaveToken = true;
+ options.RequireHttpsMetadata = false;
+ options.TokenValidationParameters = new TokenValidationParameters()
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidAudience = configuration["Jwt:Audience"],
+ ValidIssuer = configuration["Jwt:Issuer"],
+ ClockSkew = TimeSpan.Zero,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:IssuerSigningKey"]!))
+ };
+ });
+
+ return services;
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/ApplicationIdentityDbContext.cs b/AutobusApi.Infrastructure/Identity/ApplicationIdentityDbContext.cs
new file mode 100644
index 0000000..fec9505
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/ApplicationIdentityDbContext.cs
@@ -0,0 +1,24 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace AutobusApi.Infrastructure.Identity;
+
+public class ApplicationIdentityDbContext : IdentityDbContext, int>
+{
+ public ApplicationIdentityDbContext(DbContextOptions options)
+ : base(options) { }
+
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ base.OnModelCreating(builder);
+
+ builder.HasDefaultSchema("identity");
+
+ builder.ApplyConfigurationsFromAssembly(
+ Assembly.GetExecutingAssembly(),
+ t => t.Namespace == "AutobusApi.Infrastructure.Identity.Configurations"
+ );
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/ApplicationUser.cs b/AutobusApi.Infrastructure/Identity/ApplicationUser.cs
new file mode 100644
index 0000000..ba98a3a
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/ApplicationUser.cs
@@ -0,0 +1,8 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace AutobusApi.Infrastructure.Identity;
+
+public class ApplicationUser : IdentityUser
+{
+ public ICollection RefreshTokens { get; set; } = null!;
+}
diff --git a/AutobusApi.Infrastructure/Identity/Configurations/IdentityRoleClaimConfiguration.cs b/AutobusApi.Infrastructure/Identity/Configurations/IdentityRoleClaimConfiguration.cs
new file mode 100644
index 0000000..4ee5f58
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Configurations/IdentityRoleClaimConfiguration.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AutobusApi.Infrastructure.Identity.Configurations;
+
+public class IdentityRoleClaimConfiguration : IEntityTypeConfiguration>
+{
+ public void Configure(EntityTypeBuilder> builder)
+ {
+ builder
+ .ToTable("identity_role_claims");
+
+ builder
+ .Property(rc => rc.Id)
+ .HasColumnName("id");
+
+ builder
+ .Property(rc => rc.RoleId)
+ .HasColumnName("role_id");
+
+ builder
+ .Property(rc => rc.ClaimType)
+ .HasColumnName("claim_type");
+
+ builder
+ .Property(rc => rc.ClaimValue)
+ .HasColumnName("claim_value");
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Configurations/IdentityRoleConfiguration.cs b/AutobusApi.Infrastructure/Identity/Configurations/IdentityRoleConfiguration.cs
new file mode 100644
index 0000000..3e4198a
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Configurations/IdentityRoleConfiguration.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AutobusApi.Infrastructure.Identity.Configurations;
+
+public class IdentityRoleConfiguration : IEntityTypeConfiguration>
+{
+ public void Configure(EntityTypeBuilder> builder)
+ {
+ builder
+ .ToTable("identity_roles");
+
+ builder
+ .Property(r => r.Id)
+ .HasColumnName("id");
+
+ builder
+ .Property(r => r.Name)
+ .HasColumnName("name");
+
+ builder
+ .Property(r => r.NormalizedName)
+ .HasColumnName("normalized_name");
+
+ builder
+ .Property(r => r.ConcurrencyStamp)
+ .HasColumnName("concurrency_stamp");
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserClaimConfiguration.cs b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserClaimConfiguration.cs
new file mode 100644
index 0000000..d4b97b1
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserClaimConfiguration.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AutobusApi.Infrastructure.Identity.Configurations;
+
+public class IdentityUserClaimConfiguration : IEntityTypeConfiguration>
+{
+ public void Configure(EntityTypeBuilder> builder)
+ {
+ builder
+ .ToTable("identity_user_claims");
+
+ builder
+ .Property(uc => uc.Id)
+ .HasColumnName("id");
+
+ builder
+ .Property(uc => uc.UserId)
+ .HasColumnName("user_id");
+
+ builder
+ .Property(uc => uc.ClaimType)
+ .HasColumnName("claim_type");
+
+ builder
+ .Property(uc => uc.ClaimValue)
+ .HasColumnName("claim_value");
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserConfiguration.cs b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserConfiguration.cs
new file mode 100644
index 0000000..28a61fd
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserConfiguration.cs
@@ -0,0 +1,123 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AutobusApi.Infrastructure.Identity.Configurations;
+
+public class IdentityUserConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder
+ .ToTable("identity_users");
+
+ // builder
+ // .Ignore(u => u.UserName);
+ //
+ // builder
+ // .Ignore(u => u.NormalizedUserName);
+
+ builder
+ .Ignore(u => u.PhoneNumber);
+
+ builder
+ .Ignore(u => u.PhoneNumberConfirmed);
+
+ builder
+ .Property(u => u.Id)
+ .HasColumnName("id");
+
+ builder
+ .Property(u => u.Email)
+ .HasColumnName("email");
+
+ builder
+ .Property(u => u.NormalizedEmail)
+ .HasColumnName("normalized_email");
+
+ builder
+ .Property(u => u.EmailConfirmed)
+ .HasColumnName("email_confirmed");
+
+ builder
+ .Property(u => u.PasswordHash)
+ .HasColumnName("password_hash");
+
+ builder
+ .Property(u => u.SecurityStamp)
+ .HasColumnName("security_stamp");
+
+ builder
+ .Property(u => u.ConcurrencyStamp)
+ .HasColumnName("concurrency_stamp");
+
+ builder
+ .Property(u => u.TwoFactorEnabled)
+ .HasColumnName("two_factor_enabled");
+
+ builder
+ .Property(u => u.LockoutEnabled)
+ .HasColumnName("lockout_enabled");
+
+ builder
+ .Property(u => u.LockoutEnd)
+ .HasColumnName("lockout_end");
+
+ builder
+ .Property(u => u.AccessFailedCount)
+ .HasColumnName("access_failed_count");
+
+ builder
+ .OwnsMany(u => u.RefreshTokens,
+ refreshToken =>
+ {
+ refreshToken
+ .ToTable("identity_user_refresh_tokens");
+
+ refreshToken
+ .HasKey(rt => rt.Id)
+ .HasName("id");
+
+ refreshToken
+ .WithOwner(rt => rt.ApplicationUser)
+ .HasForeignKey(rt => rt.ApplicationUserId)
+ .HasConstraintName("fk_identityUserRefreshTokens_identityUser_userId");
+
+ refreshToken
+ .Property(rt => rt.Id)
+ .HasColumnName("id")
+ .HasColumnType("int")
+ .IsRequired();
+
+ refreshToken
+ .Property(rt => rt.ApplicationUserId)
+ .HasColumnName("identity_user_id")
+ .HasColumnType("int")
+ .IsRequired();
+
+ refreshToken
+ .Property(rt => rt.Value)
+ .HasColumnName("value")
+ .HasColumnType("varchar(256)")
+ .IsRequired();
+
+ refreshToken
+ .Property(rt => rt.CreationDateTimeUtc)
+ .HasColumnName("creation_timestamp_utc")
+ .HasColumnType("timestamptz")
+ .IsRequired();
+
+ refreshToken
+ .Property(rt => rt.ExpirationDateTimeUtc)
+ .HasColumnName("expiration_timestamp_utc")
+ .HasColumnType("timestamptz")
+ .IsRequired();
+
+ refreshToken
+ .Property(rt => rt.RevokationDateTimeUtc)
+ .HasColumnName("revokation_timestamp_utc")
+ .HasColumnType("timestamptz")
+ .IsRequired(false);
+ }
+ );
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserLoginConfigurations.cs b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserLoginConfigurations.cs
new file mode 100644
index 0000000..9c7a367
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserLoginConfigurations.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AutobusApi.Infrastructure.Identity.Configurations;
+
+public class IdentityUserLoginConfiguration : IEntityTypeConfiguration>
+{
+ public void Configure(EntityTypeBuilder> builder)
+ {
+ builder
+ .ToTable("identity_user_logins");
+
+ builder
+ .Property(ul => ul.LoginProvider)
+ .HasColumnName("login_provider");
+
+ builder
+ .Property(ul => ul.ProviderKey)
+ .HasColumnName("provider_key");
+
+ builder
+ .Property(ul => ul.ProviderDisplayName)
+ .HasColumnName("provider_display_name");
+
+ builder
+ .Property(ul => ul.UserId)
+ .HasColumnName("user_id");
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserRoleConfiguration.cs b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserRoleConfiguration.cs
new file mode 100644
index 0000000..02b2bf1
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserRoleConfiguration.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AutobusApi.Infrastructure.Identity.Configurations;
+
+public class IdentityUserRoleConfiguration : IEntityTypeConfiguration>
+{
+ public void Configure(EntityTypeBuilder> builder)
+ {
+ builder
+ .ToTable("identity_user_roles");
+
+ builder
+ .Property(ur => ur.UserId)
+ .HasColumnName("user_id");
+
+ builder
+ .Property(ur => ur.RoleId)
+ .HasColumnName("role_id");
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserTokenConfiguration.cs b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserTokenConfiguration.cs
new file mode 100644
index 0000000..a0b2f7b
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Configurations/IdentityUserTokenConfiguration.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AutobusApi.Infrastructure.Identity.Configurations;
+
+public class IdentityUserTokenConfiguration : IEntityTypeConfiguration>
+{
+ public void Configure(EntityTypeBuilder> builder)
+ {
+ builder
+ .ToTable("identity_user_tokens");
+
+ builder
+ .Property(ut => ut.UserId)
+ .HasColumnName("user_id");
+
+ builder
+ .Property(ut => ut.LoginProvider)
+ .HasColumnName("login_provider");
+
+ builder
+ .Property(ut => ut.Name)
+ .HasColumnName("name");
+
+ builder
+ .Property(ut => ut.Value)
+ .HasColumnName("value");
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Migrations/20231113193302_InitialCreate.Designer.cs b/AutobusApi.Infrastructure/Identity/Migrations/20231113193302_InitialCreate.Designer.cs
new file mode 100644
index 0000000..ea1bc8f
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Migrations/20231113193302_InitialCreate.Designer.cs
@@ -0,0 +1,357 @@
+//
+using System;
+using AutobusApi.Infrastructure.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace AutobusApi.Infrastructure.Identity.Migrations
+{
+ [DbContext(typeof(ApplicationIdentityDbContext))]
+ [Migration("20231113193302_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("identity")
+ .HasAnnotation("ProductVersion", "7.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("AutobusApi.Infrastructure.Identity.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer")
+ .HasColumnName("access_failed_count");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text")
+ .HasColumnName("concurrency_stamp");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("email");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean")
+ .HasColumnName("email_confirmed");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("lockout_enabled");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("lockout_end");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("normalized_email");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text")
+ .HasColumnName("security_stamp");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("two_factor_enabled");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("identity_users", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text")
+ .HasColumnName("concurrency_stamp");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("normalized_name");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("identity_roles", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("RoleId")
+ .HasColumnType("integer")
+ .HasColumnName("role_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("identity_role_claims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("identity_user_claims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("provider_display_name");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("identity_user_logins", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.Property("RoleId")
+ .HasColumnType("integer")
+ .HasColumnName("role_id");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("identity_user_roles", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("identity_user_tokens", "identity");
+ });
+
+ modelBuilder.Entity("AutobusApi.Infrastructure.Identity.ApplicationUser", b =>
+ {
+ b.OwnsMany("AutobusApi.Infrastructure.Identity.RefreshToken", "RefreshTokens", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id"));
+
+ b1.Property("ApplicationUserId")
+ .HasColumnType("int")
+ .HasColumnName("identity_user_id");
+
+ b1.Property("CreationDateTimeUtc")
+ .HasColumnType("timestamptz")
+ .HasColumnName("creation_timestamp_utc");
+
+ b1.Property("ExpirationDateTimeUtc")
+ .HasColumnType("timestamptz")
+ .HasColumnName("expiration_timestamp_utc");
+
+ b1.Property("RevokationDateTimeUtc")
+ .HasColumnType("timestamptz")
+ .HasColumnName("revokation_timestamp_utc");
+
+ b1.Property("Value")
+ .IsRequired()
+ .HasColumnType("varchar(256)")
+ .HasColumnName("value");
+
+ b1.HasKey("Id")
+ .HasName("id");
+
+ b1.HasIndex("ApplicationUserId");
+
+ b1.ToTable("identity_user_refresh_tokens", "identity");
+
+ b1.WithOwner("ApplicationUser")
+ .HasForeignKey("ApplicationUserId")
+ .HasConstraintName("fk_identityUserRefreshTokens_identityUser_userId");
+
+ b1.Navigation("ApplicationUser");
+ });
+
+ b.Navigation("RefreshTokens");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Migrations/20231113193302_InitialCreate.cs b/AutobusApi.Infrastructure/Identity/Migrations/20231113193302_InitialCreate.cs
new file mode 100644
index 0000000..e8f9c00
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Migrations/20231113193302_InitialCreate.cs
@@ -0,0 +1,288 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace AutobusApi.Infrastructure.Identity.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "identity");
+
+ migrationBuilder.CreateTable(
+ name: "identity_roles",
+ schema: "identity",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ normalized_name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ concurrency_stamp = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_identity_roles", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "identity_users",
+ schema: "identity",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ normalized_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true),
+ email_confirmed = table.Column(type: "boolean", nullable: false),
+ password_hash = table.Column(type: "text", nullable: true),
+ security_stamp = table.Column(type: "text", nullable: true),
+ concurrency_stamp = table.Column(type: "text", nullable: true),
+ two_factor_enabled = table.Column(type: "boolean", nullable: false),
+ lockout_end = table.Column(type: "timestamp with time zone", nullable: true),
+ lockout_enabled = table.Column(type: "boolean", nullable: false),
+ access_failed_count = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_identity_users", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "identity_role_claims",
+ schema: "identity",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ role_id = table.Column(type: "integer", nullable: false),
+ claim_type = table.Column(type: "text", nullable: true),
+ claim_value = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_identity_role_claims", x => x.id);
+ table.ForeignKey(
+ name: "FK_identity_role_claims_identity_roles_role_id",
+ column: x => x.role_id,
+ principalSchema: "identity",
+ principalTable: "identity_roles",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "identity_user_claims",
+ schema: "identity",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ user_id = table.Column(type: "integer", nullable: false),
+ claim_type = table.Column(type: "text", nullable: true),
+ claim_value = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_identity_user_claims", x => x.id);
+ table.ForeignKey(
+ name: "FK_identity_user_claims_identity_users_user_id",
+ column: x => x.user_id,
+ principalSchema: "identity",
+ principalTable: "identity_users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "identity_user_logins",
+ schema: "identity",
+ columns: table => new
+ {
+ login_provider = table.Column(type: "text", nullable: false),
+ provider_key = table.Column(type: "text", nullable: false),
+ provider_display_name = table.Column(type: "text", nullable: true),
+ user_id = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_identity_user_logins", x => new { x.login_provider, x.provider_key });
+ table.ForeignKey(
+ name: "FK_identity_user_logins_identity_users_user_id",
+ column: x => x.user_id,
+ principalSchema: "identity",
+ principalTable: "identity_users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "identity_user_refresh_tokens",
+ schema: "identity",
+ columns: table => new
+ {
+ id = table.Column(type: "int", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ value = table.Column(type: "varchar(256)", nullable: false),
+ creation_timestamp_utc = table.Column(type: "timestamptz", nullable: false),
+ expiration_timestamp_utc = table.Column(type: "timestamptz", nullable: false),
+ revokation_timestamp_utc = table.Column(type: "timestamptz", nullable: true),
+ identity_user_id = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("id", x => x.id);
+ table.ForeignKey(
+ name: "fk_identityUserRefreshTokens_identityUser_userId",
+ column: x => x.identity_user_id,
+ principalSchema: "identity",
+ principalTable: "identity_users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "identity_user_roles",
+ schema: "identity",
+ columns: table => new
+ {
+ user_id = table.Column(type: "integer", nullable: false),
+ role_id = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_identity_user_roles", x => new { x.user_id, x.role_id });
+ table.ForeignKey(
+ name: "FK_identity_user_roles_identity_roles_role_id",
+ column: x => x.role_id,
+ principalSchema: "identity",
+ principalTable: "identity_roles",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_identity_user_roles_identity_users_user_id",
+ column: x => x.user_id,
+ principalSchema: "identity",
+ principalTable: "identity_users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "identity_user_tokens",
+ schema: "identity",
+ columns: table => new
+ {
+ user_id = table.Column(type: "integer", nullable: false),
+ login_provider = table.Column(type: "text", nullable: false),
+ name = table.Column(type: "text", nullable: false),
+ value = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_identity_user_tokens", x => new { x.user_id, x.login_provider, x.name });
+ table.ForeignKey(
+ name: "FK_identity_user_tokens_identity_users_user_id",
+ column: x => x.user_id,
+ principalSchema: "identity",
+ principalTable: "identity_users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_identity_role_claims_role_id",
+ schema: "identity",
+ table: "identity_role_claims",
+ column: "role_id");
+
+ migrationBuilder.CreateIndex(
+ name: "RoleNameIndex",
+ schema: "identity",
+ table: "identity_roles",
+ column: "normalized_name",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_identity_user_claims_user_id",
+ schema: "identity",
+ table: "identity_user_claims",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_identity_user_logins_user_id",
+ schema: "identity",
+ table: "identity_user_logins",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_identity_user_refresh_tokens_identity_user_id",
+ schema: "identity",
+ table: "identity_user_refresh_tokens",
+ column: "identity_user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_identity_user_roles_role_id",
+ schema: "identity",
+ table: "identity_user_roles",
+ column: "role_id");
+
+ migrationBuilder.CreateIndex(
+ name: "EmailIndex",
+ schema: "identity",
+ table: "identity_users",
+ column: "normalized_email");
+
+ migrationBuilder.CreateIndex(
+ name: "UserNameIndex",
+ schema: "identity",
+ table: "identity_users",
+ column: "NormalizedUserName",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "identity_role_claims",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "identity_user_claims",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "identity_user_logins",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "identity_user_refresh_tokens",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "identity_user_roles",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "identity_user_tokens",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "identity_roles",
+ schema: "identity");
+
+ migrationBuilder.DropTable(
+ name: "identity_users",
+ schema: "identity");
+ }
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/Migrations/ApplicationIdentityDbContextModelSnapshot.cs b/AutobusApi.Infrastructure/Identity/Migrations/ApplicationIdentityDbContextModelSnapshot.cs
new file mode 100644
index 0000000..a4da1aa
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/Migrations/ApplicationIdentityDbContextModelSnapshot.cs
@@ -0,0 +1,354 @@
+//
+using System;
+using AutobusApi.Infrastructure.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace AutobusApi.Infrastructure.Identity.Migrations
+{
+ [DbContext(typeof(ApplicationIdentityDbContext))]
+ partial class ApplicationIdentityDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("identity")
+ .HasAnnotation("ProductVersion", "7.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("AutobusApi.Infrastructure.Identity.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer")
+ .HasColumnName("access_failed_count");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text")
+ .HasColumnName("concurrency_stamp");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("email");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean")
+ .HasColumnName("email_confirmed");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("lockout_enabled");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("lockout_end");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("normalized_email");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text")
+ .HasColumnName("security_stamp");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("two_factor_enabled");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("identity_users", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text")
+ .HasColumnName("concurrency_stamp");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("normalized_name");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("identity_roles", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("RoleId")
+ .HasColumnType("integer")
+ .HasColumnName("role_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("identity_role_claims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("identity_user_claims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("provider_display_name");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("identity_user_logins", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.Property("RoleId")
+ .HasColumnType("integer")
+ .HasColumnName("role_id");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("identity_user_roles", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("identity_user_tokens", "identity");
+ });
+
+ modelBuilder.Entity("AutobusApi.Infrastructure.Identity.ApplicationUser", b =>
+ {
+ b.OwnsMany("AutobusApi.Infrastructure.Identity.RefreshToken", "RefreshTokens", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id"));
+
+ b1.Property("ApplicationUserId")
+ .HasColumnType("int")
+ .HasColumnName("identity_user_id");
+
+ b1.Property("CreationDateTimeUtc")
+ .HasColumnType("timestamptz")
+ .HasColumnName("creation_timestamp_utc");
+
+ b1.Property("ExpirationDateTimeUtc")
+ .HasColumnType("timestamptz")
+ .HasColumnName("expiration_timestamp_utc");
+
+ b1.Property("RevokationDateTimeUtc")
+ .HasColumnType("timestamptz")
+ .HasColumnName("revokation_timestamp_utc");
+
+ b1.Property("Value")
+ .IsRequired()
+ .HasColumnType("varchar(256)")
+ .HasColumnName("value");
+
+ b1.HasKey("Id")
+ .HasName("id");
+
+ b1.HasIndex("ApplicationUserId");
+
+ b1.ToTable("identity_user_refresh_tokens", "identity");
+
+ b1.WithOwner("ApplicationUser")
+ .HasForeignKey("ApplicationUserId")
+ .HasConstraintName("fk_identityUserRefreshTokens_identityUser_userId");
+
+ b1.Navigation("ApplicationUser");
+ });
+
+ b.Navigation("RefreshTokens");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("AutobusApi.Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/AutobusApi.Infrastructure/Identity/RefreshToken.cs b/AutobusApi.Infrastructure/Identity/RefreshToken.cs
new file mode 100644
index 0000000..5f78e43
--- /dev/null
+++ b/AutobusApi.Infrastructure/Identity/RefreshToken.cs
@@ -0,0 +1,22 @@
+namespace AutobusApi.Infrastructure.Identity;
+
+public class RefreshToken
+{
+ public int Id { get; set; }
+
+ public string Value { get; set; } = null!;
+
+ public DateTime CreationDateTimeUtc { get; set; }
+
+ public DateTime ExpirationDateTimeUtc { get; set; }
+
+ public DateTime? RevokationDateTimeUtc { get; set; }
+
+ public bool IsExpired => DateTime.UtcNow >= ExpirationDateTimeUtc;
+
+ public bool IsActive => RevokationDateTimeUtc is null && !IsExpired;
+
+ public int ApplicationUserId { get; set; }
+
+ public ApplicationUser ApplicationUser { get; set; } = null!;
+}
diff --git a/AutobusApi.Infrastructure/Services/IdentityService.cs b/AutobusApi.Infrastructure/Services/IdentityService.cs
new file mode 100644
index 0000000..ffbb6ea
--- /dev/null
+++ b/AutobusApi.Infrastructure/Services/IdentityService.cs
@@ -0,0 +1,174 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using AutobusApi.Application.Common.Exceptions;
+using AutobusApi.Application.Common.Interfaces;
+using AutobusApi.Application.Common.Models.Identity;
+using AutobusApi.Domain.Enums;
+using AutobusApi.Infrastructure.Identity;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.IdentityModel.Tokens;
+
+namespace AutobusApi.Infrastructure.Services;
+
+public class IdentityService : IIdentityService
+{
+ private readonly UserManager _userManager;
+ private readonly IConfiguration _configuration;
+
+ public IdentityService(
+ UserManager userManager,
+ IConfiguration configuration)
+ {
+ _userManager = userManager;
+ _configuration = configuration;
+ }
+
+ public async Task RegisterAsync(
+ string email,
+ string password,
+ CancellationToken cancellationToken)
+ {
+ var userWithSameEmail = await _userManager.FindByEmailAsync(email);
+ if (userWithSameEmail is not null)
+ {
+ throw new RegistrationException("User with given email already registered.");
+ }
+
+ var newUser = new ApplicationUser
+ {
+ UserName = email,
+ Email = email
+ };
+
+ var createUserResult = await _userManager.CreateAsync(newUser, password);
+ var addToRoleResult = await _userManager.AddToRoleAsync(newUser, IdentityRoles.User.ToString());
+ }
+
+ public async Task LoginAsync(
+ string email,
+ string password,
+ CancellationToken cancellationToken)
+ {
+ var user = await _userManager.FindByEmailAsync(email);
+ if (user is null)
+ {
+ throw new LoginException("No users registered with given email.");
+ }
+
+ var isPasswordCorrect = await _userManager.CheckPasswordAsync(user, password);
+ if (!isPasswordCorrect)
+ {
+ throw new LoginException("Given password is incorrect.");
+ }
+
+ var jwtSecurityToken = await CreateJwtAsync(user, cancellationToken);
+ var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
+
+ var refreshToken = user.RefreshTokens.FirstOrDefault(t => t.IsActive);
+ if (refreshToken is null)
+ {
+ refreshToken = CreateRefreshToken();
+ user.RefreshTokens.Add(refreshToken);
+ await _userManager.UpdateAsync(user);
+ }
+
+ return new TokensModel(accessToken, refreshToken.Value);
+ }
+
+ public async Task RenewAccessTokenAsync(
+ string refreshToken,
+ CancellationToken cancellationToken)
+ {
+ var user = await _userManager.Users.SingleOrDefaultAsync(u => u.RefreshTokens.Any(rt => rt.Value == refreshToken));
+ if (user is null)
+ {
+ throw new RenewAccessTokenException($"Refresh token {refreshToken} was not found.");
+ }
+
+ var refreshTokenObject = user.RefreshTokens.Single(rt => rt.Value == refreshToken);
+ if (!refreshTokenObject.IsActive)
+ {
+ throw new RenewAccessTokenException("Refresh token is inactive.");
+ }
+
+ var jwtSecurityToken = await CreateJwtAsync(user, cancellationToken);
+ var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
+
+ return new TokensModel(accessToken, refreshToken);
+ }
+
+ public async Task RevokeRefreshTokenAsync(
+ string refreshToken,
+ CancellationToken cancellationToken)
+ {
+ var user = await _userManager.Users.SingleOrDefaultAsync(u => u.RefreshTokens.Any(t => t.Value == refreshToken));
+ if (user is null)
+ {
+ throw new RevokeRefreshTokenException("Invalid refreshToken");
+ }
+
+ var refreshTokenObject = user.RefreshTokens.Single(x => x.Value == refreshToken);
+ if (!refreshTokenObject.IsActive)
+ {
+ throw new RevokeRefreshTokenException("RefreshToken already revoked");
+ }
+
+ refreshTokenObject.RevokationDateTimeUtc = DateTime.UtcNow;
+ await _userManager.UpdateAsync(user);
+ }
+
+ private async Task CreateJwtAsync(
+ ApplicationUser user,
+ CancellationToken cancellationToken)
+ {
+ var userClaims = await _userManager.GetClaimsAsync(user);
+
+ var roles = await _userManager.GetRolesAsync(user);
+ var roleClaims = new List();
+ foreach (var role in roles)
+ {
+ roleClaims.Add(new Claim("roles", role));
+ }
+
+ var claims = new List()
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
+ new Claim(JwtRegisteredClaimNames.Email, user.Email)
+ }
+ .Union(userClaims)
+ .Union(roleClaims);
+
+ var jwtExpirationDateTimeUtc = DateTime.UtcNow.AddMinutes(Double.Parse(_configuration["Jwt:AccessTokenValidityInMinutes"]));
+
+ var symmetricSecurityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:IssuerSigningKey"]));
+ var signingCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256);
+
+ var jwtSecurityToken = new JwtSecurityToken(
+ issuer: _configuration["Jwt:Issuer"],
+ audience: _configuration["Jwt:Audience"],
+ claims: claims,
+ expires: jwtExpirationDateTimeUtc,
+ signingCredentials: signingCredentials);
+
+ return jwtSecurityToken;
+ }
+
+ private RefreshToken CreateRefreshToken()
+ {
+ var randomNumber = new byte[32];
+
+ using var rng = RandomNumberGenerator.Create();
+ rng.GetNonZeroBytes(randomNumber);
+
+ return new RefreshToken
+ {
+ Value = Convert.ToBase64String(randomNumber),
+ CreationDateTimeUtc = DateTime.UtcNow,
+ ExpirationDateTimeUtc = DateTime.UtcNow.AddDays(Double.Parse(_configuration["Jwt:RefreshTokenValidityInDays"]))
+ };
+ }
+}
diff --git a/AutobusApi.IntegrationTests/AutobusApi.IntegrationTests.csproj b/AutobusApi.IntegrationTests/AutobusApi.IntegrationTests.csproj
index 86a36ef..0fd8016 100644
--- a/AutobusApi.IntegrationTests/AutobusApi.IntegrationTests.csproj
+++ b/AutobusApi.IntegrationTests/AutobusApi.IntegrationTests.csproj
@@ -1,24 +1,28 @@
-
-
-
- net7.0
- enable
- enable
-
- false
-
-
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
+
+
+
+ net7.0
+ enable
+ enable
+
+ false
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/AutobusApi.IntegrationTests/CustomWebApplicationFactory.cs b/AutobusApi.IntegrationTests/CustomWebApplicationFactory.cs
new file mode 100644
index 0000000..2daa36d
--- /dev/null
+++ b/AutobusApi.IntegrationTests/CustomWebApplicationFactory.cs
@@ -0,0 +1,46 @@
+using System.Data.Common;
+using AutobusApi.Infrastructure.Identity;
+using AutoubsApi.Infrastructure.Data;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.EntityFrameworkCore;
+
+namespace AutobusApi.IntegrationTests;
+
+public class CustomWebApplicationFactory
+ : WebApplicationFactory where TProgram : class
+{
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ var dbContextDescriptor = services.SingleOrDefault(
+ d => d.ServiceType ==
+ typeof(DbContextOptions));
+
+ services.Remove(dbContextDescriptor);
+
+ var identityDbContextDescriptor = services.SingleOrDefault(
+ d => d.ServiceType ==
+ typeof(DbContextOptions));
+
+ services.Remove(identityDbContextDescriptor);
+
+ var dbConnectionDescriptor = services.SingleOrDefault(
+ d => d.ServiceType ==
+ typeof(DbConnection));
+
+ services.Remove(dbConnectionDescriptor);
+
+ services.AddDbContext