Skip to content

Commit b86caf7

Browse files
committed
feat: add OAuth 2 implementation
1 parent 7f50a3e commit b86caf7

53 files changed

Lines changed: 2080 additions & 419 deletions

Some content is hidden

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

user-management/src/Stickerlandia.UserManagement.Agnostic/PostgresUserRepository.cs

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,7 @@ public async Task UpdateAccount(UserAccount userAccount)
121121
// Create outbox items for domain events
122122
foreach (var evt in userAccount.DomainEvents)
123123
{
124-
var outboxItem = new PostgresOutboxItem
125-
{
126-
Id = Guid.NewGuid().ToString(),
127-
EventType = evt.EventName,
128-
EventData = evt.ToJsonString(),
129-
EmailAddress = userAccount.EmailAddress,
130-
EventTime = DateTime.UtcNow,
131-
Processed = false,
132-
Failed = false
133-
};
134-
135-
await dbContext.OutboxItems.AddAsync(outboxItem);
124+
await this.StoreEventFor(userAccount.Id.Value, evt);
136125
}
137126

138127
await dbContext.SaveChangesAsync();
@@ -223,6 +212,25 @@ public async Task<bool> DoesEmailExistAsync(string emailAddress)
223212
}
224213
}
225214

215+
public async Task StoreEventFor(string accountId, DomainEvent domainEvent)
216+
{
217+
ArgumentException.ThrowIfNullOrEmpty(accountId, nameof(accountId));
218+
ArgumentNullException.ThrowIfNull(domainEvent, nameof(domainEvent));
219+
220+
var outboxItem = new PostgresOutboxItem
221+
{
222+
Id = Guid.NewGuid().ToString(),
223+
EventType = domainEvent.EventName,
224+
EventData = domainEvent.ToJsonString(),
225+
EmailAddress = accountId,
226+
EventTime = DateTime.UtcNow,
227+
Processed = false,
228+
Failed = false
229+
};
230+
231+
await dbContext.OutboxItems.AddAsync(outboxItem);
232+
}
233+
226234
public async Task<List<OutboxItem>> GetUnprocessedItemsAsync(int maxCount = 100)
227235
{
228236
try

user-management/src/Stickerlandia.UserManagement.Agnostic/ServiceExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ public static IServiceCollection AddPostgresAuthServices(this IServiceCollection
6767
options.UseOpenIddict();
6868
});
6969

70-
var identityOptions = services.AddIdentity<PostgresUserAccount, IdentityRole>()
70+
var identityOptions = services.AddIdentity<PostgresUserAccount, IdentityRole>(options =>
71+
{
72+
options.User.RequireUniqueEmail = true;
73+
})
7174
.AddEntityFrameworkStores<UserManagementDbContext>()
7275
.AddDefaultTokenProviders();
7376

user-management/src/Stickerlandia.UserManagement.Agnostic/UserManagementDbContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
66
using Microsoft.EntityFrameworkCore;
7+
using Stickerlandia.UserManagement.Core;
78

89
namespace Stickerlandia.UserManagement.Agnostic;
910

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
@page
2+
@model AccessDeniedModel
3+
@{
4+
ViewData["Title"] = "Access denied";
5+
}
6+
7+
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
8+
<div class="sm:mx-auto sm:w-full sm:max-w-md">
9+
<img src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
10+
alt="Stickerlandia Logo"
11+
class="mx-auto h-10 w-auto"/>
12+
<h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-gray-900">Access denied</h2>
13+
</div>
14+
15+
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-md">
16+
<div class="text-center space-y-6">
17+
<div class="rounded-md bg-red-50 p-4">
18+
<div class="flex">
19+
<div class="flex-shrink-0">
20+
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
21+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
22+
</svg>
23+
</div>
24+
<div class="ml-3">
25+
<h3 class="text-sm font-medium text-red-800">
26+
Access Denied
27+
</h3>
28+
<div class="mt-2 text-sm text-red-700">
29+
<p>You do not have permission to access this resource. This could be because:</p>
30+
<ul class="list-disc list-inside mt-2 space-y-1">
31+
<li>You are not signed in</li>
32+
<li>Your account doesn't have the required permissions</li>
33+
<li>The resource you're trying to access has been restricted</li>
34+
</ul>
35+
</div>
36+
</div>
37+
</div>
38+
</div>
39+
40+
<div class="space-y-4">
41+
<div>
42+
<a asp-page="./Login"
43+
class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
44+
Sign in
45+
</a>
46+
</div>
47+
<div>
48+
<a href="/"
49+
class="flex w-full justify-center rounded-md bg-white px-3 py-1.5 text-sm/6 font-semibold text-gray-900 outline-1 -outline-offset-1 outline-gray-300 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
50+
Go to home page
51+
</a>
52+
</div>
53+
<div>
54+
<button onclick="history.back()"
55+
class="flex w-full justify-center rounded-md bg-white px-3 py-1.5 text-sm/6 font-semibold text-gray-900 outline-1 -outline-offset-1 outline-gray-300 hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
56+
Go back
57+
</button>
58+
</div>
59+
</div>
60+
61+
<div class="text-sm text-gray-500">
62+
<p>If you believe this is an error, please contact support.</p>
63+
</div>
64+
</div>
65+
</div>
66+
</div>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
#nullable disable
4+
5+
#pragma warning disable CA1515
6+
7+
using Microsoft.AspNetCore.Mvc.RazorPages;
8+
9+
namespace Stickerlandia.UserManagement.Api.Areas.Identity.Pages.Account
10+
{
11+
/// <summary>
12+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
13+
/// directly from your code. This API may change or be removed in future releases.
14+
/// </summary>
15+
public class AccessDeniedModel : PageModel
16+
{
17+
/// <summary>
18+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
19+
/// directly from your code. This API may change or be removed in future releases.
20+
/// </summary>
21+
public void OnGet()
22+
{
23+
}
24+
}
25+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
@page
2+
@model LoginModel
3+
4+
@{
5+
ViewData["Title"] = "Log in";
6+
}
7+
8+
<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
9+
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
10+
<img src="~/img/dd_logo_v_rgb.png" alt="Datadog Logo"
11+
class="mx-auto h-10 w-auto"/>
12+
<h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-gray-900">Sign in to your account</h2>
13+
</div>
14+
15+
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
16+
<form id="account" method="post" class="space-y-6">
17+
<div>
18+
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
19+
<label asp-for="Input.Email" class="block text-sm/6 font-medium text-gray-900">Email</label>
20+
<div class="mt-2">
21+
<input asp-for="Input.Email"
22+
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6"
23+
autocomplete="username" aria-required="true" placeholder="name@example.com"/>
24+
<span asp-validation-for="Input.Email" class="text-danger"></span>
25+
</div>
26+
</div>
27+
28+
<div>
29+
<div class="flex items-center justify-between">
30+
31+
<label asp-for="Input.Password" class="block text-sm/6 font-medium text-gray-900">Password</label>
32+
<div class="text-sm">
33+
<a id="forgot-password" asp-page="./ForgotPassword"
34+
class="font-semibold text-indigo-600 hover:text-indigo-500">Forgot your password?</a>
35+
</div>
36+
</div>
37+
<div class="mt-2">
38+
<input asp-for="Input.Password"
39+
class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6"
40+
autocomplete="current-password"
41+
aria-required="true" placeholder="password"/>
42+
<span asp-validation-for="Input.Password" class="text-danger"></span>
43+
</div>
44+
</div>
45+
46+
<div>
47+
<button id="login-submit"
48+
type="submit"
49+
class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
50+
Sign in
51+
</button>
52+
</div>
53+
</form>
54+
55+
<p class="mt-10 text-center text-sm/6 text-gray-500">
56+
Not a member?
57+
<a asp-page="./Register" class="font-semibold text-indigo-600 hover:text-indigo-500"
58+
asp-route-returnUrl="@Model.ReturnUrl">Register as a new user</a>
59+
</p>
60+
</div>
61+
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
62+
@{
63+
if ((Model.ExternalLogins?.Count ?? 0) > 0)
64+
{
65+
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl"
66+
method="post" class="space-y-6">
67+
<div>
68+
<p>
69+
@foreach (var provider in Model.ExternalLogins!)
70+
{
71+
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" name="provider" value="@provider.Name"
72+
title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
73+
}
74+
</p>
75+
</div>
76+
</form>
77+
}
78+
}
79+
</div>
80+
</div>
81+
@* *@
82+
@* @section Scripts { *@
83+
@* <partial name="_ValidationScriptsPartial" /> *@
84+
@* } *@
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
#nullable disable
4+
5+
#pragma warning disable CA1515, CA2227, CA1056, CA1054, CA1848, CA1034
6+
7+
using System.ComponentModel.DataAnnotations;
8+
using Microsoft.AspNetCore.Authentication;
9+
using Microsoft.AspNetCore.Identity;
10+
using Microsoft.AspNetCore.Mvc;
11+
using Microsoft.AspNetCore.Mvc.RazorPages;
12+
using Stickerlandia.UserManagement.Core;
13+
14+
namespace Stickerlandia.UserManagement.Api.Areas.Identity.Pages.Account
15+
{
16+
public class LoginModel : PageModel
17+
{
18+
private readonly SignInManager<PostgresUserAccount> _signInManager;
19+
private readonly ILogger<LoginModel> _logger;
20+
21+
public LoginModel(SignInManager<PostgresUserAccount> signInManager, ILogger<LoginModel> logger)
22+
{
23+
_signInManager = signInManager;
24+
_logger = logger;
25+
}
26+
27+
/// <summary>
28+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
29+
/// directly from your code. This API may change or be removed in future releases.
30+
/// </summary>
31+
[BindProperty]
32+
public InputModel Input { get; set; }
33+
34+
/// <summary>
35+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
36+
/// directly from your code. This API may change or be removed in future releases.
37+
/// </summary>
38+
public IList<AuthenticationScheme> ExternalLogins { get; set; }
39+
40+
/// <summary>
41+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
42+
/// directly from your code. This API may change or be removed in future releases.
43+
/// </summary>
44+
public string ReturnUrl { get; set; }
45+
46+
/// <summary>
47+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
48+
/// directly from your code. This API may change or be removed in future releases.
49+
/// </summary>
50+
[TempData]
51+
public string ErrorMessage { get; set; }
52+
53+
/// <summary>
54+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
55+
/// directly from your code. This API may change or be removed in future releases.
56+
/// </summary>
57+
public class InputModel
58+
{
59+
/// <summary>
60+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
61+
/// directly from your code. This API may change or be removed in future releases.
62+
/// </summary>
63+
[Required]
64+
[EmailAddress]
65+
public string Email { get; set; }
66+
67+
/// <summary>
68+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
69+
/// directly from your code. This API may change or be removed in future releases.
70+
/// </summary>
71+
[Required]
72+
[DataType(DataType.Password)]
73+
public string Password { get; set; }
74+
75+
/// <summary>
76+
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
77+
/// directly from your code. This API may change or be removed in future releases.
78+
/// </summary>
79+
[Display(Name = "Remember me?")]
80+
public bool RememberMe { get; set; }
81+
}
82+
83+
public async Task OnGetAsync(string returnUrl = null)
84+
{
85+
if (!string.IsNullOrEmpty(ErrorMessage))
86+
{
87+
ModelState.AddModelError(string.Empty, ErrorMessage);
88+
}
89+
90+
returnUrl ??= Url.Content("~/");
91+
92+
// Clear the existing external cookie to ensure a clean login process
93+
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
94+
95+
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
96+
97+
ReturnUrl = returnUrl;
98+
}
99+
100+
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
101+
{
102+
returnUrl ??= Url.Content("~/");
103+
104+
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
105+
106+
if (ModelState.IsValid)
107+
{
108+
// This doesn't count login failures towards account lockout
109+
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
110+
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
111+
if (result.Succeeded)
112+
{
113+
_logger.LogInformation("User logged in.");
114+
return LocalRedirect(returnUrl);
115+
}
116+
if (result.RequiresTwoFactor)
117+
{
118+
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
119+
}
120+
if (result.IsLockedOut)
121+
{
122+
_logger.LogWarning("User account locked out.");
123+
return RedirectToPage("./Lockout");
124+
}
125+
else
126+
{
127+
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
128+
return Page();
129+
}
130+
}
131+
132+
// If we got this far, something failed, redisplay form
133+
return Page();
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)