Skip to content

Commit 3bfe2a7

Browse files
committed
Add CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore & Implementation
1 parent f471640 commit 3bfe2a7

File tree

12 files changed

+384
-0
lines changed

12 files changed

+384
-0
lines changed

UltimateAuth.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<Project Path="src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj" />
1515
<Project Path="src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj" Id="0a8cdd12-a8c4-4530-87e8-ae778c46322b" />
1616
<Project Path="src/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Server.Users.csproj" Id="30d5db36-6dc8-46f6-9139-8b6b3d6053d5" />
17+
<Project Path="src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj" Id="1fd362d5-864b-4bb3-97be-9095d94cfdba" />
1718
<Project Path="src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj" Id="62ee7b1d-46ce-4f2e-985d-1e794f891b8b" />
1819
<Project Path="src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj" Id="6abfb7a6-ea36-42db-a843-38054dd40fd8" />
1920
<Project Path="src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj" Id="5b9a090d-1689-4a81-9dfa-3ba69f0bda38" />
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
2+
3+
internal sealed class CredentialUserMapping<TUser, TUserId>
4+
{
5+
public Func<TUser, TUserId> UserId { get; init; } = default!;
6+
public Func<TUser, string> Username { get; init; } = default!;
7+
public Func<TUser, string> PasswordHash { get; init; } = default!;
8+
public Func<TUser, long> SecurityVersion { get; init; } = default!;
9+
public Func<TUser, bool> CanAuthenticate { get; init; } = default!;
10+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<Version>0.0.1-preview</Version>
8+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
9+
</PropertyGroup>
10+
11+
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
12+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.22" />
13+
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.22" />
14+
</ItemGroup>
15+
<ItemGroup Condition=" '$(TargetFramework)' == 'net9.0' ">
16+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
17+
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
18+
</ItemGroup>
19+
<ItemGroup Condition=" '$(TargetFramework)' == 'net10.0' ">
20+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
21+
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.1" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<ProjectReference Include="..\..\CodeBeam.UltimateAuth.Core\CodeBeam.UltimateAuth.Core.csproj" />
26+
</ItemGroup>
27+
28+
</Project>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Linq.Expressions;
2+
3+
namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore
4+
{
5+
internal static class ConventionResolver
6+
{
7+
public static Expression<Func<TUser, TProp>>? TryResolve<TUser, TProp>(params string[] names)
8+
{
9+
var prop = typeof(TUser)
10+
.GetProperties()
11+
.FirstOrDefault(p =>
12+
names.Contains(p.Name, StringComparer.OrdinalIgnoreCase) &&
13+
typeof(TProp).IsAssignableFrom(p.PropertyType));
14+
15+
if (prop is null)
16+
return null;
17+
18+
var param = Expression.Parameter(typeof(TUser), "u");
19+
var body = Expression.Property(param, prop);
20+
21+
return Expression.Lambda<Func<TUser, TProp>>(body, param);
22+
}
23+
}
24+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore
2+
{
3+
internal static class CredentialUserMappingBuilder
4+
{
5+
public static CredentialUserMapping<TUser, TUserId> Build<TUser, TUserId>(CredentialUserMappingOptions<TUser, TUserId> options)
6+
{
7+
if (options.UserId is null)
8+
{
9+
var expr = ConventionResolver.TryResolve<TUser, TUserId>("Id", "UserId");
10+
if (expr != null)
11+
options.ApplyUserId(expr);
12+
}
13+
14+
if (options.Username is null)
15+
{
16+
var expr = ConventionResolver.TryResolve<TUser, string>(
17+
"Username",
18+
"UserName",
19+
"Email",
20+
"EmailAddress",
21+
"Login");
22+
23+
if (expr != null)
24+
options.ApplyUsername(expr);
25+
}
26+
27+
// Never add "Password" as a convention to avoid accidental mapping to plaintext password properties
28+
if (options.PasswordHash is null)
29+
{
30+
var expr = ConventionResolver.TryResolve<TUser, string>(
31+
"PasswordHash",
32+
"Passwordhash",
33+
"PasswordHashV2");
34+
35+
if (expr != null)
36+
options.ApplyPasswordHash(expr);
37+
}
38+
39+
if (options.SecurityVersion is null)
40+
{
41+
var expr = ConventionResolver.TryResolve<TUser, long>(
42+
"SecurityVersion",
43+
"SecurityStamp",
44+
"AuthVersion");
45+
46+
if (expr != null)
47+
options.ApplySecurityVersion(expr);
48+
}
49+
50+
51+
if (options.UserId is null)
52+
throw new InvalidOperationException("UserId mapping is required. Use MapUserId(...) or ensure a conventional property exists.");
53+
54+
if (options.Username is null)
55+
throw new InvalidOperationException("Username mapping is required. Use MapUsername(...) or ensure a conventional property exists.");
56+
57+
if (options.PasswordHash is null)
58+
throw new InvalidOperationException("PasswordHash mapping is required. Use MapPasswordHash(...) or ensure a conventional property exists.");
59+
60+
if (options.SecurityVersion is null)
61+
throw new InvalidOperationException("SecurityVersion mapping is required. Use MapSecurityVersion(...) or ensure a conventional property exists.");
62+
63+
var canAuthenticateExpr = options.CanAuthenticate ?? (_ => true);
64+
65+
return new CredentialUserMapping<TUser, TUserId>
66+
{
67+
UserId = options.UserId.Compile(),
68+
Username = options.Username.Compile(),
69+
PasswordHash = options.PasswordHash.Compile(),
70+
SecurityVersion = options.SecurityVersion.Compile(),
71+
CanAuthenticate = canAuthenticateExpr.Compile()
72+
};
73+
}
74+
}
75+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.Linq.Expressions;
2+
3+
namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
4+
5+
public sealed class CredentialUserMappingOptions<TUser, TUserId>
6+
{
7+
internal Expression<Func<TUser, TUserId>>? UserId { get; private set; }
8+
internal Expression<Func<TUser, string>>? Username { get; private set; }
9+
internal Expression<Func<TUser, string>>? PasswordHash { get; private set; }
10+
internal Expression<Func<TUser, long>>? SecurityVersion { get; private set; }
11+
internal Expression<Func<TUser, bool>>? CanAuthenticate { get; private set; }
12+
13+
public void MapUserId(Expression<Func<TUser, TUserId>> expr) => UserId = expr;
14+
public void MapUsername(Expression<Func<TUser, string>> expr) => Username = expr;
15+
public void MapPasswordHash(Expression<Func<TUser, string>> expr) => PasswordHash = expr;
16+
public void MapSecurityVersion(Expression<Func<TUser, long>> expr) => SecurityVersion = expr;
17+
18+
/// <summary>
19+
/// Optional. If not specified, all users are allowed to authenticate.
20+
/// Use this to enforce custom user state rules (e.g. Active, Locked, Suspended).
21+
/// Users that can't authenticate don't show up in authentication results.
22+
/// </summary>
23+
public void MapCanAuthenticate(Expression<Func<TUser, bool>> expr) => CanAuthenticate = expr;
24+
25+
internal void ApplyUserId(Expression<Func<TUser, TUserId>> expr) => UserId = expr;
26+
internal void ApplyUsername(Expression<Func<TUser, string>> expr) => Username = expr;
27+
internal void ApplyPasswordHash(Expression<Func<TUser, string>> expr) => PasswordHash = expr;
28+
internal void ApplySecurityVersion(Expression<Func<TUser, long>> expr) => SecurityVersion = expr;
29+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using CodeBeam.UltimateAuth.Core.Domain;
2+
3+
namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore
4+
{
5+
internal sealed class EfCoreAuthUser<TUserId> : IUser<TUserId>
6+
{
7+
public TUserId UserId { get; }
8+
9+
IReadOnlyDictionary<string, object>? IUser<TUserId>.Claims => null;
10+
11+
public EfCoreAuthUser(TUserId userId)
12+
{
13+
UserId = userId;
14+
}
15+
}
16+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using CodeBeam.UltimateAuth.Core.Abstractions;
2+
using CodeBeam.UltimateAuth.Core.Domain;
3+
using CodeBeam.UltimateAuth.Core.Infrastructure;
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
8+
9+
internal sealed class EfCoreUserStore<TUser, TUserId> : IUAuthUserStore<TUserId> where TUser : class
10+
{
11+
private readonly DbContext _db;
12+
private readonly CredentialUserMapping<TUser, TUserId> _map;
13+
14+
public EfCoreUserStore(DbContext db, IOptions<CredentialUserMappingOptions<TUser, TUserId>> options)
15+
{
16+
_db = db;
17+
_map = CredentialUserMappingBuilder.Build(options.Value);
18+
}
19+
20+
public async Task<IUser<TUserId>?> FindByIdAsync(string? tenantId, TUserId userId, CancellationToken ct = default)
21+
{
22+
var user = await _db.Set<TUser>().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct);
23+
24+
if (user is null || !_map.CanAuthenticate(user))
25+
return null;
26+
27+
return new EfCoreAuthUser<TUserId>(_map.UserId(user));
28+
}
29+
30+
public async Task<UserRecord<TUserId>?> FindByUsernameAsync(string? tenantId, string username, CancellationToken ct = default)
31+
{
32+
var user = await _db.Set<TUser>().FirstOrDefaultAsync(u => _map.Username(u) == username, ct);
33+
34+
if (user is null || !_map.CanAuthenticate(user))
35+
return null;
36+
37+
return new UserRecord<TUserId>
38+
{
39+
Id = _map.UserId(user),
40+
Username = _map.Username(user),
41+
PasswordHash = _map.PasswordHash(user),
42+
IsActive = true,
43+
CreatedAt = DateTimeOffset.UtcNow,
44+
IsDeleted = false
45+
};
46+
}
47+
48+
public async Task<IUser<TUserId>?> FindByLoginAsync(string? tenantId, string login, CancellationToken ct = default)
49+
{
50+
var user = await _db.Set<TUser>().FirstOrDefaultAsync(u => _map.Username(u) == login, ct);
51+
52+
if (user is null || !_map.CanAuthenticate(user))
53+
return null;
54+
55+
return new EfCoreAuthUser<TUserId>(_map.UserId(user));
56+
}
57+
58+
public Task<string?> GetPasswordHashAsync(string? tenantId, TUserId userId, CancellationToken ct = default)
59+
{
60+
return _db.Set<TUser>()
61+
.Where(u => _map.UserId(u)!.Equals(userId))
62+
.Select(u => _map.PasswordHash(u))
63+
.FirstOrDefaultAsync(ct);
64+
}
65+
66+
public Task SetPasswordHashAsync(string? tenantId, TUserId userId, string passwordHash, CancellationToken token = default)
67+
{
68+
throw new NotSupportedException("Password updates are not supported by EfCoreUserStore. " +
69+
"Use application-level user management services.");
70+
}
71+
72+
public async Task<long> GetSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken ct = default)
73+
{
74+
var user = await _db.Set<TUser>().FirstOrDefaultAsync(u => _map.UserId(u)!.Equals(userId), ct);
75+
return user is null ? 0 : _map.SecurityVersion(user);
76+
}
77+
78+
public Task IncrementSecurityVersionAsync(string? tenantId, TUserId userId, CancellationToken token = default)
79+
{
80+
throw new NotSupportedException("Security version updates must be handled by the application.");
81+
}
82+
83+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using CodeBeam.UltimateAuth.Core.Abstractions;
2+
using Microsoft.Extensions.DependencyInjection;
3+
4+
namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore;
5+
6+
public static class ServiceCollectionExtensions
7+
{
8+
public static IServiceCollection AddUltimateAuthEfCoreCredentials<TUser, TUserId>(
9+
this IServiceCollection services,
10+
Action<CredentialUserMappingOptions<TUser, TUserId>> configure)
11+
where TUser : class
12+
{
13+
services.Configure(configure);
14+
15+
services.AddScoped<IUAuthUserStore<TUserId>, EfCoreUserStore<TUser, TUserId>>();
16+
17+
return services;
18+
}
19+
}

0 commit comments

Comments
 (0)