Skip to content

Commit 4e07fb1

Browse files
committed
Implement login functionality with JWT authentication and GraphQL integration
1 parent 749159f commit 4e07fb1

43 files changed

Lines changed: 471 additions & 193 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

PhantomDave.BankTracking.Api/PhantomDave.BankTracking.Api.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
<ItemGroup>
1212
<PackageReference Include="BananaCakePop.Middleware" Version="17.0.0" />
1313
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
14+
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="15.1.11" />
1415
<PackageReference Include="HotChocolate.AspNetCore.CommandLine" Version="15.1.11" />
1516
<PackageReference Include="HotChocolate.Types.Analyzers" Version="15.1.11">
1617
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1718
<PrivateAssets>all</PrivateAssets>
1819
</PackageReference>
20+
<PackageReference Include="HotChocolate.Authorization" Version="15.1.11" />
1921
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
2022
<PackageReference Include="Microsoft.EntityFrameworkCore.DynamicLinq" Version="9.6.9" />
2123
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />

PhantomDave.BankTracking.Api/Program.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
using HotChocolate.Execution.Configuration;
33
using PhantomDave.BankTracking.Api.Services;
44
using PhantomDave.BankTracking.Data.Extensions;
5+
using Microsoft.AspNetCore.Authentication.JwtBearer;
6+
using Microsoft.IdentityModel.Tokens;
7+
using System.Text;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using HotChocolate.AspNetCore;
10+
using HotChocolate.Authorization;
511

612
namespace PhantomDave.BankTracking.Api;
713

@@ -15,7 +21,46 @@ public static void Main(string[] args)
1521
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not configured.");
1622
builder.Services.AddDataAccess(connectionString);
1723

24+
// Register services
1825
builder.Services.AddScoped<AccountService>();
26+
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
27+
builder.Services.AddSingleton<IJwtTokenService, JwtTokenService>();
28+
29+
// Authentication & Authorization
30+
var jwtSection = builder.Configuration.GetSection("Jwt");
31+
var secret = jwtSection["Secret"];
32+
if (string.IsNullOrWhiteSpace(secret))
33+
{
34+
throw new InvalidOperationException("JWT Secret is not configured. Please set Jwt:Secret in configuration.");
35+
}
36+
var issuer = jwtSection["Issuer"];
37+
var audience = jwtSection["Audience"];
38+
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
39+
40+
builder.Services
41+
.AddAuthentication(options =>
42+
{
43+
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
44+
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
45+
})
46+
.AddJwtBearer(options =>
47+
{
48+
options.RequireHttpsMetadata = false; // enable HTTPS in production
49+
options.SaveToken = true;
50+
options.TokenValidationParameters = new TokenValidationParameters
51+
{
52+
ValidateIssuer = !string.IsNullOrWhiteSpace(issuer),
53+
ValidIssuer = issuer,
54+
ValidateAudience = !string.IsNullOrWhiteSpace(audience),
55+
ValidAudience = audience,
56+
ValidateIssuerSigningKey = true,
57+
IssuerSigningKey = key,
58+
ValidateLifetime = true,
59+
ClockSkew = TimeSpan.FromMinutes(1)
60+
};
61+
});
62+
63+
builder.Services.AddAuthorization();
1964

2065
builder.Services.AddCors(options =>
2166
{
@@ -29,6 +74,7 @@ public static void Main(string[] args)
2974

3075
var graphqlBuilder = builder.Services
3176
.AddGraphQLServer()
77+
.AddAuthorization()
3278
.AddQueryType()
3379
.AddMutationType();
3480

@@ -40,6 +86,10 @@ public static void Main(string[] args)
4086
// Abilita CORS prima del mapping GraphQL
4187
app.UseCors();
4288

89+
// Authentication/Authorization middleware
90+
app.UseAuthentication();
91+
app.UseAuthorization();
92+
4393
app.MapGraphQL();
4494

4595
app.RunWithGraphQLCommands(args);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace PhantomDave.BankTracking.Api.Services;
2+
3+
public sealed class JwtSettings
4+
{
5+
public string Secret { get; init; } = string.Empty;
6+
public string? Issuer { get; init; }
7+
public string? Audience { get; init; }
8+
public int ExpiryMinutes { get; init; } = 60; // default 1 hour
9+
}
10+
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Security.Claims;
3+
using System.Text;
4+
using Microsoft.Extensions.Options;
5+
using Microsoft.IdentityModel.Tokens;
6+
7+
namespace PhantomDave.BankTracking.Api.Services;
8+
9+
public interface IJwtTokenService
10+
{
11+
string CreateToken(int userId, string email, IEnumerable<string>? roles = null);
12+
}
13+
14+
public sealed class JwtTokenService(IOptions<JwtSettings> options) : IJwtTokenService
15+
{
16+
private readonly JwtSettings _settings = options.Value;
17+
18+
public string CreateToken(int userId, string email, IEnumerable<string>? roles = null)
19+
{
20+
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Secret));
21+
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
22+
23+
var claims = new List<Claim>
24+
{
25+
new(ClaimTypes.NameIdentifier, userId.ToString()),
26+
new(ClaimTypes.Name, email),
27+
new(JwtRegisteredClaimNames.Sub, email),
28+
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
29+
};
30+
31+
if (!string.IsNullOrWhiteSpace(_settings.Audience))
32+
{
33+
claims.Add(new Claim(JwtRegisteredClaimNames.Aud, _settings.Audience!));
34+
}
35+
36+
if (roles != null)
37+
{
38+
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
39+
}
40+
41+
var token = new JwtSecurityToken(
42+
issuer: _settings.Issuer,
43+
audience: _settings.Audience,
44+
claims: claims,
45+
expires: DateTime.UtcNow.AddMinutes(_settings.ExpiryMinutes),
46+
signingCredentials: creds);
47+
48+
return new JwtSecurityTokenHandler().WriteToken(token);
49+
}
50+
}
51+

PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using PhantomDave.BankTracking.Api.Services;
22
using PhantomDave.BankTracking.Api.Types.ObjectTypes;
3+
using HotChocolate.Authorization;
34

45
namespace PhantomDave.BankTracking.Api.Types.Mutations;
56

@@ -12,6 +13,7 @@ public class AccountMutations
1213
/// <summary>
1314
/// Create a new account
1415
/// </summary>
16+
[AllowAnonymous]
1517
public async Task<AccountType> CreateAccount(
1618
string email,
1719
string password,
@@ -98,5 +100,38 @@ public async Task<AccountType> CreateAccount(
98100

99101
return AccountType.FromAccount(account);
100102
}
103+
104+
/// <summary>
105+
/// Login and get JWT
106+
/// </summary>
107+
[AllowAnonymous]
108+
public async Task<AuthPayload> Login(
109+
string email,
110+
string password,
111+
[Service] AccountService accountService,
112+
[Service] IJwtTokenService tokenService)
113+
{
114+
var account = await accountService.LoginAccountAsync(email, password);
115+
if (account is null)
116+
{
117+
throw new GraphQLException(
118+
ErrorBuilder.New()
119+
.SetMessage("Invalid credentials.")
120+
.SetCode("UNAUTHENTICATED")
121+
.Build());
122+
}
123+
124+
var token = tokenService.CreateToken(account.Id, account.Email);
125+
return new AuthPayload
126+
{
127+
Token = token,
128+
Account = AccountType.FromAccount(account)
129+
};
130+
}
101131
}
102132

133+
public sealed class AuthPayload
134+
{
135+
public string Token { get; set; } = string.Empty;
136+
public AccountType Account { get; set; } = default!;
137+
}

PhantomDave.BankTracking.Api/Types/Queries/AccountQueries.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using PhantomDave.BankTracking.Api.Services;
22
using PhantomDave.BankTracking.Api.Types.ObjectTypes;
3+
using HotChocolate.Authorization;
34

45
namespace PhantomDave.BankTracking.Api.Types.Queries;
56

@@ -12,6 +13,7 @@ public class AccountQueries
1213
/// <summary>
1314
/// Get all accounts
1415
/// </summary>
16+
[Authorize]
1517
public async Task<IEnumerable<AccountType>> GetAccounts(
1618
[Service] AccountService accountService)
1719
{
@@ -20,8 +22,9 @@ public async Task<IEnumerable<AccountType>> GetAccounts(
2022
}
2123

2224
/// <summary>
23-
/// Get an account by ID
25+
/// Get an account by email
2426
/// </summary>
27+
[Authorize]
2528
public async Task<AccountType?> GetAccountByEmail(
2629
string email,
2730
[Service] AccountService accountService)
@@ -30,4 +33,3 @@ public async Task<IEnumerable<AccountType>> GetAccounts(
3033
return account != null ? AccountType.FromAccount(account) : null;
3134
}
3235
}
33-

PhantomDave.BankTracking.Api/appsettings.Development.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,11 @@
77
"Default": "Information",
88
"Microsoft.AspNetCore": "Warning"
99
}
10+
},
11+
"Jwt": {
12+
"Secret": "dev_secret_change_me_please_and_use_user_secrets_in_prod",
13+
"Issuer": "PhantomDave.BankTracking",
14+
"Audience": "PhantomDave.BankTracking.Client",
15+
"ExpiryMinutes": 60
1016
}
1117
}

PhantomDave.BankTracking.Api/appsettings.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,11 @@
88
"Microsoft.AspNetCore": "Information"
99
}
1010
},
11-
"AllowedHosts": "*"
11+
"AllowedHosts": "*",
12+
"Jwt": {
13+
"Secret": "please_override_in_environment",
14+
"Issuer": "PhantomDave.BankTracking",
15+
"Audience": "PhantomDave.BankTracking.Client",
16+
"ExpiryMinutes": 60
17+
}
1218
}

frontend/angular.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,32 @@
1111
"projects": {
1212
"frontend": {
1313
"projectType": "application",
14-
"schematics": {},
14+
"schematics": {
15+
"@schematics/angular:component": {
16+
"skipTests": true
17+
},
18+
"@schematics/angular:service": {
19+
"skipTests": true
20+
},
21+
"@schematics/angular:directive": {
22+
"skipTests": true
23+
},
24+
"@schematics/angular:pipe": {
25+
"skipTests": true
26+
},
27+
"@schematics/angular:guard": {
28+
"skipTests": true
29+
},
30+
"@schematics/angular:interceptor": {
31+
"skipTests": true
32+
},
33+
"@schematics/angular:resolver": {
34+
"skipTests": true
35+
},
36+
"@schematics/angular:class": {
37+
"skipTests": true
38+
}
39+
},
1540
"root": "",
1641
"sourceRoot": "src",
1742
"prefix": "app",

frontend/codegen.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { CodegenConfig } from '@graphql-codegen/cli';
22

3+
const schemaUrl = process.env.GRAPHQL_SCHEMA_URL || 'http://localhost:5095/graphql';
4+
35
const config: CodegenConfig = {
4-
schema: './schema.graphql',
6+
schema: schemaUrl,
57
documents: ['src/**/*.graphql'],
68
generates: {
79
'./src/generated/graphql.ts': {
@@ -36,4 +38,3 @@ const config: CodegenConfig = {
3638
};
3739

3840
export default config;
39-

0 commit comments

Comments
 (0)