Skip to content

Commit 2f368db

Browse files
committed
Merge branch 'feat/services-evaluation' of https://github.com/Azm-Tech/cce-platform into feat/services-evaluation
2 parents 46c0160 + 70ec25d commit 2f368db

148 files changed

Lines changed: 29325 additions & 335 deletions

File tree

Some content is hidden

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

backend/permissions.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,7 @@ groups:
187187
CountryProfiles:
188188
description: Generate country profiles report
189189
roles: [cce-super-admin, cce-admin]
190+
Lookup:
191+
Manage:
192+
description: Manage lookup tables (nationalities, country phone codes)
193+
roles: [cce-super-admin]

backend/src/CCE.Api.Common/Auth/AuthEndpoints.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder
2929
body.OrganizationName,
3030
body.PhoneNumber,
3131
body.Password,
32-
body.ConfirmPassword), ct).ConfigureAwait(false);
32+
body.ConfirmPassword,
33+
body.CountryCodeId), ct).ConfigureAwait(false);
3334
return result.ToCreatedHttpResult();
3435
})
3536
.AllowAnonymous()

backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,15 @@ public DevAuthHandler(
7171

7272
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
7373
{
74-
var roles = ReadRoles();
74+
// PRIORITY 1: If the request carries a real JWT (e.g. from /api/auth/login),
75+
// authenticate as the real user and skip dev-mode entirely.
76+
var realJwtResult = TryAuthenticateRealJwt();
77+
if (realJwtResult is not null)
78+
return Task.FromResult(realJwtResult);
79+
80+
// PRIORITY 2: Dev-mode auth — cookie or dev-prefixed bearer header.
81+
// Only reached when no valid real JWT is present.
82+
var roles = ReadDevRoles();
7583
if (roles is null || roles.Count == 0)
7684
{
7785
return Task.FromResult(AuthenticateResult.NoResult());
@@ -101,38 +109,29 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
101109
return Task.FromResult(AuthenticateResult.Success(ticket));
102110
}
103111

104-
private List<string>? ReadRoles()
112+
/// <summary>
113+
/// Attempts to validate the Authorization header as a real JWT issued by
114+
/// <c>/api/auth/login</c>. Returns <c>null</c> when no header is present,
115+
/// the token is invalid, or it is a dev-mode token.
116+
/// </summary>
117+
private AuthenticateResult? TryAuthenticateRealJwt()
105118
{
106-
// Prefer cookie (browser path); fall back to bearer header (curl / Postman).
107-
if (Request.Cookies.TryGetValue(DevCookieName, out var cookieValue) && !string.IsNullOrEmpty(cookieValue))
108-
{
109-
return new List<string> { cookieValue.Trim() };
110-
}
119+
if (!Request.Headers.TryGetValue("Authorization", out var auth))
120+
return null;
111121

112-
if (Request.Headers.TryGetValue("Authorization", out var auth))
113-
{
114-
var raw = auth.ToString();
122+
var raw = auth.ToString();
115123

116-
const string devPrefix = "Bearer dev:";
117-
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
118-
{
119-
return new List<string> { raw.Substring(devPrefix.Length).Trim() };
120-
}
124+
// Skip dev-prefixed tokens — they are handled by the dev-mode path.
125+
const string devPrefix = "Bearer dev:";
126+
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
127+
return null;
121128

122-
// Fallback: try to decode as a real JWT (e.g. issued by /api/auth/login)
123-
const string bearerPrefix = "Bearer ";
124-
if (raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
125-
{
126-
var token = raw.Substring(bearerPrefix.Length).Trim();
127-
return TryReadRolesFromJwt(token);
128-
}
129-
}
129+
const string bearerPrefix = "Bearer ";
130+
if (!raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
131+
return null;
130132

131-
return null;
132-
}
133+
var token = raw.Substring(bearerPrefix.Length).Trim();
133134

134-
private List<string>? TryReadRolesFromJwt(string token)
135-
{
136135
var opts = _localAuthOptions.Value;
137136
var profiles = new[] { opts.External, opts.Internal };
138137
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
@@ -155,7 +154,7 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
155154
ClockSkew = TimeSpan.FromMinutes(2),
156155
};
157156

158-
ClaimsPrincipal? principal;
157+
ClaimsPrincipal principal;
159158
try
160159
{
161160
principal = handler.ValidateToken(token, parameters, out _);
@@ -166,12 +165,61 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
166165
continue;
167166
}
168167

169-
var roles = principal.FindAll("roles").Select(c => c.Value).ToList();
170-
if (roles.Count > 0)
171-
return roles;
168+
// Extract claims directly from the validated JWT — do NOT remap to dev users.
169+
var sub = principal.FindFirstValue("sub")
170+
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
171+
if (string.IsNullOrEmpty(sub))
172+
continue;
173+
174+
var email = principal.FindFirstValue("email") ?? string.Empty;
175+
var preferredUsername = principal.FindFirstValue("preferred_username") ?? email;
176+
var name = principal.FindFirstValue("name")
177+
?? principal.FindFirstValue(ClaimTypes.Name)
178+
?? preferredUsername;
179+
180+
var claims = new List<Claim>
181+
{
182+
new("sub", sub),
183+
new("oid", sub),
184+
new("preferred_username", preferredUsername),
185+
new("name", name),
186+
new("email", email),
187+
};
188+
claims.AddRange(principal.FindAll("roles").Select(c => new Claim("roles", c.Value)));
189+
190+
var identity = new ClaimsIdentity(claims, SchemeName, "preferred_username", "roles");
191+
var realPrincipal = new ClaimsPrincipal(identity);
192+
var ticket = new AuthenticationTicket(realPrincipal, SchemeName);
193+
return AuthenticateResult.Success(ticket);
194+
}
195+
196+
Logger.LogDebug("No valid real JWT found in DevAuthHandler; falling back to dev-mode auth");
197+
return null;
198+
}
199+
200+
/// <summary>
201+
/// Reads dev-mode credentials from cookie or the <c>Bearer dev:&lt;role&gt;</c> header.
202+
/// Returns <c>null</c> when neither is present.
203+
/// </summary>
204+
private List<string>? ReadDevRoles()
205+
{
206+
// Prefer bearer header (curl / Postman) over cookie.
207+
if (Request.Headers.TryGetValue("Authorization", out var auth))
208+
{
209+
var raw = auth.ToString();
210+
const string devPrefix = "Bearer dev:";
211+
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
212+
{
213+
return new List<string> { raw.Substring(devPrefix.Length).Trim() };
214+
}
215+
}
216+
217+
// Fall back to cookie (browser path).
218+
if (Request.Cookies.TryGetValue(DevCookieName, out var cookieValue) && !string.IsNullOrEmpty(cookieValue))
219+
{
220+
return new List<string> { cookieValue.Trim() };
172221
}
173222

174-
Logger.LogWarning("JWT validation failed for all profiles in DevAuthHandler");
175223
return null;
176224
}
177225
}

backend/src/CCE.Api.Common/Localization/Resources.yaml

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,8 @@ IDENTITY_USERNAME_EXISTS:
5151
en: "Username already taken"
5252

5353
IDENTITY_INVALID_CREDENTIALS:
54-
ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول"
55-
en: "Sorry, a problem occurred during login"
56-
57-
IDENTITY_INVALID_TOKEN:
58-
ar: "رمز الوصول غير صالح"
59-
en: "Invalid access token"
60-
61-
IDENTITY_ACCOUNT_DEACTIVATED:
62-
ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول"
63-
en: "Sorry, a problem occurred during login"
54+
ar: "البريد الإلكتروني أو كلمة المرور غير صحيحة"
55+
en: "Invalid email or password"
6456

6557
IDENTITY_NOT_AUTHENTICATED:
6658
ar: "المستخدم غير مصادق"
@@ -78,6 +70,14 @@ IDENTITY_STATE_REP_ASSIGNMENT_EXISTS:
7870
ar: "التعيين موجود بالفعل"
7971
en: "Assignment already exists"
8072

73+
IDENTITY_INVALID_TOKEN:
74+
ar: "رمز الوصول غير صالح"
75+
en: "Invalid access token"
76+
77+
IDENTITY_ACCOUNT_DEACTIVATED:
78+
ar: "الحساب غير نشط"
79+
en: "Account is deactivated"
80+
8181
CONTENT_RESOURCE_NOT_FOUND:
8282
ar: "المورد غير موجود"
8383
en: "Resource not found"
@@ -201,12 +201,12 @@ USER_NOT_FOUND:
201201
en: "Sorry, user not found"
202202

203203
EMAIL_EXISTS:
204-
ar: "عذرًا، حدثت مشكلة أثناء إنشاء الحساب"
205-
en: "Sorry, a problem occurred while creating the account"
204+
ar: "البريد الإلكتروني مستخدم بالفعل"
205+
en: "An account with this email already exists"
206206

207207
INVALID_CREDENTIALS:
208-
ar: "عذرًا، حدثت مشكلة أثناء تسجيل الدخول"
209-
en: "Sorry, a problem occurred during login"
208+
ar: "البريد الإلكتروني أو كلمة المرور غير صحيحة"
209+
en: "Invalid email or password"
210210

211211
NOT_AUTHENTICATED:
212212
ar: "المستخدم غير مصادق"
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using CCE.Api.Common.Auth;
2+
using CCE.Api.Common.Extensions;
3+
using CCE.Application.Common.Interfaces;
4+
using CCE.Application.Content.Commands.UploadAsset;
5+
using CCE.Application.Content.Queries.GetAssetById;
6+
using CCE.Infrastructure;
7+
using MediatR;
8+
using Microsoft.AspNetCore.Builder;
9+
using Microsoft.AspNetCore.Http;
10+
using Microsoft.AspNetCore.Routing;
11+
using Microsoft.Extensions.Options;
12+
13+
namespace CCE.Api.External.Endpoints;
14+
15+
public static class AssetEndpoints
16+
{
17+
public static IEndpointRouteBuilder MapAssetEndpoints(this IEndpointRouteBuilder app)
18+
{
19+
var assets = app.MapGroup("/api/assets").WithTags("Assets").RequireAuthorization();
20+
21+
assets.MapPost("", async (
22+
IFormFile file,
23+
ICurrentUserAccessor currentUser,
24+
IMediator mediator,
25+
IOptions<CceInfrastructureOptions> infraOpts,
26+
CancellationToken ct) =>
27+
{
28+
if (currentUser.GetUserId() is null) return Results.Unauthorized();
29+
30+
if (file is null || file.Length == 0)
31+
return Results.BadRequest(new { error = "Upload requires a non-empty file." });
32+
33+
var allowed = infraOpts.Value.AllowedAssetMimeTypes;
34+
if (!allowed.Contains(file.ContentType, System.StringComparer.OrdinalIgnoreCase))
35+
return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
36+
37+
await using var stream = file.OpenReadStream();
38+
var result = await mediator.Send(
39+
new UploadAssetCommand(stream, file.FileName, file.ContentType, file.Length),
40+
ct).ConfigureAwait(false);
41+
42+
return result.Success
43+
? Results.Created($"/api/assets/{result.Data!.Id}", result)
44+
: result.ToHttpResult();
45+
})
46+
.WithName("UploadAsset")
47+
.DisableAntiforgery()
48+
.WithMetadata(new RequestSizeLimitMetadataImpl(20L * 1024L * 1024L));
49+
50+
assets.MapGet("{id:guid}", async (
51+
System.Guid id,
52+
ICurrentUserAccessor currentUser,
53+
IMediator mediator,
54+
CancellationToken ct) =>
55+
{
56+
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
57+
if (userId == System.Guid.Empty) return Results.Unauthorized();
58+
59+
var result = await mediator.Send(new GetAssetByIdQuery(id), ct).ConfigureAwait(false);
60+
61+
if (!result.Success || result.Data!.UploadedById != userId)
62+
return Results.NotFound();
63+
64+
return result.ToHttpResult();
65+
})
66+
.WithName("GetAssetById");
67+
68+
return app;
69+
}
70+
71+
private sealed class RequestSizeLimitMetadataImpl : Microsoft.AspNetCore.Http.Metadata.IRequestSizeLimitMetadata
72+
{
73+
public RequestSizeLimitMetadataImpl(long? max) { MaxRequestBodySize = max; }
74+
public long? MaxRequestBodySize { get; }
75+
}
76+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using CCE.Application.Lookups.Queries.GetCountryCodeById;
2+
using CCE.Application.Lookups.Queries.ListCountryCodes;
3+
using MediatR;
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Routing;
7+
8+
namespace CCE.Api.External.Endpoints;
9+
10+
public static class CountryCodesPublicEndpoints
11+
{
12+
public static IEndpointRouteBuilder MapCountryCodesPublicEndpoints(this IEndpointRouteBuilder app)
13+
{
14+
var group = app.MapGroup("/api/country-codes").WithTags("CountryCodes");
15+
16+
group.MapGet("", async (
17+
string? search, bool? isActive,
18+
IMediator mediator, CancellationToken ct) =>
19+
{
20+
var query = new ListCountryCodesQuery(Search: search, IsActive: isActive);
21+
var result = await mediator.Send(query, ct).ConfigureAwait(false);
22+
return Results.Ok(result);
23+
})
24+
.AllowAnonymous()
25+
.WithName("ListPublicCountryCodes");
26+
27+
group.MapGet("/{id:guid}", async (
28+
System.Guid id,
29+
IMediator mediator, CancellationToken ct) =>
30+
{
31+
var result = await mediator.Send(new GetCountryCodeByIdQuery(id), ct).ConfigureAwait(false);
32+
return result.Success ? Results.Ok(result) : Results.NotFound(result);
33+
})
34+
.AllowAnonymous()
35+
.WithName("GetPublicCountryCodeById");
36+
37+
return app;
38+
}
39+
}

0 commit comments

Comments
 (0)