Skip to content

Commit 181b245

Browse files
authored
Sign In + Sign Out + Whoami (#8)
* Connecting to brapi.dev * Historical prices endpoint * Improving brapi.dev health check * Fix internal class unit testing * Fix seq and rabbitmq ACA volume mounting * Streaming and formatting the brapi quotes * Global response formatter * Global error handler * Fix OutOfRangeException bug * Adding MongoDB connection * Fix brapi quote casting * Creating Price mongo collection * Caching price data in MongoDB * Unit tests * Fixing unit tests * Post-merge fix * Stock Searching * Idempotency on stock caching * Segregating Price and Stock repositories * Idempotency on prices appending * Stock cache TTL * Pagination * Unit tests * Managed identity fix * Fix in-memory stock filtering when Sector null * Account creation * DB migrations * E-mail confirmation * Outbox pattern * Password reset endpoint * Unit tests * Sign in endpoint * Get Account Info endpoint * Sign out endpoint
1 parent 99596ce commit 181b245

24 files changed

Lines changed: 478 additions & 5 deletions

Directory.Packages.props

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,26 @@
55
<ItemGroup>
66
<PackageVersion Include="AspNetCore.HealthChecks.AzureKeyVault" Version="9.0.0" />
77
<PackageVersion Include="AspNetCore.HealthChecks.MongoDb" Version="9.0.0" />
8+
<PackageVersion Include="AspNetCore.HealthChecks.MongoDb" Version="9.0.0" />
89
<PackageVersion Include="AspNetCore.HealthChecks.Publisher.Seq" Version="9.0.0" />
910
<PackageVersion Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" />
1011
<PackageVersion Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
1112
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
1213
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
1314
<PackageVersion Include="AspNetCore.HealthChecks.Uris" Version="9.0.0" />
1415
<PackageVersion Include="AutoFixture" Version="4.18.1" />
16+
<PackageVersion Include="AutoFixture" Version="4.18.1" />
1517
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
1618
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
1719
<PackageVersion Include="Carter" Version="8.0.0" />
1820
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
1921
<PackageVersion Include="Mapster" Version="7.4.0" />
2022
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.0.11" />
2123
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.0.11" />
24+
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.0.11" />
25+
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.0.11" />
2226
<PackageVersion Include="MediatR" Version="13.1.0" />
27+
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.22" />
2328
<PackageVersion Include="Microsoft.AspNetCore.Identity" Version="2.3.1" />
2429
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.22" />
2530
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.21" />
@@ -29,6 +34,12 @@
2934
</PackageVersion>
3035
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.22" />
3136
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.22" />
37+
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.22">
38+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
39+
<PrivateAssets>all</PrivateAssets>
40+
</PackageVersion>
41+
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.22" />
42+
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.22" />
3243
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
3344
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="8.0.2" />
3445
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
@@ -37,6 +48,8 @@
3748
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
3849
<PackageVersion Include="MongoDB.Driver" Version="3.5.2" />
3950
<PackageVersion Include="Moq" Version="4.20.72" />
51+
<PackageVersion Include="MongoDB.Driver" Version="3.5.2" />
52+
<PackageVersion Include="Moq" Version="4.20.72" />
4053
<PackageVersion Include="NSubstitute" Version="5.3.0" />
4154
<PackageVersion Include="Polly" Version="8.6.4" />
4255
<PackageVersion Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
@@ -54,6 +67,7 @@
5467
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.15.0.120848" />
5568
<PackageVersion Include="Swashbuckle.AspNetCore" Version="9.0.6" />
5669
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
70+
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
5771
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
5872
<PackageVersion Include="xunit.v3" Version="3.2.0" />
5973
</ItemGroup>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Security.Claims;
3+
using System.Text;
4+
using Kairos.Account.Configuration;
5+
using Kairos.Account.Domain;
6+
using Kairos.Account.Infra;
7+
using Kairos.Shared.Contracts;
8+
using Kairos.Shared.Contracts.Account.AccessAccount;
9+
using MediatR;
10+
using Microsoft.AspNetCore.Identity;
11+
using Microsoft.EntityFrameworkCore;
12+
using Microsoft.Extensions.Logging;
13+
using Microsoft.Extensions.Options;
14+
using Microsoft.IdentityModel.Tokens;
15+
16+
namespace Kairos.Account.Business.UseCases;
17+
18+
internal sealed class AccessAccountUseCase(
19+
IOptions<Settings> config,
20+
ILogger<AccessAccountUseCase> logger,
21+
SignInManager<Investor> identity,
22+
AccountContext db
23+
) : IRequestHandler<AccessAccountCommand, Output<string>>
24+
{
25+
readonly JwtOptions _settings = config.Value.Jwt;
26+
27+
public async Task<Output<string>> Handle(
28+
AccessAccountCommand input,
29+
CancellationToken cancellationToken)
30+
{
31+
var enrichers = new Dictionary<string, object?>
32+
{
33+
["Identifier"] = input.Identifier.Value,
34+
["CorrelationId"] = input.CorrelationId
35+
};
36+
37+
using (logger.BeginScope(enrichers))
38+
{
39+
try
40+
{
41+
var id = input.Identifier;
42+
43+
var account = await db.Investors.FirstOrDefaultAsync(
44+
i =>
45+
(id.Type == AccountIdentifier.Document && i.Document == id.Value) ||
46+
(id.Type == AccountIdentifier.Email && i.Email == id.Value) ||
47+
(id.Type == AccountIdentifier.PhoneNumber && i.PhoneNumber == id.Value) ||
48+
(id.Type == AccountIdentifier.AccountNumber && i.Id.ToString() == id.Value),
49+
cancellationToken);
50+
51+
if (account is null)
52+
{
53+
logger.LogWarning("Sign-in failed. Account not found.");
54+
return Output<string>.PolicyViolation(["Identificador ou senha inválidos."]);
55+
}
56+
57+
var result = await identity.CheckPasswordSignInAsync(
58+
account,
59+
input.Password,
60+
lockoutOnFailure: true);
61+
62+
if (result.IsLockedOut)
63+
{
64+
logger.LogWarning("Sign-in failed. Account is locked out.");
65+
return Output<string>.PolicyViolation(["Esta conta está bloqueada. Tente novamente após 5 minutos."]);
66+
}
67+
68+
if (result.IsNotAllowed)
69+
{
70+
logger.LogWarning("Sign-in failed. Email not confirmed.");
71+
return Output<string>.PolicyViolation(["Confirme seu e-mail antes de acessar a conta."]);
72+
}
73+
74+
if (result.Succeeded is false)
75+
{
76+
logger.LogWarning("Sign-in failed. Invalid password.");
77+
return Output<string>.PolicyViolation(["Identificador ou senha inválidos."]);
78+
}
79+
80+
logger.LogInformation("Sign-in successful. Generating token.");
81+
82+
var token = GenerateJwtToken(account);
83+
84+
return Output<string>.Ok(token, ["Autenticação realizada com sucesso!"]);
85+
}
86+
catch (Exception ex)
87+
{
88+
logger.LogError(ex, "An unexpected error occurred during sign-in.");
89+
return Output<string>.UnexpectedError([ex.Message]);
90+
}
91+
}
92+
}
93+
94+
string GenerateJwtToken(Investor account)
95+
{
96+
var tokenHandler = new JwtSecurityTokenHandler();
97+
var key = Encoding.ASCII.GetBytes(_settings.Secret);
98+
99+
var claims = new List<Claim>
100+
{
101+
new(JwtRegisteredClaimNames.Sub, account.Id.ToString()),
102+
new(JwtRegisteredClaimNames.Email, account.Email!),
103+
new(JwtRegisteredClaimNames.Name, account.Name),
104+
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
105+
};
106+
107+
var tokenDescriptor = new SecurityTokenDescriptor
108+
{
109+
Subject = new ClaimsIdentity(claims),
110+
Expires = DateTime.UtcNow.AddMinutes(_settings.ExpiryMinutes),
111+
Issuer = _settings.Issuer,
112+
Audience = _settings.Audience,
113+
SigningCredentials = new SigningCredentials(
114+
new SymmetricSecurityKey(key),
115+
SecurityAlgorithms.HmacSha256Signature)
116+
};
117+
118+
var token = tokenHandler.CreateToken(tokenDescriptor);
119+
120+
return tokenHandler.WriteToken(token);
121+
}
122+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using Kairos.Account.Infra;
2+
using Kairos.Shared.Contracts;
3+
using Kairos.Shared.Contracts.Account;
4+
using Kairos.Shared.Contracts.Account.GetAccountInfo;
5+
using MediatR;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace Kairos.Account.Business.UseCases;
9+
10+
internal sealed class GetAccountInfoUseCase(
11+
ILogger<GetAccountInfoUseCase> logger,
12+
AccountContext db) : IRequestHandler<GetAccountInfoQuery, Output<AccountInfo>>
13+
{
14+
public async Task<Output<AccountInfo>> Handle(GetAccountInfoQuery input, CancellationToken cancellationToken)
15+
{
16+
try
17+
{
18+
var account = await db.Investors.FindAsync(input.Id, cancellationToken);
19+
20+
if (account is null)
21+
{
22+
logger.LogWarning(
23+
"Attempted to access non-existent account with ID {AccountId}",
24+
input.Id);
25+
return Output<AccountInfo>.PolicyViolation(["Conta não encontrada."]);
26+
}
27+
28+
return Output<AccountInfo>.Ok(new AccountInfo(
29+
account.Id,
30+
account.Name,
31+
account.Birthdate,
32+
account.Gender,
33+
account.PhoneNumber ?? string.Empty,
34+
account.Document,
35+
account.Email!,
36+
Address: null,
37+
ProfilePicUrl: null
38+
));
39+
}
40+
catch (Exception ex)
41+
{
42+
logger.LogError(ex, "An unexpected error occurred.");
43+
return Output<AccountInfo>.UnexpectedError([
44+
"Um erro inesperado ocorreu...",
45+
ex.Message]);
46+
}
47+
}
48+
}

src/Account/DependencyInjection.cs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
using System.Reflection;
2+
using System.Text;
3+
using Kairos.Account.Configuration;
24
using Kairos.Account.Domain;
35
using Kairos.Account.Infra;
46
using Kairos.Account.Infra.Consumers;
57
using Kairos.Shared.Contracts.Account;
68
using MassTransit;
9+
using Microsoft.AspNetCore.Authentication.JwtBearer;
710
using Microsoft.AspNetCore.Identity;
811
using Microsoft.EntityFrameworkCore;
912
using Microsoft.Extensions.Configuration;
1013
using Microsoft.Extensions.DependencyInjection;
14+
using Microsoft.Extensions.Options;
15+
using Microsoft.IdentityModel.Tokens;
1116

1217
namespace Kairos.Account;
1318

@@ -16,16 +21,21 @@ public static class DependencyInjection
1621
public static IServiceCollection AddAccount(
1722
this IServiceCollection services,
1823
IConfigurationManager config)
24+
IConfigurationManager config)
1925
{
26+
services.Configure<Settings>(config);
27+
2028
return services
2129
.AddIdentity(config)
2230
.AddMediatR(cfg =>
2331
{
2432
cfg.LicenseKey = config["Keys:MediatR"];
2533
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
26-
});
34+
})
35+
.AddAuth();
2736
}
2837

38+
public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x)
2939
public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x)
3040
{
3141
x.AddConsumers(Assembly.GetExecutingAssembly());
@@ -34,6 +44,11 @@ public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistra
3444
c.UseSqlServer();
3545
c.UseBusOutbox();
3646
});
47+
x.AddEntityFrameworkOutbox<AccountContext>(c =>
48+
{
49+
c.UseSqlServer();
50+
c.UseBusOutbox();
51+
});
3752

3853
return x;
3954
}
@@ -81,8 +96,50 @@ static IServiceCollection AddIdentity(
8196
o.User.RequireUniqueEmail = true;
8297
})
8398
.AddEntityFrameworkStores<AccountContext>()
99+
.AddSignInManager()
84100
.AddDefaultTokenProviders();
85101

86102
return services;
87103
}
104+
105+
static IServiceCollection AddAuth(this IServiceCollection services)
106+
{
107+
var jwt = services
108+
.BuildServiceProvider()
109+
.GetRequiredService<IOptions<Settings>>()
110+
.Value.Jwt;
111+
112+
services
113+
.AddAuthentication(o =>
114+
{
115+
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
116+
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
117+
})
118+
.AddJwtBearer(o =>
119+
{
120+
o.TokenValidationParameters = new TokenValidationParameters
121+
{
122+
ValidateIssuer = true,
123+
ValidateAudience = true,
124+
ValidateLifetime = true,
125+
ValidateIssuerSigningKey = true,
126+
ValidIssuer = jwt.Issuer,
127+
ValidAudience = jwt.Audience,
128+
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret))
129+
};
130+
131+
o.Events = new JwtBearerEvents
132+
{
133+
OnMessageReceived = context =>
134+
{
135+
context.Token = context.Request.Cookies[jwt.CookieName];
136+
return Task.CompletedTask;
137+
}
138+
};
139+
});
140+
141+
services.AddAuthorization();
142+
143+
return services;
144+
}
88145
}

src/Account/Domain/Investor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ internal sealed class Investor : KairosAccount
1212
public string Name { get; private set; }
1313
public string Document { get; private set; }
1414
public DateTime Birthdate { get; private set; }
15-
public Gender Gender { get; private set; }
15+
public Gender Gender { get; }
1616
public PersonType Type { get; private set; }
1717

1818
Investor(
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Kairos.Account.Configuration;
2+
3+
public sealed class JwtOptions
4+
{
5+
public required string CookieName { get; init; }
6+
public required string Secret { get; init; }
7+
public required string Issuer { get; init; }
8+
public required string Audience { get; init; }
9+
public required int ExpiryMinutes { get; init; }
10+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Kairos.Account.Configuration;
2+
3+
internal partial class Settings
4+
{
5+
public required JwtOptions Jwt { get; init; }
6+
}

src/Account/Kairos.Account.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
<ItemGroup>
88
<ProjectReference Include="../Shared/Kairos.Shared.csproj" />
99
<InternalsVisibleTo Include="Kairos.Account.UnitTests" />
10+
<InternalsVisibleTo Include="Kairos.Gateway" />
1011
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
1112
</ItemGroup>
1213
<ItemGroup>
14+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
1315
<PackageReference Include="Microsoft.AspNetCore.Identity" />
1416
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
1517
</ItemGroup>

0 commit comments

Comments
 (0)